mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952e9e2974 | ||
|
|
887845418e | ||
|
|
f7c4ef774d | ||
|
|
08a0d0c28d | ||
|
|
61df450366 | ||
|
|
5d25ed6bf8 | ||
|
|
391cf75ece | ||
|
|
7ee2d6fd7e | ||
|
|
b1d11fccd5 | ||
|
|
05f8dda1e3 | ||
|
|
9968f2ba50 | ||
|
|
0ecf2fb8da |
@@ -76,7 +76,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -113,7 +112,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
@@ -170,7 +168,6 @@ jobs:
|
||||
}
|
||||
Start-Sleep -Seconds (20 * $attempt)
|
||||
}
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -202,7 +199,6 @@ jobs:
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
@@ -227,7 +223,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
@@ -270,7 +265,6 @@ jobs:
|
||||
fi
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
|
||||
- name: Install Playwright browsers
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
@@ -27,7 +27,6 @@ jobs:
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ valknut-report.json/
|
||||
.plans/
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
packages/protocol/src/generated/validation/*.aot.ts
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.104-beta.3 - 2026-07-04
|
||||
## 0.1.104-beta.4 - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
### Improved
|
||||
|
||||
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
|
||||
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
|
||||
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
|
||||
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
@@ -92,10 +93,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
||||
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
||||
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- New fields: `.optional()` with a sensible default.
|
||||
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
||||
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
|
||||
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
|
||||
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
|
||||
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
||||
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||
|
||||
@@ -37,6 +37,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
|
||||
|
||||
---
|
||||
|
||||
## Runtime PID Lock
|
||||
|
||||
**Path:** `$PASEO_HOME/paseo.pid`
|
||||
|
||||
The daemon writes this file when it starts and removes it when it exits cleanly.
|
||||
It is a runtime lock, not a durable settings file.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `pid` | `number` | Owner process PID for stop/status operations |
|
||||
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
|
||||
| `hostname` | `string` | Host that created the lock |
|
||||
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
|
||||
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
|
||||
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
|
||||
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
|
||||
|
||||
Desktop-managed is a read-time classification by the desktop app: the daemon
|
||||
executable must be the desktop app executable or live inside the same desktop
|
||||
install. The CLI reports the raw `executablePath` but does not try to classify
|
||||
it because an npm-installed CLI has no authoritative desktop install root.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Record
|
||||
|
||||
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
||||
|
||||
@@ -178,6 +178,15 @@ defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
of commands. Both run sequentially.
|
||||
|
||||
Lifecycle commands run in the worktree through a stable script shell: `bash`
|
||||
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
||||
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
||||
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
||||
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
|
||||
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Protocol Validation
|
||||
|
||||
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
|
||||
|
||||
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
|
||||
|
||||
## Runtime Path
|
||||
|
||||
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
|
||||
|
||||
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
|
||||
|
||||
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
|
||||
|
||||
## Codegen Ownership
|
||||
|
||||
The protocol package owns generation.
|
||||
|
||||
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
|
||||
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
|
||||
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
|
||||
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
|
||||
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
|
||||
|
||||
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
|
||||
|
||||
## Regression Tests
|
||||
|
||||
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
|
||||
|
||||
- discriminated-union branch output must propagate `.default()` fields
|
||||
- current sequential item routing must accept `tool_call`-like status branches
|
||||
- generated runtime imports must keep `.js` extensions for packaged Node ESM
|
||||
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
|
||||
|
||||
## Schema Purity
|
||||
|
||||
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
|
||||
|
||||
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
|
||||
|
||||
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-2QXdrDE327fIF5THUve9NRk6Jx1gSUIWaNc7aTOrX+s=
|
||||
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
|
||||
|
||||
Generated
+49
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -21797,9 +21797,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -28563,9 +28563,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -35069,6 +35069,26 @@
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-aot": {
|
||||
"version": "0.20.4",
|
||||
"resolved": "https://registry.npmjs.org/zod-aot/-/zod-aot-0.20.4.tgz",
|
||||
"integrity": "sha512-vVuKSG4MpJw3bRQu7UEd6ziJ8+AJY3JB2rWqshnSIRJmhxWFb3FiZ9QYnYcldKYZRzO7edgid8BxJkGbZg43OA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"get-tsconfig": "^4.14.0",
|
||||
"jiti": "^2.7.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"unplugin": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"zod-aot": "dist/cli/index.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
@@ -35132,7 +35152,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36150,12 +36170,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.104-beta.3",
|
||||
"@getpaseo/protocol": "0.1.104-beta.3",
|
||||
"@getpaseo/server": "0.1.104-beta.3",
|
||||
"@getpaseo/client": "0.1.104-beta.4",
|
||||
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||
"@getpaseo/server": "0.1.104-beta.4",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36401,10 +36421,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.104-beta.3",
|
||||
"@getpaseo/relay": "0.1.104-beta.3",
|
||||
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||
"@getpaseo/relay": "0.1.104-beta.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36415,7 +36435,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36658,7 +36678,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37554,7 +37574,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37786,19 +37806,20 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
"vitest": "^4.1.6",
|
||||
"zod-aot": "0.20.4"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38016,15 +38037,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.104-beta.3",
|
||||
"@getpaseo/highlight": "0.1.104-beta.3",
|
||||
"@getpaseo/protocol": "0.1.104-beta.3",
|
||||
"@getpaseo/relay": "0.1.104-beta.3",
|
||||
"@getpaseo/client": "0.1.104-beta.4",
|
||||
"@getpaseo/highlight": "0.1.104-beta.4",
|
||||
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||
"@getpaseo/relay": "0.1.104-beta.4",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38561,7 +38582,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -58,7 +58,7 @@
|
||||
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -341,7 +341,7 @@ const CATALOG_DATA = [
|
||||
description: "ByteDance's official TRAE coding agent with native ACP support",
|
||||
version: "manual",
|
||||
iconId: "traecli",
|
||||
installLink: "https://docs.trae.cn/cli",
|
||||
installLink: "https://docs.trae.cn/cli_get-started-with-trae-cli",
|
||||
command: ["traecli", "acp", "serve"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.104-beta.3",
|
||||
"@getpaseo/protocol": "0.1.104-beta.3",
|
||||
"@getpaseo/server": "0.1.104-beta.3",
|
||||
"@getpaseo/client": "0.1.104-beta.4",
|
||||
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||
"@getpaseo/server": "0.1.104-beta.4",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface LocalDaemonPidInfo {
|
||||
hostname?: string;
|
||||
uid?: number;
|
||||
listen?: string;
|
||||
executablePath?: string;
|
||||
desktopManaged?: boolean;
|
||||
}
|
||||
|
||||
@@ -255,6 +256,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
||||
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
|
||||
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
|
||||
listen: resolveListenField(parsed.listen, parsed.sockPath),
|
||||
executablePath: typeof parsed.executablePath === "string" ? parsed.executablePath : undefined,
|
||||
desktopManaged: parsed.desktopManaged === true ? true : undefined,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Command } from "commander";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import { runStatusCommand } from "./status.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execCommand: vi.fn(async () => ({ stdout: process.execPath, stderr: "" })),
|
||||
findExecutable: vi.fn(async () => null),
|
||||
getOrCreateServerId: vi.fn(() => "srv_test"),
|
||||
loadConfig: vi.fn(() => ({
|
||||
listen: "127.0.0.1:6767",
|
||||
relayEnabled: true,
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
relayUseTls: false,
|
||||
relayPublicUseTls: false,
|
||||
})),
|
||||
resolvePaseoHome: vi.fn((env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo"),
|
||||
spawnProcess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@getpaseo/server", () => ({
|
||||
execCommand: mocks.execCommand,
|
||||
findExecutable: mocks.findExecutable,
|
||||
getOrCreateServerId: mocks.getOrCreateServerId,
|
||||
loadConfig: mocks.loadConfig,
|
||||
resolvePaseoHome: mocks.resolvePaseoHome,
|
||||
spawnProcess: mocks.spawnProcess,
|
||||
}));
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function createStatusHome(pidLock: Record<string, unknown>): Promise<string> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-status-"));
|
||||
tempRoots.push(root);
|
||||
const home = path.join(root, ".paseo");
|
||||
await mkdir(home, { recursive: true });
|
||||
await writeFile(path.join(home, "paseo.pid"), JSON.stringify(pidLock));
|
||||
return home;
|
||||
}
|
||||
|
||||
async function readStatusJson(home: string): Promise<Record<string, unknown>> {
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
process.env.PATH = "";
|
||||
const result = await runStatusCommand({ home } as CommandOptions, {} as Command);
|
||||
return result.schema.serialize?.(result.data[0]) as Record<string, unknown>;
|
||||
} finally {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("daemon status desktop management fields", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test("reports executable identity without deriving desktop management in the CLI", async () => {
|
||||
const executablePath =
|
||||
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper";
|
||||
const home = await createStatusHome({
|
||||
pid: process.pid,
|
||||
startedAt: "2026-07-06T00:00:00.000Z",
|
||||
hostname: "dev-host",
|
||||
uid: 501,
|
||||
listen: "/tmp/paseo-status.sock",
|
||||
executablePath,
|
||||
});
|
||||
|
||||
await expect(readStatusJson(home)).resolves.toMatchObject({
|
||||
daemonExecutablePath: executablePath,
|
||||
desktopManaged: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps reporting legacy desktopManaged locks for old readers", async () => {
|
||||
const home = await createStatusHome({
|
||||
pid: process.pid,
|
||||
startedAt: "2026-07-06T00:00:00.000Z",
|
||||
hostname: "dev-host",
|
||||
uid: 501,
|
||||
listen: "/tmp/paseo-status.sock",
|
||||
desktopManaged: true,
|
||||
});
|
||||
|
||||
await expect(readStatusJson(home)).resolves.toMatchObject({
|
||||
daemonExecutablePath: null,
|
||||
desktopManaged: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ interface DaemonStatus {
|
||||
owner: string | null;
|
||||
logPath: string;
|
||||
daemonNode: string;
|
||||
daemonExecutablePath: string | null;
|
||||
cliNode: string;
|
||||
cliVersion: string;
|
||||
daemonVersion: string | null;
|
||||
@@ -126,6 +127,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
{ key: "Owner", value: status.owner ?? "-" },
|
||||
{ key: "Logs", value: status.logPath },
|
||||
{ key: "Daemon Node", value: status.daemonNode },
|
||||
{ key: "PID Lock Executable", value: status.daemonExecutablePath ?? "-" },
|
||||
{ key: "CLI Node", value: status.cliNode },
|
||||
{ key: "CLI", value: status.cliVersion },
|
||||
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
|
||||
@@ -405,9 +407,11 @@ export async function runStatusCommand(
|
||||
owner,
|
||||
logPath: state.logPath,
|
||||
daemonNode,
|
||||
daemonExecutablePath: state.pidInfo?.executablePath ?? null,
|
||||
cliNode,
|
||||
cliVersion,
|
||||
daemonVersion,
|
||||
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
|
||||
desktopManaged: state.pidInfo?.desktopManaged === true,
|
||||
providers,
|
||||
note,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "node ../../scripts/clean-package-dist.mjs",
|
||||
"build": "tsc -p tsconfig.json --incremental false",
|
||||
"build": "npm run build --workspace=@getpaseo/protocol && tsc -p tsconfig.json --incremental false",
|
||||
"build:clean": "npm run clean && npm run build",
|
||||
"prepack": "npm run build:clean",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.104-beta.3",
|
||||
"@getpaseo/relay": "0.1.104-beta.3",
|
||||
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||
"@getpaseo/relay": "0.1.104-beta.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeAgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
GetProvidersSnapshotResponseMessage,
|
||||
ListProviderModelsResponseMessage,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
|
||||
type ProvidersSnapshotUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "providers_snapshot_update" }
|
||||
>["payload"];
|
||||
|
||||
// COMPAT(model-normalize): daemon normalizes at source (provider-registry) — shim covers older daemons; drop when floor >= v0.1.104
|
||||
function normalizeAgentModels(
|
||||
models: AgentModelDefinition[] | undefined,
|
||||
): AgentModelDefinition[] | undefined {
|
||||
if (!models) {
|
||||
return models;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const normalized = models.map((model) => {
|
||||
const next = normalizeAgentModelDefinition(model);
|
||||
changed ||= next !== model;
|
||||
return next;
|
||||
});
|
||||
|
||||
return changed ? normalized : models;
|
||||
}
|
||||
|
||||
function normalizeProviderSnapshotEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry {
|
||||
const models = normalizeAgentModels(entry.models);
|
||||
return models === entry.models ? entry : { ...entry, models };
|
||||
}
|
||||
|
||||
function normalizeProviderSnapshotEntries(
|
||||
entries: ProviderSnapshotEntry[],
|
||||
): ProviderSnapshotEntry[] {
|
||||
let changed = false;
|
||||
const normalized = entries.map((entry) => {
|
||||
const next = normalizeProviderSnapshotEntry(entry);
|
||||
changed ||= next !== entry;
|
||||
return next;
|
||||
});
|
||||
|
||||
return changed ? normalized : entries;
|
||||
}
|
||||
|
||||
export function normalizeListProviderModelsPayload(
|
||||
payload: ListProviderModelsPayload,
|
||||
): ListProviderModelsPayload {
|
||||
const models = normalizeAgentModels(payload.models);
|
||||
return models === payload.models ? payload : { ...payload, models };
|
||||
}
|
||||
|
||||
export function normalizeProvidersSnapshotPayload<
|
||||
T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload,
|
||||
>(payload: T): T {
|
||||
const entries = normalizeProviderSnapshotEntries(payload.entries);
|
||||
return entries === payload.entries ? payload : { ...payload, entries };
|
||||
}
|
||||
|
||||
export function normalizeProviderSnapshotUpdateMessage(
|
||||
msg: SessionOutboundMessage,
|
||||
): SessionOutboundMessage {
|
||||
if (msg.type !== "providers_snapshot_update") {
|
||||
return msg;
|
||||
}
|
||||
|
||||
return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload) };
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
DaemonUpdateResponseSchema,
|
||||
SessionInboundMessageSchema,
|
||||
type ServerInfoStatusPayload,
|
||||
WSOutboundMessageSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
|
||||
import type {
|
||||
AgentStreamEventPayload,
|
||||
AgentSnapshotPayload,
|
||||
@@ -118,6 +118,11 @@ import {
|
||||
type WebSocketFactory,
|
||||
} from "./daemon-client-transport.js";
|
||||
import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js";
|
||||
import {
|
||||
normalizeListProviderModelsPayload,
|
||||
normalizeProviderSnapshotUpdateMessage,
|
||||
normalizeProvidersSnapshotPayload,
|
||||
} from "./compat/normalize-provider-models.js";
|
||||
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
|
||||
import type {
|
||||
BrowserAutomationExecuteRequest,
|
||||
@@ -370,6 +375,7 @@ type WriteProjectConfigPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "write_project_config_response" }
|
||||
>["payload"];
|
||||
|
||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||
type ListCommandsDraftConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
@@ -3728,7 +3734,7 @@ export class DaemonClient {
|
||||
provider: AgentProvider,
|
||||
options?: { cwd?: string; requestId?: string },
|
||||
): Promise<ListProviderModelsPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "list_provider_models_request",
|
||||
@@ -3739,6 +3745,7 @@ export class DaemonClient {
|
||||
// Provider SDK cold starts (especially model discovery) can exceed 60s.
|
||||
timeout: 90000,
|
||||
});
|
||||
return normalizeListProviderModelsPayload(payload);
|
||||
}
|
||||
|
||||
async listProviderModes(
|
||||
@@ -3788,7 +3795,7 @@ export class DaemonClient {
|
||||
cwd?: string;
|
||||
requestId?: string;
|
||||
}): Promise<GetProvidersSnapshotPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "get_providers_snapshot_request",
|
||||
@@ -3796,6 +3803,7 @@ export class DaemonClient {
|
||||
},
|
||||
responseType: "get_providers_snapshot_response",
|
||||
});
|
||||
return normalizeProvidersSnapshotPayload(payload);
|
||||
}
|
||||
|
||||
async getDaemonConfig(
|
||||
@@ -4754,7 +4762,7 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = WSOutboundMessageSchema.safeParse(parsedJson);
|
||||
const parsed = validateWSOutboundMessage(parsedJson);
|
||||
if (!parsed.success) {
|
||||
const msgType =
|
||||
parsedJson != null &&
|
||||
@@ -5017,8 +5025,10 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private handleSessionMessage(msg: SessionOutboundMessage): void {
|
||||
if (msg.type === "status") {
|
||||
const serverInfo = parseServerInfoStatusPayload(msg.payload);
|
||||
const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg);
|
||||
|
||||
if (consumerMessage.type === "status") {
|
||||
const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
|
||||
if (serverInfo) {
|
||||
this.lastServerInfoMessage = serverInfo;
|
||||
if (this.connectionState.status === "connecting") {
|
||||
@@ -5034,39 +5044,39 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === "terminal_stream_exit") {
|
||||
this.terminalStreams.removeTerminal(msg.payload.terminalId);
|
||||
if (consumerMessage.type === "terminal_stream_exit") {
|
||||
this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
|
||||
}
|
||||
|
||||
if (this.rawMessageListeners.size > 0) {
|
||||
for (const handler of this.rawMessageListeners) {
|
||||
try {
|
||||
handler(msg);
|
||||
handler(consumerMessage);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlers = this.messageHandlers.get(msg.type);
|
||||
const handlers = this.messageHandlers.get(consumerMessage.type);
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(msg);
|
||||
handler(consumerMessage);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const event = this.toEvent(msg);
|
||||
const event = this.toEvent(consumerMessage);
|
||||
if (event) {
|
||||
for (const handler of this.eventListeners) {
|
||||
handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
this.resolveWaiters(msg);
|
||||
this.resolveWaiters(consumerMessage);
|
||||
}
|
||||
|
||||
private resolveWaiters(msg: SessionOutboundMessage): void {
|
||||
|
||||
@@ -629,20 +629,37 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
|
||||
});
|
||||
|
||||
const snapshotUpdates: string[] = [];
|
||||
const snapshotModelDefaults: Array<string | undefined> = [];
|
||||
const unsubscribe = client.providers.subscribe((update) => {
|
||||
snapshotUpdates.push(update.generatedAt);
|
||||
snapshotModelDefaults.push(update.entries[0]?.models?.[0]?.defaultThinkingOptionId);
|
||||
});
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
cwd: "/repo/sdk",
|
||||
entries: [{ provider: "codex", status: "ready", enabled: true }],
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.5",
|
||||
label: "GPT-5.5",
|
||||
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generatedAt: "2026-05-16T01:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(snapshotUpdates).toEqual(["2026-05-16T01:00:00.000Z"]);
|
||||
expect(snapshotModelDefaults).toEqual(["high"]);
|
||||
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -7,6 +7,8 @@ import { createDaemonCommandHandlers } from "./daemon-manager";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home",
|
||||
desktopExecutablePath: "/opt/Paseo/Paseo",
|
||||
managedDaemonExecutablePath: "/opt/Paseo/resources/Paseo",
|
||||
settings: {
|
||||
releaseChannel: "stable",
|
||||
daemon: {
|
||||
@@ -23,7 +25,9 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/tmp/paseo-user-data"),
|
||||
getPath: vi.fn((name: string) =>
|
||||
name === "exe" ? mocks.desktopExecutablePath : "/tmp/paseo-user-data",
|
||||
),
|
||||
getVersion: vi.fn(() => "1.2.3"),
|
||||
isPackaged: true,
|
||||
},
|
||||
@@ -91,6 +95,30 @@ function createMockChildProcess(): MockChildProcess {
|
||||
return child;
|
||||
}
|
||||
|
||||
function pidLockPath(): string {
|
||||
return `${mocks.paseoHome}/paseo.pid`;
|
||||
}
|
||||
|
||||
function writePidLock(input: {
|
||||
pid: number;
|
||||
executablePath?: string;
|
||||
desktopManaged?: boolean;
|
||||
}): void {
|
||||
mkdirSync(mocks.paseoHome, { recursive: true });
|
||||
writeFileSync(pidLockPath(), JSON.stringify(input));
|
||||
}
|
||||
|
||||
function writeManagedPidLock(pid: number): void {
|
||||
writePidLock({
|
||||
pid,
|
||||
executablePath: mocks.managedDaemonExecutablePath,
|
||||
});
|
||||
}
|
||||
|
||||
function removePidLock(): void {
|
||||
rmSync(pidLockPath(), { force: true });
|
||||
}
|
||||
|
||||
function scheduleFailedStartup(child: MockChildProcess): void {
|
||||
setImmediate(() => {
|
||||
child.emit("exit", 1, null);
|
||||
@@ -150,7 +178,54 @@ describe("daemon-manager commands", () => {
|
||||
expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
|
||||
});
|
||||
|
||||
it("derives desktop management from the pid lock executable path", async () => {
|
||||
writeManagedPidLock(4242);
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "running",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 4242,
|
||||
listen: "127.0.0.1:6767",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: false,
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.desktop_daemon_status()).resolves.toEqual({
|
||||
serverId: "server-1",
|
||||
status: "running",
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: null,
|
||||
pid: 4242,
|
||||
home: mocks.paseoHome,
|
||||
version: "1.2.3",
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the legacy desktopManaged lock field for old locks", async () => {
|
||||
writePidLock({ pid: 4242, desktopManaged: true });
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "running",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 4242,
|
||||
listen: "127.0.0.1:6767",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: false,
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.desktop_daemon_status()).resolves.toMatchObject({
|
||||
status: "running",
|
||||
pid: 4242,
|
||||
desktopManaged: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes running desktop daemon stops through external CLI daemon stop", async () => {
|
||||
writeManagedPidLock(4242);
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "running",
|
||||
@@ -159,7 +234,10 @@ describe("daemon-manager commands", () => {
|
||||
listen: "127.0.0.1:6767",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped" })
|
||||
.mockImplementationOnce(async () => {
|
||||
removePidLock();
|
||||
return { action: "stopped" };
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
serverId: "",
|
||||
@@ -226,6 +304,7 @@ describe("daemon-manager commands", () => {
|
||||
});
|
||||
|
||||
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
|
||||
writeManagedPidLock(7675);
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stale_pid",
|
||||
@@ -236,7 +315,10 @@ describe("daemon-manager commands", () => {
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped" })
|
||||
.mockImplementationOnce(async () => {
|
||||
removePidLock();
|
||||
return { action: "stopped" };
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
@@ -269,6 +351,7 @@ describe("daemon-manager commands", () => {
|
||||
});
|
||||
|
||||
it("records the renderer stop reason when stopping the desktop daemon", async () => {
|
||||
writeManagedPidLock(4242);
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "running",
|
||||
@@ -277,7 +360,10 @@ describe("daemon-manager commands", () => {
|
||||
listen: "127.0.0.1:6767",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" })
|
||||
.mockImplementationOnce(async () => {
|
||||
removePidLock();
|
||||
return { action: "stopped", reason: "lifecycle_shutdown_rpc" };
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
serverId: "",
|
||||
@@ -302,6 +388,7 @@ describe("daemon-manager commands", () => {
|
||||
});
|
||||
|
||||
it("uses a stale reachable desktop daemon when the version matches", async () => {
|
||||
writeManagedPidLock(7675);
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
@@ -330,6 +417,7 @@ describe("daemon-manager commands", () => {
|
||||
});
|
||||
|
||||
it("restarts a stale reachable desktop daemon when the version differs", async () => {
|
||||
writeManagedPidLock(7675);
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stale_pid",
|
||||
@@ -350,21 +438,27 @@ describe("daemon-manager commands", () => {
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped" })
|
||||
.mockImplementationOnce(async () => {
|
||||
removePidLock();
|
||||
return { action: "stopped" };
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
serverId: "",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "running",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-2",
|
||||
pid: 8888,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: true,
|
||||
.mockImplementationOnce(async () => {
|
||||
writeManagedPidLock(8888);
|
||||
return {
|
||||
localDaemon: "running",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-2",
|
||||
pid: 8888,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: true,
|
||||
};
|
||||
});
|
||||
mocks.spawnProcess.mockReturnValue(createMockChildProcess());
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import type { DesktopSettings } from "../settings/desktop-settings.js";
|
||||
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
|
||||
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
|
||||
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed.js";
|
||||
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||
@@ -123,18 +124,43 @@ function logFilePath(): string {
|
||||
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
|
||||
}
|
||||
|
||||
export function isDesktopManagedDaemonRunningSync(): boolean {
|
||||
function deriveDesktopManagedFromPidLock(lock: Record<string, unknown>): boolean {
|
||||
const executablePath = toTrimmedString(lock.executablePath);
|
||||
if (executablePath) {
|
||||
return deriveDesktopManagedFromExecutablePath({
|
||||
daemonExecutablePath: executablePath,
|
||||
desktopExecutablePath: app.getPath("exe"),
|
||||
platform: process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
|
||||
return lock.desktopManaged === true;
|
||||
}
|
||||
|
||||
function readPidLockRecord(): Record<string, unknown> | null {
|
||||
try {
|
||||
const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8");
|
||||
const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown };
|
||||
if (lock.desktopManaged !== true) return false;
|
||||
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
|
||||
return isProcessRunning(lock.pid);
|
||||
const lock = JSON.parse(raw) as unknown;
|
||||
return isRecord(lock) ? lock : null;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readDesktopManagedFromPidLock(): boolean {
|
||||
const lock = readPidLockRecord();
|
||||
return lock !== null && deriveDesktopManagedFromPidLock(lock);
|
||||
}
|
||||
|
||||
export function isDesktopManagedDaemonRunningSync(): boolean {
|
||||
const lock = readPidLockRecord();
|
||||
if (lock === null) return false;
|
||||
if (!deriveDesktopManagedFromPidLock(lock)) return false;
|
||||
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
|
||||
return isProcessRunning(lock.pid);
|
||||
}
|
||||
|
||||
function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record<string, unknown> {
|
||||
return {
|
||||
status: status.status,
|
||||
@@ -276,7 +302,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
||||
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
|
||||
const hasRunningLocalProcess = localDaemon === "running";
|
||||
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
|
||||
const desktopManaged = payload.desktopManaged === true;
|
||||
const desktopManaged = readDesktopManagedFromPidLock();
|
||||
const apiReachable = connectedDaemon === "reachable";
|
||||
let status: DesktopDaemonState = "stopped";
|
||||
if (apiReachable || hasRunningLocalProcess) {
|
||||
@@ -418,7 +444,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
detached: true,
|
||||
envMode: "internal",
|
||||
env: invocation.env,
|
||||
envOverlay: { PASEO_DESKTOP_MANAGED: "1", PASEO_WEB_UI_ENABLED: "false" },
|
||||
envOverlay: { PASEO_WEB_UI_ENABLED: "false" },
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed";
|
||||
|
||||
describe("desktop managed daemon executable derivation", () => {
|
||||
it("treats the macOS Helper inside the app bundle as desktop managed", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
daemonExecutablePath:
|
||||
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
|
||||
platform: "darwin",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a macOS Helper from a different app bundle", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
daemonExecutablePath:
|
||||
"/Applications/Other.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
|
||||
platform: "darwin",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats executables inside the Windows install directory as desktop managed", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
|
||||
daemonExecutablePath:
|
||||
"c:\\users\\me\\appdata\\local\\programs\\paseo\\resources\\Paseo.exe",
|
||||
platform: "win32",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects system Node on Windows", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
|
||||
daemonExecutablePath: "C:\\Program Files\\nodejs\\node.exe",
|
||||
platform: "win32",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats Linux executables from the same install directory as desktop managed", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "/opt/Paseo/Paseo",
|
||||
daemonExecutablePath: "/opt/Paseo/resources/Paseo",
|
||||
platform: "linux",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects npm-installed CLI daemon executables", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "/opt/Paseo/Paseo",
|
||||
daemonExecutablePath: "/usr/local/bin/node",
|
||||
platform: "linux",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import path from "node:path";
|
||||
|
||||
interface DesktopManagedInput {
|
||||
daemonExecutablePath: string | null | undefined;
|
||||
desktopExecutablePath: string;
|
||||
platform: NodeJS.Platform;
|
||||
}
|
||||
|
||||
function pathModuleForPlatform(platform: NodeJS.Platform): typeof path.win32 | typeof path.posix {
|
||||
return platform === "win32" ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
function normalizeForComparison(filePath: string, platform: NodeJS.Platform): string {
|
||||
const pathModule = pathModuleForPlatform(platform);
|
||||
const normalized = pathModule.normalize(filePath.trim());
|
||||
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function resolveMacAppRoot(filePath: string): string | null {
|
||||
const marker = ".app/";
|
||||
const markerIndex = filePath.indexOf(marker);
|
||||
if (markerIndex === -1) {
|
||||
return filePath.endsWith(".app") ? filePath : null;
|
||||
}
|
||||
return filePath.slice(0, markerIndex + ".app".length);
|
||||
}
|
||||
|
||||
function resolveDesktopInstallRoot(input: {
|
||||
desktopExecutablePath: string;
|
||||
platform: NodeJS.Platform;
|
||||
}): string {
|
||||
const normalized = normalizeForComparison(input.desktopExecutablePath, input.platform);
|
||||
if (input.platform === "darwin") {
|
||||
return resolveMacAppRoot(normalized) ?? path.posix.dirname(normalized);
|
||||
}
|
||||
|
||||
return pathModuleForPlatform(input.platform).dirname(normalized);
|
||||
}
|
||||
|
||||
function isSamePathOrInside(input: {
|
||||
candidatePath: string;
|
||||
parentPath: string;
|
||||
platform: NodeJS.Platform;
|
||||
}): boolean {
|
||||
const pathModule = pathModuleForPlatform(input.platform);
|
||||
const candidatePath = normalizeForComparison(input.candidatePath, input.platform);
|
||||
const parentPath = normalizeForComparison(input.parentPath, input.platform);
|
||||
const relative = pathModule.relative(parentPath, candidatePath);
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative.length > 0 && !relative.startsWith("..") && !pathModule.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveDesktopManagedFromExecutablePath(input: DesktopManagedInput): boolean {
|
||||
if (!input.daemonExecutablePath?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const desktopInstallRoot = resolveDesktopInstallRoot({
|
||||
desktopExecutablePath: input.desktopExecutablePath,
|
||||
platform: input.platform,
|
||||
});
|
||||
return isSamePathOrInside({
|
||||
candidatePath: input.daemonExecutablePath,
|
||||
parentPath: desktopInstallRoot,
|
||||
platform: input.platform,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Protocol Validator Codegen
|
||||
|
||||
This directory is build-time only. `ws-outbound.compile.ts` is the zod-aot discovery entry for the inbound WebSocket validator.
|
||||
|
||||
The generated runtime file is written to `../src/generated/validation/ws-outbound.aot.ts` and is not committed. The protocol package owns every generation trigger through its npm lifecycle scripts.
|
||||
|
||||
`zod-aot` is exact-pinned, and the protocol generator applies the small compiler patches it requires before generation. Treat changes to those patches like compiler changes: regenerate, inspect the output, and run the protocol validation regression tests before shipping.
|
||||
@@ -0,0 +1,4 @@
|
||||
import { compile } from "zod-aot";
|
||||
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../src/messages.js";
|
||||
|
||||
export const WSOutboundMessageSchema = compile(SourceWSOutboundMessageSchema);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -23,7 +23,12 @@
|
||||
"build:clean": "npm run clean && npm run build",
|
||||
"prepack": "npm run build:clean",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"generate:validators": "node scripts/generate-validation-aot.mjs",
|
||||
"watch": "concurrently --kill-others --names validation,tsc --prefix-colors green,yellow \"node scripts/watch-validation-aot.mjs\" \"tsc -p tsconfig.json --watch --preserveWatchOutput\"",
|
||||
"prebuild": "npm run generate:validators",
|
||||
"pretypecheck": "npm run generate:validators",
|
||||
"pretest": "npm run generate:validators"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
@@ -31,6 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
"vitest": "^4.1.6",
|
||||
"zod-aot": "0.20.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const source = resolve(packageRoot, "codegen/ws-outbound.compile.ts");
|
||||
const runtimeSchemaMetadata = resolve(packageRoot, "src/validation/ws-outbound-schema-metadata.ts");
|
||||
const output = resolve(packageRoot, "src/generated/validation/ws-outbound.aot.ts");
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const zodAotEntry = require.resolve("zod-aot");
|
||||
const zodAotRoot = resolve(dirname(zodAotEntry), "..");
|
||||
const emitterPath = resolve(zodAotRoot, "dist/cli/emitter.js");
|
||||
const discriminatedUnionPath = resolve(
|
||||
zodAotRoot,
|
||||
"dist/core/codegen/schemas/discriminated-union.js",
|
||||
);
|
||||
|
||||
async function ensureZodAotRuntimeImportExtensionPatch() {
|
||||
const emitter = await readFile(emitterPath, "utf8");
|
||||
if (emitter.includes('sourceRelPath.endsWith(".js")')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const before = 'let importPath = sourceRelPath.replace(/\\.[cm]?[jt]sx?$/, "");';
|
||||
const after =
|
||||
'let importPath = sourceRelPath.endsWith(".js")\n ? sourceRelPath\n : sourceRelPath.replace(/\\.[cm]?[jt]sx?$/, "");';
|
||||
if (!emitter.includes(before)) {
|
||||
throw new Error("zod-aot emitter shape changed; update the runtime import extension patch");
|
||||
}
|
||||
await writeFile(emitterPath, emitter.replace(before, after));
|
||||
}
|
||||
|
||||
async function ensureZodAotDiscriminatedUnionOutputPatch() {
|
||||
let discriminatedUnionEmitter = await readFile(discriminatedUnionPath, "utf8");
|
||||
if (
|
||||
discriminatedUnionEmitter.includes(
|
||||
"const needsOutputPropagation = ir.options.some(hasMutation);",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const importBefore = 'import { escapeString } from "../context.js";';
|
||||
const importAfter = 'import { escapeString, hasMutation } from "../context.js";';
|
||||
const outputFlagBefore = "const discKey = escapeString(ir.discriminator);\n let code = emit `";
|
||||
const outputFlagAfter =
|
||||
"const discKey = escapeString(ir.discriminator);\n const needsOutputPropagation = ir.options.some(hasMutation);\n let code = emit `";
|
||||
const propagationBefore =
|
||||
" ${g.visit(option, { input: objVar, output: objVar })}\n break;`;";
|
||||
const propagationAfter =
|
||||
' ${g.visit(option, { input: objVar, output: objVar })}\n ${needsOutputPropagation ? `${g.output}=${objVar};` : ""}\n break;`;';
|
||||
|
||||
if (
|
||||
!discriminatedUnionEmitter.includes(importBefore) ||
|
||||
!discriminatedUnionEmitter.includes(outputFlagBefore) ||
|
||||
!discriminatedUnionEmitter.includes(propagationBefore)
|
||||
) {
|
||||
throw new Error("zod-aot discriminated-union emitter shape changed; update the output patch");
|
||||
}
|
||||
|
||||
discriminatedUnionEmitter = discriminatedUnionEmitter
|
||||
.replace(importBefore, importAfter)
|
||||
.replace(outputFlagBefore, outputFlagAfter)
|
||||
.replace(propagationBefore, propagationAfter);
|
||||
await writeFile(discriminatedUnionPath, discriminatedUnionEmitter);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
ensureZodAotRuntimeImportExtensionPatch(),
|
||||
ensureZodAotDiscriminatedUnionOutputPatch(),
|
||||
]);
|
||||
|
||||
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
|
||||
await Promise.all([
|
||||
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
|
||||
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
|
||||
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
|
||||
]);
|
||||
|
||||
const schemas = await discoverSchemas(source, { cacheBust: true });
|
||||
if (schemas.length === 0) {
|
||||
throw new Error(`No zod-aot compile() exports found in ${relative(packageRoot, source)}`);
|
||||
}
|
||||
|
||||
const compiled = compileSchemas(schemas, { mode: "inline" });
|
||||
const runtimeImportPath = relative(dirname(output), runtimeSchemaMetadata)
|
||||
.replace(/\.[cm]?[jt]sx?$/, ".js")
|
||||
.split(sep)
|
||||
.join("/");
|
||||
const content = generateCompiledFileContent(compiled, runtimeImportPath, {
|
||||
zodCompat: false,
|
||||
}).replace(
|
||||
"// AUTO-GENERATED by zod-aot — DO NOT EDIT",
|
||||
"// @ts-nocheck\n// AUTO-GENERATED by zod-aot — DO NOT EDIT",
|
||||
);
|
||||
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
await writeFile(output, content);
|
||||
|
||||
console.info(
|
||||
`generated ${relative(packageRoot, output)} from ${relative(packageRoot, source)} (${schemas
|
||||
.map((schema) => schema.exportName)
|
||||
.join(", ")})`,
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { dirname, join, relative, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const protocolSrcDir = join(packageRoot, "src");
|
||||
const pollIntervalMs = 1000;
|
||||
|
||||
let currentFingerprint = "";
|
||||
let generateInFlight = false;
|
||||
let generateAgain = false;
|
||||
|
||||
async function collectSourceFiles(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name);
|
||||
const relativePath = relative(protocolSrcDir, path);
|
||||
const parts = relativePath.split(sep);
|
||||
|
||||
if (parts[0] === "generated") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await collectSourceFiles(path)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith(".ts")) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function fingerprintSources() {
|
||||
const files = await collectSourceFiles(protocolSrcDir);
|
||||
const stats = await Promise.all(
|
||||
files.sort().map(async (file) => {
|
||||
const fileStat = await stat(file);
|
||||
return `${relative(packageRoot, file)}:${fileStat.mtimeMs}:${fileStat.size}`;
|
||||
}),
|
||||
);
|
||||
|
||||
return stats.join("\n");
|
||||
}
|
||||
|
||||
function runGenerate() {
|
||||
if (generateInFlight) {
|
||||
generateAgain = true;
|
||||
return;
|
||||
}
|
||||
|
||||
generateInFlight = true;
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const child = spawn(npmCommand, ["run", "generate:validators"], {
|
||||
cwd: packageRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
generateInFlight = false;
|
||||
if (code !== 0) {
|
||||
console.error(
|
||||
signal
|
||||
? `validation AOT generation stopped by ${signal}`
|
||||
: `validation AOT generation failed with exit code ${code}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (generateAgain) {
|
||||
generateAgain = false;
|
||||
runGenerate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const nextFingerprint = await fingerprintSources();
|
||||
if (currentFingerprint && nextFingerprint !== currentFingerprint) {
|
||||
runGenerate();
|
||||
}
|
||||
currentFingerprint = nextFingerprint;
|
||||
} catch (error) {
|
||||
console.error("validation AOT watcher failed to scan protocol sources", error);
|
||||
}
|
||||
}
|
||||
|
||||
currentFingerprint = await fingerprintSources();
|
||||
console.log("Watching protocol schema sources for validation AOT changes...");
|
||||
setInterval(poll, pollIntervalMs);
|
||||
@@ -0,0 +1,11 @@
|
||||
# Generated protocol validators
|
||||
|
||||
`ws-outbound.aot.ts` is generated by zod-aot from `packages/protocol/codegen/ws-outbound.compile.ts`.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run generate:validators --workspace=@getpaseo/protocol
|
||||
```
|
||||
|
||||
The generated TypeScript is intentionally gitignored. zod-aot is exact-pinned because this is a young solo-maintainer project; keep the generated validator behind protocol regression tests when upgrading it.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
|
||||
import { AgentProviderSchema } from "../provider-manifest.js";
|
||||
|
||||
export const LoopLogEntrySchema = z.object({
|
||||
seq: z.number().int().positive(),
|
||||
|
||||
@@ -38,41 +38,6 @@ describe("provider snapshot message schemas", () => {
|
||||
expect(parsed.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizes thinking option defaults on provider snapshot models", () => {
|
||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
models: [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "MiniMax-M2.7",
|
||||
label: "MiniMax-M2.7",
|
||||
isDefault: true,
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
thinkingOptions: [
|
||||
{ id: "off", label: "Off" },
|
||||
{ id: "max", label: "Max", isDefault: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.models).toEqual([
|
||||
{
|
||||
provider: "claude",
|
||||
id: "MiniMax-M2.7",
|
||||
label: "MiniMax-M2.7",
|
||||
isDefault: true,
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
thinkingOptions: [
|
||||
{ id: "off", label: "Off" },
|
||||
{ id: "max", label: "Max", isDefault: true },
|
||||
],
|
||||
defaultThinkingOptionId: "max",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("defaults missing enabled state in providers snapshot response entries", () => {
|
||||
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
|
||||
type: "get_providers_snapshot_response",
|
||||
|
||||
@@ -2,9 +2,9 @@ import { z } from "zod";
|
||||
import { TerminalActivitySchema } from "./terminal-activity.js";
|
||||
import { CLIENT_CAPS } from "./client-capabilities.js";
|
||||
import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js";
|
||||
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits";
|
||||
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
|
||||
import { normalizeAgentModelDefinition, TOOL_CALL_ICON_NAMES } from "./agent-types.js";
|
||||
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
|
||||
import { AgentProviderSchema } from "./provider-manifest.js";
|
||||
import { TOOL_CALL_ICON_NAMES } from "./agent-types.js";
|
||||
import {
|
||||
ChatCreateRequestSchema,
|
||||
ChatListRequestSchema,
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
ScheduleDeleteResponseSchema,
|
||||
ScheduleRunOnceResponseSchema,
|
||||
ScheduleUpdateResponseSchema,
|
||||
} from "@getpaseo/protocol/schedule/rpc-schemas";
|
||||
} from "./schedule/rpc-schemas.js";
|
||||
import {
|
||||
LoopRunRequestSchema,
|
||||
LoopListRequestSchema,
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
LoopInspectResponseSchema,
|
||||
LoopLogsResponseSchema,
|
||||
LoopStopResponseSchema,
|
||||
} from "@getpaseo/protocol/loop/rpc-schemas";
|
||||
} from "./loop/rpc-schemas.js";
|
||||
import {
|
||||
BrowserAutomationExecuteRequestSchema,
|
||||
BrowserAutomationExecuteResponseSchema,
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
type PaseoMetadataGenerationEntry,
|
||||
type PaseoScriptEntryRaw,
|
||||
type ProjectConfigRpcError,
|
||||
} from "@getpaseo/protocol/paseo-config-schema";
|
||||
} from "./paseo-config-schema.js";
|
||||
export {
|
||||
PaseoConfigRawSchema,
|
||||
PaseoLifecycleCommandRawSchema,
|
||||
@@ -247,19 +247,17 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
|
||||
AgentFeatureSelectSchema,
|
||||
]);
|
||||
|
||||
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z
|
||||
.object({
|
||||
provider: AgentProviderSchema,
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
contextWindowMaxTokens: z.number().optional(),
|
||||
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
|
||||
defaultThinkingOptionId: z.string().optional(),
|
||||
})
|
||||
.transform(normalizeAgentModelDefinition);
|
||||
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
contextWindowMaxTokens: z.number().optional(),
|
||||
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
|
||||
defaultThinkingOptionId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ProviderSnapshotEntrySchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
@@ -361,20 +359,21 @@ const AgentPermissionActionSchema = z.object({
|
||||
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
|
||||
});
|
||||
|
||||
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = z.union([
|
||||
z.object({
|
||||
behavior: z.literal("allow"),
|
||||
selectedActionId: z.string().optional(),
|
||||
updatedInput: z.record(z.string(), z.unknown()).optional(),
|
||||
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||
}),
|
||||
z.object({
|
||||
behavior: z.literal("deny"),
|
||||
selectedActionId: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
interrupt: z.boolean().optional(),
|
||||
}),
|
||||
]);
|
||||
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
|
||||
z.discriminatedUnion("behavior", [
|
||||
z.object({
|
||||
behavior: z.literal("allow"),
|
||||
selectedActionId: z.string().optional(),
|
||||
updatedInput: z.record(z.string(), z.unknown()).optional(),
|
||||
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||
}),
|
||||
z.object({
|
||||
behavior: z.literal("deny"),
|
||||
selectedActionId: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
interrupt: z.boolean().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionRequest, unknown> =
|
||||
z.object({
|
||||
@@ -554,13 +553,16 @@ const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({
|
||||
error: z.null(),
|
||||
});
|
||||
|
||||
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown> = z.union([
|
||||
ToolCallRunningPayloadSchema,
|
||||
ToolCallCompletedPayloadSchema,
|
||||
ToolCallFailedPayloadSchema,
|
||||
ToolCallCanceledPayloadSchema,
|
||||
]);
|
||||
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown> =
|
||||
z.discriminatedUnion("status", [
|
||||
ToolCallRunningPayloadSchema,
|
||||
ToolCallCompletedPayloadSchema,
|
||||
ToolCallFailedPayloadSchema,
|
||||
ToolCallCanceledPayloadSchema,
|
||||
]);
|
||||
|
||||
// zod-aot 0.20.4 miscompiles this as a nested discriminated union by omitting
|
||||
// the inner tool_call branch from the generated outer dispatch.
|
||||
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknown> = z.union([
|
||||
z.object({
|
||||
type: z.literal("user_message"),
|
||||
@@ -3113,7 +3115,9 @@ export const SetDaemonConfigResponseMessageSchema = z.object({
|
||||
|
||||
export const ReadProjectConfigResponseMessageSchema = z.object({
|
||||
type: z.literal("read_project_config_response"),
|
||||
payload: z.discriminatedUnion("ok", [
|
||||
// zod-aot 0.2.0 miscompiles boolean discriminators as string options
|
||||
// (`"true"`/`"false"`), so keep this sequential until upstream fixes it.
|
||||
payload: z.union([
|
||||
z.object({
|
||||
requestId: z.string(),
|
||||
repoRoot: z.string(),
|
||||
@@ -3132,7 +3136,9 @@ export const ReadProjectConfigResponseMessageSchema = z.object({
|
||||
|
||||
export const WriteProjectConfigResponseMessageSchema = z.object({
|
||||
type: z.literal("write_project_config_response"),
|
||||
payload: z.discriminatedUnion("ok", [
|
||||
// zod-aot 0.2.0 miscompiles boolean discriminators as string options
|
||||
// (`"true"`/`"false"`), so keep this sequential until upstream fixes it.
|
||||
payload: z.union([
|
||||
z.object({
|
||||
requestId: z.string(),
|
||||
repoRoot: z.string(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
|
||||
import { AgentProviderSchema } from "../provider-manifest.js";
|
||||
|
||||
export const ScheduleStatusSchema = z.enum(["active", "paused", "completed"]);
|
||||
export type ScheduleStatus = z.infer<typeof ScheduleStatusSchema>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ToolCallTimelineItem } from "./agent-types.js";
|
||||
import { getPaseoToolLeafName, isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
|
||||
import { stripCwdPrefix } from "./path-utils.js";
|
||||
|
||||
export type ToolCallDisplayInput = Pick<
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../messages.js";
|
||||
|
||||
export const WSOutboundMessageSchema = { schema: SourceWSOutboundMessageSchema };
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { z } from "zod";
|
||||
import { WSOutboundMessageSchema } from "../generated/validation/ws-outbound.aot.js";
|
||||
import type { WSOutboundMessage } from "../messages.js";
|
||||
|
||||
type WSOutboundValidationResult =
|
||||
| { success: true; data: WSOutboundMessage }
|
||||
| { success: false; error: z.ZodError };
|
||||
|
||||
interface WSOutboundGeneratedValidator {
|
||||
safeParse(input: unknown): WSOutboundValidationResult;
|
||||
}
|
||||
|
||||
// zod-aot emits runtime JavaScript from WSOutboundMessageSchema but not its TypeScript surface.
|
||||
// Protocol regression tests cover the patched compiler cases behind this boundary.
|
||||
const wsOutboundValidator = WSOutboundMessageSchema as WSOutboundGeneratedValidator;
|
||||
|
||||
export function validateWSOutboundMessage(input: unknown): WSOutboundValidationResult {
|
||||
return wsOutboundValidator.safeParse(input);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createJiti } from "jiti";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WSOutboundMessageSchema as GeneratedWSOutboundMessageSchema } from "../../src/generated/validation/ws-outbound.aot.js";
|
||||
|
||||
interface GeneratedSchema {
|
||||
safeParse(input: unknown): { success: boolean; data?: unknown };
|
||||
}
|
||||
|
||||
const protocolRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const generatedWSOutboundPath = resolve(
|
||||
protocolRoot,
|
||||
"src/generated/validation/ws-outbound.aot.ts",
|
||||
);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
async function compileInlineSchema(sourceSchema: string): Promise<GeneratedSchema> {
|
||||
const scratchRoot = resolve(protocolRoot, "../../.tmp");
|
||||
await mkdir(scratchRoot, { recursive: true });
|
||||
const tempDir = await mkdtemp(join(scratchRoot, "paseo-zod-aot-"));
|
||||
|
||||
try {
|
||||
const sourcePath = join(tempDir, "schema.source.js");
|
||||
const outputPath = join(tempDir, "schema.generated.ts");
|
||||
await writeFile(join(tempDir, "package.json"), '{"type":"module"}\n');
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
[
|
||||
'import { z } from "zod";',
|
||||
'import { compile } from "zod-aot";',
|
||||
sourceSchema,
|
||||
"export const Schema = compile(SourceSchema);",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const zodAotEntry = require.resolve("zod-aot");
|
||||
const zodAotRoot = resolve(dirname(zodAotEntry), "..");
|
||||
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
|
||||
await Promise.all([
|
||||
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
|
||||
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
|
||||
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
|
||||
]);
|
||||
|
||||
const schemas = await discoverSchemas(sourcePath, { cacheBust: true });
|
||||
const compiled = compileSchemas(schemas, { mode: "inline" });
|
||||
const content = generateCompiledFileContent(compiled, "./schema.source.js", {
|
||||
zodCompat: false,
|
||||
});
|
||||
await writeFile(outputPath, content);
|
||||
|
||||
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
||||
const generated = await jiti.import(outputPath);
|
||||
return generated.Schema as GeneratedSchema;
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("WS outbound zod-aot validation", () => {
|
||||
it("applies defaults inside discriminated-union branches", async () => {
|
||||
const schema = await compileInlineSchema(`
|
||||
const SourceSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("with_default"),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("without_default"),
|
||||
label: z.string(),
|
||||
}),
|
||||
]);
|
||||
`);
|
||||
|
||||
expect(schema.safeParse({ type: "with_default" })).toMatchObject({
|
||||
success: true,
|
||||
data: { type: "with_default", enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("routes tool-call-like status unions through the current sequential item union", async () => {
|
||||
const schema = await compileInlineSchema(`
|
||||
const ToolCallItemSchema = z.discriminatedUnion("status", [
|
||||
z.object({ type: z.literal("tool_call"), status: z.literal("running"), callId: z.string() }),
|
||||
z.object({ type: z.literal("tool_call"), status: z.literal("completed"), callId: z.string(), output: z.string() }),
|
||||
z.object({ type: z.literal("tool_call"), status: z.literal("failed"), callId: z.string(), error: z.string() }),
|
||||
z.object({ type: z.literal("tool_call"), status: z.literal("canceled"), callId: z.string() }),
|
||||
]);
|
||||
|
||||
const TimelineItemSchema = z.union([
|
||||
z.object({ type: z.literal("assistant_message"), text: z.string() }),
|
||||
ToolCallItemSchema,
|
||||
]);
|
||||
|
||||
const SourceSchema = z.object({
|
||||
item: TimelineItemSchema,
|
||||
});
|
||||
`);
|
||||
|
||||
expect(
|
||||
schema.safeParse({ item: { type: "tool_call", status: "running", callId: "run" } }),
|
||||
).toMatchObject({
|
||||
success: true,
|
||||
data: { item: { type: "tool_call", status: "running", callId: "run" } },
|
||||
});
|
||||
expect(
|
||||
schema.safeParse({
|
||||
item: { type: "tool_call", status: "completed", callId: "done", output: "ok" },
|
||||
}),
|
||||
).toMatchObject({
|
||||
success: true,
|
||||
data: { item: { type: "tool_call", status: "completed", callId: "done", output: "ok" } },
|
||||
});
|
||||
expect(
|
||||
schema.safeParse({
|
||||
item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" },
|
||||
}),
|
||||
).toMatchObject({
|
||||
success: true,
|
||||
data: { item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" } },
|
||||
});
|
||||
expect(
|
||||
schema.safeParse({ item: { type: "tool_call", status: "canceled", callId: "stop" } }),
|
||||
).toMatchObject({
|
||||
success: true,
|
||||
data: { item: { type: "tool_call", status: "canceled", callId: "stop" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a minimal valid envelope and rejects a corrupted envelope", () => {
|
||||
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "pong" }).success).toBe(true);
|
||||
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "not_a_message" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits runtime imports with .js extensions", async () => {
|
||||
const generated = await readFile(generatedWSOutboundPath, "utf8");
|
||||
expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -65,10 +65,10 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.104-beta.3",
|
||||
"@getpaseo/highlight": "0.1.104-beta.3",
|
||||
"@getpaseo/protocol": "0.1.104-beta.3",
|
||||
"@getpaseo/relay": "0.1.104-beta.3",
|
||||
"@getpaseo/client": "0.1.104-beta.4",
|
||||
"@getpaseo/highlight": "0.1.104-beta.4",
|
||||
"@getpaseo/protocol": "0.1.104-beta.4",
|
||||
"@getpaseo/relay": "0.1.104-beta.4",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -766,6 +766,7 @@ describe("browser MCP tools", () => {
|
||||
agentStorage: agentStorage as AgentStorage,
|
||||
providerSnapshotManager:
|
||||
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
|
||||
browserToolsEnabled: true,
|
||||
browserToolsBroker: broker as BrowserToolsBroker,
|
||||
callerAgentId: "agent-1",
|
||||
logger,
|
||||
@@ -839,6 +840,7 @@ describe("browser MCP tools", () => {
|
||||
agentStorage: agentStorage as AgentStorage,
|
||||
providerSnapshotManager:
|
||||
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
|
||||
browserToolsEnabled: true,
|
||||
browserToolsBroker: broker as BrowserToolsBroker,
|
||||
callerAgentId: "agent-1",
|
||||
logger,
|
||||
@@ -893,50 +895,24 @@ describe("browser MCP tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps browser tools registered when browser tools are disabled", async () => {
|
||||
it("does not register browser tools when browser tools are disabled", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "agent-1",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId: BROWSER_WORKSPACE_ID,
|
||||
});
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
requestId: "req-browser-disabled",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_disabled",
|
||||
message: "Browser tools are disabled.",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
browserToolsBroker: { execute } as never,
|
||||
browserToolsEnabled: false,
|
||||
callerAgentId: "agent-1",
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "browser_list_tabs");
|
||||
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(lookupTool(server, "browser_snapshot")).not.toBeUndefined();
|
||||
expect(execute).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
cwd: REPO_CWD,
|
||||
workspaceId: BROWSER_WORKSPACE_ID,
|
||||
command: { command: "list_tabs", args: {} },
|
||||
});
|
||||
expect(response.structuredContent).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_disabled",
|
||||
message: "Browser tools are disabled.",
|
||||
retryable: false,
|
||||
},
|
||||
context: { agentId: "agent-1", cwd: REPO_CWD, workspaceId: BROWSER_WORKSPACE_ID },
|
||||
});
|
||||
expect(lookupTool(server, "browser_list_tabs")).toBeUndefined();
|
||||
expect(lookupTool(server, "browser_snapshot")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("wires browser tools through the browser tools broker", async () => {
|
||||
@@ -955,6 +931,7 @@ describe("browser MCP tools", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
browserToolsEnabled: true,
|
||||
browserToolsBroker: { execute } as never,
|
||||
callerAgentId: "agent-1",
|
||||
logger,
|
||||
@@ -994,6 +971,7 @@ describe("browser MCP tools", () => {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
browserToolsEnabled: true,
|
||||
browserToolsBroker: { execute } as never,
|
||||
callerAgentId: "agent-1",
|
||||
logger,
|
||||
|
||||
@@ -25,6 +25,11 @@ const mockState = vi.hoisted(() => {
|
||||
env?: Record<string, string>;
|
||||
providerParams?: unknown;
|
||||
}>,
|
||||
trae: [] as Array<{
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
providerParams?: unknown;
|
||||
}>,
|
||||
pi: [] as ConstructorEntry[],
|
||||
genericAcp: [] as Array<{
|
||||
command: string[];
|
||||
@@ -41,6 +46,7 @@ const mockState = vi.hoisted(() => {
|
||||
this.constructorArgs.codex = [];
|
||||
this.constructorArgs.copilot = [];
|
||||
this.constructorArgs.cursor = [];
|
||||
this.constructorArgs.trae = [];
|
||||
this.constructorArgs.pi = [];
|
||||
this.constructorArgs.genericAcp = [];
|
||||
this.isCommandAvailable.mockReset();
|
||||
@@ -375,6 +381,59 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/trae-acp-agent.js", () => ({
|
||||
TraeACPAgentClient: class TraeACPAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "acp";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: {
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
providerParams?: unknown;
|
||||
}) {
|
||||
this.runtimeSettings = {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: options.command,
|
||||
},
|
||||
env: options.env,
|
||||
};
|
||||
mockState.constructorArgs.trae.push({
|
||||
command: options.command,
|
||||
env: options.env,
|
||||
providerParams: options.providerParams,
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
buildProviderRegistry,
|
||||
@@ -638,6 +697,33 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
|
||||
expect(mockState.constructorArgs.genericAcp).toEqual([]);
|
||||
});
|
||||
|
||||
test("traecli provider extending acp uses TraeACPAgentClient", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
traecli: {
|
||||
extends: "acp",
|
||||
label: "TRAE CLI",
|
||||
command: ["traecli", "acp", "serve"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.traecli.createClient(logger).provider).toBe("traecli");
|
||||
expect(mockState.constructorArgs.trae).toEqual([
|
||||
{
|
||||
command: ["traecli", "acp", "serve"],
|
||||
env: undefined,
|
||||
providerParams: undefined,
|
||||
},
|
||||
{
|
||||
command: ["traecli", "acp", "serve"],
|
||||
env: undefined,
|
||||
providerParams: undefined,
|
||||
},
|
||||
]);
|
||||
expect(mockState.constructorArgs.genericAcp).toEqual([]);
|
||||
});
|
||||
|
||||
test('extends: "acp" without command throws', () => {
|
||||
expect(() =>
|
||||
buildProviderRegistry(logger, {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
||||
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
|
||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
|
||||
import {
|
||||
@@ -660,6 +661,9 @@ function addDerivedProviders(
|
||||
if (providerId === "kiro") {
|
||||
return new KiroACPAgentClient(acpOptions);
|
||||
}
|
||||
if (providerId === "traecli") {
|
||||
return new TraeACPAgentClient(acpOptions);
|
||||
}
|
||||
return new GenericACPAgentClient(acpOptions);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ import { transformPiModels } from "./pi/agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
import * as spawnUtils from "../../../utils/spawn.js";
|
||||
|
||||
@@ -540,7 +541,10 @@ describe("ACPAgentSession terminal tools", () => {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
const shell = spawnUtils.platformShell();
|
||||
const shell = buildStringCommandShellInvocation({
|
||||
command: "git -C /repo status --short",
|
||||
windowsShell: "cmd",
|
||||
});
|
||||
|
||||
await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
@@ -549,12 +553,50 @@ describe("ACPAgentSession terminal tools", () => {
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
shell.command,
|
||||
[...shell.flag, "git -C /repo status --short"],
|
||||
expect.objectContaining({ cwd: "/repo" }),
|
||||
shell.shell,
|
||||
shell.args,
|
||||
expect.objectContaining({
|
||||
cwd: "/repo",
|
||||
envOverlay: expect.objectContaining({ BASH_ENV: undefined }),
|
||||
shell: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves cmd semantics for single-string terminal commands on Windows", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
|
||||
await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: "echo %TEMP% && echo ok",
|
||||
cwd: "C:\\repo",
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"cmd.exe",
|
||||
["/c", "echo %TEMP% && echo ok"],
|
||||
expect.objectContaining({
|
||||
cwd: "C:\\repo",
|
||||
envOverlay: expect.objectContaining({ BASH_ENV: undefined }),
|
||||
shell: false,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves explicit terminal argv", async () => {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
|
||||
@@ -102,7 +102,11 @@ import {
|
||||
} from "../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnvOverlay,
|
||||
} from "../../../utils/string-command-shell.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import {
|
||||
type DiagnosticEntry,
|
||||
toDiagnosticErrorMessage,
|
||||
@@ -181,7 +185,7 @@ function toACPRequestError(error: unknown): Error {
|
||||
function resolveTerminalCommand(
|
||||
command: string,
|
||||
args?: string[],
|
||||
): { command: string; args: string[] } {
|
||||
): { command: string; args: string[]; shell?: boolean } {
|
||||
if (args && args.length > 0) {
|
||||
return { command, args };
|
||||
}
|
||||
@@ -190,8 +194,8 @@ function resolveTerminalCommand(
|
||||
return { command, args: [] };
|
||||
}
|
||||
|
||||
const shell = platformShell();
|
||||
return { command: shell.command, args: [...shell.flag, command] };
|
||||
const shell = buildStringCommandShellInvocation({ command, windowsShell: "cmd" });
|
||||
return { command: shell.shell, args: shell.args, shell: false };
|
||||
}
|
||||
|
||||
function formatDurationMs(startedAt: number): string {
|
||||
@@ -2159,12 +2163,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
|
||||
);
|
||||
const terminalCommand = resolveTerminalCommand(params.command, params.args);
|
||||
const commandEnvOverlays =
|
||||
terminalCommand.shell === false ? [env, createStringCommandShellEnvOverlay()] : [env];
|
||||
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
|
||||
cwd: params.cwd ?? this.config.cwd,
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [env],
|
||||
overlays: commandEnvOverlays,
|
||||
}),
|
||||
shell: terminalCommand.shell,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
interface TraeACPAgentClientOptions {
|
||||
logger: Logger;
|
||||
command: [string, ...string[]];
|
||||
env?: Record<string, string>;
|
||||
providerId?: string;
|
||||
label?: string;
|
||||
providerParams?: unknown;
|
||||
}
|
||||
|
||||
const TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class TraeACPAgentClient extends GenericACPAgentClient {
|
||||
constructor(options: TraeACPAgentClientOptions) {
|
||||
super({
|
||||
logger: options.logger,
|
||||
command: options.command,
|
||||
env: options.env,
|
||||
providerId: options.providerId,
|
||||
label: options.label,
|
||||
providerParams: options.providerParams,
|
||||
// traecli publishes slash commands and skills asynchronously via available_commands_update.
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export interface PaseoToolHostDependencies {
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
) => Promise<string>;
|
||||
browserToolsEnabled?: boolean;
|
||||
browserToolsBroker?: BrowserToolsBroker | null;
|
||||
paseoHome?: string;
|
||||
worktreesRoot?: string;
|
||||
@@ -1027,7 +1028,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
return toCatalog();
|
||||
}
|
||||
|
||||
if (options.browserToolsBroker) {
|
||||
if (options.browserToolsEnabled && options.browserToolsBroker) {
|
||||
registerBrowserTools({
|
||||
registerTool,
|
||||
broker: options.browserToolsBroker,
|
||||
|
||||
@@ -484,9 +484,7 @@ export async function createPaseoDaemon(
|
||||
logger,
|
||||
);
|
||||
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
|
||||
const browserToolsBroker = new BrowserToolsBroker({
|
||||
policy: browserToolsPolicy,
|
||||
});
|
||||
const browserToolsBroker = new BrowserToolsBroker({});
|
||||
|
||||
const serverId = getOrCreateServerId(config.paseoHome, { logger });
|
||||
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
|
||||
@@ -1018,6 +1016,7 @@ export async function createPaseoDaemon(
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,
|
||||
createPaseoWorktree: createPaseoWorktreeForTools,
|
||||
browserToolsEnabled: browserToolsPolicy.isEnabled(),
|
||||
browserToolsBroker,
|
||||
paseoHome: config.paseoHome,
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import { BrowserToolsBroker, type BrowserHostClient } from "./broker.js";
|
||||
import { StaticBrowserToolsPolicy } from "./policy.js";
|
||||
|
||||
const BROWSER_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const SECOND_BROWSER_ID = "22222222-2222-4222-8222-222222222222";
|
||||
@@ -65,9 +64,8 @@ class FailingBrowserHostClient implements BrowserHostClient {
|
||||
}
|
||||
}
|
||||
|
||||
function createBroker(options: { enabled: boolean; timeoutMs?: number }): BrowserToolsBroker {
|
||||
function createBroker(options: { timeoutMs?: number } = {}): BrowserToolsBroker {
|
||||
return new BrowserToolsBroker({
|
||||
policy: new StaticBrowserToolsPolicy(options.enabled),
|
||||
defaultTimeoutMs: options.timeoutMs ?? 100,
|
||||
createRequestId: () => "req-1",
|
||||
});
|
||||
@@ -82,22 +80,8 @@ describe("BrowserToolsBroker", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("disabled returns browser_disabled", async () => {
|
||||
const broker = createBroker({ enabled: false });
|
||||
|
||||
await expect(broker.execute({ command: snapshotCommand() })).resolves.toEqual({
|
||||
requestId: "req-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_disabled",
|
||||
message: "Browser tools are disabled. Enable daemon.browserTools.enabled to use them.",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("no connected browser host returns a retryable browser_no_host error", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
|
||||
await expect(broker.execute({ command: snapshotCommand() })).resolves.toEqual({
|
||||
requestId: "req-1",
|
||||
@@ -111,7 +95,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("invalid browser requests return structured failures without contacting a host", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const client = new FakeBrowserHostClient("host-1");
|
||||
broker.registerClient(client);
|
||||
|
||||
@@ -136,7 +120,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("capable browser host receives request and returns response", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const client = new FakeBrowserHostClient("host-1");
|
||||
broker.registerClient(client);
|
||||
|
||||
@@ -194,7 +178,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("single browser host receives snapshot requests", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const client = new FakeBrowserHostClient("host-1");
|
||||
broker.registerClient(client);
|
||||
|
||||
@@ -246,7 +230,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("new tabs target the most recently registered host and tab commands stay with that host", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const firstHost = new FakeBrowserHostClient("host-1");
|
||||
const recentHost = new FakeBrowserHostClient("host-2");
|
||||
broker.registerClient(firstHost);
|
||||
@@ -319,7 +303,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("list tabs aggregates all hosts and seeds browser id affinity", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const firstHost = new FakeBrowserHostClient("host-1");
|
||||
const secondHost = new FakeBrowserHostClient("host-2");
|
||||
broker.registerClient(firstHost);
|
||||
@@ -489,7 +473,7 @@ describe("BrowserToolsBroker", () => {
|
||||
: never
|
||||
: never;
|
||||
}>)("routes $name to the host that owns the browser id", async ({ command, result }) => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const other = new FakeBrowserHostClient("host-1");
|
||||
const owner = new FakeBrowserHostClient("host-2");
|
||||
broker.registerClient(other);
|
||||
@@ -531,7 +515,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("successful close_tab clears browser id host affinity", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const other = new FakeBrowserHostClient("host-1");
|
||||
const owner = new FakeBrowserHostClient("host-2");
|
||||
broker.registerClient(other);
|
||||
@@ -586,7 +570,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("failed list tabs aggregation does not seed browser id affinity", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const firstHost = new FakeBrowserHostClient("host-1");
|
||||
const secondHost = new FakeBrowserHostClient("host-2");
|
||||
broker.registerClient(firstHost);
|
||||
@@ -651,7 +635,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("unsupported commands are rejected before sending to the routed host", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const client = new FakeBrowserHostClient("host-1", {
|
||||
supportedCommands: ["list_tabs"],
|
||||
hostKind: "desktop app",
|
||||
@@ -700,7 +684,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("unregistering a host strands its browser ids instead of routing them to another host", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const owner = new FakeBrowserHostClient("host-1");
|
||||
const unregisterOwner = broker.registerClient(owner);
|
||||
|
||||
@@ -742,7 +726,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("reconnecting the same host reclaims its stranded browser ids", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const owner = new FakeBrowserHostClient("host-1");
|
||||
const unregisterOwner = broker.registerClient(owner);
|
||||
|
||||
@@ -803,7 +787,7 @@ describe("BrowserToolsBroker", () => {
|
||||
|
||||
test("timeout resolves browser_timeout and clears pending state", async () => {
|
||||
vi.useFakeTimers();
|
||||
const broker = createBroker({ enabled: true, timeoutMs: 50 });
|
||||
const broker = createBroker({ timeoutMs: 50 });
|
||||
broker.registerClient(new FakeBrowserHostClient("host-1"));
|
||||
|
||||
const resultPromise = broker.execute({ command: snapshotCommand() });
|
||||
@@ -824,7 +808,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("disconnect resolves retryable failure and clears pending request", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const client = new FakeBrowserHostClient("host-1");
|
||||
const unregister = broker.registerClient(client);
|
||||
|
||||
@@ -846,7 +830,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("replacing a host registration resolves pending requests from the old registration", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const oldHost = new FakeBrowserHostClient("host-1");
|
||||
const newHost = new FakeBrowserHostClient("host-1");
|
||||
broker.registerClient(oldHost);
|
||||
@@ -884,7 +868,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("browser host send failure resolves structured failure and clears pending request", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
broker.registerClient(new FailingBrowserHostClient());
|
||||
|
||||
await expect(broker.execute({ command: snapshotCommand() })).resolves.toEqual({
|
||||
@@ -900,7 +884,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("explicit browser failure response propagates typed error", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const client = new FakeBrowserHostClient("host-1");
|
||||
broker.registerClient(client);
|
||||
|
||||
@@ -929,7 +913,7 @@ describe("BrowserToolsBroker", () => {
|
||||
});
|
||||
|
||||
test("invalid browser response resolves a structured failure and clears pending state", async () => {
|
||||
const broker = createBroker({ enabled: true });
|
||||
const broker = createBroker();
|
||||
const client = new FakeBrowserHostClient("host-1");
|
||||
broker.registerClient(client);
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type BrowserAutomationExecuteResponse,
|
||||
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import { browserToolsFailure, type BrowserToolsResponsePayload } from "./errors.js";
|
||||
import type { BrowserToolsPolicy } from "./policy.js";
|
||||
|
||||
export interface BrowserHostClient {
|
||||
id: string;
|
||||
@@ -40,7 +39,6 @@ interface RegisteredBrowserHost {
|
||||
}
|
||||
|
||||
export interface BrowserToolsBrokerOptions {
|
||||
policy: BrowserToolsPolicy;
|
||||
defaultTimeoutMs?: number;
|
||||
createRequestId?: () => string;
|
||||
}
|
||||
@@ -48,7 +46,6 @@ export interface BrowserToolsBrokerOptions {
|
||||
const DEFAULT_BROWSER_TOOLS_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class BrowserToolsBroker {
|
||||
private readonly policy: BrowserToolsPolicy;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private readonly createRequestId: () => string;
|
||||
private readonly clients = new Map<string, RegisteredBrowserHost>();
|
||||
@@ -58,7 +55,6 @@ export class BrowserToolsBroker {
|
||||
private registrationSequence = 0;
|
||||
|
||||
public constructor(options: BrowserToolsBrokerOptions) {
|
||||
this.policy = options.policy;
|
||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_BROWSER_TOOLS_TIMEOUT_MS;
|
||||
this.createRequestId = options.createRequestId ?? (() => `browser_${randomUUID()}`);
|
||||
}
|
||||
@@ -117,14 +113,6 @@ export class BrowserToolsBroker {
|
||||
public async execute(input: BrowserToolsExecuteInput): Promise<BrowserToolsResponsePayload> {
|
||||
const requestId = input.requestId ?? this.createRequestId();
|
||||
|
||||
if (!this.policy.isEnabled()) {
|
||||
return browserToolsFailure({
|
||||
requestId,
|
||||
code: "browser_disabled",
|
||||
message: "Browser tools are disabled. Enable daemon.browserTools.enabled to use them.",
|
||||
});
|
||||
}
|
||||
|
||||
const request = BrowserAutomationExecuteRequestSchema.safeParse({
|
||||
type: "browser.automation.execute.request",
|
||||
requestId,
|
||||
|
||||
@@ -4,14 +4,6 @@ export interface BrowserToolsPolicy {
|
||||
isEnabled(): boolean;
|
||||
}
|
||||
|
||||
export class StaticBrowserToolsPolicy implements BrowserToolsPolicy {
|
||||
public constructor(private readonly enabled: boolean) {}
|
||||
|
||||
public isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
export class DaemonConfigBrowserToolsPolicy implements BrowserToolsPolicy {
|
||||
public constructor(private readonly configStore: Pick<DaemonConfigStore, "get">) {}
|
||||
|
||||
|
||||
@@ -472,27 +472,6 @@ const routedToolCases = [
|
||||
}>;
|
||||
|
||||
const brokerErrorCases = [
|
||||
{
|
||||
name: "disabled browser tools",
|
||||
toolName: "browser_list_tabs",
|
||||
input: {},
|
||||
payload: {
|
||||
requestId: "req-disabled",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_disabled",
|
||||
message: "Browser tools are disabled. Enable daemon.browserTools.enabled to use them.",
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Browser tools are disabled. Enable browser tools on the host, then try again.",
|
||||
},
|
||||
],
|
||||
context: { agentId: "agent-1", cwd: "/repo", workspaceId: "wks_workspace_a" },
|
||||
},
|
||||
{
|
||||
name: "typed timeout errors",
|
||||
toolName: "browser_snapshot",
|
||||
|
||||
@@ -14,7 +14,11 @@ import type {
|
||||
AgentTimelineItem,
|
||||
AgentProvider,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { execCommand, platformShell } from "../utils/spawn.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnvOverlay,
|
||||
} from "../utils/string-command-shell.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
import type {
|
||||
ProviderSnapshotManager,
|
||||
ResolvedProviderCreateConfig,
|
||||
@@ -266,10 +270,15 @@ async function runVerifyCheck(options: {
|
||||
}): Promise<LoopVerifyCheckResult> {
|
||||
const startedAt = nowIso();
|
||||
try {
|
||||
const shell = platformShell();
|
||||
const result = await execCommand(shell.command, [...shell.flag, options.command], {
|
||||
const shell = buildStringCommandShellInvocation({
|
||||
command: options.command,
|
||||
windowsShell: "cmd",
|
||||
});
|
||||
const result = await execCommand(shell.shell, shell.args, {
|
||||
cwd: options.cwd,
|
||||
envOverlay: createStringCommandShellEnvOverlay(),
|
||||
maxBuffer: MAX_VERIFY_OUTPUT_BYTES,
|
||||
shell: false,
|
||||
});
|
||||
return {
|
||||
command: options.command,
|
||||
|
||||
@@ -6,6 +6,29 @@ import { describe, expect, test } from "vitest";
|
||||
import { acquirePidLock, getPidLockInfo, releasePidLock, updatePidLock } from "./pid-lock.js";
|
||||
|
||||
describe("pid-lock ownership", () => {
|
||||
test("records executable identity without writing desktop management intent", async () => {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-exec-"));
|
||||
const previousDesktopManaged = process.env.PASEO_DESKTOP_MANAGED;
|
||||
|
||||
try {
|
||||
process.env.PASEO_DESKTOP_MANAGED = "1";
|
||||
|
||||
await acquirePidLock(paseoHome, null, { ownerPid: process.pid });
|
||||
|
||||
const lock = await getPidLockInfo(paseoHome);
|
||||
expect(lock?.executablePath).toBe(process.execPath);
|
||||
expect(lock?.desktopManaged).toBeUndefined();
|
||||
} finally {
|
||||
if (previousDesktopManaged === undefined) {
|
||||
delete process.env.PASEO_DESKTOP_MANAGED;
|
||||
} else {
|
||||
process.env.PASEO_DESKTOP_MANAGED = previousDesktopManaged;
|
||||
}
|
||||
await releasePidLock(paseoHome, { ownerPid: process.pid });
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("writes and releases lock for explicit owner pid", async () => {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-"));
|
||||
const ownerPid = process.pid + 10_000;
|
||||
@@ -22,6 +45,7 @@ describe("pid-lock ownership", () => {
|
||||
const lock = await getPidLockInfo(paseoHome);
|
||||
expect(lock?.pid).toBe(ownerPid);
|
||||
expect(lock?.listen).toBeNull();
|
||||
expect(lock?.executablePath).toBe(process.execPath);
|
||||
|
||||
await (
|
||||
updatePidLock as unknown as (
|
||||
|
||||
@@ -10,6 +10,8 @@ export const pidLockInfoSchema = z.object({
|
||||
hostname: z.string(),
|
||||
uid: z.number(),
|
||||
listen: z.string().nullable(),
|
||||
executablePath: z.string().optional(),
|
||||
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
|
||||
desktopManaged: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -99,7 +101,7 @@ export async function acquirePidLock(
|
||||
hostname: hostname(),
|
||||
uid: process.getuid?.() ?? 0,
|
||||
listen,
|
||||
...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}),
|
||||
executablePath: process.execPath,
|
||||
};
|
||||
|
||||
let fd;
|
||||
|
||||
@@ -14,7 +14,6 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||
import { StaticBrowserToolsPolicy } from "./browser-tools/policy.js";
|
||||
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import type { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
@@ -256,7 +255,6 @@ async function startBrowserToolsDaemonHarness(): Promise<BrowserToolsDaemonHarne
|
||||
|
||||
function createBroker(): BrowserToolsBroker {
|
||||
return new BrowserToolsBroker({
|
||||
policy: new StaticBrowserToolsPolicy(true),
|
||||
defaultTimeoutMs: 500,
|
||||
createRequestId: createRequestIdSequence(),
|
||||
});
|
||||
|
||||
@@ -113,19 +113,3 @@ export async function execCommand(
|
||||
windowsHide: true,
|
||||
}) as Promise<ExecCommandResult>;
|
||||
}
|
||||
|
||||
export function platformShell(): { command: string; flag: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
return { command: "cmd.exe", flag: ["/c"] };
|
||||
}
|
||||
|
||||
return { command: "/bin/sh", flag: ["-lc"] };
|
||||
}
|
||||
|
||||
export function platformBash(): { command: string; flag: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
return { command: "cmd.exe", flag: ["/c"] };
|
||||
}
|
||||
|
||||
return { command: "/bin/bash", flag: ["-lc"] };
|
||||
}
|
||||
|
||||
@@ -1,21 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnv,
|
||||
} from "./string-command-shell.js";
|
||||
|
||||
function hasBashOnPath(): boolean {
|
||||
const result = spawnSync("bash", ["-c", "true"], { stdio: "ignore" });
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
describe("buildStringCommandShellInvocation", () => {
|
||||
it("uses bash login-command semantics on unix platforms", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it("uses bash script semantics on unix platforms", () => {
|
||||
expect(
|
||||
buildStringCommandShellInvocation({
|
||||
command: 'echo "hello"',
|
||||
platform: "darwin",
|
||||
}),
|
||||
).toEqual({
|
||||
shell: "/bin/bash",
|
||||
args: ["-lc", 'echo "hello"'],
|
||||
shell: "bash",
|
||||
args: ["-c", 'echo "hello"'],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses powershell command semantics on windows", () => {
|
||||
it.skipIf(process.platform === "win32" || !hasBashOnPath())(
|
||||
"preserves the supplied PATH when login profiles rewrite it",
|
||||
() => {
|
||||
const home = mkdtempSync(join(tmpdir(), "paseo-shell-home-"));
|
||||
tempDirs.push(home);
|
||||
const binDir = join(home, "bin");
|
||||
mkdirSync(binDir);
|
||||
|
||||
const shimPath = join(binDir, "paseo-shim");
|
||||
writeFileSync(shimPath, "#!/bin/sh\nprintf 'shim:%s\\n' \"$1\"\n");
|
||||
chmodSync(shimPath, 0o755);
|
||||
writeFileSync(join(home, ".bash_profile"), "export PATH=/usr/bin:/bin\n");
|
||||
const bashEnvPath = join(home, "bash-env");
|
||||
writeFileSync(bashEnvPath, "export PATH=/usr/bin:/bin\n");
|
||||
|
||||
const invocation = buildStringCommandShellInvocation({
|
||||
command: "command -v paseo-shim >/dev/null && paseo-shim ok",
|
||||
});
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
PATH: `${binDir}${delimiter}${process.env.PATH ?? "/usr/bin:/bin"}`,
|
||||
BASH_ENV: bashEnvPath,
|
||||
};
|
||||
|
||||
const stdout = execFileSync(invocation.shell, invocation.args, {
|
||||
encoding: "utf8",
|
||||
env: createStringCommandShellEnv(env),
|
||||
});
|
||||
|
||||
expect(stdout.trim()).toBe("shim:ok");
|
||||
},
|
||||
);
|
||||
|
||||
it("uses powershell command semantics on windows by default", () => {
|
||||
expect(
|
||||
buildStringCommandShellInvocation({
|
||||
command: "Write-Output 'hello'",
|
||||
@@ -33,4 +88,17 @@ describe("buildStringCommandShellInvocation", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("can preserve cmd command semantics on windows", () => {
|
||||
expect(
|
||||
buildStringCommandShellInvocation({
|
||||
command: "echo %TEMP% && echo ok",
|
||||
platform: "win32",
|
||||
windowsShell: "cmd",
|
||||
}),
|
||||
).toEqual({
|
||||
shell: "cmd.exe",
|
||||
args: ["/c", "echo %TEMP% && echo ok"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface BuildStringCommandShellInvocationOptions {
|
||||
command: string;
|
||||
platform?: NodeJS.Platform;
|
||||
windowsShell?: "powershell" | "cmd";
|
||||
}
|
||||
|
||||
export interface StringCommandShellInvocation {
|
||||
@@ -8,12 +9,31 @@ export interface StringCommandShellInvocation {
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export function createStringCommandShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const sanitized = { ...env };
|
||||
delete sanitized.BASH_ENV;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function createStringCommandShellEnvOverlay(): Record<string, string | undefined> {
|
||||
return { BASH_ENV: undefined };
|
||||
}
|
||||
|
||||
export function buildStringCommandShellInvocation(
|
||||
options: BuildStringCommandShellInvocationOptions,
|
||||
): StringCommandShellInvocation {
|
||||
const platform = options.platform ?? process.platform;
|
||||
|
||||
// Project-authored command strings use a stable script shell. The caller supplies
|
||||
// the environment; shell startup files should not rewrite it behind our back.
|
||||
if (platform === "win32") {
|
||||
if (options.windowsShell === "cmd") {
|
||||
return {
|
||||
shell: "cmd.exe",
|
||||
args: ["/c", options.command],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shell: "powershell",
|
||||
args: [
|
||||
@@ -28,7 +48,7 @@ export function buildStringCommandShellInvocation(
|
||||
}
|
||||
|
||||
return {
|
||||
shell: "/bin/bash",
|
||||
args: ["-lc", options.command],
|
||||
shell: "bash",
|
||||
args: ["-c", options.command],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { type ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn());
|
||||
const spawnProcessMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("child_process")>("child_process");
|
||||
@@ -13,11 +16,34 @@ vi.mock("child_process", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./spawn.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./spawn.js")>("./spawn.js");
|
||||
return {
|
||||
...actual,
|
||||
spawnProcess: spawnProcessMock,
|
||||
};
|
||||
});
|
||||
|
||||
function emitSuccessfulClose(child: ChildProcess): void {
|
||||
child.emit("close", 0);
|
||||
}
|
||||
|
||||
function createSpawnChildStub(): ChildProcess {
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
Object.assign(child, {
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
});
|
||||
queueMicrotask(() => emitSuccessfulClose(child));
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("worktree shell selection", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset();
|
||||
spawnProcessMock.mockReset();
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
@@ -29,6 +55,7 @@ describe("worktree shell selection", () => {
|
||||
return {};
|
||||
},
|
||||
);
|
||||
spawnProcessMock.mockImplementation(createSpawnChildStub);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -46,6 +73,8 @@ describe("worktree shell selection", () => {
|
||||
});
|
||||
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-"));
|
||||
const originalBashEnv = process.env.BASH_ENV;
|
||||
process.env.BASH_ENV = "should-not-leak";
|
||||
try {
|
||||
mkdirSync(join(worktreePath, ".git"), { recursive: true });
|
||||
writeFileSync(
|
||||
@@ -79,7 +108,74 @@ describe("worktree shell selection", () => {
|
||||
expect.objectContaining({ cwd: worktreePath }),
|
||||
expect.any(Function),
|
||||
);
|
||||
const execOptions = execFileMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv };
|
||||
expect(execOptions.env?.BASH_ENV).toBeUndefined();
|
||||
} finally {
|
||||
if (originalBashEnv === undefined) {
|
||||
delete process.env.BASH_ENV;
|
||||
} else {
|
||||
process.env.BASH_ENV = originalBashEnv;
|
||||
}
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("routes streamed setup command execution through powershell on win32", async () => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-"));
|
||||
const originalBashEnv = process.env.BASH_ENV;
|
||||
process.env.BASH_ENV = "should-not-leak";
|
||||
try {
|
||||
writeFileSync(
|
||||
join(worktreePath, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ["Write-Output 'setup'"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const { runWorktreeSetupCommands } = await import("./worktree.js");
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath,
|
||||
branchName: "main",
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv: {
|
||||
PASEO_SOURCE_CHECKOUT_PATH: worktreePath,
|
||||
PASEO_ROOT_PATH: worktreePath,
|
||||
PASEO_WORKTREE_PATH: worktreePath,
|
||||
PASEO_BRANCH_NAME: "main",
|
||||
PASEO_WORKTREE_PORT: "12345",
|
||||
},
|
||||
onEvent: () => {},
|
||||
});
|
||||
|
||||
expect(spawnProcessMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnProcessMock).toHaveBeenCalledWith(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"Write-Output 'setup'",
|
||||
],
|
||||
expect.objectContaining({ cwd: worktreePath, shell: false }),
|
||||
);
|
||||
const spawnOptions = spawnProcessMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv };
|
||||
expect(spawnOptions.env?.BASH_ENV).toBeUndefined();
|
||||
} finally {
|
||||
if (originalBashEnv === undefined) {
|
||||
delete process.env.BASH_ENV;
|
||||
} else {
|
||||
process.env.BASH_ENV = originalBashEnv;
|
||||
}
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,8 +33,9 @@ import {
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
chmodSync,
|
||||
} from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { delimiter, dirname, join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import net from "node:net";
|
||||
|
||||
@@ -637,6 +638,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("runs setup commands with the daemon PATH instead of login profile PATH", async () => {
|
||||
const home = join(tempDir, "host-home");
|
||||
const binDir = join(tempDir, "daemon-bin");
|
||||
mkdirSync(home);
|
||||
mkdirSync(binDir);
|
||||
|
||||
const shimPath = join(binDir, "paseo-shim");
|
||||
writeFileSync(shimPath, "#!/bin/sh\nprintf 'shim:%s\\n' \"$1\"\n");
|
||||
chmodSync(shimPath, 0o755);
|
||||
writeFileSync(join(home, ".bash_profile"), "export PATH=/usr/bin:/bin\n");
|
||||
const bashEnvPath = join(home, "bash-env");
|
||||
writeFileSync(bashEnvPath, "export PATH=/usr/bin:/bin\n");
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: "command -v paseo-shim >/dev/null && paseo-shim ok > setup-path.log",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const originalHome = process.env.HOME;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalBashEnv = process.env.BASH_ENV;
|
||||
process.env.HOME = home;
|
||||
process.env.PATH = `${binDir}${delimiter}${originalPath ?? "/usr/bin:/bin"}`;
|
||||
process.env.BASH_ENV = bashEnvPath;
|
||||
|
||||
try {
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath: repoDir,
|
||||
branchName: "main",
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv: {
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoDir,
|
||||
PASEO_ROOT_PATH: repoDir,
|
||||
PASEO_WORKTREE_PATH: repoDir,
|
||||
PASEO_BRANCH_NAME: "main",
|
||||
PASEO_WORKTREE_PORT: "12345",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
if (originalBashEnv === undefined) {
|
||||
delete process.env.BASH_ENV;
|
||||
} else {
|
||||
process.env.BASH_ENV = originalBashEnv;
|
||||
}
|
||||
}
|
||||
|
||||
expect(readFileSync(join(repoDir, "setup-path.log"), "utf8").trim()).toBe("shim:ok");
|
||||
});
|
||||
|
||||
it("treats blank lifecycle strings as empty", () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
|
||||
@@ -6,7 +6,10 @@ import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnv,
|
||||
} from "./string-command-shell.js";
|
||||
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
|
||||
export {
|
||||
PaseoConfigRawSchema,
|
||||
@@ -489,6 +492,7 @@ async function execSetupCommandStreamed(options: {
|
||||
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -607,7 +611,7 @@ export async function runWorktreeSetupCommands(options: {
|
||||
branchName: options.branchName,
|
||||
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
|
||||
}));
|
||||
const setupEnv = createExternalProcessEnv(process.env, runtimeEnv);
|
||||
const setupEnv = createStringCommandShellEnv(createExternalProcessEnv(process.env, runtimeEnv));
|
||||
|
||||
const results: WorktreeSetupCommandResult[] = [];
|
||||
for (const [index, cmd] of setupCommands.entries()) {
|
||||
@@ -715,17 +719,19 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
|
||||
const teardownEnv: NodeJS.ProcessEnv = createExternalProcessEnv(process.env, {
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
|
||||
// from the source checkout.
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||
// Backward-compatible alias.
|
||||
PASEO_ROOT_PATH: repoRootPath,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: branchName,
|
||||
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
|
||||
});
|
||||
const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv(
|
||||
createExternalProcessEnv(process.env, {
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
|
||||
// from the source checkout.
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||
// Backward-compatible alias.
|
||||
PASEO_ROOT_PATH: repoRootPath,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: branchName,
|
||||
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
const results: WorktreeTeardownCommandResult[] = [];
|
||||
for (const cmd of teardownCommands) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.104-beta.3",
|
||||
"version": "0.1.104-beta.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -56,7 +56,7 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in
|
||||
- [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/users/overview), Alibaba's Qwen coding assistant.
|
||||
- [siGit Code](https://github.com/getsigit/sigit), local-first coding agent with optional on-device LLM.
|
||||
- [Stakpak](https://stakpak.dev/), Rust-based DevOps agent.
|
||||
- [TRAE CLI](https://docs.trae.cn/cli), ByteDance's official TRAE coding agent.
|
||||
- [TRAE CLI](https://docs.trae.cn/cli_get-started-with-trae-cli), ByteDance's official TRAE coding agent.
|
||||
- [VT Code](https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md), open-source multi-provider coding agent.
|
||||
|
||||
The in-app catalog is the canonical, version-pinned source. Anything not listed here can still be added manually, see [Custom providers](/docs/custom-providers).
|
||||
|
||||
Reference in New Issue
Block a user