Skip to content

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

Merged
2witstudios merged 22 commits into
pu/local-env-gafrom
pu/local-env-global-assistant
Sep 13, 2026
Merged

2witstudios merged 22 commits into
pu/local-env-gafrom
pu/local-env-global-assistant

Conversation

@2witstudios

@2witstudios 2witstudios commented Sep 12, 2026

Copy link
Copy Markdown
Owner

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 canRunCode parity

This 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.

  • A CLOUD env has no owner, so there is nothing to opt in to: the drive permission IS the visibility. decideEnvReach gains a sprite arm keyed on an injected mayRunCodeInEnvDrive, computed as canRunCode({ userId, driveId: env.driveId }) — same kill switch, same payer tier (the drive's owner), same drive access and canEdit. A VIEWER is refused exactly as in-drive. Used verbatim, never re-derived.
  • The parity promise is a TEST, not a comment. For every verdict canRunCode can produce, the reach verdict must equal it; neither a stray visibility flag nor the actor's identity can make them diverge.
  • LOCAL_ENVS_ENABLED gates 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.
  • Local machines are unchanged: owner + 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's env.driveId !== driveId comparison is satisfied, not bypassed; the negative that a driveless spawn is refused a Sprite env stays true and unmodified; decideAgentSessionAccess still 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 through PATCH .../envs/[envId], with a toggle on Settings → Local environments. The pure decideEnvReach decides 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 correlated EXISTS on drive_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 decideBind still 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_environments

What. A discovery tool plus GET /api/env-bridge/environments (404 with the flag off). listVisibleToGlobalAssistantByOwner applies 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 environmentId

What. Required on all four tools. The zod boundary constrains the shape; productionResolveEnvironmentTarget resolves it server-side.

Why. Three properties, each the fix for a different way July's target went wrong (cf576fbc1):

  • Mandatory — omission is a schema error the model sees, never a fallback. Removed at every layer: acquireSandbox refuses missing_environment rather than picking a default, and the two deliberately-unaddressed consumers (git/gh toolkit, copy_content) now name the conversation's own target explicitly.
  • OpaqueisCuid alone accepts main, staging and prod, precisely the values the post-mortem saw invented, so the schema also requires 20–32 characters. The length floor is the half that works.
  • Described — every description says copy, never construct. The post-mortem records that the prompt and descriptions encouraged the bad value, so the wording is asserted by a test.

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_enabled at 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. spawnAgentSession branches on substrate; agent_workspaces_env_needs_drive_check dropped (migration 0296); a named environment runs in its own session via findActiveByOwnerAndEnv. This is the other half of leaf C (see above): it is what turns C's environment_routing_not_enabled refusal 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 decideAgentSessionAccess derives access from driveId alone — 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, 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 payer is refused before a sandbox wakes. No new refusal vocabulary; reconnect still 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_audit already carries envId, 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 of agent_workspaces.envId must go through spawnAgentSession), 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, and decideEnvReach took 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. conversationKind now rides the actor context (failing closed: only an explicit 'global' is global) and decideEnvReach refuses not_global first, 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_environments runs 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 — contradicting canRunCode's own rule that the tier leg keys on the payer. gateDiscovery asks "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 refused no_drive_access; the bind gate had it too, so the spawn failed as well. A local env authorizes on machine ownership ([D-6], decideBind, and decideEnvReach immediately before), so the drive is now the billing coordinate only: gateDriveId is absent for a local target, and the bind gate calls canRunCode with no driveId. Kill switch and tier leg untouched; a non-owner falls to bind_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 f69171ad2 and 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 refused bind_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, and list_environments had 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.
  • the env-bridge route inventory (in list-route.test.ts's "no code path sends an approval TO the machine" invariant) — it pins the exact set of api/env-bridge/* directories so a new route family must be acknowledged on purpose. environments is 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/lib exports 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 in 3fcaaab17 was 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 — anyReachableEnvironmentPayerAllows owns the iteration; each caller keeps its own authorizer, and that difference is genuine and now stated: the strip stands where tool registration stands and asks canRunCodeForSession, 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 catch swallowed, so it held with the flag check deleted. It now asserts the observable consequence — nothing listed, nothing authorized — and kills it.

7547f2f89knip.json ignores src/**/__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.

72db9a541openAt was spreading the whole payer object onto the actor context, so gateDriveId (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 spreads ctx wholesale, 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_ENABLED is 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 injected true while production is always off.

Also fixed, the minor: 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 threaded and asserted.

Gates

  • Exports-map sweep — every @pagespace/lib/* subpath touched by the diff has a packages/lib/package.json entry; two new ones added (env-bridge/decide-env-reach, services/sandbox/environment-directory). No new @pagespace/db subpath.
  • db:generate is 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" and drizzle-kit check prints "Everything's fine". drive_envs is table-level excluded from the GDPR export, so the new column needs no collector.
  • tsc --noEmit clean in packages/lib, packages/db, apps/web, apps/marketing.
  • bunx eslint clean on all 19 touched apps/web files and on the touched marketing file.
  • Mutation: 73/73 real mutants killed, 19 no-op controls survived, by line index, rebuilding packages/lib dist between mutate and run. (Correction: commit 36678d864's message says "4/4" for that fix; the sweep was 3 real mutants and 1 control. The total above is the accurate one.)
  • Verified against a real Postgres, not blind — the previous head's one genuine test failure was a DB-backed suite I could not run locally, so I stood one up. Migrations 0295 and 0296 apply to a clean database: visibleToGlobalAssistant is NOT NULL DEFAULT false, agent_workspaces_env_needs_drive_check is absent, and its sibling agent_workspaces_env_no_sprite_check is still there. env-sessions.integration 23/23, the DB-backed lib suites 857/857, web agent-workspaces + ai 5153/5153.
  • bun run knip:check reports 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.
  • The full web suite passes against a real Postgres apart from two known local-only classes: the tiptap/ESM module-load failures and chat-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:generate a no-op; leaves flipped on the board.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646

2witstudios and others added 6 commits September 12, 2026 06:50
…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
@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 5388e6b7-679e-4f9f-a808-113fc2e674c1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/src/lib/ai/tools/sandbox-tools-runtime.ts Outdated
Comment thread apps/web/src/lib/ai/tools/sandbox-tools.ts
Comment thread apps/web/src/lib/ai/tools/sandbox-tools.ts Outdated
2witstudios and others added 12 commits September 12, 2026 07:39
…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
@2witstudios 2witstudios changed the title feat(env-bridge): a global assistant reaches any environment it may see — visibility plus a mandatory opaque envId 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 Sep 13, 2026
… 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
Comment thread scripts/ga-cloud-reach-gate/rows.ts Fixed
2witstudios and others added 2 commits September 12, 2026 22:41
…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
@2witstudios
2witstudios merged commit 1e61cee into pu/local-env-ga Sep 13, 2026
11 checks passed
@2witstudios
2witstudios deleted the pu/local-env-global-assistant branch September 16, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants