Skip to content

CONTRACT: feat(ui-server): run governed and strict Claude turns on the turn egress proxy on managed storage - #2645

Merged
schlessera merged 9 commits into
feat/1665-egress-measurement-044from
feat/1665-turn-egress-activation-044
Oct 22, 2026
Merged

schlessera merged 9 commits into
feat/1665-egress-measurement-044from
feat/1665-turn-egress-activation-044

Conversation

@schlessera

@schlessera schlessera commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Refs #1665

Top layer of the #1665 stack. Parent: #2644 (feat/1665-egress-measurement-044). Bottom: #2399.

Round 2 applies the maintainer's 2026-10-22 rulings on #1665 (comment), which removes the two regressions that blocked round 1:

  1. Activation needs managed native storage. A governed or strict Claude turn runs on the turn proxy only when the native manager acquired its startup view with the stage operations. Without that storage the turn keeps today's network, as the ruling requires, and worker-host status says so. ws-scope-caps passes again.
  2. Custom Claude origins are authorized per host configuration. The relay forwards to the server's own route, and releases the host's Claude login to a custom origin only when BRAIN_UI_CLAUDE_OAUTH_ORIGINS lists it. On copied-fixture hosts subscription-login keeps today's behaviour. On managed hosts an operator must list the origin.

What changed

  • Turn proxy for Claude turns. Governed and strict Claude turns on a subscription profile run on their own turn egress proxy and the host inference relay, on hosts with managed native storage. Every connection becomes a durable egress_connection event, and a recording failure ends the turn. A connection to an outward host skips its approval card only while every classified call in progress is a visible read, or an approved outward action naming that host. Round 1 built this part; it is kept unchanged.
  • The managed-storage gate. GovernedTurnHold.managedStorage comes from isManagedWorkerStartupView, the same attestation the relay's route guard needs. run-session composes the proxy only when it is true.
  • Worker-host status. runtime.workerHost.governed.egress is { active, detail }. It is active: true on a Btrfs subvolume root named brain. detail names the routed turns (subscription Claude) and the unrouted ones (API-key Claude, Pi). This is an additive contract change, documented in docs/integration-contract/http.md.
  • Custom origins. The relay's upstream is the profile's baseUrl from BRAIN_UI_CLAUDE_PROFILES, else the server's ANTHROPIC_BASE_URL, else first-party. The base path is kept, and the worker's base URL is http://localhost<basePath>. Only https://api.anthropic.com and the exact origins in BRAIN_UI_CLAUDE_OAUTH_ORIGINS may receive the login. Anything else is refused twice before the credential is read: at the prompt gate, and again on every dispatch. Native settings may repeat the server's route in an equivalent spelling (same canonical origin and base path), but never move it. Redirects stay refused.
  • Model cache hardening. The model-discovery cache lives in the brain, where a governed worker can write. It now reads back only the fields discovery writes, so a cache entry can no longer give a profile a baseUrl, credential variable names, aliases or tools. An independent review found this; without the fix, brain content could pick the route within the authorized origins.

Why this shape

Ruling 1 limits default-on to managed storage, and leaves the relay-without-storage work to a follow-up: #2654. In production createApp refuses to boot without the native manager, so every hosted turn gets the proxy. The no-storage path is what copied-fixture hosts and direct WsHost compositions use.

Ruling 2's decision record requires an explicit authorization "in trusted host configuration for this credential", but no record named the shape. The coordinator chose a server environment variable (recorded in claude-code-runtime.md; the maintainer may override it). Brain content cannot reach the server environment. The host has one Claude login, so the list is per credential.

Migration

An operator whose subscription profile baseUrl or ANTHROPIC_BASE_URL points Claude at a custom origin must add that exact origin to BRAIN_UI_CLAUDE_OAUTH_ORIGINS (for example https://gateway.ithaca.invalid). Otherwise those turns are refused before their prompt is sent. The changeset and docs/hosting/agent-workers.md say so.

Proof

Head 06c3cec79, parent 547795928 (#2644, refreshed onto origin/main 4ba50d824).

Round 3 refresh. The coordinator's stack merge hit conflicts against main. This layer took the refreshed parent in three merges:

Stack squash simulation. In a scratch worktree at origin/main 4ba50d824, each layer's own diff (parent head → layer head) was squashed on in order: #2399 8406e0062, #2644 547795928, #2645 06c3cec79. All three applied without conflict, and the final tree equals this head's tree. Each head contains 4ba50d824 and merges cleanly with origin/main on its own.

bun run check:pr --base feat/1665-egress-measurement-044

Result on 06c3cec79: passed (exit 0). Lint, metadata and the fast phase pass (763 tests / 36 files), and import-graph feedback passes 3801 tests across 330 files. An earlier round 3 run on this head was killed from outside and produced no result.

Round 2's result on acc8e1ac1: passed (exit 0), 744 fast and 3724 import-graph tests. An earlier run on this head failed two tests while another preflight held the shared per-UID maintenance ledger (Maintenance temporary storage is busy, and scratch left read-only). Both files pass in isolation: sync-mirror and native-worker-startup-order, 15/15. The run before that found the env-descriptor list missing the new variable, which is fixed in acc8e1ac1.

  • Native hosted proof, round 3 (exclusive Btrfs slot, under the flock): bun scripts/prove-native-host.ts --backend=claude passed on head 06c3cec79 for app uid 0 and 1000 (2/2 each, exit 0). Both boot receipts report governed.egress.active: true.
  • Native hosted proof, round 2: the same command passed for app uid 0 and 1000 on head c5c0191af (the gate commit; the later commits change only the route comparison, cache reading, status wording, API reports and tests). On a real managed brain, the boot receipt reports governed.egress.active: true, and governed turns are admitted and complete. The proof does not yet assert per-connection records from that harness.
  • Focused suites pass: native-route-qualification (13, real native CLI offline in bwrap), turn-egress-composition (3), worker-host-status, ws-scope-caps (4), subscription-login (2), model-discovery, env, env-parity, api-surface and decision-citations (44). Types and lint are clean.
  • Failing-first. On round 1's head 1db46b91a, ws-scope-caps fails both Claude cases with Native output requires a manager-acquired immutable startup view and configured stage operations. On the pre-change runner source, the new custom-origin cases fail: CUSTOM_ORIGIN_LISTED_RECEIVES_HOST_LOGIN, and CUSTOM_ORIGIN_UNLISTED_REFUSED_AT_THE_GATE in both unlisted cases.
  • Mutations, each restored byte-for-byte:
    • Managed-storage gate removed from run-session: both Claude ws-scope-caps cases fail with the native-output error, and TURN_EGRESS_UNMANAGED_STORAGE_KEEPS_NETWORK fails.
    • Status always active: TURN_EGRESS_STATUS_UNMANAGED_INACTIVE fails.
    • Prompt-gate origin check skipped: CUSTOM_ORIGIN_UNLISTED_REFUSED_AT_THE_GATE fails for unlisted and other-port. The per-dispatch check still holds, so no credential is forwarded.
    • Gate and per-dispatch authorization both bypassed: CUSTOM_ORIGIN_UNLISTED_NO_CREDENTIAL_FORWARDING fails, because the login is forwarded to the unlisted gateway.
    • Host list not passed to the per-dispatch resolver: CUSTOM_ORIGIN_LISTED_RECEIVES_HOST_LOGIN fails.
    • Origin matching ignores the port: CUSTOM_ORIGIN_UNLISTED_NO_CREDENTIAL_FORWARDING fails for the other-port case.
    • Raw string route comparison: CUSTOM_ORIGIN_PROJECT_SAME_ROUTE_ALLOWED fails.
    • Route comparison ignores the path: CUSTOM_ORIGIN_PROJECT_OTHER_ROUTE_NO_FORWARDING fails.
    • Cache read back unsanitized: MODEL_CACHE_CARRIES_NO_ROUTE_OR_CREDENTIAL fails.
    • Round 1's outward-exception mutations (five) target code this round does not change.
  • Independent review: gpt-6-astra reviewed c5c0191af adversarially. It found four issues, all fixed in e159ab1bf/acc8e1ac1: the brain-writable model cache supplying a route, raw-string route comparison, status overstating API-key turns, and stale API reports. Base-path handling and redirect/refresh recipients were verified with no findings.
  • Citations: lines moved in turn-runner.ts, run-session.ts, governed-turn.ts, host-boundary.ts, protocol.ts, config/env.ts and worker-startup-view.ts. Every citation under them is re-anchored, and all 44 citation checks pass.

Checklist

  • Focused local preflight/behavioral receipts and complete selected hosted proof passed; actual head/base/job checkout evidence is in Proof. (Local preflight above; hosted proof is the coordinator's.)
  • Acceptance criteria from the issue are all met, or the gap is named below.
  • The PR and completed/advanced issues have verified release milestones under docs/process/github.md.
  • A changeset is included, or this changes nothing a consumer can see.
  • No personal data anywhere in the diff — the leakage gate covers the whole tree.
  • The integration contract index or relevant component is updated in this same commit, or nothing in the machine surface moved.
  • Docs under docs/ reflect the change, or nothing documented moved.
  • Provider history stays append-only (docs/decisions/provider-history.md): the provider-history guard passes, or nothing on a turn's path changed.

Anything left open

Merging this layer turns egress on by default for every hosted governed or strict subscription Claude turn on managed storage, which is every production createApp host.

Remaining #1665 work, not delivered by this stack:

  • The relay without managed storage: #2654, the ruling's follow-up.
  • Pi: an ordinary relay consumer with per-request native auth, refresh and command semantics, plus Pi's per-credential origin enforcement and its host-config shape. The dormant local Pi preparation is a maintenance-dispatch prerequisite with no production caller, not a relay consumer, so it is not published here.
  • API-key Claude profiles through the relay with a host-held key.
  • Per-connection egress records asserted in the native hosted harness, and the default mail-submission outward hosts.
  • G6 taint on non-relay connections (ui-server: a session that ingested untrusted content gets tighter egress and outward rules and flags its steering writes #1667).
  • brain-hosting-template should list BRAIN_UI_CLAUDE_OAUTH_ORIGINS in its environment contract once this ships.

🤖 Generated with Claude Code

schlessera and others added 2 commits October 22, 2026 13:42
…call

The turn proxy cannot tell which process opened a connection, so G5's
exception follows the tool calls in progress: between a call's outward
classification and its result. A connection to an outward host skips its
card only while every classified call in progress is a visible read or an
approved outward action naming that host. Any unknown or opaque call in
progress withholds the exception. The backend receives a bridge that feeds
these observations to the proxy.

A host without a governance recorder still enforces the turn's egress
policy; only the records are absent.

Refs #1665

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e turn egress proxy

The gate allows default-on when each failing path's gap is stated in the
capabilities note. A governed or strict Claude turn now gets its own turn
proxy and the host inference relay. Each connection is a durable governance
event attributed to the turn, and a recording failure ends the turn. The
capabilities note states recorded egress, and the remaining reachable-socket
gap (#2633), only when the backend actually routed the worker through the
proxy. API-key Claude profiles and Pi keep their current network and note.

The internal WorkerNoteFacts gains optional egress; the package-api contract
component records the automatic server selection. Moved run-session and
worker-sandbox citations follow their anchors.

Refs #1665

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@schlessera
schlessera added this pull request to stack #2646 October 22, 2026 12:19
@schlessera schlessera added type: feat A new capability or a change in user-visible behaviour. area: ui-server Hono app factory: turn coordinator, auth, session catalog, routes, cron. area: backends AgentBackend implementations and the seam they share. security Auth, isolation, egress, secrets, or anything in the threat model. contract Touches the integration contract: CONTRACT: commit, contract doc updated in the same commit. labels Oct 22, 2026
@schlessera schlessera added this to the 0.44.0 milestone Oct 22, 2026
@schlessera schlessera changed the title feat/1665 turn egress activation 044 CONTRACT: feat(ui-server): run governed and strict Claude turns on the turn egress proxy Oct 22, 2026
schlessera and others added 3 commits October 22, 2026 15:34
…tom Claude origins

Per the maintainer's 2026-10-22 rulings on #1665:

- Governed and strict Claude turns run on the turn egress proxy only when the
  native manager acquired their startup view with its stage operations.
  Elsewhere they keep today's network. `runtime.workerHost.governed.egress`
  (`{ active, detail }`) reports which applies.
- The Claude inference relay forwards to the server-selected route (profile
  baseUrl, else ANTHROPIC_BASE_URL) and releases the host login to a custom
  origin only when BRAIN_UI_CLAUDE_OAUTH_ORIGINS lists it exactly. An
  unlisted origin refuses at the prompt gate and again per dispatch, before
  the credential is read.

Refs #1665

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ss status

- Model-discovery cache entries live in the brain, which a governed worker can
  write. Read back only the fields discovery writes, so a cached entry can no
  longer supply a profile's baseUrl, credential variable names, aliases or
  tools.
- Compare native settings with the server's route as a canonical origin and
  base path, so an equivalent spelling is accepted and another path refused.
- Worker-host egress status names subscription Claude turns as routed and
  API-key Claude profiles and Pi as not yet routed.
- Regenerate the API reports for the new internal exports and fields.

Refs #1665

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e env descriptor test

Refs #1665

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@schlessera schlessera changed the title CONTRACT: feat(ui-server): run governed and strict Claude turns on the turn egress proxy CONTRACT: feat(ui-server): run governed and strict Claude turns on the turn egress proxy on managed storage Oct 22, 2026
@schlessera
schlessera marked this pull request as ready for review October 22, 2026 17:21
@schlessera
schlessera marked this pull request as draft October 22, 2026 17:22
schlessera and others added 3 commits October 22, 2026 19:23
…gress-activation-044

# Conflicts:
#	docs/decisions/governed-writable-workers.md
#	packages/ui-backend-claude/src/turn-runner.ts
…gress-activation-044

The parent's socket walk now passes with all of /var masked (#2657), so the
capabilities note, changeset, hosting doc and decision record drop the
reachable-socket gap; Pi inference remains the stated gap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…gress-activation-044

# Conflicts:
#	docs/decisions/governed-writable-workers.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: backends AgentBackend implementations and the seam they share. area: ui-server Hono app factory: turn coordinator, auth, session catalog, routes, cron. contract Touches the integration contract: CONTRACT: commit, contract doc updated in the same commit. security Auth, isolation, egress, secrets, or anything in the threat model. type: feat A new capability or a change in user-visible behaviour.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant