diff --git a/.changeset/host-backend-exec.md b/.changeset/host-backend-exec.md new file mode 100644 index 000000000..8b914dbd6 --- /dev/null +++ b/.changeset/host-backend-exec.md @@ -0,0 +1,15 @@ +--- +"@schlessera/brain-ui-sdk": minor +"@schlessera/brain-backend-claude": minor +"@schlessera/brain-backend-pi": minor +"@schlessera/brain-ui-server": minor +--- + +Pass each app's resolved exec wrapper through backend construction, runtime +probes, Claude SDK workers and Pi tools. Explicit empty host configuration +disables the optional wrapper; standalone callers omitting it retain the +ambient fallback at the existing turn and tool-spawn boundaries. +Pi workers see each configured helper file, and only that file, in ordinary and +restricted views; a wrapper or killer that is not a regular file is refused +before any relay or worker starts, as is a nonregular wrapper for an ordinary +Claude worker. diff --git a/api-report/ui-backend-claude.txt b/api-report/ui-backend-claude.txt index 8eced7cf5..3f3a41aa6 100644 --- a/api-report/ui-backend-claude.txt +++ b/api-report/ui-backend-claude.txt @@ -45,6 +45,7 @@ signatures (every public export and the types it is made of) } ClaudeBackendOptions (packages/ui-backend-claude/src/options.ts) export interface ClaudeBackendOptions { + exec?: import("@schlessera/brain-ui-sdk/server").ExecWrapperConfig; maintenance?: import("@schlessera/brain-ui-sdk/internal").MaintenanceHost; versionRequirements?: BackendVersionRequirements; brainPath: string; diff --git a/api-report/ui-backend-pi.txt b/api-report/ui-backend-pi.txt index ff4b89370..22e7ed635 100644 --- a/api-report/ui-backend-pi.txt +++ b/api-report/ui-backend-pi.txt @@ -31,6 +31,7 @@ signatures (every public export and the types it is made of) export type BackendLogFn = (level: "debug" | "info" | "warn" | "error", message: string, attrs?: Record) => void; CreatePiBackendOptions (packages/ui-backend-pi/src/backend-options.ts) export interface CreatePiBackendOptions { + exec?: import("@schlessera/brain-ui-sdk/server").ExecWrapperConfig; maintenance?: import("@schlessera/brain-ui-sdk/internal").MaintenanceHost; versionRequirements?: BackendVersionRequirements; brainPath: string; diff --git a/api-report/ui-sdk.txt b/api-report/ui-sdk.txt index f2e9b09e1..3d49a3677 100644 --- a/api-report/ui-sdk.txt +++ b/api-report/ui-sdk.txt @@ -1149,6 +1149,7 @@ export "./internal" (./src/internal.ts) webSearchProvider workerCleanupUnconfirmed workerCommandPolicyNote + workerExecHelpers workerHostBoundary workerHostProven workerIdentityHelper @@ -5108,6 +5109,7 @@ signatures (every public export and the types it is made of) } BackendModuleContext (packages/ui-sdk/src/server/backend-module.ts) export interface BackendModuleContext { + exec?: import("./exec-wrapper.js").ExecWrapperConfig; maintenance?: import("./maintenance-worker.js").MaintenanceHost; brainPath: string; config: Readonly>; diff --git a/docs/decisions/async-collaboration.md b/docs/decisions/async-collaboration.md index a51aaa70d..0455fd17e 100644 --- a/docs/decisions/async-collaboration.md +++ b/docs/decisions/async-collaboration.md @@ -83,7 +83,7 @@ and configuration audience; the old plan's “full host environment” descripti is historical. Preserve subscription billing and the selected runtime identity. Pi disables built-in tools but currently loads resources and extensions -(`createSessionResources`, `packages/ui-backend-pi/src/session-resources.ts:29-144`) +(`createSessionResources`, `packages/ui-backend-pi/src/session-resources.ts:29-145`) and gates extension calls (`createPermissionGate`, `packages/ui-backend-pi/src/permission-gate.ts:95-124`). Removing four curated tools cannot prove that extensions, MCP, scratch writers or in-process code have diff --git a/docs/decisions/claude-code-runtime.md b/docs/decisions/claude-code-runtime.md index cc6168238..a5726437a 100644 --- a/docs/decisions/claude-code-runtime.md +++ b/docs/decisions/claude-code-runtime.md @@ -44,7 +44,7 @@ next to it. `packages/ui-server/src/config/env.ts:506-506`) and is null when unset (`claudeCodePath`, `packages/ui-server/src/config/env.ts:1075`). The whole `agent` block is copied into the backend's module config (`config: { ...agent }`, - `packages/ui-server/src/agent/backend.ts:500`), read back as a string + `packages/ui-server/src/agent/backend.ts:505`), read back as a string (`const claudeCodePath`, `packages/ui-backend-claude/src/module.ts:266-270`) and handed to the SDK (`backend.claudeCodePath`, `packages/ui-backend-claude/src/sdk-options.ts:351`). Because of the `||` @@ -59,7 +59,7 @@ next to it. spawned it directly at this baseline. #1037 subsequently made worker execution unconditional; #1808 retired this unused adapter. The optional exec wrapper now runs inside the worker (`createWorkerSpawn`, - `packages/ui-backend-claude/src/worker-spawn.ts:86-291`). + `packages/ui-backend-claude/src/worker-spawn.ts:86-293`). - **Updated.** Nothing in the tree installs, updates, pins or reads the version of this binary. The only version probe in the server is for the `brain` CLI (`Probe the selected content CLI`, diff --git a/docs/decisions/design-kit/answer-blocks.md b/docs/decisions/design-kit/answer-blocks.md index 337141d4a..fad124073 100644 --- a/docs/decisions/design-kit/answer-blocks.md +++ b/docs/decisions/design-kit/answer-blocks.md @@ -434,7 +434,7 @@ turns the complete roster the model reached for was `bash`, `show_block`, `brain_read`, `grep`, `brain_search`, `read_file`, `brain_list`, `brain_graph`: no search-then-load round trip, ever. The brief is in the prompt on every turn unconditionally (`block: "show_block"`, -`packages/ui-backend-pi/src/session-resources.ts:200`, not behind a capability +`packages/ui-backend-pi/src/session-resources.ts:201`, not behind a capability check like the four bridge tools beside it). So pi is the structural twin of D43's `--always-load` arm and has never run any other configuration. diff --git a/docs/decisions/design-kit/show-block-brief.md b/docs/decisions/design-kit/show-block-brief.md index f362f7313..3158a3630 100644 --- a/docs/decisions/design-kit/show-block-brief.md +++ b/docs/decisions/design-kit/show-block-brief.md @@ -204,7 +204,7 @@ is in the prompt on every pi turn by construction. That makes pi's shipping configuration the structural equivalent of this record's `--always-load` **brief** arm — and only that one. pi has no no-brief arm and no supported way to have one: `block: "show_block"`, -`packages/ui-backend-pi/src/session-resources.ts:200` passes the +`packages/ui-backend-pi/src/session-resources.ts:201` passes the block brief unconditionally, where the four lines above it gate their briefs on a capability. So pi can corroborate the loaded *rate* and can say nothing at all about whether the brief matters; the 77%/77% here and #148's 76%/77% are diff --git a/docs/decisions/design-kit/tool-loading.md b/docs/decisions/design-kit/tool-loading.md index 444480f7a..201ec4cef 100644 --- a/docs/decisions/design-kit/tool-loading.md +++ b/docs/decisions/design-kit/tool-loading.md @@ -275,7 +275,7 @@ Two things stop that residue being read as a like-for-like gap, and both cut against reading pi as a second replication of the brief result. pi is on a different harness driving the model directly, and — this is the one that matters — **pi has no no-brief arm and no supported way to have one.** -`block: "show_block"`, `packages/ui-backend-pi/src/session-resources.ts:200` +`block: "show_block"`, `packages/ui-backend-pi/src/session-resources.ts:201` passes the block brief into `buildSystemPromptAppend` unconditionally, not behind a capability check like `askUser`, `location`, `activity` and `mask` on the lines above it. So every pi number was measured with the brief present. The diff --git a/docs/decisions/governed-writable-workers.md b/docs/decisions/governed-writable-workers.md index 9a59eb737..7a2869a32 100644 --- a/docs/decisions/governed-writable-workers.md +++ b/docs/decisions/governed-writable-workers.md @@ -1078,7 +1078,7 @@ classification: `Write`/`Edit`/`NotebookEdit` disallow is lifted (`disallowedTools`, `packages/ui-backend-claude/src/sdk-options.ts:312`). In pi, the direct tools and a `bash` whose working directory is the brain root - return (`const bash`, `packages/ui-backend-pi/src/tools.ts:541`). + return (`const bash`, `packages/ui-backend-pi/src/tools.ts:543`). - **Approvals do not change.** Confirmation patterns and destructive-operation approvals still run in the permission hooks. The journal adds the ability to undo; it removes no approval. @@ -1710,11 +1710,11 @@ helper the host provides: - **brain-kit owns the launcher seam** (#1677). An optional host-supplied identity helper sits in front of `bwrap`. It follows the exec wrapper's - rules (`export const EXEC_WRAPPER_ENV`, `packages/ui-sdk/src/server/exec-wrapper.ts:39`): + rules (`export const EXEC_WRAPPER_ENV`, `packages/ui-sdk/src/server/exec-wrapper.ts:42`): an absolute path in argv position 0, and the worker as its own process group. `kill(2)` across uids returns `EPERM`, so every signal goes through the kill helper. The helper's protocol - (` `, `packages/ui-sdk/src/server/exec-wrapper.ts:53`) + (` `, `packages/ui-sdk/src/server/exec-wrapper.ts:56`) gains `STOP` and `CONT`. A scope cap without a delegated cgroup stops and resumes the worker through them (see "Scope caps"). Signalling the process group is not enough. `bwrap --new-session` puts the payload in a session and @@ -2178,7 +2178,7 @@ is now consumer-qualified independently of reference classification (#2028). The This mapping was checked against core's (`loadModules`, `packages/core/src/lib/module-loader.ts:95-103`), (`importManifest`, `packages/core/src/lib/module-loader.ts:192-224`), Pi's (`build`, -`packages/ui-backend-pi/src/session-resources.ts:85-129`) and the installed +`packages/ui-backend-pi/src/session-resources.ts:86-130`) and the installed Pi 0.99.2 package-manager/settings consumers and Claude SDK 0.3.293 settings schema. Nothing imports or evaluates repository configuration on the host. Followed JSON resources do not acquire configuration-consumer semantics merely diff --git a/docs/decisions/http-api-boundary.md b/docs/decisions/http-api-boundary.md index f176aa6bc..f159eb434 100644 --- a/docs/decisions/http-api-boundary.md +++ b/docs/decisions/http-api-boundary.md @@ -46,11 +46,11 @@ explicit supported rows, not silently excluded by this distinction. The source audit on 2026-09-30 used `e0875269b21b707482545be9e94901acb897893b`. The public health mount precedes auth -(`createHealthRoutes`, `packages/ui-server/src/app.ts:770`), the API guard follows -public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:791`), and the -WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:973`). +(`createHealthRoutes`, `packages/ui-server/src/app.ts:771`), the API guard follows +public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:792`), and the +WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:974`). Static serving is conditional (`if (options.staticRoot)`, -`packages/ui-server/src/app.ts:993-1014`). That order establishes access; +`packages/ui-server/src/app.ts:994-1015`). That order establishes access; it does not select compatibility status. A hermetic real-app launch, with temporary brain root, in-memory database, diff --git a/docs/decisions/module-mcp-tools.md b/docs/decisions/module-mcp-tools.md index 47133e8d0..835f0206a 100644 --- a/docs/decisions/module-mcp-tools.md +++ b/docs/decisions/module-mcp-tools.md @@ -413,7 +413,7 @@ failed. - **The pi backend.** It does not use `brain mcp`. It builds its brain tools in-process with its own names (`export const TOOL_RISK`, - `packages/ui-backend-pi/src/tools.ts:223`), and it keeps `bash`, so + `packages/ui-backend-pi/src/tools.ts:225`), and it keeps `bash`, so `brain jobs review` is reachable there already. Serving module tools on pi would need collision rules against pi's flat namespace (`read_file`, `ask_user` and `web_search` all parse as `_`). It needs its diff --git a/docs/decisions/pi-endpoint-routing.md b/docs/decisions/pi-endpoint-routing.md index b4a559bab..a968c9d09 100644 --- a/docs/decisions/pi-endpoint-routing.md +++ b/docs/decisions/pi-endpoint-routing.md @@ -106,7 +106,7 @@ The concrete adapter integration points are: | Declared-model validation (`export function toModel`, `packages/ui-backend-pi/src/profiles.ts:57-77`) | Keep built-in validation and identity refusal, then select the configured model from the inference runtime. The current function returns the raw catalog object. | | Ordinary/autonomous new sessions (`async newSession`, `packages/ui-backend-pi/src/native-session-runtime.ts:41-72`) | Supply the native configured runtime and its selected model to the real `createAgentSession` call. The worker uses `SessionManager.inMemory`; the server persists ordinary JSONL over pipes, and autonomous turns remain nonpersistent. | | Ordinary persisted resumes (`async openSession`, `packages/ui-backend-pi/src/native-session-runtime.ts:74-109`) | Supply the configured runtime without a new model override; reject/dispose if `modelFallbackMessage` is present. | -| Native resources (`const agentDir = getAgentDir()`, `packages/ui-backend-pi/src/session-resources.ts:89-128`) | Use the same native agent-directory discovery as settings/resources. Preserve loader failure behavior and its inline permission gate. | +| Native resources (`const agentDir = getAgentDir()`, `packages/ui-backend-pi/src/session-resources.ts:90-129`) | Use the same native agent-directory discovery as settings/resources. Preserve loader failure behavior and its inline permission gate. | Runtime construction belongs at these concrete session boundaries. No runtime factory option, new public seam or authentication wrapper is needed. Each hosted turn reconstructs its native inference runtime inside a fresh diff --git a/docs/decisions/policy-write-boundary.md b/docs/decisions/policy-write-boundary.md index 5dc3445f2..15826001f 100644 --- a/docs/decisions/policy-write-boundary.md +++ b/docs/decisions/policy-write-boundary.md @@ -45,7 +45,7 @@ This is a change to the execution architecture. Current Claude assembly creates SDK options and in-process bridge tools (`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:96-472`); pi builds its worker-local resource loader and calls `reload()` (`createSessionResources`, -`packages/ui-backend-pi/src/session-resources.ts:29-113`). A child-only wrapper +`packages/ui-backend-pi/src/session-resources.ts:29-114`). A child-only wrapper cannot be treated as proof for those parent-side paths. The investigation records the concrete unsandboxed extension-initialization escape. diff --git a/docs/decisions/runtime-requirements.md b/docs/decisions/runtime-requirements.md index 906a74002..1628bebcf 100644 --- a/docs/decisions/runtime-requirements.md +++ b/docs/decisions/runtime-requirements.md @@ -274,8 +274,8 @@ reciprocal host-version negotiation is involved. | `probeBrainCliVersion` / `brainCliCommand` (`export async function probeBrainCliVersion`, `packages/ui-server/src/brain/client.ts:122-189`) | Reuse executable selection, wrapper, deadline and cleanup; add host floor composition, strict comparison and invocation verification without duplicating the 0.33.0 constant. | | `installedAgentSdkVersion` (`export function installedAgentSdkVersion`, `packages/ui-backend-claude/src/runtime-probe.ts:55-61`) | Retain importer-relative resolution; validate package name/version and the owner's manifest range. | | `selectedSpawn` / `probeClaudeRuntime` (`export function selectedSpawn`, `packages/ui-backend-claude/src/runtime-probe.ts:69-96`; `export async function probeClaudeRuntime`, `packages/ui-backend-claude/src/runtime-probe.ts:127-147`) | Keep SDK-selected command capture, native/JS override argv, environment, timeout and group cleanup; add strict identity validation and composed runtime floors. | -| `probeBackendRuntimes` (`export async function probeBackendRuntimes`, `packages/ui-server/src/agent/backend.ts:392-439`) | Forward validated host requirements; retain active-backend selection and separate unmeasured-pair logging. | -| `BackendModuleContext` / `BackendRuntimeReport` (`export interface BackendModuleContext`, `packages/ui-sdk/src/server/backend-module.ts:96-109`; `export interface BackendRuntimeReport`, `packages/ui-sdk/src/server/backend-module.ts:116-129`) | Extend the existing construction context for optional minimums. Keep report identities and `measured` meanings; a compatibility result must never occupy `measured.matches`. | +| `probeBackendRuntimes` (`export async function probeBackendRuntimes`, `packages/ui-server/src/agent/backend.ts:394-444`) | Forward validated host requirements; retain active-backend selection and separate unmeasured-pair logging. | +| `BackendModuleContext` / `BackendRuntimeReport` (`export interface BackendModuleContext`, `packages/ui-sdk/src/server/backend-module.ts:96-111`; `export interface BackendRuntimeReport`, `packages/ui-sdk/src/server/backend-module.ts:118-131`) | Extend the existing construction context for optional minimums. Keep report identities and `measured` meanings; a compatibility result must never occupy `measured.matches`. | | `MEASURED_RUNTIME`, its guard and `scripts/measure-claude-runtime.ts` | Keep the constant, exact pair guard, keyless behavior probe and dated measurement receipts. A permitted pair can still be unmeasured. | | Turn `reportRuntime` / `init` (`const reportRuntime`, `packages/ui-backend-claude/src/turn-runner.ts:169-176`; `if (msg.type === "system"`, `packages/ui-backend-claude/src/turn-runner.ts:587-602`) | Preserve actual per-turn SDK/runtime observations, billing/permission guards and normal failure terminal behavior. | | Index schema and graph floors | Remain reader-specific integer schema checks; do not compare them as SemVer or turn `brain.db` into authoritative state. | diff --git a/docs/decisions/session-principals.md b/docs/decisions/session-principals.md index 5ef373b87..50cdd9869 100644 --- a/docs/decisions/session-principals.md +++ b/docs/decisions/session-principals.md @@ -96,9 +96,9 @@ Both reviewers verified every row below against the source. | Cookie verify | `auth.ts:265-296` | strict `^(\d+)\.(\d+)$`, safe integers, future/expired rejection, `cookieEpoch === sessionsEpoch(db)` | | Global epoch | `auth.ts:302-318` | `settings` row; missing = 0; non-integer **throws** | | Global revoke | `auth.ts:321-330` | `setSetting` (overflow-guarded) + `clients.closeAll(1008, …)` | -| Auth routes | `authRoutes`, `app.ts:776` | mounted **before** the guard at `authGuard(authMode`, `app.ts:791`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:808`) | -| Passkey management | `passkeyManagementRoutes`, `app.ts:801` | mounted **after** the guard ("Mount AFTER the auth guard") | -| WS guard | `"/ws"`, `app.ts:973-990` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade | +| Auth routes | `authRoutes`, `app.ts:777` | mounted **before** the guard at `authGuard(authMode`, `app.ts:792`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:808`) | +| Passkey management | `passkeyManagementRoutes`, `app.ts:802` | mounted **after** the guard ("Mount AFTER the auth guard") | +| WS guard | `"/ws"`, `app.ts:974-991` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade | | WS upgrade | `ws/connection.ts:262-264` | `createWsUpgrade` ignores the request context | | WS admission | `ws/connection.ts:84` | `clients.add(ws)` — the socket's identity is unknown | | Turn record | `ws/run-session.ts:134-142` | recorder built from `{turnId, sessionId, billing}`; `RunningTurn` (`ws/turns.ts:55-77`) has no actor | diff --git a/docs/decisions/voice-permission.md b/docs/decisions/voice-permission.md index bcdcd65d6..faa1b923d 100644 --- a/docs/decisions/voice-permission.md +++ b/docs/decisions/voice-permission.md @@ -313,7 +313,7 @@ description is the SDK's own (`canUseTool` passes `description: opts.description` straight through to `createToolPermissionRequest`, `permission-hooks.ts:300-310`) and is not written to be heard. And `ClaudeBackendOptions.confirmBashPatterns` -(`Regex sources`, `packages/ui-backend-claude/src/options.ts:53-63`) +(`Regex sources`, `packages/ui-backend-claude/src/options.ts:55-65`) lets a deployment supply its own patterns, which have no phrase when given as bare regex sources (the `{ pattern, effect }` form carries one). Both fall back to the same payload-free @@ -486,7 +486,7 @@ The mechanism is the one the repo already has: a declared tool allowlist bound to a turn — `InferenceProfile.allowedTools` (`allowedTools?: string[]`, `packages/ui-backend-claude/src/profiles.ts:27`) and `ClaudeBackendOptions.allowedTools` (`Backend-wide tool allowlist`, -`options.ts:50-51`), resolved into the SDK's `allowedTools` per turn (the +`options.ts:52-53`), resolved into the SDK's `allowedTools` per turn (the `allowed` array, from `const allowed`, `sdk-options.ts:180`, and what it becomes, `allowedTools: allowed`, `:309`). The voice posture is one named entry in that mechanism. diff --git a/docs/integration-contract.md b/docs/integration-contract.md index a2e8eb7fd..dc2ebd9f6 100644 --- a/docs/integration-contract.md +++ b/docs/integration-contract.md @@ -113,6 +113,7 @@ Original fragment identities remain available at this entry point. - [Exec wrapper in app configuration (additive, #1363)](integration-contract/package-api.md#exec-wrapper-in-app-configuration-additive-1363) (original line 410). +- [Backend host exec configuration (additive, #1429)](integration-contract/package-api.md#backend-host-exec-configuration-additive-1429). diff --git a/docs/integration-contract/package-api.md b/docs/integration-contract/package-api.md index e29a7c5a8..4b22b58b9 100644 --- a/docs/integration-contract/package-api.md +++ b/docs/integration-contract/package-api.md @@ -3610,6 +3610,29 @@ terminal span returns false without modifying the row. The recorder persists this identity in both the queryable session column and the existing attributes. This additive API ships in a minor release. +## Backend host exec configuration (additive, #1429) + +`BackendModuleContext`, `ClaudeBackendOptions`, `CreatePiBackendOptions` and +`BackendRegistryOptions` accept optional `exec: ExecWrapperConfig`. +`probeBackendRuntimes` accepts the same optional configuration as its sixth +argument. The app passes its resolved `ServerConfig.exec` to startup probes +and backend construction. Both first-party backends use that host configuration +for runtime probes, Claude SDK workers and Pi grep/bash tools, including tools +inside Pi workers. An explicit empty object disables the optional wrapper; +an absent field preserves ambient-wrapper compatibility. Standalone backend +factories preserve omission, resolving the ambient wrapper and killer at the +existing turn/tool-spawn boundaries; Pi serializes that host fallback into each +worker turn. Explicit host configurations are copied at construction. + +A Pi worker's view gains each configured helper file read-only at its +configured path, and nothing else of the directory holding it, in ordinary and +restricted workers alike. Each helper a worker runs, the Pi wrapper and killer +and the ordinary Claude worker's wrapper, must be an absolute path naming a +regular file, directly or through a symlink; anything else refuses the turn +before any relay, reservation or worker is acquired. Existing argv, environment +and permission semantics remain unchanged; the optional wrapper never replaces +worker isolation. + ## Module metadata admission cancellation (additive, #2457) `BrainClient.modules({ signal })` accepts an optional abort signal. It cancels diff --git a/docs/plans/async-collaboration-requirements.md b/docs/plans/async-collaboration-requirements.md index 5c4f69f3e..7449e3306 100644 --- a/docs/plans/async-collaboration-requirements.md +++ b/docs/plans/async-collaboration-requirements.md @@ -226,7 +226,7 @@ Stated before the requirements because five of them derive from it. that is the shape to copy, including its `close()` lifecycle. - R19. **The cron backstop has independent authorization before the general guard.** Mount it on the existing listener before - (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:791`). + (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:792`). Authorize a boot-minted ephemeral token, rotated each boot and stored in a 0600 runtime file, with the actual socket address as an additional check. Proxy headers cannot authorize it. The poke succeeds in every auth mode diff --git a/docs/plans/async-collaboration.md b/docs/plans/async-collaboration.md index 436886fe4..d03ec1f66 100644 --- a/docs/plans/async-collaboration.md +++ b/docs/plans/async-collaboration.md @@ -131,7 +131,7 @@ system gate. It is not a prerequisite to rebuild or a new independent package. owns manual triggers/history; it does not tick the inbox. - **Two connections:** (`export function createUiDb(`, `packages/ui-server/src/db/client.ts:25-37`) sets WAL, foreign keys and a 5-second busy timeout. Claims are immediate transactions. -- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:791`) follows public routes; +- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:792`) follows public routes; (`export function authGuard(`, `packages/ui-server/src/middleware/auth.ts:189-249`) binds principals in each auth mode. An internal poke needs independent token authorization before this guard. - **Permission parking:** (`requestPermission: (req) => {`, `packages/ui-server/src/ws/bridge.ts:281-375`). Timeout unwind is diff --git a/docs/plans/host-owned-web-search.md b/docs/plans/host-owned-web-search.md index 0eb921956..3c5bbd701 100644 --- a/docs/plans/host-owned-web-search.md +++ b/docs/plans/host-owned-web-search.md @@ -216,7 +216,7 @@ runtime authority or a public plugin seam. Both candidates need them: Current source confirms why a bridge is needed: ordinary initialization copies only auth/models/settings (`const init`, -`packages/ui-backend-pi/src/worker-session.ts:117-119`), then selects private Pi +`packages/ui-backend-pi/src/worker-session.ts:121-123`), then selects private Pi state. The worker RPC schema has an existing bounded, grant-checked bridge but no search method (`export const piWorkerMessage`, `packages/ui-backend-pi/src/worker-protocol.ts:64-75`). Resource discovery remains diff --git a/docs/plans/navigation-recovery.md b/docs/plans/navigation-recovery.md index 5577225bc..570a1bda0 100644 --- a/docs/plans/navigation-recovery.md +++ b/docs/plans/navigation-recovery.md @@ -397,7 +397,7 @@ implementation. Any PR moving these lines owns their citation repairs. - Activity resolution: (`.get("/activity/runs", (c) => {`, `packages/ui-server/src/routes/activity.ts:143-281`). -- API authorization: (`app.use("/api/*", authGuard(authMode, auth, db));`, `packages/ui-server/src/app.ts:791`). +- API authorization: (`app.use("/api/*", authGuard(authMode, auth, db));`, `packages/ui-server/src/app.ts:792`). - Queued acceptance: (`// Queue it as the session's next turn; report queued immediately.`, `packages/ui-server/src/ws/run-session.ts:933-978`). diff --git a/docs/plans/policy-write-boundary.md b/docs/plans/policy-write-boundary.md index a741fbbcb..ac7372409 100644 --- a/docs/plans/policy-write-boundary.md +++ b/docs/plans/policy-write-boundary.md @@ -38,7 +38,7 @@ At the investigation baseline, pi created sessions directly in the server. The same SDK construction is now worker-local (`async newSession`, `packages/ui-backend-pi/src/native-session-runtime.ts:41-72`). Its resource loader initializes extensions (`createSessionResources`, -`packages/ui-backend-pi/src/session-resources.ts:29-111`). The permission extension +`packages/ui-backend-pi/src/session-resources.ts:29-112`). The permission extension registers a tool-call handler (`createPermissionGate`, `packages/ui-backend-pi/src/permission-gate.ts:95-119`); it cannot interpose arbitrary filesystem calls during extension initialization or execution. The experiment diff --git a/packages/ui-backend-claude/src/backend.ts b/packages/ui-backend-claude/src/backend.ts index f3304401c..df8188419 100644 --- a/packages/ui-backend-claude/src/backend.ts +++ b/packages/ui-backend-claude/src/backend.ts @@ -56,7 +56,7 @@ export { DEFAULT_CONFIRM_BASH_PATTERNS } from "@schlessera/brain-ui-sdk/server"; * routes permission / ask-user / location round-trips through the host bridge. */ export function createClaudeBackend(options: ClaudeBackendOptions): AgentBackend { - options = { ...options, ...(options.versionRequirements ? { versionRequirements: { ...options.versionRequirements } } : {}), + options = { ...options, ...(options.exec !== undefined ? { exec: { ...options.exec } } : {}), ...(options.versionRequirements ? { versionRequirements: { ...options.versionRequirements } } : {}), // One resolved allowance and budget for the backend's lifetime (#1625). workerState: resolveWorkerStateLimits({ ...options.workerState, names: { allowance: "workerState.allowanceBytes", budget: "workerState.budgetBytes" } }), diff --git a/packages/ui-backend-claude/src/config/env.ts b/packages/ui-backend-claude/src/config/env.ts index b50d72b3e..3331358d2 100644 --- a/packages/ui-backend-claude/src/config/env.ts +++ b/packages/ui-backend-claude/src/config/env.ts @@ -190,13 +190,17 @@ export function envSnapshot(extraNames: readonly string[] = []): NodeJS.ProcessE } /** - * The exec wrapper, or undefined. Read at turn start rather than cached: the - * process may gain the variable after this module loads, and a stale read of a - * privilege boundary is the wrong kind of stale. + * Resolve explicitly supplied environment at a compatibility edge. Host exec + * configuration takes precedence, including an explicitly empty object. */ -export function resolveExecConfig(env: NodeJS.ProcessEnv = process.env): ExecWrapperConfig { +export function resolveExecConfig(env: NodeJS.ProcessEnv): ExecWrapperConfig { return { wrapper: validateExecWrapper(env[EXEC_WRAPPER_ENV]), killer: validateExecWrapper(env[EXEC_KILLER_ENV]), }; } + +/** Legacy hosts omitting exec retain the ambient wrapper at their compatibility edge. */ +export function ambientExecConfig(): ExecWrapperConfig { + return resolveExecConfig(process.env); +} diff --git a/packages/ui-backend-claude/src/module.ts b/packages/ui-backend-claude/src/module.ts index 2ba3e00cd..0215a54cf 100644 --- a/packages/ui-backend-claude/src/module.ts +++ b/packages/ui-backend-claude/src/module.ts @@ -10,7 +10,7 @@ import { defineBackendModule } from "@schlessera/brain-ui-sdk/server"; import { isThinkingLevel } from "@schlessera/brain-ui-sdk/protocol"; import { createClaudeBackend } from "./backend.js"; -import { readEnvVar, resolveExecConfig } from "./config/env.js"; +import { readEnvVar, ambientExecConfig } from "./config/env.js"; import { createModelSource } from "./model-discovery.js"; import { probeClaudeRuntime } from "./runtime-probe.js"; import { turnEnv } from "./sdk-options.js"; @@ -167,7 +167,7 @@ export const backendModule: BackendModule = defineBackendModule({ // The environment a turn on the default profile gets: the built-in one, // resolved as resolveFromEnv resolves it. env: turnEnv(defineProfiles([builtinWithDefaultModel(BUILTIN_PROFILE, context)])[0]!), - exec: resolveExecConfig(), + exec: context.exec ?? ambientExecConfig(), ...(context.versionRequirements ? { versionRequirements: context.versionRequirements } : {}), ...(context.log ? { log: context.log } : {}), }); @@ -269,6 +269,7 @@ export const backendModule: BackendModule = defineBackendModule({ const turnTempBytes = configNumber(context, "turnTempBytes"); const turnTempBudgetBytes = configNumber(context, "turnTempBudgetBytes"); const backend = createClaudeBackend({ + exec: context.exec, ...(context.maintenance ? { maintenance: context.maintenance } : {}), brainPath: context.brainPath, workerState: { ...(allowanceBytes !== undefined ? { allowanceBytes } : {}), ...(budgetBytes !== undefined ? { budgetBytes } : {}) }, diff --git a/packages/ui-backend-claude/src/options.ts b/packages/ui-backend-claude/src/options.ts index 14af0bad8..c53158fca 100644 --- a/packages/ui-backend-claude/src/options.ts +++ b/packages/ui-backend-claude/src/options.ts @@ -19,6 +19,8 @@ export type BackendLogFn = ( ) => void; export interface ClaudeBackendOptions { + /** Resolved host wrapper. Explicit {} disables wrapping; omission retains ambient compatibility. */ + exec?: import("@schlessera/brain-ui-sdk/server").ExecWrapperConfig; /** Host-only admission for short startup Git queries; absent standalone callers retain private defaults. */ maintenance?: import("@schlessera/brain-ui-sdk/internal").MaintenanceHost; /** Full SemVer minima, composed with the package's SDK range. Runtime checks precede prompt release on start and resume. */ diff --git a/packages/ui-backend-claude/src/sdk-options.ts b/packages/ui-backend-claude/src/sdk-options.ts index f672f75a3..becc183a1 100644 --- a/packages/ui-backend-claude/src/sdk-options.ts +++ b/packages/ui-backend-claude/src/sdk-options.ts @@ -25,7 +25,7 @@ import { visibleToolName } from "@schlessera/brain-ui-sdk/server"; import { createBrainUiMcpServer, ASK_USER_TOOL_NAME } from "./ask-user-tool.js"; import { ASK_USER_LIST_TOOL_NAME } from "./ask-user-list-tool.js"; import { ASK_USER_RANK_TOOL_NAME } from "./ask-user-rank-tool.js"; -import { envSnapshot, resolveExecConfig } from "./config/env.js"; +import { envSnapshot, ambientExecConfig } from "./config/env.js"; import { GET_LOCATION_TOOL_NAME } from "./location-tool.js"; import { MASK_TOOL_NAME } from "./mask-tool.js"; import { userMessage } from "./turn-input.js"; @@ -353,7 +353,7 @@ function assembleClaudeSdkTurn( } // Every runtime-owned executor enters the worker. An optional host exec // wrapper runs inside it and can never replace the filesystem boundary. - const worker = createWorkerSpawn(backend.brainPath, resolveExecConfig(), abortController.signal, + const worker = createWorkerSpawn(backend.brainPath, backend.exec ?? ambientExecConfig(), abortController.signal, envelope ? { inferenceDir: envelope.relay.dir } : undefined, sandbox, limits, // Files the answer links in turn temp are promoted into scratch and the // saved answer links the copies (#1655); a turn that saves nothing has no answer to keep. diff --git a/packages/ui-backend-claude/src/turn-runner.ts b/packages/ui-backend-claude/src/turn-runner.ts index fe1f059cb..c0b4b22f2 100644 --- a/packages/ui-backend-claude/src/turn-runner.ts +++ b/packages/ui-backend-claude/src/turn-runner.ts @@ -39,7 +39,7 @@ import type { ActiveTurn } from "./turn-lock.js"; import { createTurnLockBinding } from "./turn-lock.js"; import { createTurnInput, type TurnInput } from "./turn-input.js"; import { DEFAULT_ALLOWED_TOOLS, VOICE_ALLOWED_TOOLS } from "./tool-policy.js"; -import { resolveExecConfig } from "./config/env.js"; +import { ambientExecConfig } from "./config/env.js"; import { assertClaudeRuntime, claudeRuntimeRequirements } from "./version-requirements.js"; import type { KeyedLock } from "@schlessera/brain-ui-sdk/server"; @@ -413,7 +413,7 @@ export function createClaudeTurnRunner(options: { const report = await probeClaudeRuntime({ brainPath: options.backend.brainPath, ...(options.backend.claudeCodePath !== undefined ? { claudeCodePath: options.backend.claudeCodePath } : {}), - env: sdkTurn.options.env ?? {}, exec: resolveExecConfig(), + env: sdkTurn.options.env ?? {}, exec: options.backend.exec ?? ambientExecConfig(), versionRequirements: options.backend.versionRequirements, phase, signal: abortController.signal, log: options.log, }); diff --git a/packages/ui-backend-claude/src/worker-spawn.ts b/packages/ui-backend-claude/src/worker-spawn.ts index 61e35e4fa..1ca177e4d 100644 --- a/packages/ui-backend-claude/src/worker-spawn.ts +++ b/packages/ui-backend-claude/src/worker-spawn.ts @@ -6,7 +6,7 @@ import { deferredWorkerSpawn } from "./deferred-worker-spawn.js"; import type { SpawnOptions, SpawnedProcess } from "@anthropic-ai/claude-agent-sdk"; import { existsSync, statSync } from "node:fs"; import { isAbsolute } from "node:path"; -import { prepareRecoveredWorkerFiles, createTurnTempPromoter, launchAgentWorker, prepareNativeWorkerOutput, type PreparedWorkerOutput, mergeLimitReports, mib, requireWorkerHost, resolveTurnTempLimits, resolveWorkerStateLimits, retainWorkerStartupView, +import { prepareRecoveredWorkerFiles, workerExecHelpers, createTurnTempPromoter, launchAgentWorker, prepareNativeWorkerOutput, type PreparedWorkerOutput, mergeLimitReports, mib, requireWorkerHost, resolveTurnTempLimits, resolveWorkerStateLimits, retainWorkerStartupView, WORKER_SCRATCH, WORKER_STATE, type LaunchedWorker, type TurnTempPromotionRecord, type WorkerLimitReport, type WorkerLimitsPlan, type WorkerSandbox, type WorkerStateHandle, type WorkerStateLimits } from "@schlessera/brain-ui-sdk/internal"; import { FIRST_PARTY_FAILURE_CLASSES, type StateRoomObservation } from "@schlessera/brain-ui-sdk/protocol"; @@ -91,6 +91,8 @@ export function createWorkerSpawn(brainPath: string, config: ExecWrapperConfig, persistMemory?: import("@schlessera/brain-ui-sdk/server").StartTurnRequest["persistClaudeMemory"], /** Host-prepared ordinary transport; selecting it keeps native credentials on the host. */ egress?: { proxyDir: string }) { + // The wrapper becomes the worker's first program: a regular file, never a directory bound into its view (#1429). + if (!restricted) workerExecHelpers({ wrapper: config.wrapper }); const completions: Promise[] = []; const reports: Array<() => WorkerLimitReport> = []; const limits = () => mergeLimitReports(reports.map(report => report())); diff --git a/packages/ui-backend-claude/tests/host-exec.test.ts b/packages/ui-backend-claude/tests/host-exec.test.ts new file mode 100644 index 000000000..b52ca7780 --- /dev/null +++ b/packages/ui-backend-claude/tests/host-exec.test.ts @@ -0,0 +1,104 @@ +import { mockWorkerHostForSdkStream } from "./helpers/worker-host.js"; +import { expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, existsSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as worker from "@schlessera/brain-ui-sdk/internal"; +import { createClaudeBackend } from "../src/backend.js"; +import { backendModule } from "../src/module.js"; +import * as factory from "../src/backend.js"; +import { createWorkerSpawn } from "../src/worker-spawn.js"; + +mockWorkerHostForSdkStream(); + +test("Claude factory/module turns carry host exec to runtime probe and SDK worker argv (#1429)", async () => { + const root = mkdtempSync(join(tmpdir(), "odysseus-host-exec-")); + const brain = join(root, "brain"), home = join(root, "home"); + mkdirSync(brain); mkdirSync(home); + const binary = join(root, "claude"); + writeFileSync(binary, '#!/bin/sh\necho "9.9.9 (Claude Code)"\n', { mode: 0o755 }); + const wrappers = ["a", "b", "c"].map(name => { + const path = join(root, name), log = join(root, name + ".log"); + writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' "$@" >> '${log}'\nexec "$@"\n`, { mode: 0o755 }); + return { path, log }; + }); + const [a, b, c] = wrappers as [typeof wrappers[number], typeof wrappers[number], typeof wrappers[number]]; + const oldWrapper = process.env.BRAIN_UI_EXEC_WRAPPER; + const oldConfig = process.env.CLAUDE_CONFIG_DIR; + process.env.BRAIN_UI_EXEC_WRAPPER = b.path; + process.env.CLAUDE_CONFIG_DIR = home; + const commands: string[][] = []; + const stop = new Error("controlled worker launch boundary"); + const launch = spyOn(worker, "launchAgentWorker").mockImplementation(input => { + commands.push([...input.command]); + throw stop; + }); + const queryFn = (({ options }: any) => ({ + initializationResult: async () => ({}), + async *[Symbol.asyncIterator]() { + const child = options.spawnClaudeCodeProcess({ command: binary, args: ["--offline-turn"], + env: { PATH: process.env.PATH, HOME: home, CLAUDE_CONFIG_DIR: home }, + signal: new AbortController().signal }); + await new Promise((_, reject) => child.once("error", reject)); + yield {}; + }, + })) as never; + const construct = createClaudeBackend; + // Keep module-owned construction, injecting only the keyless provider collaborator. + const constructor = spyOn(factory, "createClaudeBackend").mockImplementation(options => construct({ ...options, queryFn })); + try { + for (const module of [false, true]) { + for (const exec of [{ wrapper: a.path, killer: "/bin/true" }, { wrapper: c.path }, {}, undefined]) { + process.env.BRAIN_UI_EXEC_WRAPPER = b.path; + const options = { brainPath: brain, claudeCodePath: binary, exec, queryFn, + versionRequirements: { runtime: "2.1.283" }, log: () => {} }; + const resolved = module ? await backendModule.resolveFromEnv({ + brainPath: brain, config: { claudeCodePath: binary, modelDiscovery: false }, exec, + versionRequirements: options.versionRequirements, profiles: [], settings: {}, confirmBashPatterns: [], + }) : null; + if (resolved && !resolved.ok) throw resolved.error; + const backend = resolved?.ok ? resolved.value.backend : construct(options); + if (exec === undefined) process.env.BRAIN_UI_EXEC_WRAPPER = c.path; + const frames: unknown[] = []; + await backend.startTurn({ prompt: "Return to Ithaca", signal: new AbortController().signal, + workerSandbox: worker.prepareWorkerSandbox(brain, { home, profile: "governed", areas: [] }), + bridge: { emit: frame => frames.push(frame), requestPermission: async () => ({ behavior: "allow" }) } }); + const expected = exec === undefined ? c.path : exec.wrapper; + expect(commands.at(-1), exec === undefined ? "OMITTED_HOST_EXEC_REMAINS_LIVE" : "SDK_WORKER_HOST_ARGV").toEqual([ + ...(expected ? [expected] : []), binary, "--offline-turn", + ]); + expect(frames.some((frame: any) => frame.type === "error" && frame.message.includes(stop.message))).toBe(true); + } + } + for (const [wrapper, count] of [[a, 2], [b, 0], [c, 4]] as const) { + expect(existsSync(wrapper.log), "PER_TURN_PROBE_USED_HOST_WRAPPER").toBe(count > 0); + expect(existsSync(wrapper.log) ? readFileSync(wrapper.log, "utf8").trim().split("\n") : []).toEqual( + Array.from({ length: count }, () => [binary, "--version"]).flat(), + ); + } + } finally { + constructor.mockRestore(); launch.mockRestore(); + if (oldWrapper === undefined) delete process.env.BRAIN_UI_EXEC_WRAPPER; else process.env.BRAIN_UI_EXEC_WRAPPER = oldWrapper; + if (oldConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = oldConfig; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("an ordinary Claude worker refuses a nonregular host wrapper before any worker (#1429)", () => { + const root = mkdtempSync(join(tmpdir(), "odysseus-host-exec-dir-")); + const helpers = join(root, "helpers"), linked = join(root, "linked"); + mkdirSync(helpers); symlinkSync(helpers, linked); + const launch = spyOn(worker, "launchAgentWorker"); + try { + for (const wrapper of [helpers, linked]) { + expect(() => createWorkerSpawn(root, { wrapper }, new AbortController().signal), "CLAUDE_WORKER_WRAPPER_REGULAR") + .toThrow(/exec wrapper .* regular file/); + } + // The restricted envelope launches no operator wrapper, so it does not consult one. + expect(() => createWorkerSpawn(root, { wrapper: helpers }, new AbortController().signal, { inferenceDir: join(root, "relay") })).not.toThrow(); + expect(launch).not.toHaveBeenCalled(); + } finally { + launch.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/ui-backend-claude/tests/runtime-probe.test.ts b/packages/ui-backend-claude/tests/runtime-probe.test.ts index bfa8cdd2d..013dacaf4 100644 --- a/packages/ui-backend-claude/tests/runtime-probe.test.ts +++ b/packages/ui-backend-claude/tests/runtime-probe.test.ts @@ -197,3 +197,31 @@ describe("isMeasuredRuntime", () => { expect(isMeasuredRuntime(undefined, MEASURED_RUNTIME.agentSdk)).toBe(false); }); }); + +test("module probe selects host A/C and explicit empty over ambient B (#1429)", async () => { + const dir = tempDir("host-exec-probe-"); + const binary = fakeBinary(dir, "claude", "9.9.9 (Claude Code)"); + const previous = process.env.BRAIN_UI_EXEC_WRAPPER; + const wrap = (name: string) => { + const path = join(dir, name), log = join(dir, name + ".log"); + writeFileSync(path, `#!/bin/sh\nprintf '%s\n' "$@" >> '${log}'\nexec "$@"\n`, { mode: 0o755 }); + return { path, log }; + }; + const a = wrap("a"), b = wrap("b"), c = wrap("c"); + process.env.BRAIN_UI_EXEC_WRAPPER = b.path; + try { + for (const exec of [{ wrapper: a.path }, { wrapper: c.path }, {}]) { + await backendModule.probeRuntime!({ brainPath: dir, config: { claudeCodePath: binary.path }, + profiles: [], confirmBashPatterns: null, settings: {}, exec }); + } + expect(invocations(a.log), "HOST_PROBE_A").toEqual([binary.path, "--version"]); + expect(invocations(c.log), "HOST_PROBE_C").toEqual([binary.path, "--version"]); + expect(invocations(b.log), "EXPLICIT_EMPTY_NOT_AMBIENT").toEqual([]); + await backendModule.probeRuntime!({ brainPath: dir, config: { claudeCodePath: binary.path }, + profiles: [], confirmBashPatterns: null, settings: {} }); + expect(invocations(b.log), "ABSENT_RETAINS_AMBIENT").toEqual([binary.path, "--version"]); + } finally { + if (previous === undefined) delete process.env.BRAIN_UI_EXEC_WRAPPER; + else process.env.BRAIN_UI_EXEC_WRAPPER = previous; + } +}); diff --git a/packages/ui-backend-pi/src/backend-options.ts b/packages/ui-backend-pi/src/backend-options.ts index ed23190f6..1faf78f67 100644 --- a/packages/ui-backend-pi/src/backend-options.ts +++ b/packages/ui-backend-pi/src/backend-options.ts @@ -94,6 +94,8 @@ export interface PiProfile { } export interface CreatePiBackendOptions { + /** Resolved host wrapper. Explicit {} disables wrapping; omission retains ambient compatibility. */ + exec?: import("@schlessera/brain-ui-sdk/server").ExecWrapperConfig; /** Host-only admission for short startup Git queries; absent standalone callers retain private defaults. */ maintenance?: import("@schlessera/brain-ui-sdk/internal").MaintenanceHost; /** Full SemVer minimum for the primary SDK; composed with all three manifest constraints. Pi has no separate runtime identity. */ diff --git a/packages/ui-backend-pi/src/backend.ts b/packages/ui-backend-pi/src/backend.ts index 972787c8c..f2af578ad 100644 --- a/packages/ui-backend-pi/src/backend.ts +++ b/packages/ui-backend-pi/src/backend.ts @@ -73,7 +73,7 @@ const CAPABILITIES: BackendCapabilities = { }; export function createPiBackend(options: CreatePiBackendOptions): AgentBackend { - options = { ...options, ...(options.versionRequirements ? { versionRequirements: { ...options.versionRequirements } } : {}) }; + options = { ...options, ...(options.exec !== undefined ? { exec: { ...options.exec } } : {}), ...(options.versionRequirements ? { versionRequirements: { ...options.versionRequirements } } : {}) }; assertPiSdks(options.versionRequirements, "backend construction"); const brainPath = options.brainPath; const sessionDir = options.sessionFactory diff --git a/packages/ui-backend-pi/src/config/env.ts b/packages/ui-backend-pi/src/config/env.ts index 41aea6578..b1f2339dc 100644 --- a/packages/ui-backend-pi/src/config/env.ts +++ b/packages/ui-backend-pi/src/config/env.ts @@ -142,12 +142,10 @@ export function resolveEnv(env: NodeJS.ProcessEnv = process.env): PiBackendEnv { } /** - * The exec wrapper and its cancellation helper. Resolved per spawn rather than - * cached at module scope, matching this file's rule for everything else: the - * process may gain the variables after this module loads, and a stale read of - * a privilege boundary is the wrong kind of stale. + * Resolve explicitly supplied environment at a compatibility edge. Host exec + * configuration takes precedence, including an explicitly empty object. */ -export function resolveExecConfig(env: NodeJS.ProcessEnv = process.env): ExecWrapperConfig { +export function resolveExecConfig(env: NodeJS.ProcessEnv): ExecWrapperConfig { return { wrapper: validateExecWrapper(env[EXEC_WRAPPER_ENV]), killer: validateExecWrapper(env[EXEC_KILLER_ENV]), @@ -246,3 +244,8 @@ export function subprocessEnv( ...extraNames, ]); } + +/** Legacy hosts omitting exec retain the ambient wrapper at their compatibility edge. */ +export function ambientExecConfig(): ExecWrapperConfig { + return resolveExecConfig(process.env); +} diff --git a/packages/ui-backend-pi/src/module.ts b/packages/ui-backend-pi/src/module.ts index 72a0df8fe..6a3171b29 100644 --- a/packages/ui-backend-pi/src/module.ts +++ b/packages/ui-backend-pi/src/module.ts @@ -188,6 +188,7 @@ export const backendModule: BackendModule = defineBackendModule({ const turnTempBytes = typeof context.config.turnTempBytes === "number" ? context.config.turnTempBytes : undefined; const turnTempBudgetBytes = typeof context.config.turnTempBudgetBytes === "number" ? context.config.turnTempBudgetBytes : undefined; const backend = createPiBackend({ + exec: context.exec, ...(context.maintenance ? { maintenance: context.maintenance } : {}), brainPath: context.brainPath, turnTemp: { ...(turnTempBytes !== undefined ? { allowanceBytes: turnTempBytes } : {}), diff --git a/packages/ui-backend-pi/src/session-resources.ts b/packages/ui-backend-pi/src/session-resources.ts index 6d2086add..8532422df 100644 --- a/packages/ui-backend-pi/src/session-resources.ts +++ b/packages/ui-backend-pi/src/session-resources.ts @@ -53,6 +53,7 @@ export function createSessionResources(options: { function buildToolkit(caps: SessionCaps): SessionToolkit { const turnContext = createTurnContext(); const tools = createBrainTools({ + exec: backend.exec, brain, turn: turnContext, lock, diff --git a/packages/ui-backend-pi/src/tools.ts b/packages/ui-backend-pi/src/tools.ts index 130f99d29..9f0fac8c4 100644 --- a/packages/ui-backend-pi/src/tools.ts +++ b/packages/ui-backend-pi/src/tools.ts @@ -51,7 +51,7 @@ import { isIsoDate, readDocumentPart, SEARCH_SORTS, type SearchOptions } from "@ import { createPiBridgeTools } from "./bridge-tools.js"; import { resolveEnabledWebSearchEnvNames, - resolveExecConfig, + ambientExecConfig, subprocessEnv, } from "./config/env.js"; import type { BrainAccess } from "./brain-access.js"; @@ -192,6 +192,8 @@ export function toolLockFromWriteLock(writeLock: WriteLock): ToolLock { } export interface BrainToolDeps { + /** Resolved host wrapper. Explicit {} disables wrapping; omission retains ambient compatibility. */ + exec?: import("@schlessera/brain-ui-sdk/server").ExecWrapperConfig; /** Worker shell writes stay in fresh scratch; never a privileged cwd. */ shellCwd?: string; directWrites?: boolean; @@ -382,7 +384,7 @@ export function createBrainTools(deps: BrainToolDeps): ToolDefinition[] { // Containment: scope the search path to the repo; grep runs with cwd=repo. const rel = params.path ?? "."; resolveOrThrow(rel); - const exec = resolveExecConfig(); + const exec = deps.exec ?? ambientExecConfig(); const proc = spawn(wrapCommand(["grep", "-rInE", "--", params.pattern, rel], exec.wrapper), { cwd: brain.root, env: subprocessEnv(resolveEnabledWebSearchEnvNames()), @@ -563,7 +565,7 @@ export function createBrainTools(deps: BrainToolDeps): ToolDefinition[] { // sibling tool calls in one message. return lock.withKey(bashLockKey(params.command), async () => { if (signal?.aborted) throw new DOMException("Tool cancelled before subprocess execution", "AbortError"); - const exec = resolveExecConfig(); + const exec = deps.exec ?? ambientExecConfig(); const proc = spawn(wrapCommand(["bash", "-lc", cmd], exec.wrapper), { cwd: deps.shellCwd ?? brain.root, env: childEnv, diff --git a/packages/ui-backend-pi/src/worker-entry.ts b/packages/ui-backend-pi/src/worker-entry.ts index 322fffef2..a861ba079 100644 --- a/packages/ui-backend-pi/src/worker-entry.ts +++ b/packages/ui-backend-pi/src/worker-entry.ts @@ -18,7 +18,7 @@ import type { CreatePiBackendOptions, PiSessionLike, SessionEnv, SessionToolkit interface Init { type: "init"; - backend: Pick; + backend: Pick; env: SessionEnv; sessionId: string; resume: boolean; diff --git a/packages/ui-backend-pi/src/worker-session.ts b/packages/ui-backend-pi/src/worker-session.ts index 2061f770b..bf95b7e04 100644 --- a/packages/ui-backend-pi/src/worker-session.ts +++ b/packages/ui-backend-pi/src/worker-session.ts @@ -1,5 +1,5 @@ import { brainWorkerPath } from "@schlessera/brain-common/internal/worker-areas"; -import { workerCleanupUnconfirmed, sessionPromptContext, describeClient } from "@schlessera/brain-ui-sdk/internal"; +import { workerCleanupUnconfirmed, sessionPromptContext, describeClient, workerExecHelpers } from "@schlessera/brain-ui-sdk/internal"; import { safeResolve } from "@schlessera/brain/internal"; import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; import { join, relative, isAbsolute } from "node:path"; @@ -23,7 +23,7 @@ import { configuredProfiles, resolveModelSpec, toModel } from "./profiles.js"; import { createTurnContext } from "./turn-context.js"; import { createPiBridgeTools, REQUEST_IMAGE_MASK_INPUT_SCHEMA } from "./bridge-tools.js"; import { DEFAULT_PI_ALLOWED_TOOLS } from "./tools.js"; -import { subprocessEnv } from "./config/env.js"; +import { subprocessEnv, ambientExecConfig } from "./config/env.js"; import { savePiSession, readPiSession, piSessionDirectory, syncPiAuth } from "./session-storage.js"; import { checkSessionSize, PI_WORKER_STATE, SessionTooLargeError, pipeMessages, sendPipe, piWorkerMessage, type PiWorkerMessage } from "./worker-protocol.js"; @@ -39,6 +39,10 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi env.workerSignal?.throwIfAborted(); const declared = resumeId ? undefined : toModel(resolveModelSpec(backend, profileId)); const restricted = env.autonomous?.containment === "restricted"; + // Selected once per worker and serialized into its local tools. Helpers are + // checked before any relay, reservation or worker exists (#1429). + const exec = backend.exec ?? ambientExecConfig(); + const execHelpers = workerExecHelpers(exec); let entries: import("@earendil-works/pi-coding-agent").FileEntry[] | undefined; if (resumeId) { // Current server-owned transcripts have fixed UUID names. Opening one must @@ -114,7 +118,7 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi throw new BackendRequestError("Pi prepared npm resources must be inside the selected agent directory's npm tree."); } } - const init = { type: "init", governed: workerSandbox?.profile === "governed", sharedLock: Boolean(workerLock), backend: { brainPath: backend.brainPath, profiles: configuredProfiles(backend), model: backend.model, + const init = { type: "init", governed: workerSandbox?.profile === "governed", sharedLock: Boolean(workerLock), backend: { exec, brainPath: backend.brainPath, profiles: configuredProfiles(backend), model: backend.model, loadExtensions: envelope ? false : backend.loadExtensions, systemPromptAppend: backend.systemPromptAppend }, env: workerSessionEnv, sessionId, resume: Boolean(resumeId), entries, profileId, agentFiles, fixedAppend: promptContext?.append(), ...(cachedNpmModules ? { cachedNpmModules } : {}), ...(envelope ? { inference: envelope.inference } : {}) }; // Every worker's /tmp is a private tmpfs of exactly this allowance, which @@ -163,6 +167,8 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi } sandbox = { ...sandbox, readPaths: [...(sandbox.readPaths ?? restrictedReadPaths([process.execPath, entry])), cachedNpmModules] }; } + // Worker-local tools run the host's helpers: each file alone, never its parent. + if (execHelpers.length && !envelope) sandbox = { ...sandbox, readPaths: [...(sandbox.readPaths ?? restrictedReadPaths([process.execPath, entry])), ...execHelpers] }; const limits = launchLimits({}); sandboxNote = [workerSandboxNote(sandbox, { ...(identity ? { identity } : {}), hardTimeoutMs: env.turnBudgetMs, limits: [...workerNoteLimits(limits.status), turnTempLimit(reservation.turnTempBytes, env.caps.turnTemp === true ? "turn_temp" : undefined), @@ -180,7 +186,7 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi child = launchAgentWorker({ brainPath: backend.brainPath, turnTempBytes: reservation.turnTempBytes, ...(preparedOutput ? { preparedOutput } : workerStartupView && sandbox.profile !== "governed" ? { startupView: retainWorkerStartupView(workerStartupView) } : {}), command: [process.execPath, entry], env: workerEnv, sandbox, limits, ...(workerLock ? { outputLock: workerLock } : {}), - ...(envelope ? { restricted: { readPaths: restrictedReadPaths([entry]), inferenceDir: envelope.relay.dir } } : {}), + ...(envelope ? { restricted: { readPaths: [...restrictedReadPaths([entry]), ...execHelpers], inferenceDir: envelope.relay.dir } } : {}), }); launchedChild = child; backend.log?.("debug", "Pi startup phase", { phase: "spawn", durationMs: performance.now() - spawning, elapsedMs: performance.now() - startupStarted }); diff --git a/packages/ui-backend-pi/tests/exec-wrapper.test.ts b/packages/ui-backend-pi/tests/exec-wrapper.test.ts index 33140cdef..b45f3be5e 100644 --- a/packages/ui-backend-pi/tests/exec-wrapper.test.ts +++ b/packages/ui-backend-pi/tests/exec-wrapper.test.ts @@ -48,10 +48,11 @@ function writeWrapper(name: string): { path: string; argvLog: string } { return { path, argvLog }; } -function toolsFor(root: string) { +function toolsFor(root: string, exec?: { wrapper?: string; killer?: string }) { const turn = createTurnContext(); const tools = createBrainTools({ brain: createBrainAccess(root), + exec, turn, lock: toolLockFromKeyed(createKeyedLock()), }); @@ -162,3 +163,29 @@ describe("the grep tool", () => { } }, 30_000); }); + +test("host A/C and explicit empty override ambient B for both Pi spawn tools (#1429)", async () => { + const brain = await makeEmptyBrain(); + const a = writeWrapper("host-a"), b = writeWrapper("ambient-b"), c = writeWrapper("host-c"); + process.env.BRAIN_UI_EXEC_WRAPPER = b.path; + writeFileSync(join(brain.root, "hit.md"), "needle"); + try { + for (const tool of ["bash", "grep"] as const) { + for (const wrapper of [a, c]) { + const result = await toolsFor(brain.root, { wrapper: wrapper.path })[tool]!.execute( + "host", tool === "bash" ? { command: "echo needle" } : { pattern: "needle" }, + undefined, undefined, CTX); + expect(resultText(result)).toContain("needle"); + expect(existsSync(wrapper.argvLog), "HOST_WRAPPER_SELECTED").toBe(true); + } + await toolsFor(brain.root, {})[tool]!.execute( + "empty", tool === "bash" ? { command: "echo needle" } : { pattern: "needle" }, + undefined, undefined, CTX); + expect(existsSync(b.argvLog), "EXPLICIT_EMPTY_NOT_AMBIENT").toBe(false); + } + expect(readFileSync(a.argvLog, "utf8").split("\n").filter(Boolean)).toEqual([ + Bun.which("bash")!, "-lc", "echo needle", Bun.which("grep")!, "-rInE", "--", "needle", ".", + ]); + expect(readFileSync(c.argvLog, "utf8")).toBe(readFileSync(a.argvLog, "utf8")); + } finally { brain.cleanup(); } +}); diff --git a/packages/ui-backend-pi/tests/fixtures/host-exec-worker.ts b/packages/ui-backend-pi/tests/fixtures/host-exec-worker.ts new file mode 100644 index 000000000..679973e90 --- /dev/null +++ b/packages/ui-backend-pi/tests/fixtures/host-exec-worker.ts @@ -0,0 +1,40 @@ +/** Offline worker peer: the real worker-local grep/bash tools under the host's serialized exec helpers (#1429). */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { createKeyedLock } from "@schlessera/brain-ui-sdk/server"; +import { createBrainAccess } from "../../src/brain-access"; +import { createBrainTools, toolLockFromKeyed } from "../../src/tools"; +import { createTurnContext } from "../../src/turn-context"; +import { pipeMessages, sendPipe } from "../../src/worker-protocol"; + +const text = (result: any) => result.content.map((part: { text: string }) => part.text).join(""); +const read = (path: string) => existsSync(path) ? readFileSync(path, "utf8") : ""; + +for await (const message of pipeMessages(process.stdin)) { + const init = message as any; + if (init.type !== "init") continue; + const exec = init.backend.exec as { wrapper?: string; killer?: string }; + const tools = createBrainTools({ brain: createBrainAccess(init.backend.brainPath), exec, turn: createTurnContext(), + lock: toolLockFromKeyed(createKeyedLock()) }); + const run = (name: string, params: Record, signal?: AbortSignal) => + tools.find(tool => tool.name === name)!.execute("odysseus", params as never, signal, undefined, {} as never); + const report: Record = { exec }; + try { + report.grep = text(await run("grep", { pattern: "wine-dark" })); + report.bash = text(await run("bash", { command: "echo Ithaca" })); + const controller = new AbortController(); + const started = performance.now(); + setTimeout(() => controller.abort(), 300); + try { await run("bash", { command: "sleep 30" }, controller.signal); report.cancel = "completed"; } + catch (error) { report.cancel = (error as Error).name; } + report.cancelMs = Math.round(performance.now() - started); + } catch (error) { report.error = (error as Error).message; } + report.wrapperLog = read("/tmp/odysseus-wrapper.log"); + report.killerLog = read("/tmp/odysseus-killer.log"); + const helpers = dirname(exec.killer ?? "/"); + report.helperEntries = existsSync(helpers) ? readdirSync(helpers).sort() : null; + report.privateEntries = existsSync(dirname(helpers)) ? readdirSync(dirname(helpers)).sort() : null; + report.siblingVisible = existsSync(`${helpers}/penelope.txt`); + sendPipe(process.stdout, { type: "ready", sessionId: init.sessionId, model: { id: JSON.stringify(report) }, levels: [], cost: 0 }); + await Bun.sleep(30_000); +} diff --git a/packages/ui-backend-pi/tests/worker-host-exec.test.ts b/packages/ui-backend-pi/tests/worker-host-exec.test.ts new file mode 100644 index 000000000..da6284f09 --- /dev/null +++ b/packages/ui-backend-pi/tests/worker-host-exec.test.ts @@ -0,0 +1,119 @@ +/** + * Host exec helpers inside real Pi workers (#1429). Real bubblewrap launches + * of each worker view; only the worker program is an offline peer that runs + * the real worker-local grep/bash tools with the serialized host exec. + */ +import { expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import * as worker from "@schlessera/brain-ui-sdk/internal"; +import * as autonomous from "../src/autonomous-envelope.js"; +import { openPiWorkerSession } from "../src/worker-session.js"; +import { createFixtureWorkerStartupView } from "../../ui-sdk/tests/helpers/worker-startup-view-fixture.js"; + +const caps = { askUser: false, askUserList: false, askUserRank: false, location: false, activity: false, mask: false }; +const restrictedTurn = { autonomous: { origin: "autonomous" as const, persistence: "none" as const, containment: "restricted" as const, + allowedTools: [], systemPromptAppend: "Odysseus offline fixture." } }; + +/** A brain beside a private directory holding the helpers, an alias to one of them, and secrets next to both. */ +function layout() { + const root = mkdtempSync("/var/tmp/odysseus-host-exec-"), brain = join(root, "brain"), privateDir = join(root, "private"); + const helpers = join(privateDir, "helpers"); + mkdirSync(brain); mkdirSync(helpers, { recursive: true }); mkdirSync(join(privateDir, "links")); + writeFileSync(join(brain, "voyage.md"), "The wine-dark sea\n"); + writeFileSync(join(helpers, "wrap"), '#!/bin/sh\nprintf \'%s\\n\' "$@" >> /tmp/odysseus-wrapper.log\nexec "$@"\n', { mode: 0o755 }); + writeFileSync(join(helpers, "kill"), '#!/bin/sh\nprintf \'%s %s\\n\' "$1" "$2" >> /tmp/odysseus-killer.log\nexec /bin/kill -s "$2" -- "-$1"\n', { mode: 0o755 }); + writeFileSync(join(helpers, "penelope.txt"), "the shroud\n"); + writeFileSync(join(privateDir, "ithaca-secret.txt"), "the bow\n"); + symlinkSync("../helpers/wrap", join(privateDir, "links", "wrap")); + return { root, brain, privateDir, helpers, wrapperAlias: join(privateDir, "links", "wrap"), killer: join(helpers, "kill") }; +} + +function saveEnv(names: string[]) { + const saved = Object.fromEntries(names.map(name => [name, process.env[name]])); + return () => { for (const [name, value] of Object.entries(saved)) if (value === undefined) delete process.env[name]; else process.env[name] = value; }; +} + +for (const profile of ["governed", "strict", "restricted"] as const) { + test(`Pi ${profile} worker runs worker-local grep/bash through masked host helpers and cancels with the killer`, async () => { + const fixture = layout(); + const { root, brain } = fixture; + const view = profile !== "governed" ? createFixtureWorkerStartupView(brain) : undefined; + const sandbox: worker.WorkerSandbox = { brain, profile, restricted: profile === "restricted", areas: [], scratchAliasesRemoved: [], + maskPaths: [fixture.privateDir] }; + const restoreEnv = saveEnv(["ANTHROPIC_API_KEY", "PI_CODING_AGENT_DIR", "BRAIN_UI_EXEC_WRAPPER", "BRAIN_UI_EXEC_KILLER"]); + process.env.PI_CODING_AGENT_DIR = join(root, "pi-agent"); + process.env.ANTHROPIC_API_KEY = "sk-odysseus-offline"; + process.env.BRAIN_UI_EXEC_WRAPPER = "/bin/false"; + const probe = spyOn(worker.workerHostBoundary, "probe").mockReturnValue({ ok: true }); + const nativeLaunch = worker.launchAgentWorker; + let child: worker.LaunchedWorker | undefined; + const launch = spyOn(worker, "launchAgentWorker").mockImplementation(options => { + child = nativeLaunch({ ...options, command: [process.execPath, "--no-env-file", join(import.meta.dir, "fixtures/host-exec-worker.ts")] }); + return child; + }); + let session: Awaited>["session"] | undefined; + try { + session = (await openPiWorkerSession({ brainPath: brain, model: "anthropic/claude-sonnet-4-6", + exec: { wrapper: fixture.wrapperAlias, killer: fixture.killer } }, join(root, "sessions"), { + workerSandbox: sandbox, workerStartupView: view, ...(profile === "restricted" ? restrictedTurn : {}), caps, + })).session; + const report = JSON.parse(session.model!.id); + expect(report.error, "HOST_HELPER_VISIBLE_IN_WORKER").toBeUndefined(); + expect(report.grep).toContain("voyage.md:1:The wine-dark sea"); + expect(report.bash).toBe("Ithaca"); + const wrapped = report.wrapperLog.split("\n"); + expect(wrapped, "WORKER_TOOLS_USED_HOST_WRAPPER").toEqual(expect.arrayContaining(["-rInE", "wine-dark", "-lc", "echo Ithaca", "sleep 30"])); + expect(report.cancel).toBe("AbortError"); + expect(report.killerLog, "WORKER_KILLER_CANCELLED_GROUP").toMatch(/^\d+ TERM\n$/); + expect(report.cancelMs).toBeLessThan(10_000); + expect({ helpers: report.helperEntries, private: report.privateEntries, sibling: report.siblingVisible }, "HELPER_SIBLINGS_STAY_MASKED") + .toEqual({ helpers: ["kill", "wrap"], private: ["helpers", "links"], sibling: false }); + } finally { + try { + session?.dispose(); child?.kill("SIGKILL"); await child?.exited.catch(() => {}); + launch.mockRestore(); probe.mockRestore(); restoreEnv(); + await view?.cleanup(); + } finally { rmSync(root, { recursive: true, force: true }); } + } + }, 30_000); +} + +const refusals = [ + ["a directory wrapper", (f: ReturnType) => ({ exec: { wrapper: f.helpers } }), /exec wrapper .* regular file/], + ["a wrapper linked to a directory", (f: ReturnType) => { + symlinkSync(f.helpers, join(f.root, "helpers-link")); + return { exec: { wrapper: join(f.root, "helpers-link") } }; + }, /exec wrapper .* regular file/], + ["a directory killer", (f: ReturnType) => ({ exec: { wrapper: f.wrapperAlias, killer: f.helpers } }), /exec killer .* regular file/], + ["a killer linked to a directory", (f: ReturnType) => { + symlinkSync(f.privateDir, join(f.root, "private-link")); + return { exec: { killer: join(f.root, "private-link") } }; + }, /exec killer .* regular file/], + ["a relative ambient wrapper", () => { process.env.BRAIN_UI_EXEC_WRAPPER = "helpers/wrap"; return {}; }, /must be an absolute path/], +] as const; + +for (const [name, configure, message] of refusals) for (const restricted of [false, true]) { + test(`Pi ${restricted ? "restricted" : "ordinary"} session refuses ${name} before any relay or worker`, async () => { + const fixture = layout(); + const restoreEnv = saveEnv(["ANTHROPIC_API_KEY", "PI_CODING_AGENT_DIR", "BRAIN_UI_EXEC_WRAPPER", "BRAIN_UI_EXEC_KILLER"]); + process.env.PI_CODING_AGENT_DIR = join(fixture.root, "pi-agent"); + process.env.ANTHROPIC_API_KEY = "sk-odysseus-offline"; + delete process.env.BRAIN_UI_EXEC_WRAPPER; delete process.env.BRAIN_UI_EXEC_KILLER; + const envelope = spyOn(autonomous, "piAutonomousEnvelope"); + const launch = spyOn(worker, "launchAgentWorker").mockImplementation(() => { throw new Error("worker launched"); }); + try { + const options = configure(fixture); + await expect(openPiWorkerSession({ brainPath: fixture.brain, model: "anthropic/claude-sonnet-4-6", ...options }, + join(fixture.root, "sessions"), { + workerSandbox: { brain: fixture.brain, profile: "governed", restricted: false, areas: [], scratchAliasesRemoved: [] }, + ...(restricted ? restrictedTurn : {}), caps, + })).rejects.toThrow(message); + expect(envelope, "NO_RELAY_BEFORE_HELPER_REFUSAL").not.toHaveBeenCalled(); + expect(launch, "NO_WORKER_BEFORE_HELPER_REFUSAL").not.toHaveBeenCalled(); + } finally { + envelope.mockRestore(); launch.mockRestore(); restoreEnv(); + rmSync(fixture.root, { recursive: true, force: true }); + } + }); +} diff --git a/packages/ui-backend-pi/tests/worker-session.test.ts b/packages/ui-backend-pi/tests/worker-session.test.ts index c23f6201c..2ff81f9c2 100644 --- a/packages/ui-backend-pi/tests/worker-session.test.ts +++ b/packages/ui-backend-pi/tests/worker-session.test.ts @@ -375,3 +375,23 @@ for (const mode of ["result", "orphan", "direct"] as const) test(`Pi authoritati } }); + +test("host exec survives the Pi worker init boundary; omitted factory options retain ambient (#1429)", async () => { + const old = process.env.BRAIN_UI_EXEC_WRAPPER; + process.env.BRAIN_UI_EXEC_WRAPPER = "/bin/true"; + try { + for (const exec of [{ wrapper: "/bin/false", killer: "/bin/true" }, {}, undefined]) { + const frames: ServerMessage[] = []; + const backend = createPiBackend({ brainPath: brain, sessionDir, exec }); + if (exec === undefined) process.env.BRAIN_UI_EXEC_WRAPPER = "/bin/false"; + await backend.startTurn({ + prompt: "Return to Ithaca", signal: new AbortController().signal, + bridge: { emit: frame => frames.push(frame), requestPermission: async () => ({ behavior: "allow" }) }, + }); + expect(terminal(frames)).toMatchObject({ outcome: "success" }); + expect(receivedInit.backend.exec, exec === undefined ? "PI_WORKER_OMITTED_EXEC_REMAINS_LIVE" : "PI_WORKER_HOST_EXEC").toEqual(exec ?? { wrapper: "/bin/false" }); + } + } finally { + if (old === undefined) delete process.env.BRAIN_UI_EXEC_WRAPPER; else process.env.BRAIN_UI_EXEC_WRAPPER = old; + } +}); diff --git a/packages/ui-sdk/src/internal.ts b/packages/ui-sdk/src/internal.ts index 3b62c75ae..fd38d5893 100644 --- a/packages/ui-sdk/src/internal.ts +++ b/packages/ui-sdk/src/internal.ts @@ -14,7 +14,7 @@ export { GIT_LOCK_KEY } from "./server/lock-keys.js"; export { VERSION_PROBE_TIMEOUT_MS } from "./server/version-probe.js"; export { WEB_SEARCH_FALLBACK_ON, WEB_SEARCH_PROVIDERS, WEB_SEARCH_PROVIDER_KEYS, hasWebSearchCredential, readWebSearchOverride, readWebSearchRouting, resolveWebSearchConfigPath, webSearchBrief, webSearchProvider } from "./server/web-search.js"; export { canonicalModelId } from "./protocol-helpers.js"; -export { execWrapperSpawnOptions } from "./server/exec-wrapper.js"; +export { execWrapperSpawnOptions, workerExecHelpers } from "./server/exec-wrapper.js"; export { assertScratchMask, assertScratchMaskAsync } from "./server/bridge-tools/mask.js"; export { startInferenceRelay, ANTHROPIC_INFERENCE_ROUTES, INFERENCE_PLACEHOLDER, WORKER_INFERENCE_DIR, WORKER_INFERENCE_SOCKET, MAX_INFERENCE_REQUEST_BYTES } from "./server/inference-relay.js"; diff --git a/packages/ui-sdk/src/server/backend-module.ts b/packages/ui-sdk/src/server/backend-module.ts index be0253a3d..743e151b4 100644 --- a/packages/ui-sdk/src/server/backend-module.ts +++ b/packages/ui-sdk/src/server/backend-module.ts @@ -94,6 +94,8 @@ export interface BackendModelSource { /** @experimental Inputs a host supplies without knowing a backend's implementation. */ export interface BackendModuleContext { + /** Resolved host wrapper. Explicit {} disables wrapping; omission retains ambient compatibility. */ + exec?: import("./exec-wrapper.js").ExecWrapperConfig; /** Trusted host maintenance admission for startup helpers; never worker authority. */ maintenance?: import("./maintenance-worker.js").MaintenanceHost; brainPath: string; diff --git a/packages/ui-sdk/src/server/exec-wrapper.ts b/packages/ui-sdk/src/server/exec-wrapper.ts index 67f2d2582..c9715cfab 100644 --- a/packages/ui-sdk/src/server/exec-wrapper.ts +++ b/packages/ui-sdk/src/server/exec-wrapper.ts @@ -29,8 +29,11 @@ * the argv, the spawn options are empty, and a kill is the plain kill it * always was. That path is what every existing deployment is on. */ +import { statSync } from "node:fs"; import { isAbsolute } from "node:path"; +import { BackendRequestError } from "./backend.js"; + /** * The environment variable each package's chokepoint reads this from. * @@ -129,6 +132,27 @@ function absoluteProgram(program: string, wrapper: string): string { return resolved; } +/** + * The helpers a worker runs itself, as the read-only paths its view needs + * (#1429). Each names a regular file, directly or through a symlink: binding a + * directory would hand the worker its siblings. Checked before any relay, + * reservation or worker is acquired; the host keeps its targets stable from + * here through launch. + */ +export function workerExecHelpers(config: ExecWrapperConfig): string[] { + const helpers: string[] = []; + for (const [role, path] of [["wrapper", config.wrapper], ["killer", config.killer]] as const) { + if (!path) continue; + let regular = false; + try { regular = isAbsolute(path) && statSync(path).isFile(); } catch { /* Missing or unreadable: refused below. */ } + if (!regular) { + throw new BackendRequestError(`The exec ${role} ${JSON.stringify(path)} must be an absolute path to a regular file an agent worker can run.`); + } + helpers.push(path); + } + return helpers; +} + /** * Spawn options to merge in. Empty without a wrapper, so an unwrapped spawn is * byte-identical to what it was before this existed. diff --git a/packages/ui-server/src/agent/backend.ts b/packages/ui-server/src/agent/backend.ts index 3e4338eb0..948d60375 100644 --- a/packages/ui-server/src/agent/backend.ts +++ b/packages/ui-server/src/agent/backend.ts @@ -41,6 +41,8 @@ function toBackendLog(log: Logger): BackendLogFn { } export interface BackendRegistryOptions { + /** Resolved host wrapper. Explicit {} disables wrapping; omission retains ambient compatibility. */ + exec?: import("@schlessera/brain-ui-sdk/server").ExecWrapperConfig; maintenance?: import("@schlessera/brain-ui-sdk/internal").MaintenanceHost; /** Explicit host minima forwarded to every active descriptor. */ versionRequirements?: Readonly>; @@ -394,13 +396,15 @@ export async function probeBackendRuntimes( brainPath: string, log?: Logger, load: (specifier: string) => unknown = (specifier) => createRequire(import.meta.url)(specifier), - versionRequirements?: Readonly> + versionRequirements?: Readonly>, + exec?: import("@schlessera/brain-ui-sdk/server").ExecWrapperConfig ): Promise { const validated = validateBackendVersionRequirements(agent, versionRequirements); const probes: BackendRuntimeProbe[] = []; for (const entry of activeFirstPartyBackends(agent)) { const descriptor = backendDescriptorFromModule(entry.id, load(entry.specifier)); const report = await verifyBackendRequirements(descriptor, { + exec, brainPath, config: { ...agent }, profiles: [], @@ -495,6 +499,7 @@ export function createBackendRegistry(options: BackendRegistryOptions): BackendR const { descriptor } = parsed; const baseContext: BackendModuleContext = { + exec: options.exec, ...(options.maintenance ? { maintenance: options.maintenance } : {}), brainPath, config: { ...agent }, diff --git a/packages/ui-server/src/app.ts b/packages/ui-server/src/app.ts index 2f9a3835a..89bc87e2c 100644 --- a/packages/ui-server/src/app.ts +++ b/packages/ui-server/src/app.ts @@ -372,7 +372,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise { + const root = mkdtempSync(join(tmpdir(), "odysseus-app-exec-")); + const old = process.env.BRAIN_UI_EXEC_WRAPPER; + const wrap = (name: string) => { + const path = join(root, name), log = join(root, name + ".log"); + // Boot maintenance runs with a private /tmp; only record when the host + // path exists. Backend probes/tools below run at their real spawn boundary. + writeFileSync(path, `#!/bin/sh\nif [ -d '${root}' ]; then printf '%s\\n' "$@" >> '${log}'; fi\nexec "$@"\n`, { mode: 0o755 }); + return { path, log }; + }; + const a = wrap("a"), b = wrap("b"), c = wrap("c"); + process.env.BRAIN_UI_EXEC_WRAPPER = b.path; + const binary = join(root, "claude"); + writeFileSync(binary, '#!/bin/sh\necho "9.9.9 (Claude Code)"\n', { mode: 0o755 }); + const makeClaude = claude.createClaudeBackend, makePi = pi.createPiBackend; + const queryFn = (() => ({ + initializationResult: async () => ({}), getSettings: async () => ({ effective: {}, sources: [] }), + async *[Symbol.asyncIterator]() { + yield { type: "system", subtype: "init", session_id: "odysseus", claude_code_version: "9.9.9" }; + yield { type: "result", subtype: "success", session_id: "odysseus", total_cost_usd: 0, duration_ms: 1, num_turns: 1 }; + }, + })) as never; + const claudeFactory = spyOn(claude, "createClaudeBackend").mockImplementation(options => makeClaude({ ...options, queryFn })); + const piFactory = spyOn(pi, "createPiBackend").mockImplementation(options => makePi({ + ...options, sessionFactory: { + async newSession(_profile, toolkit) { + return { sessionId: "odysseus", subscribe: () => () => {}, + async prompt() { + const result = await toolkit!.tools.find(t => t.name === "bash")!.execute("host-exec", + { command: "echo Ithaca" }, undefined, undefined, {} as never); + expect(result.content).toEqual([{ type: "text", text: expect.stringContaining("Ithaca") }]); + }, + abort: async () => {}, dispose() {}, getSessionStats: () => ({ cost: 0 }), + }; + }, + async openSession() { throw new Error("new turns only"); }, + }, + })); + const apps: Awaited>[] = []; + const failures: unknown[] = []; + try { + for (const [index, exec] of [{ wrapper: a.path }, { wrapper: c.path }, {}, undefined].entries()) { + const brain = join(root, "brain-" + index); + mkdirSync(join(brain, "node_modules/.bin"), { recursive: true }); + writeFileSync(join(brain, "node_modules/.bin/brain"), '#!/bin/sh\necho "0.42.0"\n', { mode: 0o755 }); + const config = resolveServerConfig({ NODE_ENV: "test", AUTH_MODE: "none", HOST: "127.0.0.1", + DB_PATH: ":memory:", BRAIN_PATH: brain, AGENT_BACKEND: backendId, CLAUDE_CODE_PATH: binary, + PI_MODEL: "anthropic/claude-sonnet-4-5", BRAIN_UI_PRICING_DISCOVERY: "0" }); + config.exec = exec; + apps.push(await createFixtureApp({ config, observability: createRecordingObservability(), + versionRequirements: backendId === "claude" ? { backends: { claude: { runtime: "2.1.283" } } } : {} })); + } + // Both A and C exist before either runs: constructing C must not retarget A. + for (const app of apps) { + const backend = await app.wsHost.registry.getDefaultBackend(); + const frames: any[] = []; + await backend.startTurn({ prompt: "Return to Ithaca", signal: new AbortController().signal, + bridge: { emit: frame => frames.push(frame), requestPermission: async () => ({ behavior: "allow" }) } }); + expect(frames.at(-1), "APP_BACKEND_TURN_COMPLETES").toMatchObject({ type: "result", outcome: "success" }); + } + for (const wrapper of [a, b, c]) { + expect(existsSync(wrapper.log), "APP_HOST_WRAPPER_USED").toBe(true); + const lines = readFileSync(wrapper.log, "utf8").trim().split("\n"); + if (backendId === "claude") { + expect(lines.filter(line => line === binary), "APP_RUNTIME_PROBE_WRAPPER").toHaveLength(3); + } else { + expect(lines.filter(line => line === "echo Ithaca"), "APP_PI_BASH_WRAPPER").toHaveLength(1); + } + } + } catch (error) { + failures.push(error); + } finally { + const cleanup = [ + ...apps.reverse().map(app => () => app.close()), + () => claudeFactory.mockRestore(), + () => piFactory.mockRestore(), + () => { if (old === undefined) delete process.env.BRAIN_UI_EXEC_WRAPPER; else process.env.BRAIN_UI_EXEC_WRAPPER = old; }, + () => rmSync(root, { recursive: true, force: true }), + ]; + for (const action of cleanup) { try { await action(); } catch (error) { failures.push(error); } } + } + if (failures.length) throw new AggregateError(failures, "App fixture or cleanup failed"); +});