feat(env-bridge): a global assistant reaches any environment it may see — cloud by canRunCode parity, local by the owner's opt-in, both by a mandatory opaque envId - #2616
Conversation
…assistant, default off Leaf A of "a global assistant reaches any environment it may see". The global assistant is the one agent whose context spans every drive a person belongs to, so what it may reach is a deliberate choice rather than a consequence of ownership. `drive_envs.visibleToGlobalAssistant` records that choice: NOT NULL, DEFAULT false, and the absence of a value is never a grant. The flag lives on `drive_envs`, not on the local sibling, so a cloud Sprite env and a user's own machine answer one question through one column — the schema docblock says why. A Sprite env has no enrolling owner, so no surface can set it true for one today: fail-closed for that substrate by construction rather than by omission. Visibility is not authority. Turning it on changes nothing about who may drive the machine — `decideBind`'s owner-only bind ([D-6], invariant 13) is untouched, and the test asserts a non-owner is still refused on a fully visible env. Changing the flag is the OWNER's alone, decided by `drive_env_local.ownerId` and never by a drive role: the store's compare-and-set is one UPDATE on `drive_envs` whose owner predicate is a correlated EXISTS on the sibling. `decideEnvReach` (pure) is asked on EVERY call rather than once at bind, which is what makes turning visibility off refuse the next call instead of honouring an earlier reach. Its three refusals — not_found, not_owner, not_visible — surface ONE sentence (`ENV_UNREACHABLE_MESSAGE`), so an id that does not exist and one the caller may not see are indistinguishable from outside. Migration 0295 from `bun run db:generate` (second run: "No schema changes"; `drizzle-kit check`: "Everything's fine"). `drive_envs` is table-level excluded from the GDPR export, so the new column needs no collector. Mutation: 10/10 killed, 2 no-op controls survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
… opaque ids Leaf B of "a global assistant reaches any environment it may see". The mandatory id the next leaf adds is only safe if the model gets it from somewhere rather than inventing it. This is that somewhere. `listVisibleToGlobalAssistantByOwner` applies TWO conditions in SQL, not one: the caller OWNS the machine (`drive_env_local.ownerId`) AND the environment is visible to the global assistant. Ownership is the real access filter and is deliberately not drive membership — a drive relationship is not an entitlement to every row inside it (the trap PR #2609 hit) — which is also why a machine in a drive the owner has since LEFT is still listed: it is their computer. A revoked machine is excluded: listing it would hand the model an id guaranteed to refuse. Each row carries an opaque id, a human label, the substrate and the owning drive, so the model addresses by id and speaks to the person by label. The conversation's OWN sandbox is a row like any other, listed first and addressed by the conversation's own id — the address the runtime already resolves a session through — so leaf C leaves no implicit path a model can take while believing it is somewhere else. An empty list is never returned as an empty list: a model handed `[]` with a mandatory id to fill has every incentive to invent one, so the answer says in words that there is nothing to copy. Every answer, empty or not, carries the anti-invention instruction — July's post-mortem (`cf576fbc1`) records that the prompt and the tool descriptions actively encouraged the bad value, so the wording is part of the fix. `GET /api/env-bridge/environments` is the HTTP surface, 404 when the flag is off like every other route in the family; the tool answers an empty environment set on that path rather than a bare failure. Mutation: 7/7 killed, 2 no-op controls survived. The store's own two-condition predicate is pinned by three new rows in the Postgres integration suite (CI). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…cution tool Leaf C of "a global assistant reaches any environment it may see". `bash`, `writeFile`, `readFile` and `editFile` now take a REQUIRED `environmentId`. Three properties, each the fix for a different way July's removed `target` went wrong (`cf576fbc1`): - **Mandatory.** Omitting it is a zod validation error the model sees and can correct — never a silent fallback to the conversation's own sandbox. The fallback is removed at every layer, not just the schema: `acquireSandbox` refuses `missing_environment` rather than picking a default, and the two deliberately-unaddressed consumers (the git/gh toolkit, `copy_content`) now name the conversation's own target explicitly. - **Opaque.** The zod boundary constrains the SHAPE, not free text. `isCuid` is a loose heuristic that accepts `main`, `staging` and `prod` — precisely the values the post-mortem saw invented — so the schema also requires 20-32 characters, which every real 24-character cuid2 satisfies and no plausible invention does. - **Described.** Every tool description says to copy an id from `list_environments` and never to construct one. July's post-mortem records that the prompt and the descriptions actively encouraged the bad value, so the wording is part of the mechanism. `productionResolveEnvironmentTarget` resolves the id server-side: the conversation's own id addresses its own sandbox (so the default sandbox is named exactly like any other environment and has no implicit path), and any other id must pass the pure `decideEnvReach` — it exists, the caller owns it, and its owner made it visible. All three refusals, plus the flag-off case, surface ONE sentence, so an id that does not exist and one the caller may not see are indistinguishable from outside. Visibility is re-read on every call, so switching it off refuses the next call rather than honouring an earlier reach. The resolved target rides the context to the runner as `ctx.environment`, with `label` read from the ROW and never echoed from the model's input — what leaf E names in results. INTERMEDIATE STATE, closed by leaf D: a valid, reachable NAMED environment resolves and is then refused `environment_routing_not_enabled` at acquire, rather than running somewhere the caller did not name. Leaf D replaces that one branch with real routing. Mutation: 10/10 killed, 3 no-op controls survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
Leaf D of "a global assistant reaches any environment it may see" — the blocker [D-4] recorded, closed. `spawnAgentSession` compared `env.driveId !== driveId`, and a dashboard conversation carries `driveId === null` against a NOT NULL column, so the comparison could never succeed. It now branches on substrate: - a LOCAL env goes through `gateLocalEnvBind` — [D-6] made binding structurally owner-only (`decideBind` compares the requester to `drive_env_local.ownerId`, with no actor role anywhere in its input), so the ownership check already exists, is load-bearing, and is what a driveless session passes or fails on. This removed a redundant scoping check, not an ownership one. - every OTHER substrate keeps the drive agreement unchanged and for the original reason: a Sprite env is drive-owned with no owner of its own, and `decideAgentSessionAccess` derives access from `driveId` alone, so a driveless session bound to one would route work into a drive's shared filesystem through a path that never looked at that drive. A driveless spawn is still refused a Sprite env, structurally. `agent_workspaces_env_needs_drive_check` is dropped (migration 0296, generated; second run "No schema changes", `drizzle-kit check` clean) because it could not express that distinction. Its schema docblock is rewritten to say where the guarantee now lives, and the service's matrix pins BOTH arms — including the negative that a non-owner is still refused a local env with or without a drive, so removing the redundant check cannot have made the bind gate the only thing standing between a stranger and someone's hardware. Routing: a named environment runs in ITS OWN session, found or spawned once per (owner, environment) via `findActiveByOwnerAndEnv`. One conversation naming two environments holds two sessions — never one session re-pointed, which is the predecessor's `switch_machine`: it races every concurrent call in the same session and silently moves work a previous call believed was elsewhere. That session is driveless (invariant 1) and holds no conversation: it is a working context a conversation borrows by naming it, not a thread container. Billing and authorization follow the ENVIRONMENT, not the conversation. The resolver returns the payer coordinates alongside the target, the call-time gate runs on those, and `resolveBillingSession` resolves the same session through the same find-or-spawn — so a drive environment bills that drive's owner exactly as it does today, and a credit-exhausted or ineligible payer is refused before a sandbox wakes. A vanished drive fails the call closed rather than charging the caller. No new refusal vocabulary: a paused, revoked, disconnected or policy-refusing environment refuses through the existing bind/sign gates with the existing typed reason, and `reconnect` still resolves a local address through the host registry with the ACTING principal — so a mis-addressed call at the owner's own machine still waits for their passkey-verified click. Mutation: 7/7 killed, 2 no-op controls survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
Leaf E of "a global assistant reaches any environment it may see".
With a mandatory id the remaining failure mode is copying a REAL id for the
WRONG environment — a call that succeeds, somewhere nobody meant. Every
`bash` / `writeFile` / `readFile` / `editFile` result now carries
`environment: { id, label }`, which puts the target in the model's context each
turn and makes a wrong one visible to the person immediately rather than only to
whoever opens the audit log.
Both fields come from the SERVER's resolved target and are never echoed from the
tool's input — the test names a different id than the resolver returns and
asserts the result reports the resolver's. If they were echoed, a mis-addressed
call would confirm the address the model already believed, which is the opposite
of what this is for.
Refusals name it too, so a wrong target reads as a wrong target rather than as a
broken tool — but only refusals that got far enough to resolve one. A refusal
that never resolved an environment names none: there is nothing to name, and
naming one would be inventing it.
Stamped in ONE place at the tool boundary rather than at the several dozen sites
that build a result, so a runner added later cannot forget. It records nothing
new: `drive_env_grant_audit` already carries `envId` for every grant, and the id
surfaced is that same id — asserted, alongside the audit-write count staying at
one.
Mutation: 4/4 killed, 1 no-op control survived.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…elog for the global assistant's reach Leaf F of "a global assistant reaches any environment it may see". The posture document gains a section that records what changed and states plainly that the global assistant is the agent most exposed to prompt injection in the product — its context spans every drive the person belongs to, so R-6's surface is not one drive's shared pages but all of them at once, which is the reason its reach is a deliberate per-environment choice and default off. It records the decision behind the mandatory opaque id with the July evidence that produced it: an optional free-text `target` on these exact tools, removed two days later (`cf576fbc1`) because the model habitually invented a plausible value, and the post-mortem's finding that the prompt and descriptions actively encouraged it. Three new layer rows, two new register entries (R-16 session-level taint, R-17 a real id for the wrong environment), two new flag-on checklist items, and [D-4] rewritten from deferred to resolved. Session-level taint is named as the remaining gap rather than left implied, on every surface: this phase adds *visibility* and *addressing*, and neither is a control on what the assistant was persuaded to want. **The local-only limit is stated everywhere, because it would otherwise be discovered.** A Sprite (cloud) env has no enrolling owner, so nothing can make one visible and the discovery list is local-only in practice: what ships is "any local machine you made visible, plus this conversation's own sandbox", not "any sandbox". The posture doc also says what would decide a cloud env's visibility if that is ever taken — a drive role rather than an enrolling owner, most plausibly the drive owner alone, and what it would change in `decideEnvReach` and the discovery read — and files it as its own decision rather than building it. The customer page carries the same limits as the internal document and no capability the document does not: each new claim is followed by its exact limit, including that a real id for the wrong machine is still possible and that nothing treats a conversation as tainted by what it has read. The changelog tells a person what they must turn on, by name and by page, for any of this to happen. A documentation test pins all of it, for the same reason `tool-registry-docs` exists: these are the claims an operator and a machine owner act on. Mutation: 5/5 killed, 1 no-op control survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…sture document Review catch: the posture document — what an auditor and the founder actually read — was silent on the most consequential change in the phase. `grep -c env_needs_drive` returned 0, the layers table had rows for visibility, the mandatory id and the named result but NONE for routing, and migration 0296's DROP of `agent_workspaces_env_needs_drive_check` appeared only in the schema file's docblock. Three places, because a dropped constraint is exactly what [D-4] warned about: - a LAYERS row for the drive agreement, naming both branches — `gateLocalEnvBind` (owner-only, [D-6]) for a local env, the surviving `env.driveId === driveId` for every other substrate — with the verdict that this row exists because the guarantee MOVED: from a database CHECK that cannot be got wrong to two code branches that can; - PROSE saying the CHECK was dropped, why it was right about a Sprite env (still enforced, and pinned by a negative test rather than inferred) and wrong about a local one (where [D-6] already made binding structurally owner-only, so this removed a redundant scoping check rather than an ownership one), and where the guarantee lives now; - R-18 in the residual register for the trade itself: a CHECK is enforced against every writer forever, including one nobody has written yet; two branches are enforced only against callers that go through them. Any new writer of `agent_workspaces.envId` must go through `spawnAgentSession`. The documentation test lands with the prose and asserts all three, so they cannot drift apart. Mutation: 3/3 killed (heading removed, layer row gutted, R-18 emptied). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6bbb449c17
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…nmentId comes from The other half of July's fix. `cf576fbc1`'s post-mortem found that the PROMPT and the tool descriptions both encouraged the invented `target`; leaf C fixed the descriptions with the field, and the sandbox guidance section said nothing about addressing at all — so a model meeting a newly-required field would learn where an id comes from only by failing a call first. One bullet, gated on actually holding one of the four addressed tools (the git/gh toolkit and the shell family take no `environmentId`, and telling them to pass one is the same class of mistake). It says the two things that matter: call `list_environments` and copy an id exactly — never construct, guess or shorten one, and there is no default — and read back the environment every result names, because that is what makes a wrong target recoverable. Mutation: 2/2 killed (bullet dropped; bullet named for tools that take no environmentId), 1 no-op control survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…vironment Codex P1 on #2616, and it broke a promise the product makes in three places: the column is `visibleToGlobalAssistant`, the settings toggle says "Let your global assistant use this machine", and the changelog says the same — but nothing enforced it. `buildSandboxTools()` is constructed ONCE (`ai-tools.ts`) and shared by every turn, `resolveSandboxToolEligibilityForConversation` is called from both the global and the page chat pipelines, and `decideEnvReach` took only `{ actorId, env }` — so by construction nothing downstream could tell a dashboard turn from a page turn. A sandbox-enabled page agent in a drive the person can execute in could list and address the machine they had switched on for their dashboard assistant. Switching a laptop on for the assistant you talk to from the dashboard is not switching it on for every sandbox-enabled agent in every drive you belong to. The fix puts the fact in the PURE decision rather than at a call site, because there are two call sites and they must not drift: `decideEnvReach` gains `conversationKind` and refuses `not_global` FIRST, before the row is consulted, so a page turn cannot probe for an id's existence through the deny order or through a read it was never entitled to make. `conversationMayReachPersistentEnvironments` is the one predicate discovery and resolution share. `conversationKind` rides `SandboxActorContext`, resolved from the chat source and failing CLOSED: only an explicit `'global'` is global, and a surface that did not say what it is reads as `'page'`. This narrows PERSISTENT ENVIRONMENTS, not code execution — a page conversation still reaches its own sandbox exactly as before, and there is a row for that. The toggle copy is unchanged: the promise was already the narrow one, and the code now matches it rather than the copy being widened to match the code. Rows: a page conversation gets the own-sandbox row and nothing else and never touches the flag or the store; a page conversation naming a REAL visible env it owns is refused with the SAME sentence as an unreachable one, while the same id from the dashboard assistant resolves; an absent kind fails closed. Mutation: 5/5 killed, 2 no-op controls survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
… not drive membership Codex P2 on #2616: the owner-after-departure case the discovery list deliberately advertises always failed. `list_environments` still shows a machine in a drive its owner has left — "it is their computer", which the leaf required and the machines route has always done — but every call on it was refused `no_drive_access`: both gates passed the env's `driveId` to `canRunCode`, and `authorizeUser` denies when `getUserDrivePermissions(...).hasAccess` is false. The entitlement for a local env is OWNING THE MACHINE, and the codebase already says so: [D-6] makes binding structurally owner-only, `decideBind` compares the requester to `drive_env_local.ownerId` with no actor role in its input at all, and `decideEnvReach` has just proved the same thing. Authorizing against the drive as well was a redundant check with a real cost — the same shape as leaf D's drive comparison, and removed for the same reason. `canRunCode` already keeps the two legs apart, so the seam existed: the drive is now supplied as the PAYER coordinate only. The tool path carries it as `SandboxPayerCoordinates.gateDriveId` (absent for a local target, present for every other substrate and for the conversation's own sandbox); the bind path calls `canRunCode` with no `driveId`. The kill switch and the tier leg — resolved against the env's drive owner — both still run, and a non-owner now falls to `bind_policy`, which is the accurate reason rather than a drive one. **The consequence is stated, not slipped in** (R-19 and a new layers row): a person who has left a drive can run on their own machine and that drive's owner is still metered for the active runtime, because the env row is drive-owned and drive-paid. The drive's remedy is the one [D-6] already gives it — Delete or Revoke, both held by drive admins and neither needing the owner. Rows on both paths: the departed owner runs; the billing coordinate is unchanged; the kill switch still refuses them; the tier leg still refuses them and still keys on the env's payer; a Sprite env keeps the drive-role leg. Mutation: 4/4 killed, 1 no-op control survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…nversation's payer Codex P1 on #2616, plus the two CI failures on the previous head. **Discovery gated on the wrong payer.** `list_environments` runs no code, but it went through the same gate as a run — on the CONVERSATION's coordinates, because it has no target. So a free-tier person who owns a visible machine in a Pro-owned drive could never obtain the id of the one place they can run, even though `openAt` deliberately switches to the ENVIRONMENT's payer and would have authorized it. `canRunCode`'s own docblock says the tier leg keys on the payer precisely so "a free-tier member of a Pro-owned drive is still eligible"; discovery was the single surface contradicting it. `gateDiscovery` asks the honest question — may this actor run ANYWHERE they can reach? — trying the conversation's own coordinates first and, only if those refuse for something other than the KILL SWITCH, each reachable environment's payer in the shape `openAt` will actually gate the run with. The kill switch is never second-guessed. It is never the security boundary either: the id it hands back is useless without `openAt`'s full gate on the target's own payer. **The pipeline strip needed the same fix or the tool fix changed nothing**: `resolveSandboxToolEligibilityForConversation` strips the whole compute family from the request before `list_environments` can be called, by the same conversation-tier decision. It now falls back to the same reachable-environment question, bounded (owner-scoped, after the cheap check fails) and failing closed on any error, since it widens eligibility. Also in this commit, both CI failures from the previous head: - **knip**: `LIST_ENVIRONMENTS_TOOL_NAME` was a new unused export. Used rather than ignored — it and the new `ADDRESSED_SANDBOX_TOOL_NAMES` now back the literals in `SANDBOX_CORE_TOOL_NAMES` and the system prompt's addressing bullet, so the tool's name has one definition. - **A STALE integration row, not a regression**: `env-sessions.integration.test.ts` asserted "a local env in ANOTHER drive is env_not_found — the drive check runs before the gate", which is the rationale leaf D deliberately inverted. The caller OWNS that machine, so it now reaches the bind gate and refuses `not_connected`. Re-pointed AND re-titled, with three sibling rows so it proves the new intent rather than accepting a new string: the same env BINDS once connected (asserting the session's drive and the env's drive genuinely differ), a non-owner is still refused `bind_policy`, and a DRIVELESS global-assistant session binds a connected local env it owns — the case [D-4] blocked. The suite's helper no longer hardcodes the session's drive into the gate row; it reads the env's own, which is what made the cross-drive case expressible at all. Verified against a real Postgres this time, not blind: migrations 0295 and 0296 applied to a clean database (`visibleToGlobalAssistant` NOT NULL DEFAULT false present; `agent_workspaces_env_needs_drive_check` absent; its sibling CHECK still there), env-sessions 23/23, the DB-backed lib suites 857/857, and web agent-workspaces + ai 5153/5153. Mutation: 1/1 killed, 1 no-op control survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…declared in the bridge inventory Two repo-wide gates caught the new surface, and both were doing their job. Found by running the FULL web unit suite against a real Postgres rather than only the directories I had touched — neither of these lives near the code they guard. - **`registry-coverage`**: every AI tool must have a rich renderer, and `list_environments` had none, so it would have rendered as raw JSON. It gets a real one rather than a stub, because this is the one place a person can see WHICH of their machines the assistant can currently reach — and, by absence, which they have not switched on. It shows the label (what a person recognises) above the id (what the next call will name), so a wrong target is legible here too, and carries the notice so "nothing to copy" reads the same way to the person as it does to the model. - **`list-route`'s bridge inventory**: the `no code path sends an approval TO the machine` invariant pins the set of `api/env-bridge/*` directories, so a new route family has to be acknowledged on purpose by someone who has just read what the test is for. `environments` is added with that reasoning stated: it is a GET over rows the caller owns and returns no frame to any machine, which the scan above it is what actually enforces. Mutation: 1/1 killed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
… per environment The payer is a property of the DRIVE, so several machines in one drive are one question rather than several. Both fallbacks — the discovery gate and the pipeline eligibility strip — now iterate distinct drive ids: a person with ten machines across two drives costs two payer lookups instead of ten, and both paths are only reached when the cheap conversation-payer check has already refused. Behaviour is unchanged (the answer is a disjunction over the same set); this bounds a loop that was bounded only by the listing cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…e-environment fallback, with rows Review catch on `3fcaaab17`: the per-drive dedup was correct but written TWICE — the same `new Set(...map(env => env.driveId))` loop in the discovery gate and in the pipeline eligibility strip — and asserted in neither. That is the shape this PR already rejected once: when discovery and resolution had to agree, the rule was extracted into one predicate with a row asserting they cannot drift. Same standard here. `anyReachableEnvironmentPayerAllows` owns the ITERATION — which payers get asked, once per distinct drive, failing closed. Both callers keep their own AUTHORIZER, and that difference is deliberate rather than accidental: the eligibility strip stands where tool REGISTRATION stands and asks `canRunCodeForSession`, the same question the rest of that module asks, while the discovery gate stands where a call stands and asks the full call-time gate. The module doc and both call sites say which and why. Sharing it matters for the reason the fallback exists at all: fixing one without the other changes nothing, because the strip removes the tool before the gate can allow it. Rows: two machines in ONE drive resolve that payer ONCE and are still eligible; machines across TWO drives ask twice; a drive whose payer does not resolve is skipped WITHOUT refusing the others; the first allowing drive stops the loop; and every uncertainty fails closed (flag off, listing throws, nothing reachable). Plus a source scan pinning that neither caller has grown its own copy of the loop, and that each still names its own authorizer. The kill switch is unaffected and still refuses regardless of what is reachable — `productionSandboxDiscoveryGate` returns the direct verdict on `kill_switch_off` before this is reached, which its own suite covers. Mutation: 3/3 killed, 1 no-op control survived. The flag-off row initially SURVIVED its mutant — a throwing authorizer was being swallowed by the fail-closed catch, so the assertion held with the flag check deleted. It now asserts the observable consequence (nothing is listed, nothing is authorized) and kills it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
knip ignores `src/**/__tests__/**` (knip.json), so an export consumed only by a test reads to it as dead code — the same gate that failed the Lint job on an earlier head for `LIST_ENVIRONMENTS_TOOL_NAME`. `ReachableEnvironmentPayerDeps` and `defaultReachableEnvironmentPayerDeps` were both in that position. Both are now module-private, and the test derives the deps shape from the function's own signature (`NonNullable<Parameters<typeof anyReachableEnvironmentPayerAllows>[0]['deps']>`), which is stricter than the import it replaces: the fake cannot drift from what the parameter actually takes. Caught before pushing by reading knip.json rather than by another CI round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
Fresh-eyes read of the finished diff. `openAt` spread the whole `SandboxPayerCoordinates` onto the context, so `gateDriveId` — an input to the GATE, not a fact about the actor — rode along as an undeclared field on an object passed to every runner. It is inert today: nothing spreads `ctx` wholesale (every consumer, including the audit writer, picks explicit fields), and TypeScript will not let anyone read it since it is not on `SandboxActorContext`. But a stray property on a context this widely passed is a trap for the first person who writes `...ctx`, and it would reach an audit row the day they do. Split off at the one place it is produced, so the property cannot exist rather than merely being unused. Behaviour is identical — the gate still receives the same value, pinned by the mutant that points it back at the billing drive and makes the departed-owner row go red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…s for the dropped constraint The Unit Tests failure on `72db9a541`, and the same class as the lib one I already re-pointed — in a package whose integration suite I had never run. `bun run --filter @pagespace/db test:integration` is its own command; running lib and web is not a substitute, and that is how these two survived. Both assertions were about `agent_workspaces_env_needs_drive_check`, dropped by migration 0296: - the CHECK inventory expected TWO constraints and now finds one. Re-pointed to assert the ABSENCE explicitly against `pg_constraint`, which is the only place a dropped constraint can be observed — the schema file no longer mentions it, so nothing else can. The surviving `agent_workspaces_env_no_sprite_check` is still asserted NOT VALID, so the two-stage migration rule is untouched. - "should REFUSE an env-bound session with no drive" asserted the rationale leaf D deliberately inverted. Re-pointed AND re-titled: the insert now succeeds, and the row reads the shape back (`driveId` null, `envId` set) rather than merely not throwing. Its comment records why the CHECK was right about a SPRITE env and wrong about a LOCAL one, and where the guarantee lives now — `spawnAgentSession`'s two branches, whose own matrix pins the sprite negative directly. Verified on a CLEAN database: dropped and recreated with `timezone = UTC`, migrated from zero, then `@pagespace/db test:integration` 71/71 with zero assertion failures (the 4 remaining files need `ADMIN_DATABASE_URL`, a separate admin database CI provides). Two artefacts of my own local rig, both confirmed as rig rather than code: `accessible-page-ids`' expired-grant row fails on a non-UTC server (`now()` resolves via session TZ) and passes under UTC; and pointing `ADMIN_DATABASE_URL` at the same database lets the admin provisioning tests mutate cluster roles and break unrelated suites — hence the rebuild. Lib drive-envs + agent-workspaces 29/29 files and web ai + agent-workspaces + env-bridge + tool-calls 296/296 files re-run green on that clean database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…canRunCode parity SCOPE CORRECTION on #2616. Local-only was the wrong deliverable: the founder's ask was "any of the sandboxes", and a Sprite env IS the cloud sandbox. **Founder ruling (2026-09-12): "if the user can, their global assistant should be able to."** Exact parity with what the person can already do inside the drive — nothing more, nothing less. A CLOUD env has no owner, so there is nothing to opt in to and no per-env toggle: the DRIVE PERMISSION is the visibility. `decideEnvReach` gains a `sprite` arm keyed on an injected `mayRunCodeInEnvDrive`, which the caller computes with `canRunCode({ userId, driveId: env.driveId })` — the same kill switch, payer tier (the drive's owner), drive access and `canEdit`. A VIEWER is refused exactly as in-drive. It is `canRunCode` VERBATIM, never re-derived, and the parity promise is a TEST rather than a comment: for every verdict `canRunCode` can produce, the reach verdict must equal it, and neither a stray visibility flag nor the actor's identity can make them diverge. **The session shape (point 3), which is what keeps every Sprite invariant intact rather than relaxed:** a cloud env's session is bound to THAT ENV'S DRIVE (`driveId = env.driveId`). So `spawnAgentSession`'s `env.driveId !== driveId` comparison is SATISFIED, not bypassed; the negative that a DRIVELESS spawn is refused a Sprite env stays true and untouched; `decideAgentSessionAccess` keeps deriving access from the drive; billing keeps resolving to that drive's owner. Only a LOCAL machine's session is driveless — it belongs to a person, not a drive. No STOP clause was needed. **Local machines are unchanged**: owner-only + `visibleToGlobalAssistant` + [D-6] bind. `visibleToGlobalAssistant` stays a local-machine column and is not consulted for a cloud env in either direction — as a gate it would make every cloud env permanently unreachable, and as a grant it would let a row widen what the drive decided. Per-call re-check stays: losing drive edit access, or the kill switch going off, refuses the next call even with a live session. Page conversations are unchanged (`not_global`, before the permission is even asked). Discovery lists cloud envs from drives the person owns or has ACCEPTED membership of — a candidate filter, never the authorization answer (the trap PR #2609 hit) — then applies `canRunCode` once per DISTINCT drive. Bounded, per the findMany-limit rule. `NO_VISIBLE_ENVIRONMENTS_NOTICE` now names both ways in. Docs corrected on all four surfaces, and R-20 names the new blast radius honestly: injection in a global conversation can act in any cloud env the person can edit. That is parity, not escalation — their own in-drive agents can already do it — but it AGGREGATES, reachable from one conversation instead of many, by the agent that reads the most untrusted content. Red-first throughout. Mutation: 6/6 killed (parity broken open, broken shut, arms swapped, permission assumed, cloud session driveless, billing driveless), 1 no-op control survived. Verified: lib 570 files, web 20,725 tests, db integration 71/71 — zero assertion failures (the only reds are the known ADMIN_DATABASE_URL and unbuilt-editor classes). tsc clean across lib/db/web/marketing, eslint clean on all 26 touched web files, knip flags none of the new modules, `db:generate` a no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
… reach was dead in production BLOCKING review finding on `c72c4996a`, and it made the whole cloud correction a no-op on every deployment: `LOCAL_ENVS_ENABLED` is off everywhere, and it was short-circuiting the cloud path in THREE places — the discovery gate's fallback, the pipeline eligibility strip, and (worse, and not in the report) RESOLUTION itself, which refused before it had even looked the environment up. So the founder's ruling failed for exactly the case it exists for: a person whose OWN tier cannot run a personal sandbox, but who has edit access to a PAID team drive, can run code in that drive — and got no tools, no discovery and no resolution from the dashboard. Only people who could already run a personal sandbox got through. The flag is the cloud opt-in for exposing PERSONAL HARDWARE to a shared drive (invariant 11). A drive's own cloud sandbox is not personal hardware. It is now applied PER SUBSTRATE, in the one place that decides it — `listGlobalAssistantEnvironments` — so discovery, resolution and the eligibility strip share one answer: - local rows are not listed, and not even READ, while the flag is off; - cloud rows are never gated on it; - resolution asks the flag only AFTER the lookup, and only for a local env, because the question cannot be answered without knowing the substrate. The refusal is still the single message, so a caller learns nothing from it. The kill switch and the payer-tier leg are untouched: both still apply to cloud through `canRunCode`. **Red first, against the flag's REAL reader** (`process.env.LOCAL_ENVS_ENABLED`), never an injected stub — the defect was invisible to every existing test precisely because they injected `true` while production is always off. The new rows: flag off, a free-tier user with edit on a paid drive keeps the cloud env listed; flag off, a visible local machine is neither listed nor read; flag on, both appear; and the cloud half still answers to `canRunCode` either way. Also the minor finding: discovery and resolution asked `canRunCode` with different origins, so an agent-origin call could see a laxer list than the next call would honour. The origin is now threaded into discovery and asserted, so the two ask the identical question; the registration-time strip has no request to take an origin from and documents its user-origin default. One stale row re-pointed rather than deleted: it asserted the flag refused BEFORE any read, which is the exact short-circuit that had to go. Mutation: 4/4 killed — a local machine leaking in with the flag off, the local opt-in ignored entirely, the opt-in no longer gating resolution, and the flag gating CLOUD resolution again (the production defect restored). 1 no-op control survived. Verified: web 20,734 tests, lib 13,262 tests, zero assertion failures; tsc clean; eslint clean on every touched web file; knip flags none of these modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
… and row harness Prep for the app proof of #2616 @ f7b4a24. Everything here needs no build; the production build and the rows themselves wait for a build slot. `seed-gate.ts` creates EVERY precondition, so the run is reproducible from a clean database — the M1 gate was run once with two fields set by hand and was caught as a P1 twice for exactly that. Seeded: U on a sandbox-INELIGIBLE free tier; drive P (paid) with U an accepted editing member and a Sprite env; drive V (paid) with U bounded VIEW-ONLY; drive X (paid) with U not a member at all; a page agent and a `type='page'` conversation for the page-refusal row; and the view-only role inside P that the revocation row demotes U to. **A discovery worth recording:** there is no `VIEWER` in the `MemberRole` enum (`OWNER | ADMIN | MEMBER`). A view-only collaborator is a `MEMBER` carrying a custom role whose `driveWidePermissions.canEdit` is false — the one shape `getUserDrivePermissions` reads as `canEdit: false`, and therefore the only way `canRunCode` can answer `insufficient_role`. Seeding a plain `MEMBER` would have made U an EDITOR of drive V, and the row meant to prove a refusal would have proved its opposite. `preflight.ts` asserts the fixture yields the authorization answers the rows depend on, by asking `canRunCode` directly, BEFORE a contended build slot is spent on it. It expects the EXACT reason, never a category: "refused" would pass on `drive_access_denied` from an unaccepted invite while proving nothing about the permission under test. Currently 4/4 PASS on a clean database. `rows.ts` drives the rows through the real chat API as the browser sends it (session cookie + `X-CSRF-Token` + matching `Origin`), and makes every permission change through `PATCH /api/drives/<id>/members/<userId>` rather than psql — rows 5 and 6 are about the product honouring a permission change, so a gate that reached past the application to arrange them would be testing a database. Two wrong assumptions were found and fixed while writing it, which is the point of doing this before the slot: there is no `POST /api/ai/conversations`, and `/api/ai/chat` is the single entry for BOTH surfaces (it decides from the conversation, not the URL), so the page row uses a seeded `type='page'` conversation against that same endpoint. Credentials lined up and proven, not assumed: a Sprites token minted from a Fly org token drove a real create → exec → delete round trip (`uname -a` returned `Linux … -fly`, which is the evidence row 2 needs), and the probe sprite was deleted. Provider keys for the model that must actually call the tools are present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…aint broken CodeQL alert 342 (`js/file-access-to-http`) on `rows.ts`: the seed file's data flows to `fetch(GATE_BASE_URL)`. That is a real risk, not a false positive — the seed carries REAL session cookies for the seeded users, and the harness sends them as request headers, so a `GATE_BASE_URL` pointing anywhere but this machine would ship those credentials off the box on a single mistyped environment variable. Fixed by breaking the taint, not by annotating it: default-setup CodeQL ignores inline `codeql[...]` suppressions, so a comment would neither clear the alert nor reduce the risk. `resolveLocalGateOrigin` parses the caller's string, checks it, and then THROWS IT AWAY: the origin every request actually uses is rebuilt from string LITERALS chosen by the check (`localhost` / `127.0.0.1` compared exactly, never as a pattern) plus a port validated as an integer in range. Nothing caller-influenced reaches `fetch`. It also refuses a non-`http:` protocol, embedded credentials, and an unparseable value, and it runs BEFORE the seed is read — so the refusal happens before the cookies are even loaded. `gateUrl` assigns `pathname`/`search` onto an origin-only `URL` rather than `new URL(path, origin)` or template concatenation. Both of those stay flagged behind a host guard because the analysis follows the string — and the assignment form additionally cannot be talked into changing hosts by a path beginning `//evil.test`, which is asserted. Rows: a remote target refuses and makes NO request (a `fetch` spy proves it); suffix look-alikes (`localhost.attacker.test`, `127.0.0.1.attacker.test`), cloud metadata, `[::1]`, non-http, credentials and a non-URL all refuse; an accepted loopback URL yields an origin with path, query and fragment stripped; out-of-range ports refuse rather than coerce; and a protocol-relative path cannot move the request off the origin. Mutation: 3/3 killed — the host check weakened to a substring match, a non-http protocol accepted, and the path allowed to move the origin. 1 no-op control survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
The GA wave 3 Stop test waited on `existsSync(pidFile)` and then read it.
`/bin/sh -c 'echo $$ > file'` creates the file and writes to it as two
separate steps, so there is a window where the file exists and holds `''`.
A read in that window yields `Number('') === 0`, and the run fails as a bare
`false !== true` that reads like a Stop bug in the daemon rather than the
test losing a race with the shell.
`readPidWhenComplete` polls on the same 5s deadline until the trimmed
content parses as a positive integer, and on expiry throws naming the last
content it read — an empty file and a missing one are different failures,
and the assertion this replaces distinguished neither.
Test-only: the daemon is untouched.
Proof, by line index on the accept condition (packages/cli, single file):
M1 `return pid` (accept on existence alone, the old semantics) RED 2/27
C `pid > 0 && Number.isInteger(pid)` (operand order) GREEN 27/27
The forced-race tests stub a file that exists immediately and is filled in
after a delay, so the window is guaranteed rather than hoped for.
Limit stated in the helper: this closes CREATE-then-WRITE, not a torn write
— a prefix of a pid parses as a positive integer. `echo $$ > file` emits the
line in one write, so that window does not exist at this call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
The one-sentence proof
A dashboard conversation can run in any cloud environment the person could already run code in, plus any local machine they switched on — and cannot run anywhere by omission or by a guessed id.
Base and target are
pu/local-env-ga, not master. Do not merge — #2590 is the founder's.Scope correction: CLOUD environments, by
canRunCodeparityThis PR originally shipped local-only. That was the wrong deliverable and it is corrected here (
c72c4996a,f7b4a24df). The founder's ask was "any of the sandboxes", and a Sprite env is the cloud sandbox.Founder ruling (2026-09-12): "if the user can, their global assistant should be able to." Exact parity with what the person can already do inside the drive — nothing more, nothing less.
decideEnvReachgains aspritearm keyed on an injectedmayRunCodeInEnvDrive, computed ascanRunCode({ userId, driveId: env.driveId })— same kill switch, same payer tier (the drive's owner), same drive access andcanEdit. A VIEWER is refused exactly as in-drive. Used verbatim, never re-derived.canRunCodecan produce, the reach verdict must equal it; neither a stray visibility flag nor the actor's identity can make them diverge.LOCAL_ENVS_ENABLEDgates local machines only. It is the opt-in for exposing personal hardware to a shared drive, applied per substrate in one place so discovery, resolution and the eligibility strip share one answer. Gating cloud on it made the whole correction a no-op in production — see the review round below.visibleToGlobalAssistant+ [D-6] bind, and a drive permission substitutes for neither half.The session shape, which is what keeps every Sprite invariant intact rather than relaxed: a cloud env's session is bound to that env's drive. So
spawnAgentSession'senv.driveId !== driveIdcomparison is satisfied, not bypassed; the negative that a driveless spawn is refused a Sprite env stays true and unmodified;decideAgentSessionAccessstill derives access from the drive; billing still resolves to that drive's owner. Only a local machine's session is driveless.Still not here: switching a conversation's default environment (it keeps its own sandbox and names a target per call), and session-level taint (R-16).
The new blast radius is named, not buried (R-20): injection in a global conversation can act in any cloud env the person can edit. That is parity, not escalation — their own in-drive agents already can — but it aggregates, reachable from one conversation instead of many, by the agent that reads the most untrusted content.
Leaf A — visibility, default off (LOCAL machines)
What.
drive_envs.visibleToGlobalAssistant(NOT NULL, DEFAULT false), owner-only to change throughPATCH .../envs/[envId], with a toggle on Settings → Local environments. The puredecideEnvReachdecides reach.Why. The global assistant's context spans every drive the person belongs to, so what it may reach is a deliberate choice rather than a consequence of ownership. The flag lives on
drive_envs, not the local sibling, so both substrates answer one question through one column — the schema docblock says why. The write's owner predicate is a correlatedEXISTSondrive_env_local.ownerId, never a drive role, so a drive admin who did not enrol the machine is refused exactly as a stranger is.Visibility is not authority: a test asserts
decideBindstill refuses a non-owner on a fully visible env. It is re-read on every call, so switching it off refuses the next call rather than honouring an earlier reach.decideEnvReach's three refusals surface one sentence, so an id that does not exist and one the caller may not see are indistinguishable.Tests. 8 service/pure rows + 5 route rows + 4 Postgres integration rows (cross-table CAS, byte-identical row for a non-owner, Sprite env and unknown env both refused).
Mutation. 10/10 killed, 2 no-op controls survived.
Leaf B —
list_environmentsWhat. A discovery tool plus
GET /api/env-bridge/environments(404 with the flag off).listVisibleToGlobalAssistantByOwnerapplies two conditions in SQL: the caller owns the machine AND it is visible. Revoked machines excluded.Why. The mandatory id in leaf C is only safe if the model gets it from somewhere. Ownership is the real access filter and deliberately not drive membership — a drive relationship is not an entitlement to every row in it (PR #2609) — which is also why a machine in a drive the owner has since left is still listed: it is their computer. The conversation's own sandbox is a row like any other, addressed by the conversation's own id, so leaf C leaves no implicit path. An empty list is never returned as an empty list: a model handed
[]with a mandatory field to fill has every incentive to invent one, so the answer says in words that there is nothing to copy.Tests. 10 pure/tool rows + 4 route rows + 3 Postgres integration rows (someone else's visible machine absent; the caller's machine in a drive they left still present).
Mutation. 7/7 killed, 2 controls survived.
Leaf C — a mandatory, opaque
environmentIdWhat. Required on all four tools. The zod boundary constrains the shape;
productionResolveEnvironmentTargetresolves it server-side.Why. Three properties, each the fix for a different way July's
targetwent wrong (cf576fbc1):acquireSandboxrefusesmissing_environmentrather than picking a default, and the two deliberately-unaddressed consumers (git/gh toolkit,copy_content) now name the conversation's own target explicitly.isCuidalone acceptsmain,stagingandprod, precisely the values the post-mortem saw invented, so the schema also requires 20–32 characters. The length floor is the half that works.Unknown / not-yours / not-visible / flag-off all surface one sentence naming
list_environments.Leaves C and D are INSEPARABLE — neither ships alone. Reading the commits in order, C lands the mandatory id and its refusals but deliberately leaves one branch unfinished: a valid, reachable NAMED environment resolves and is then refused
environment_routing_not_enabledat acquire, rather than running somewhere the caller did not name. That is a safe intermediate, not a releasable one — with C alone the only environment that works is the conversation's own sandbox. D replaces exactly that branch with real routing. A reviewer should not conclude C was releasable on its own, and neither commit should be cherry-picked without the other.Tests. 9 tool rows + 7 runtime resolver rows + 4 schema rows.
Mutation. 10/10 killed, 3 controls survived.
Leaf D — routing, and the driveless bind
What.
spawnAgentSessionbranches on substrate;agent_workspaces_env_needs_drive_checkdropped (migration 0296); a named environment runs in its own session viafindActiveByOwnerAndEnv. This is the other half of leaf C (see above): it is what turns C'senvironment_routing_not_enabledrefusal into a run.Why. The CHECK could not express the distinction it needed to. It was right about a Sprite env — drive-owned, drive-paid, no owner of its own, and
decideAgentSessionAccessderives access fromdriveIdalone — and that arm is unchanged and still enforced in the service, with a test pinning that a driveless spawn is still refused a Sprite env. It was wrong about a local env, where [D-6] already makes binding structurally owner-only, so relaxing the comparison removed a redundant scoping check rather than an ownership one ([D-4]). The schema docblock records where the guarantee now lives; a test asserts a non-owner is still refused a local env with and without a drive, so this cannot have made the bind gate the only thing standing between a stranger and someone's hardware.One session per environment, never one re-pointed — re-pointing is the predecessor's
switch_machine: it races every concurrent call in the session and silently moves work a previous call believed was elsewhere. Billing and authorization follow the environment: the resolver returns payer coordinates alongside the target, the gate runs on those, andresolveBillingSessionresolves the same session through the same find-or-spawn, so a drive environment bills that drive's owner exactly as it does today and a credit-exhausted payer is refused before a sandbox wakes. No new refusal vocabulary;reconnectstill carries the acting principal, so a mis-addressed call at the owner's machine still waits for their passkey-verified click.Tests. 6 spawn rows + 9 routing/billing rows + 1 click-path row; the DB integration row that asserted the CHECK now asserts its absence.
Mutation. 7/7 killed, 2 controls survived.
Leaf E — results name the environment
What. Every result and every post-resolution refusal carries
environment: { id, label }, stamped once at the tool boundary.Why. With a mandatory id the remaining failure is copying a real id for the wrong environment. Both fields come from the server's resolved row and are never echoed from the model's input — a test names a different id than the resolver returns and asserts the result reports the resolver's. A refusal that never resolved an environment names none: naming one would be inventing it. It records nothing new;
drive_env_grant_auditalready carriesenvId, and the test asserts the audit-write count stays at one.Tests. 7 rows. Mutation. 4/4 killed, 1 control survived.
Leaf F — docs
Posture doc: a new section, four layer rows — including one for the drive agreement naming both branches — prose on the dropped
agent_workspaces_env_needs_drive_check(why it was right about a Sprite env, wrong about a local one, and where the guarantee lives now), R-16 (session-level taint), R-17 (a real id for the wrong environment) and R-18 (the constraint-to-code-branch trade, and the rule that any new writer ofagent_workspaces.envIdmust go throughspawnAgentSession), two flag-on checklist items, [D-4] rewritten from deferred to resolved. Customer page and changelog carry the same limits and no capability the internal document does not. A documentation test pins all of it.Mutation. 8/8 killed, 1 control survived.
Review round 1 — three Codex findings, all real, all fixed
Each as its own commit, red first, mutation-checked.
P1 — discovery and resolution were not gated to global conversations (
f2d37238e). The most serious one: it broke a promise made in three places.buildSandboxTools()is constructed once and shared by every turn, anddecideEnvReachtook only{ actorId, env }, so by construction nothing could tell a dashboard turn from a page turn — a sandbox-enabled page agent could list and address the machine someone had switched on for their global assistant.conversationKindnow rides the actor context (failing closed: only an explicit'global'is global) anddecideEnvReachrefusesnot_globalfirst, before the row is read, so a page turn cannot probe for an id's existence. The rule is one shared predicate because there are two call sites. The toggle copy is unchanged — the promise was already the narrow one, and the code now matches it.P1 — discovery gated on the wrong payer (
f69171ad2).list_environmentsruns no code but went through the run gate on the conversation's payer, so a free-tier person who owns a visible machine in a Pro-owned drive could never obtain the id of the one place they can run — contradictingcanRunCode's own rule that the tier leg keys on the payer.gateDiscoveryasks "may this actor run anywhere they can reach?", never second-guessing the kill switch, and the pipeline strip (sandbox-tool-eligibility.ts) got the same fallback — without it, fixing the tool alone would have changed nothing, because the family is stripped before the tool can be called.P2 — the owner-after-departure case always failed (
36678d864). The directory deliberately lists a machine in a drive its owner has left — it is their computer — and every call on it refusedno_drive_access; the bind gate had it too, so the spawn failed as well. A local env authorizes on machine ownership ([D-6],decideBind, anddecideEnvReachimmediately before), so the drive is now the billing coordinate only:gateDriveIdis absent for a local target, and the bind gate callscanRunCodewith nodriveId. Kill switch and tier leg untouched; a non-owner falls tobind_policy, the accurate reason. Consequence written down rather than discovered (R-19 + a layers row): a departed member can run on their own machine and that drive's owner is still metered, because the env row is drive-owned and drive-paid — the drive's remedy is the Delete/Revoke [D-6] already gives it.Two CI failures on the previous head, both fixed in
f69171ad2and neither caused by the fixes: a new unused export (LIST_ENVIRONMENTS_TOOL_NAME— used rather than ignored, per the ratchet's own instruction), and a stale integration row asserting the rationale leaf D deliberately inverted. That row is re-pointed and re-titled, with three siblings so it proves the new intent rather than accepting a new string: the same env binds once connected (asserting the session's and the env's drives genuinely differ), a non-owner is still refusedbind_policy, and a driveless global session binds a connected local env it owns — the case [D-4] blocked.Two repo-wide gates caught the new surface (
df2723a80)Found by running the full web suite against a real Postgres rather than only the directories the diff touched — neither of these lives near the code it guards, and both were doing their job:
registry-coverage— every AI tool must have a rich renderer, andlist_environmentshad none, so it would have rendered as raw JSON. It gets a real one: the label above the id for each row, plus the notice. That is also the one place a person can see which of their machines the assistant can currently reach, and by absence which they have not switched on.list-route.test.ts's "no code path sends an approval TO the machine" invariant) — it pins the exact set ofapi/env-bridge/*directories so a new route family must be acknowledged on purpose.environmentsis added with the reasoning stated: a GET over rows the caller owns, returning no frame to any machine.These are the same shape as the GDPR coverage gate and the
@pagespace/libexports map: a change that adds a member to a set the product enumerates trips a test three directories away.Review round 2 — the dedup held to the standard the PR set itself
f5991fad1— the per-drive dedup in3fcaaab17was correct but written twice (discovery gate and pipeline eligibility strip) and asserted in neither. That is the shape this PR already rejected once: when discovery and resolution had to agree, the rule became one predicate with a row pinning that they cannot drift. Same standard applied —anyReachableEnvironmentPayerAllowsowns the iteration; each caller keeps its own authorizer, and that difference is genuine and now stated: the strip stands where tool registration stands and askscanRunCodeForSession, the gate stands where a call stands and asks the full call-time gate. Sharing it is the point — fixing one alone changes nothing, because the strip removes the tool before the gate can allow it. Rows: two machines in one drive resolve the payer once; two drives ask twice; a drive whose payer does not resolve is skipped without refusing the others; the first allowing drive stops the loop; every uncertainty fails closed. Plus a source scan pinning that neither caller has regrown its own loop.One of those rows initially survived its mutant: the flag-off case was asserted with a throwing authorizer, which the fail-closed
catchswallowed, so it held with the flag check deleted. It now asserts the observable consequence — nothing listed, nothing authorized — and kills it.7547f2f89—knip.jsonignoressrc/**/__tests__/**, so the two new exports consumed only by that test would have read as dead code (the same gate that failed Lint earlier). Both are module-private now, with the test deriving the deps shape from the function's own signature — stricter than the import it replaces, since the fake cannot drift from the real parameter.72db9a541—openAtwas spreading the whole payer object onto the actor context, sogateDriveId(a gate input, not a fact about the actor) rode along as an undeclared field on an object handed to every runner. Inert today — nothing spreadsctxwholesale, and TypeScript will not surface it — but it would reach an audit row the first time someone writes...ctx. Split off where it is produced, so the property cannot exist rather than merely being unused.Review round 3 — one BLOCKING defect: cloud reach was dead in production (
f7b4a24df)LOCAL_ENVS_ENABLEDis off on every deployment, and it was short-circuiting the cloud path in three places — the discovery gate's fallback, the pipeline eligibility strip, and (not in the report, and worse) resolution itself, which refused before it had even looked the environment up. So the ruling failed for exactly the case it exists for: a person whose own tier cannot run a personal sandbox, but who has edit access to a paid team drive, got no tools, no discovery and no resolution.The flag now applies per substrate, in the one place that decides it: local rows are not listed and not even read while it is off; cloud rows are never gated on it; resolution asks it only after the lookup and only for a local env, because the question cannot be answered without knowing the substrate. The refusal is still the single message.
Red first against the flag's real reader (
process.env.LOCAL_ENVS_ENABLED), never an injected stub — which is precisely why this was invisible before: every existing test injectedtruewhile production is always off.Also fixed, the minor: discovery and resolution asked
canRunCodewith different origins, so an agent-origin call could see a laxer list than the next call would honour. The origin is threaded and asserted.Gates
@pagespace/lib/*subpath touched by the diff has apackages/lib/package.jsonentry; two new ones added (env-bridge/decide-env-reach,services/sandbox/environment-directory). No new@pagespace/dbsubpath.db:generateis a no-op — migrations 0295 (add column) and 0296 (drop constraint) both generated, never hand-edited; a second run prints "No schema changes, nothing to migrate" anddrizzle-kit checkprints "Everything's fine".drive_envsis table-level excluded from the GDPR export, so the new column needs no collector.tsc --noEmitclean inpackages/lib,packages/db,apps/web,apps/marketing.bunx eslintclean on all 19 touchedapps/webfiles and on the touched marketing file.packages/libdist between mutate and run. (Correction: commit36678d864's message says "4/4" for that fix; the sweep was 3 real mutants and 1 control. The total above is the accurate one.)visibleToGlobalAssistantisNOT NULL DEFAULT false,agent_workspaces_env_needs_drive_checkis absent, and its siblingagent_workspaces_env_no_sprite_checkis still there.env-sessions.integration23/23, the DB-backed lib suites 857/857, webagent-workspaces+ai5153/5153.bun run knip:checkreports a handful of findings in this worktree that CI does not see (a known pu-worktree artefact — all in files the diff never touched); the one issue CI did report is fixed by using the constant rather than ignoring it.websuite passes against a real Postgres apart from two known local-only classes: the tiptap/ESM module-load failures andchat-mutation-matrix, both of which pass in CI and neither of which this branch touches.Exit criterion
All six leaves green and mutation-checked; an environment with visibility off is unreachable; a missing or guessed environment id refuses; a mis-addressed call at the owner's machine still needs the click;
db:generatea no-op; leaves flipped on the board.🤖 Generated with Claude Code
https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646