From f7f3b3f1d390081ac54aa52dccb4389316d86ecc Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sun, 18 Oct 2026 00:02:49 +0200 Subject: [PATCH 1/4] CONTRACT: fix(backends): use the host app exec wrapper Carry optional host exec through startup and per-turn probes, backend construction, Claude SDK spawning and Pi worker-local tools. Preserve explicit-empty and omitted-host compatibility. Add keyless runtime and propagation mutation coverage; include the additive contract, minor changeset, API report and moved citations. --- .changeset/host-backend-exec.md | 11 +++ api-report/ui-backend-claude.txt | 1 + api-report/ui-backend-pi.txt | 1 + api-report/ui-sdk.txt | 1 + docs/decisions/async-collaboration.md | 2 +- docs/decisions/claude-code-runtime.md | 2 +- docs/decisions/design-kit/answer-blocks.md | 2 +- docs/decisions/design-kit/show-block-brief.md | 2 +- docs/decisions/design-kit/tool-loading.md | 2 +- docs/decisions/governed-writable-workers.md | 4 +- docs/decisions/http-api-boundary.md | 8 +- docs/decisions/module-mcp-tools.md | 2 +- docs/decisions/pi-endpoint-routing.md | 2 +- docs/decisions/policy-write-boundary.md | 2 +- docs/decisions/runtime-requirements.md | 4 +- docs/decisions/voice-permission.md | 4 +- docs/integration-contract.md | 2 + docs/integration-contract/package-api.md | 14 +++ .../plans/async-collaboration-requirements.md | 2 +- docs/plans/async-collaboration.md | 4 +- docs/plans/navigation-recovery.md | 2 +- docs/plans/policy-write-boundary.md | 2 +- packages/ui-backend-claude/src/backend.ts | 3 +- packages/ui-backend-claude/src/config/env.ts | 12 ++- packages/ui-backend-claude/src/module.ts | 5 +- packages/ui-backend-claude/src/options.ts | 2 + packages/ui-backend-claude/src/sdk-options.ts | 4 +- packages/ui-backend-claude/src/turn-runner.ts | 4 +- .../ui-backend-claude/tests/host-exec.test.ts | 82 +++++++++++++++++ .../tests/runtime-probe.test.ts | 28 ++++++ packages/ui-backend-pi/src/backend-options.ts | 2 + packages/ui-backend-pi/src/backend.ts | 3 +- packages/ui-backend-pi/src/config/env.ts | 13 +-- packages/ui-backend-pi/src/module.ts | 1 + .../ui-backend-pi/src/session-resources.ts | 1 + packages/ui-backend-pi/src/tools.ts | 8 +- packages/ui-backend-pi/src/worker-entry.ts | 2 +- packages/ui-backend-pi/src/worker-session.ts | 2 +- .../ui-backend-pi/tests/exec-wrapper.test.ts | 29 +++++- .../tests/worker-session.test.ts | 18 ++++ packages/ui-sdk/src/server/backend-module.ts | 2 + packages/ui-server/src/agent/backend.ts | 7 +- packages/ui-server/src/app.ts | 3 +- .../ui-server/tests/backend-host-exec.test.ts | 90 +++++++++++++++++++ 44 files changed, 349 insertions(+), 48 deletions(-) create mode 100644 .changeset/host-backend-exec.md create mode 100644 packages/ui-backend-claude/tests/host-exec.test.ts create mode 100644 packages/ui-server/tests/backend-host-exec.test.ts diff --git a/.changeset/host-backend-exec.md b/.changeset/host-backend-exec.md new file mode 100644 index 000000000..2e386afa2 --- /dev/null +++ b/.changeset/host-backend-exec.md @@ -0,0 +1,11 @@ +--- +"@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 wrapper resolved at construction. 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 056890f45..a508102c3 100644 --- a/api-report/ui-sdk.txt +++ b/api-report/ui-sdk.txt @@ -4938,6 +4938,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 9e7b3c94b..bceaf1c45 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 8101acae1..fe3d1a0e4 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:504-504`) and is null when unset (`claudeCodePath`, `packages/ui-server/src/config/env.ts:1070`). The whole `agent` block is copied into the backend's module config (`config: { ...agent }`, - `packages/ui-server/src/agent/backend.ts:497`), read back as a string + `packages/ui-server/src/agent/backend.ts:502`), 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 `||` 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 e0e355a57..1414ce3c9 100644 --- a/docs/decisions/governed-writable-workers.md +++ b/docs/decisions/governed-writable-workers.md @@ -1019,7 +1019,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. @@ -1978,7 +1978,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 ab5cc0818..dfe5a880c 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:728`), the API guard follows -public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:748`), and the -WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:875`). +(`createHealthRoutes`, `packages/ui-server/src/app.ts:729`), the API guard follows +public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:749`), and the +WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:876`). Static serving is conditional (`if (options.staticRoot)`, -`packages/ui-server/src/app.ts:895-916`). That order establishes access; +`packages/ui-server/src/app.ts:896-917`). 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 3553fb0a5..31ed29662 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 a44d27961..078f59033 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:112-177`) | 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:390-437`) | 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:392-441`) | 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/voice-permission.md b/docs/decisions/voice-permission.md index 3d79f4f3b..9c436cdc7 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 50ae57117..c88e23b67 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). @@ -547,3 +548,4 @@ authenticated recovery routes with an explicit logical scope, described in [the HTTP contract](integration-contract/http.md#persisted-claude-auto-memory-history-additive-1658). The host-only copy-out callback is described in [the package API](integration-contract/package-api.md#native-persisted-claude-memory-observation-additive-1658). + diff --git a/docs/integration-contract/package-api.md b/docs/integration-contract/package-api.md index 51707ff28..bfc604914 100644 --- a/docs/integration-contract/package-api.md +++ b/docs/integration-contract/package-api.md @@ -3425,3 +3425,17 @@ the same identity is allowed; changing an established identity or patching a 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 resolve that fallback once at construction and retain both wrapper +and killer. Existing validation, argv, environment and permission semantics +remain unchanged; the optional wrapper never replaces worker isolation. diff --git a/docs/plans/async-collaboration-requirements.md b/docs/plans/async-collaboration-requirements.md index 8828e0ee3..d9f9ca3b8 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:748`). + (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:749`). 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 7ac838eb7..5e89d6fde 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:748`) follows public routes; +- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:749`) 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 @@ -143,7 +143,7 @@ system gate. It is not a prerequisite to rebuild or a new independent package. - **Filtered environment and project settings:** (`export function envSnapshot(`, `packages/ui-backend-claude/src/config/env.ts:182-190`) and (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:96-472`). Restricted execution needs narrower credentials/configuration; it does not start from the old full-host-env assumption. -- **Pi resources and extension gate:** (`export function createSessionResources(`, `packages/ui-backend-pi/src/session-resources.ts:29-144`) and +- **Pi resources and extension gate:** (`export function createSessionResources(`, `packages/ui-backend-pi/src/session-resources.ts:29-145`) and (`export function createPermissionGate(`, `packages/ui-backend-pi/src/permission-gate.ts:95-124`). Built-ins are disabled, but ambient resources/extensions and in-process execution still need containment. - **Cost timing:** (`store.rollupRun(runId);`, `packages/ui-server/src/activity/recorder.ts:651`) settles after execution; diff --git a/docs/plans/navigation-recovery.md b/docs/plans/navigation-recovery.md index 27d9d06a0..73cf5a80c 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:748`). +- API authorization: (`app.use("/api/*", authGuard(authMode, auth, db));`, `packages/ui-server/src/app.ts:749`). - Queued acceptance: (`// Queue it as the session's next turn; report queued immediately.`, `packages/ui-server/src/ws/run-session.ts:936-981`). 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 dfa36a06b..559e675d1 100644 --- a/packages/ui-backend-claude/src/backend.ts +++ b/packages/ui-backend-claude/src/backend.ts @@ -1,3 +1,4 @@ +import { ambientExecConfig } from "./config/env.js"; import { getSessionMessages as sdkGetSessionMessages, listSessions as sdkListSessions, @@ -55,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, exec: { ...(options.exec ?? ambientExecConfig()) }, ...(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/tests/host-exec.test.ts b/packages/ui-backend-claude/tests/host-exec.test.ts new file mode 100644 index 000000000..fd780f3ce --- /dev/null +++ b/packages/ui-backend-claude/tests/host-exec.test.ts @@ -0,0 +1,82 @@ +import { mockWorkerHostForSdkStream } from "./helpers/worker-host.js"; +import { expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, existsSync, rmSync, 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"; + +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]) { + 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); + 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 ? b.path : exec.wrapper; + expect(commands.at(-1), "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 of [a, b, c]) { + expect(existsSync(wrapper.log), "PER_TURN_PROBE_USED_HOST_WRAPPER").toBe(true); + expect(readFileSync(wrapper.log, "utf8").trim().split("\n")).toEqual([ + binary, "--version", binary, "--version", + ]); + } + } 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 }); + } +}); diff --git a/packages/ui-backend-claude/tests/runtime-probe.test.ts b/packages/ui-backend-claude/tests/runtime-probe.test.ts index 993334e2f..3037647ff 100644 --- a/packages/ui-backend-claude/tests/runtime-probe.test.ts +++ b/packages/ui-backend-claude/tests/runtime-probe.test.ts @@ -190,3 +190,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 4375f2697..09eb4de9a 100644 --- a/packages/ui-backend-pi/src/backend.ts +++ b/packages/ui-backend-pi/src/backend.ts @@ -1,3 +1,4 @@ +import { ambientExecConfig } from "./config/env.js"; /** * @schlessera/brain-backend-pi — the OSS-default AgentBackend, built on the upstream * pi coding-agent SDK (@earendil-works/pi-coding-agent). @@ -73,7 +74,7 @@ const CAPABILITIES: BackendCapabilities = { }; export function createPiBackend(options: CreatePiBackendOptions): AgentBackend { - options = { ...options, ...(options.versionRequirements ? { versionRequirements: { ...options.versionRequirements } } : {}) }; + options = { ...options, exec: { ...(options.exec ?? ambientExecConfig()) }, ...(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..9713a897e 100644 --- a/packages/ui-backend-pi/src/worker-session.ts +++ b/packages/ui-backend-pi/src/worker-session.ts @@ -114,7 +114,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: 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 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/worker-session.test.ts b/packages/ui-backend-pi/tests/worker-session.test.ts index c23f6201c..eb2ce763b 100644 --- a/packages/ui-backend-pi/tests/worker-session.test.ts +++ b/packages/ui-backend-pi/tests/worker-session.test.ts @@ -375,3 +375,21 @@ 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[] = []; + await createPiBackend({ brainPath: brain, sessionDir, exec }).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, "PI_WORKER_HOST_EXEC").toEqual(exec ?? { wrapper: "/bin/true" }); + } + } 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/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-server/src/agent/backend.ts b/packages/ui-server/src/agent/backend.ts index 1b718bc3d..f2044030a 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>; @@ -392,13 +394,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: [], @@ -492,6 +496,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 06dcc7fa3..3c0e7caf9 100644 --- a/packages/ui-server/src/app.ts +++ b/packages/ui-server/src/app.ts @@ -340,7 +340,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>[] = []; + 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); + } + } + } finally { + const cleanupErrors: unknown[] = []; + for (const app of apps.reverse()) { try { await app.close(); } catch (error) { cleanupErrors.push(error); } } + 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 }); + if (cleanupErrors.length) throw new AggregateError(cleanupErrors, "App fixture cleanup failed"); + } +}); From cd980abec31bdfdf092ed7ffa24e5ee03735bbd5 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sun, 18 Oct 2026 00:41:57 +0200 Subject: [PATCH 2/4] test(backends): retain app fixture failures through cleanup --- .../ui-server/tests/backend-host-exec.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/ui-server/tests/backend-host-exec.test.ts b/packages/ui-server/tests/backend-host-exec.test.ts index b8523da65..31d41f1a2 100644 --- a/packages/ui-server/tests/backend-host-exec.test.ts +++ b/packages/ui-server/tests/backend-host-exec.test.ts @@ -50,6 +50,7 @@ for (const backendId of ["claude", "pi"] as const) test(`apps pass their host wr }, })); 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); @@ -79,12 +80,17 @@ for (const backendId of ["claude", "pi"] as const) test(`apps pass their host wr expect(lines.filter(line => line === "echo Ithaca"), "APP_PI_BASH_WRAPPER").toHaveLength(1); } } + } catch (error) { + failures.push(error); } finally { - const cleanupErrors: unknown[] = []; - for (const app of apps.reverse()) { try { await app.close(); } catch (error) { cleanupErrors.push(error); } } - 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 }); - if (cleanupErrors.length) throw new AggregateError(cleanupErrors, "App fixture cleanup failed"); + 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"); }); From b4cf5234c72f879cbaa313a641403c486fa24e54 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Sun, 18 Oct 2026 01:04:11 +0200 Subject: [PATCH 3/4] CONTRACT: preserve late ambient exec fallback for omitted backend options Keep explicitly supplied host configuration copied per backend, including empty configuration. Preserve omission through standalone construction and resolve the host fallback for each Pi worker turn. Add intended-red controls for ambient changes after construction and correct the additive contract and changeset wording. --- .changeset/host-backend-exec.md | 2 +- docs/integration-contract/package-api.md | 6 ++++-- packages/ui-backend-claude/src/backend.ts | 3 +-- .../ui-backend-claude/tests/host-exec.test.ts | 16 +++++++++------- packages/ui-backend-pi/src/backend.ts | 3 +-- packages/ui-backend-pi/src/worker-session.ts | 4 ++-- .../ui-backend-pi/tests/worker-session.test.ts | 6 ++++-- 7 files changed, 22 insertions(+), 18 deletions(-) diff --git a/.changeset/host-backend-exec.md b/.changeset/host-backend-exec.md index 2e386afa2..e81cacedb 100644 --- a/.changeset/host-backend-exec.md +++ b/.changeset/host-backend-exec.md @@ -8,4 +8,4 @@ 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 wrapper resolved at construction. +ambient fallback at the existing turn and tool-spawn boundaries. diff --git a/docs/integration-contract/package-api.md b/docs/integration-contract/package-api.md index 932ee2c3e..bb54a694f 100644 --- a/docs/integration-contract/package-api.md +++ b/docs/integration-contract/package-api.md @@ -3450,6 +3450,8 @@ 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 resolve that fallback once at construction and retain both wrapper -and killer. Existing validation, argv, environment and permission semantics +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. Existing +validation, argv, environment and permission semantics remain unchanged; the optional wrapper never replaces worker isolation. diff --git a/packages/ui-backend-claude/src/backend.ts b/packages/ui-backend-claude/src/backend.ts index 559e675d1..c0e7d4a7c 100644 --- a/packages/ui-backend-claude/src/backend.ts +++ b/packages/ui-backend-claude/src/backend.ts @@ -1,4 +1,3 @@ -import { ambientExecConfig } from "./config/env.js"; import { getSessionMessages as sdkGetSessionMessages, listSessions as sdkListSessions, @@ -56,7 +55,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, exec: { ...(options.exec ?? ambientExecConfig()) }, ...(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/tests/host-exec.test.ts b/packages/ui-backend-claude/tests/host-exec.test.ts index fd780f3ce..db06892fb 100644 --- a/packages/ui-backend-claude/tests/host-exec.test.ts +++ b/packages/ui-backend-claude/tests/host-exec.test.ts @@ -48,6 +48,7 @@ test("Claude factory/module turns carry host exec to runtime probe and SDK worke 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({ @@ -56,22 +57,23 @@ test("Claude factory/module turns carry host exec to runtime probe and SDK worke }) : 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 ? b.path : exec.wrapper; - expect(commands.at(-1), "SDK_WORKER_HOST_ARGV").toEqual([ + 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 of [a, b, c]) { - expect(existsSync(wrapper.log), "PER_TURN_PROBE_USED_HOST_WRAPPER").toBe(true); - expect(readFileSync(wrapper.log, "utf8").trim().split("\n")).toEqual([ - binary, "--version", binary, "--version", - ]); + 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(); diff --git a/packages/ui-backend-pi/src/backend.ts b/packages/ui-backend-pi/src/backend.ts index 09eb4de9a..6538511a8 100644 --- a/packages/ui-backend-pi/src/backend.ts +++ b/packages/ui-backend-pi/src/backend.ts @@ -1,4 +1,3 @@ -import { ambientExecConfig } from "./config/env.js"; /** * @schlessera/brain-backend-pi — the OSS-default AgentBackend, built on the upstream * pi coding-agent SDK (@earendil-works/pi-coding-agent). @@ -74,7 +73,7 @@ const CAPABILITIES: BackendCapabilities = { }; export function createPiBackend(options: CreatePiBackendOptions): AgentBackend { - options = { ...options, exec: { ...(options.exec ?? ambientExecConfig()) }, ...(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/worker-session.ts b/packages/ui-backend-pi/src/worker-session.ts index 9713a897e..99a5ad73c 100644 --- a/packages/ui-backend-pi/src/worker-session.ts +++ b/packages/ui-backend-pi/src/worker-session.ts @@ -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"; @@ -114,7 +114,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: { exec: backend.exec, brainPath: backend.brainPath, profiles: configuredProfiles(backend), model: backend.model, + const init = { type: "init", governed: workerSandbox?.profile === "governed", sharedLock: Boolean(workerLock), backend: { exec: backend.exec ?? ambientExecConfig(), 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 diff --git a/packages/ui-backend-pi/tests/worker-session.test.ts b/packages/ui-backend-pi/tests/worker-session.test.ts index eb2ce763b..2ff81f9c2 100644 --- a/packages/ui-backend-pi/tests/worker-session.test.ts +++ b/packages/ui-backend-pi/tests/worker-session.test.ts @@ -382,12 +382,14 @@ test("host exec survives the Pi worker init boundary; omitted factory options re try { for (const exec of [{ wrapper: "/bin/false", killer: "/bin/true" }, {}, undefined]) { const frames: ServerMessage[] = []; - await createPiBackend({ brainPath: brain, sessionDir, exec }).startTurn({ + 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, "PI_WORKER_HOST_EXEC").toEqual(exec ?? { wrapper: "/bin/true" }); + 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; From 37a3b077cd7b54fa57f2fcee8412974d7650ee92 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Thu, 22 Oct 2026 11:53:29 +0200 Subject: [PATCH 4/4] CONTRACT: fix(backends): show host exec helpers inside Pi workers Pi serializes the host exec into worker-local grep/bash, so the wrapper and killer must exist inside the worker's view. Bind each configured helper file read-only into ordinary and restricted views, never its directory, and select the exec once per worker before any relay, reservation or worker is acquired. Refuse a wrapper or killer that is not an absolute regular file (directly or through a symlink), and refuse a nonregular wrapper for an ordinary Claude worker, so a directory is never bound into a worker below its masks. Real bubblewrap governed/strict/restricted workers run the real worker-local tools through a masked, aliased wrapper and cancel through the killer. Refs #1429 Co-Authored-By: Claude Opus 5.5 --- .changeset/host-backend-exec.md | 4 + api-report/ui-sdk.txt | 1 + docs/decisions/claude-code-runtime.md | 2 +- docs/decisions/governed-writable-workers.md | 4 +- docs/integration-contract.md | 1 - docs/integration-contract/package-api.md | 13 +- docs/plans/host-owned-web-search.md | 2 +- .../ui-backend-claude/src/worker-spawn.ts | 4 +- .../ui-backend-claude/tests/host-exec.test.ts | 22 +++- packages/ui-backend-pi/src/worker-session.ts | 12 +- .../tests/fixtures/host-exec-worker.ts | 40 ++++++ .../tests/worker-host-exec.test.ts | 119 ++++++++++++++++++ packages/ui-sdk/src/internal.ts | 2 +- packages/ui-sdk/src/server/exec-wrapper.ts | 24 ++++ 14 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 packages/ui-backend-pi/tests/fixtures/host-exec-worker.ts create mode 100644 packages/ui-backend-pi/tests/worker-host-exec.test.ts diff --git a/.changeset/host-backend-exec.md b/.changeset/host-backend-exec.md index e81cacedb..8b914dbd6 100644 --- a/.changeset/host-backend-exec.md +++ b/.changeset/host-backend-exec.md @@ -9,3 +9,7 @@ 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-sdk.txt b/api-report/ui-sdk.txt index 08b00943c..a0225a99c 100644 --- a/api-report/ui-sdk.txt +++ b/api-report/ui-sdk.txt @@ -1145,6 +1145,7 @@ export "./internal" (./src/internal.ts) webSearchProvider workerCleanupUnconfirmed workerCommandPolicyNote + workerExecHelpers workerHostBoundary workerHostProven workerIdentityHelper diff --git a/docs/decisions/claude-code-runtime.md b/docs/decisions/claude-code-runtime.md index 7e2da6453..93894be2e 100644 --- a/docs/decisions/claude-code-runtime.md +++ b/docs/decisions/claude-code-runtime.md @@ -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/governed-writable-workers.md b/docs/decisions/governed-writable-workers.md index 75af39d87..8f12bf68a 100644 --- a/docs/decisions/governed-writable-workers.md +++ b/docs/decisions/governed-writable-workers.md @@ -1638,11 +1638,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 diff --git a/docs/integration-contract.md b/docs/integration-contract.md index c88e23b67..6b10ad49d 100644 --- a/docs/integration-contract.md +++ b/docs/integration-contract.md @@ -548,4 +548,3 @@ authenticated recovery routes with an explicit logical scope, described in [the HTTP contract](integration-contract/http.md#persisted-claude-auto-memory-history-additive-1658). The host-only copy-out callback is described in [the package API](integration-contract/package-api.md#native-persisted-claude-memory-observation-additive-1658). - diff --git a/docs/integration-contract/package-api.md b/docs/integration-contract/package-api.md index 678d698bf..dba82ca15 100644 --- a/docs/integration-contract/package-api.md +++ b/docs/integration-contract/package-api.md @@ -3572,6 +3572,13 @@ 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. Existing -validation, argv, environment and permission semantics -remain unchanged; the optional wrapper never replaces worker isolation. +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. 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/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 index db06892fb..b52ca7780 100644 --- a/packages/ui-backend-claude/tests/host-exec.test.ts +++ b/packages/ui-backend-claude/tests/host-exec.test.ts @@ -1,12 +1,13 @@ import { mockWorkerHostForSdkStream } from "./helpers/worker-host.js"; import { expect, spyOn, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from "node:fs"; +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(); @@ -82,3 +83,22 @@ test("Claude factory/module turns carry host exec to runtime probe and SDK worke 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-pi/src/worker-session.ts b/packages/ui-backend-pi/src/worker-session.ts index 99a5ad73c..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"; @@ -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: { exec: backend.exec ?? ambientExecConfig(), 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/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-sdk/src/internal.ts b/packages/ui-sdk/src/internal.ts index 91ca3e408..30a87a4d2 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 } 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/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.