Skip to content

fix(cli): the pi adapter resumes only a session pi wrote — a codex thread id starts fresh - #1726

Merged
lilyshen0722 merged 2 commits into
mainfrom
wave/pi-adapter-session-fallback
Sep 18, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
wave/pi-adapter-session-fallback

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

sprint-impl's first pi spawn after the switch (05:06Z) failed with pi exited 1: No session found matching '01a06c46-…': the wrapper persists one session id per (agent, pod) across adapters, so a seat switched from codex hands pi its old codex thread id as a resume target. The adapter now resumes only an id that has a pi session file (<timestamp>_<id>.jsonl in the seat's session dir) and otherwise starts a fresh session under a new uuid, which the wrapper persists. Two tests added (real session → --session; foreign id → --session-id under a new id), 17/17, eslint clean. cli 0.1.46. The same file is hot-patched on this host's global 0.1.45 so the switched seats run tonight; the publish replaces it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QoB8EvqzEZEJoKwqFiBvts

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gate at 8957f95d6932081edf7d0e74fda01b720d54d706: passes; one gap to pin.

pi suites 17/17 (Node 22, the CLI's own jest); npm run lint exits 0. Each mutation changes one line (git diff --numstat 1+1-):

  • isResume = !!ctx.sessionId (trust any persisted id): 1 red
  • isResume = false (never resume): 1 red
  • match only <id>.jsonl, dropping the <timestamp>_<id>.jsonl form pi actually writes: 1 red
  • reuse the stale id (ctx.sessionId || randomUUID()): 1 red
  • freshSession: !ctx.sessionId instead of !isResume: 17/17 green. This is exactly the case this PR fixes. A switched seat arrives with a codex thread id and no pi session, so pi starts fresh, but the memory preamble would be built as a resumed turn and skip the seat's long-term memory on its first pi turn. The new test asserts the new id and --session-id, not the prompt. Adding expect(args[args.length - 1]).toContain(<the long-term memory marker>) to it pins it.

Merge order with #1727. git merge-tree on the pair conflicts in pi.js, because both edit the top of spawn(). #1727 closes the seat-token exposure that is live right now: the host's global install matches main in pi-mcp-client.mjs and pi-commonly-mcp.mjs, and only pi.js was hot-patched to this PR. So 0.1.46 should carry both, whichever lands second rebased onto the first. Publishing 0.1.46 with only this PR ships a release that still leaks the token.

samxu01 pushed a commit that referenced this pull request Sep 18, 2026
…real resume does not

sprint-review's gate on #1726: deriving freshSession from ctx.sessionId
stayed green, and a switched seat would have skipped its long-term memory
cue on its first pi turn. Pinned on the prompt argument both ways.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoB8EvqzEZEJoKwqFiBvts

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Re-gate at 66a1b1dfd5a7ac534f82df0e01e1ece57848692b: passes; the gap is pinned.

The delta since 8957f95d is test-only (adapters.pi.test.mjs, +7/−2). pi suites 17/17 on Node 22; npm run lint exits 0. Each mutation changes one line (git diff --numstat 1+1-):

  • isResume = !!ctx.sessionId: 1 red
  • isResume = false: 1 red
  • match only <id>.jsonl: 1 red
  • freshSession: !ctx.sessionId: 1 red (was 17/17 green at 8957f95d)
  • freshSession: true always: 1 red (the resume leg of the new assertion)
  • reuse the stale id: 1 red

Merge order as you laid it out works for me: this one first, then #1727 rebased onto it for a re-gate, then 0.1.46.

lilyshen0722 and others added 2 commits September 18, 2026 05:08
…ead id from before the switch starts fresh

sprint-impl's first pi spawn (2026-09-18 05:06Z) failed with `No session
found matching '01a06c46-…'`: the wrapper persists one session id per
(agent, pod) across adapters, so the seat handed pi its codex thread id as
a resume target. pi's session files are `<timestamp>_<id>.jsonl` under the
seat's session dir; an id with no file now starts a fresh session under a
new uuid, which the wrapper persists on return. Two tests: a real pi
session resumes with `--session`; a foreign id starts with `--session-id`
under a new id. cli 0.1.46.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoB8EvqzEZEJoKwqFiBvts
…real resume does not

sprint-review's gate on #1726: deriving freshSession from ctx.sessionId
stayed green, and a switched seat would have skipped its long-term memory
cue on its first pi turn. Pinned on the prompt argument both ways.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoB8EvqzEZEJoKwqFiBvts
@samxu01
samxu01 force-pushed the wave/pi-adapter-session-fallback branch from 66a1b1d to abee6ea Compare September 18, 2026 12:08
@lilyshen0722
lilyshen0722 merged commit 0011545 into main Sep 18, 2026
13 checks passed
samxu01 pushed a commit that referenced this pull request Sep 18, 2026
The self-serve install ships no environment, the daemon projected that
verbatim, and the seat spawned with no sandbox — `sandbox.mode` defaults to
'none' in the adapters, so an absent block means NO confinement while the seat
takes instructions from everyone in the room (C4-6). The c4 smoke seat had to
be hand-confined through a row PATCH.

The baseline is now both halves: the kernel MCP server (TASK-048, #1741) and an
enforced sandbox. The stored block is `sandbox: { trust: 'public' }` and carries
NO mode, because the record is platform-independent and the host is not —
`mode: 'workspace'` is Seatbelt on macOS and is refused on Linux, `mode:
'bwrap'` is meaningless on macOS, so either one moves a host fact into the
database and breaks the day the seat is re-homed (Wren 69545). The mode is
resolved at spawn instead, in cli/src/lib/sandbox/mode.js: darwin → 'workspace'
(Seatbelt), else → 'bwrap'; an explicit mode in a record still wins.

That resolution had to land in the same change, or the baseline would be worse
than the hole it closes. Measured on main: `{ trust: 'public' }` with no mode
did NOT engage anything — `publicNativeSandbox` required a mode in {workspace,
read-only} and the "public requires an enforced mode" throw only fired on the
literal 'none', so an absent mode fell straight through to a bare, unconfined
`claude` spawn, while the record claimed confinement. So the claude adapter now
resolves the mode and REFUSES any public trust that does not end up enforced,
and the codex adapter (whose mode is read-vs-write access, not a host mechanism)
defaults an absent mode to 'workspace' rather than throwing `got unset` — which
would have made every derived codex seat unspawnable.

Which environments get it is a trust boundary: environments the daemon DERIVED —
a server declaration, or a seat nobody has authored anything for (fresh mint, a
record with no environment, the TASK-048 heal path). An operator-authored local
environment keeps its own choice: a private-pod record with no sandbox is what
`agent attach` permits, and silently confining it would break seats that
deliberately run with host reach. `withDefaultSandbox` replaces an explicitly
disengaged `mode: 'none'` rather than honouring it, because on this path 'none'
and absent are the same thing to the spawn.

`assertSandboxDeclaredForPublicPod` now accepts the same shapes the daemon
writes (a public trust, or an explicit non-'none' mode), so its own baseline is
not a shape it would refuse on a public pod; attach still validates the resolved
mode's host mechanism (Seatbelt present / bwrap installed / codex version).

Tests: 6 new in default-environment.test.mjs (including "stores NO mode"), 5 in
daemon-supervisor.test.mjs, 3 in adapters.claude.environment.test.mjs (mode-less
public resolves to Seatbelt on darwin and bwrap elsewhere; an unresolvable
explicit mode refuses), 1 in adapters.codex.test.mjs, 2 in
public-pod-sandbox-gate.test.mjs. Two merged tests pinned behaviour this change
deliberately inverts: the C4-2 baseline assertion from #1741 asserted the
mcp-only baseline, and codex's "public trust fails closed when no mode is
declared" asserted the throw that would have stopped every derived codex seat.
Cli suite 39 suites / 550 passed / 10 skipped; lint:cli clean.

Verified on the real writers (real supervisor + real saveAgentToken /
loadAgentToken, temp HOME): the self-serve row shape writes {sandbox:{trust:
'public'}, mcp:[commonly], model} and the hand-confined c4-smoke shape is
byte-preserved with no rewrite on the next tick. NOT verified by running on a
Linux host — the Linux path is asserted with a mocked process.platform plus the
adapter's own bwrap branch.

Confinement holds for claude and codex. A pi seat gets the block and is NOT
confined by it: the pi adapter has no sandbox path until #1740 gives it one, so
do not read this key on a pi record as confinement.

Cli version 0.1.48 → 0.1.49 (main took 0.1.48 with #1726; Wave's #1744 slots
this in the cli chain). Not a guard: nothing is refused and no seat is stopped —
an installed seat gains the confinement on its next daemon tick. Refusing a
declared command or a foreign-origin token address is layer 2, Wave's #1744.
samxu01 pushed a commit that referenced this pull request Sep 18, 2026
The self-serve install ships no environment, the daemon projected that
verbatim, and the seat spawned with no sandbox — `sandbox.mode` defaults to
'none' in the adapters, so an absent block means NO confinement while the seat
takes instructions from everyone in the room (C4-6). The c4 smoke seat had to
be hand-confined through a row PATCH.

The baseline is now both halves: the kernel MCP server (TASK-048, #1741) and an
enforced sandbox. The stored block is `sandbox: { trust: 'public' }` and carries
NO mode, because the record is platform-independent and the host is not —
`mode: 'workspace'` is Seatbelt on macOS and is refused on Linux, `mode:
'bwrap'` is meaningless on macOS, so either one moves a host fact into the
database and breaks the day the seat is re-homed (Wren 69545). The mode is
resolved at spawn instead, in cli/src/lib/sandbox/mode.js: darwin → 'workspace'
(Seatbelt), else → 'bwrap'; an explicit mode in a record still wins.

That resolution had to land in the same change, or the baseline would be worse
than the hole it closes. Measured on main: `{ trust: 'public' }` with no mode
did NOT engage anything — `publicNativeSandbox` required a mode in {workspace,
read-only} and the "public requires an enforced mode" throw only fired on the
literal 'none', so an absent mode fell straight through to a bare, unconfined
`claude` spawn, while the record claimed confinement. So the claude adapter now
resolves the mode and REFUSES any public trust that does not end up enforced,
and the codex adapter (whose mode is read-vs-write access, not a host mechanism)
defaults an absent mode to 'workspace' rather than throwing `got unset` — which
would have made every derived codex seat unspawnable.

Which environments get it is a trust boundary: environments the daemon DERIVED —
a server declaration, or a seat nobody has authored anything for (fresh mint, a
record with no environment, the TASK-048 heal path). An operator-authored local
environment keeps its own choice: a private-pod record with no sandbox is what
`agent attach` permits, and silently confining it would break seats that
deliberately run with host reach. `withDefaultSandbox` replaces an explicitly
disengaged `mode: 'none'` rather than honouring it, because on this path 'none'
and absent are the same thing to the spawn.

`assertSandboxDeclaredForPublicPod` now accepts the same shapes the daemon
writes (a public trust, or an explicit non-'none' mode), so its own baseline is
not a shape it would refuse on a public pod; attach still validates the resolved
mode's host mechanism (Seatbelt present / bwrap installed / codex version).

Tests: 6 new in default-environment.test.mjs (including "stores NO mode"), 5 in
daemon-supervisor.test.mjs, 3 in adapters.claude.environment.test.mjs (mode-less
public resolves to Seatbelt on darwin and bwrap elsewhere; an unresolvable
explicit mode refuses), 1 in adapters.codex.test.mjs, 2 in
public-pod-sandbox-gate.test.mjs. Two merged tests pinned behaviour this change
deliberately inverts: the C4-2 baseline assertion from #1741 asserted the
mcp-only baseline, and codex's "public trust fails closed when no mode is
declared" asserted the throw that would have stopped every derived codex seat.
Cli suite 39 suites / 550 passed / 10 skipped; lint:cli clean.

Verified on the real writers (real supervisor + real saveAgentToken /
loadAgentToken, temp HOME): the self-serve row shape writes {sandbox:{trust:
'public'}, mcp:[commonly], model} and the hand-confined c4-smoke shape is
byte-preserved with no rewrite on the next tick. NOT verified by running on a
Linux host — the Linux path is asserted with a mocked process.platform plus the
adapter's own bwrap branch.

Confinement holds for claude and codex. A pi seat gets the block and is NOT
confined by it: the pi adapter has no sandbox path until #1740 gives it one, so
do not read this key on a pi record as confinement.

Cli version 0.1.48 → 0.1.49 (main took 0.1.48 with #1726; Wave's #1744 slots
this in the cli chain). Not a guard: nothing is refused and no seat is stopped —
an installed seat gains the confinement on its next daemon tick. Refusing a
declared command or a foreign-origin token address is layer 2, Wave's #1744.
samxu01 pushed a commit that referenced this pull request Sep 18, 2026
The self-serve install ships no environment, the daemon projected that
verbatim, and the seat spawned with no sandbox — `sandbox.mode` defaults to
'none' in the adapters, so an absent block means NO confinement while the seat
takes instructions from everyone in the room (C4-6). The c4 smoke seat had to
be hand-confined through a row PATCH.

The baseline is now both halves: the kernel MCP server (TASK-048, #1741) and an
enforced sandbox. The stored block is `sandbox: { trust: 'public' }` and carries
NO mode, because the record is platform-independent and the host is not —
`mode: 'workspace'` is Seatbelt on macOS and is refused on Linux, `mode:
'bwrap'` is meaningless on macOS, so either one moves a host fact into the
database and breaks the day the seat is re-homed (Wren 69545). The mode is
resolved at spawn instead, in cli/src/lib/sandbox/mode.js: darwin → 'workspace'
(Seatbelt), else → 'bwrap'; an explicit mode in a record still wins.

That resolution had to land in the same change, or the baseline would be worse
than the hole it closes. Measured on main: `{ trust: 'public' }` with no mode
did NOT engage anything — `publicNativeSandbox` required a mode in {workspace,
read-only} and the "public requires an enforced mode" throw only fired on the
literal 'none', so an absent mode fell straight through to a bare, unconfined
`claude` spawn, while the record claimed confinement. So the claude adapter now
resolves the mode and REFUSES any public trust that does not end up enforced,
and the codex adapter (whose mode is read-vs-write access, not a host mechanism)
defaults an absent mode to 'workspace' rather than throwing `got unset` — which
would have made every derived codex seat unspawnable.

Which environments get it is a trust boundary: environments the daemon DERIVED —
a server declaration, or a seat nobody has authored anything for (fresh mint, a
record with no environment, the TASK-048 heal path). An operator-authored local
environment keeps its own choice: a private-pod record with no sandbox is what
`agent attach` permits, and silently confining it would break seats that
deliberately run with host reach. `withDefaultSandbox` replaces an explicitly
disengaged `mode: 'none'` rather than honouring it, because on this path 'none'
and absent are the same thing to the spawn.

`assertSandboxDeclaredForPublicPod` now accepts the same shapes the daemon
writes (a public trust, or an explicit non-'none' mode), so its own baseline is
not a shape it would refuse on a public pod; attach still validates the resolved
mode's host mechanism (Seatbelt present / bwrap installed / codex version).

Tests: 6 new in default-environment.test.mjs (including "stores NO mode"), 5 in
daemon-supervisor.test.mjs, 3 in adapters.claude.environment.test.mjs (mode-less
public resolves to Seatbelt on darwin and bwrap elsewhere; an unresolvable
explicit mode refuses), 1 in adapters.codex.test.mjs, 2 in
public-pod-sandbox-gate.test.mjs. Two merged tests pinned behaviour this change
deliberately inverts: the C4-2 baseline assertion from #1741 asserted the
mcp-only baseline, and codex's "public trust fails closed when no mode is
declared" asserted the throw that would have stopped every derived codex seat.
Cli suite 39 suites / 550 passed / 10 skipped; lint:cli clean.

Verified on the real writers (real supervisor + real saveAgentToken /
loadAgentToken, temp HOME): the self-serve row shape writes {sandbox:{trust:
'public'}, mcp:[commonly], model} and the hand-confined c4-smoke shape is
byte-preserved with no rewrite on the next tick. NOT verified by running on a
Linux host — the Linux path is asserted with a mocked process.platform plus the
adapter's own bwrap branch.

Confinement holds for claude and codex. A pi seat gets the block and is NOT
confined by it: the pi adapter has no sandbox path until #1740 gives it one, so
do not read this key on a pi record as confinement.

Cli version 0.1.48 → 0.1.49 (main took 0.1.48 with #1726; Wave's #1744 slots
this in the cli chain). Not a guard: nothing is refused and no seat is stopped —
an installed seat gains the confinement on its next daemon tick. Refusing a
declared command or a foreign-origin token address is layer 2, Wave's #1744.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
The self-serve install ships no environment, the daemon projected that
verbatim, and the seat spawned with no sandbox — `sandbox.mode` defaults to
'none' in the adapters, so an absent block means NO confinement while the seat
takes instructions from everyone in the room (C4-6). The c4 smoke seat had to
be hand-confined through a row PATCH.

The baseline is now both halves: the kernel MCP server (TASK-048, #1741) and an
enforced sandbox. The stored block is `sandbox: { trust: 'public' }` and carries
NO mode, because the record is platform-independent and the host is not —
`mode: 'workspace'` is Seatbelt on macOS and is refused on Linux, `mode:
'bwrap'` is meaningless on macOS, so either one moves a host fact into the
database and breaks the day the seat is re-homed (Wren 69545). The mode is
resolved at spawn instead, in cli/src/lib/sandbox/mode.js: darwin → 'workspace'
(Seatbelt), else → 'bwrap'; an explicit mode in a record still wins.

That resolution had to land in the same change, or the baseline would be worse
than the hole it closes. Measured on main: `{ trust: 'public' }` with no mode
did NOT engage anything — `publicNativeSandbox` required a mode in {workspace,
read-only} and the "public requires an enforced mode" throw only fired on the
literal 'none', so an absent mode fell straight through to a bare, unconfined
`claude` spawn, while the record claimed confinement. So the claude adapter now
resolves the mode and REFUSES any public trust that does not end up enforced,
and the codex adapter (whose mode is read-vs-write access, not a host mechanism)
defaults an absent mode to 'workspace' rather than throwing `got unset` — which
would have made every derived codex seat unspawnable.

Which environments get it is a trust boundary: environments the daemon DERIVED —
a server declaration, or a seat nobody has authored anything for (fresh mint, a
record with no environment, the TASK-048 heal path). An operator-authored local
environment keeps its own choice: a private-pod record with no sandbox is what
`agent attach` permits, and silently confining it would break seats that
deliberately run with host reach. `withDefaultSandbox` replaces an explicitly
disengaged `mode: 'none'` rather than honouring it, because on this path 'none'
and absent are the same thing to the spawn.

`assertSandboxDeclaredForPublicPod` now accepts the same shapes the daemon
writes (a public trust, or an explicit non-'none' mode), so its own baseline is
not a shape it would refuse on a public pod; attach still validates the resolved
mode's host mechanism (Seatbelt present / bwrap installed / codex version).

Tests: 6 new in default-environment.test.mjs (including "stores NO mode"), 5 in
daemon-supervisor.test.mjs, 3 in adapters.claude.environment.test.mjs (mode-less
public resolves to Seatbelt on darwin and bwrap elsewhere; an unresolvable
explicit mode refuses), 1 in adapters.codex.test.mjs, 2 in
public-pod-sandbox-gate.test.mjs. Two merged tests pinned behaviour this change
deliberately inverts: the C4-2 baseline assertion from #1741 asserted the
mcp-only baseline, and codex's "public trust fails closed when no mode is
declared" asserted the throw that would have stopped every derived codex seat.
Cli suite 39 suites / 550 passed / 10 skipped; lint:cli clean.

Verified on the real writers (real supervisor + real saveAgentToken /
loadAgentToken, temp HOME): the self-serve row shape writes {sandbox:{trust:
'public'}, mcp:[commonly], model} and the hand-confined c4-smoke shape is
byte-preserved with no rewrite on the next tick. NOT verified by running on a
Linux host — the Linux path is asserted with a mocked process.platform plus the
adapter's own bwrap branch.

Confinement holds for claude and codex. A pi seat gets the block and is NOT
confined by it: the pi adapter has no sandbox path until #1740 gives it one, so
do not read this key on a pi record as confinement.

Cli version 0.1.48 → 0.1.49 (main took 0.1.48 with #1726; Wave's #1744 slots
this in the cli chain). Not a guard: nothing is refused and no seat is stopped —
an installed seat gains the confinement on its next daemon tick. Refusing a
declared command or a foreign-origin token address is layer 2, Wave's #1744.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
The self-serve install ships no environment, the daemon projected that
verbatim, and the seat spawned with no sandbox — `sandbox.mode` defaults to
'none' in the adapters, so an absent block means NO confinement while the seat
takes instructions from everyone in the room (C4-6). The c4 smoke seat had to
be hand-confined through a row PATCH.

The baseline is now both halves: the kernel MCP server (TASK-048, #1741) and an
enforced sandbox. The stored block is `sandbox: { trust: 'public' }` and carries
NO mode, because the record is platform-independent and the host is not —
`mode: 'workspace'` is Seatbelt on macOS and is refused on Linux, `mode:
'bwrap'` is meaningless on macOS, so either one moves a host fact into the
database and breaks the day the seat is re-homed (Wren 69545). The mode is
resolved at spawn instead, in cli/src/lib/sandbox/mode.js: darwin → 'workspace'
(Seatbelt), else → 'bwrap'; an explicit mode in a record still wins.

That resolution had to land in the same change, or the baseline would be worse
than the hole it closes. Measured on main: `{ trust: 'public' }` with no mode
did NOT engage anything — `publicNativeSandbox` required a mode in {workspace,
read-only} and the "public requires an enforced mode" throw only fired on the
literal 'none', so an absent mode fell straight through to a bare, unconfined
`claude` spawn, while the record claimed confinement. So the claude adapter now
resolves the mode and REFUSES any public trust that does not end up enforced,
and the codex adapter (whose mode is read-vs-write access, not a host mechanism)
defaults an absent mode to 'workspace' rather than throwing `got unset` — which
would have made every derived codex seat unspawnable.

Which environments get it is a trust boundary: environments the daemon DERIVED —
a server declaration, or a seat nobody has authored anything for (fresh mint, a
record with no environment, the TASK-048 heal path). An operator-authored local
environment keeps its own choice: a private-pod record with no sandbox is what
`agent attach` permits, and silently confining it would break seats that
deliberately run with host reach. `withDefaultSandbox` replaces an explicitly
disengaged `mode: 'none'` rather than honouring it, because on this path 'none'
and absent are the same thing to the spawn.

`assertSandboxDeclaredForPublicPod` now accepts the same shapes the daemon
writes (a public trust, or an explicit non-'none' mode), so its own baseline is
not a shape it would refuse on a public pod; attach still validates the resolved
mode's host mechanism (Seatbelt present / bwrap installed / codex version).

Tests: 6 new in default-environment.test.mjs (including "stores NO mode"), 5 in
daemon-supervisor.test.mjs, 3 in adapters.claude.environment.test.mjs (mode-less
public resolves to Seatbelt on darwin and bwrap elsewhere; an unresolvable
explicit mode refuses), 1 in adapters.codex.test.mjs, 2 in
public-pod-sandbox-gate.test.mjs. Two merged tests pinned behaviour this change
deliberately inverts: the C4-2 baseline assertion from #1741 asserted the
mcp-only baseline, and codex's "public trust fails closed when no mode is
declared" asserted the throw that would have stopped every derived codex seat.
Cli suite 39 suites / 550 passed / 10 skipped; lint:cli clean.

Verified on the real writers (real supervisor + real saveAgentToken /
loadAgentToken, temp HOME): the self-serve row shape writes {sandbox:{trust:
'public'}, mcp:[commonly], model} and the hand-confined c4-smoke shape is
byte-preserved with no rewrite on the next tick. NOT verified by running on a
Linux host — the Linux path is asserted with a mocked process.platform plus the
adapter's own bwrap branch.

Confinement holds for claude and codex. A pi seat gets the block and is NOT
confined by it: the pi adapter has no sandbox path until #1740 gives it one, so
do not read this key on a pi record as confinement.

Cli version 0.1.48 → 0.1.49 (main took 0.1.48 with #1726; Wave's #1744 slots
this in the cli chain). Not a guard: nothing is refused and no seat is stopped —
an installed seat gains the confinement on its next daemon tick. Refusing a
declared command or a foreign-origin token address is layer 2, Wave's #1744.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…andbox baseline (TASK-052) + refuse an unread trust value (TASK-059) (#1754)

* fix(cli): a daemon-provisioned seat is never born unconfined (TASK-052)

The self-serve install ships no environment, the daemon projected that
verbatim, and the seat spawned with no sandbox — `sandbox.mode` defaults to
'none' in the adapters, so an absent block means NO confinement while the seat
takes instructions from everyone in the room (C4-6). The c4 smoke seat had to
be hand-confined through a row PATCH.

The baseline is now both halves: the kernel MCP server (TASK-048, #1741) and an
enforced sandbox. The stored block is `sandbox: { trust: 'public' }` and carries
NO mode, because the record is platform-independent and the host is not —
`mode: 'workspace'` is Seatbelt on macOS and is refused on Linux, `mode:
'bwrap'` is meaningless on macOS, so either one moves a host fact into the
database and breaks the day the seat is re-homed (Wren 69545). The mode is
resolved at spawn instead, in cli/src/lib/sandbox/mode.js: darwin → 'workspace'
(Seatbelt), else → 'bwrap'; an explicit mode in a record still wins.

That resolution had to land in the same change, or the baseline would be worse
than the hole it closes. Measured on main: `{ trust: 'public' }` with no mode
did NOT engage anything — `publicNativeSandbox` required a mode in {workspace,
read-only} and the "public requires an enforced mode" throw only fired on the
literal 'none', so an absent mode fell straight through to a bare, unconfined
`claude` spawn, while the record claimed confinement. So the claude adapter now
resolves the mode and REFUSES any public trust that does not end up enforced,
and the codex adapter (whose mode is read-vs-write access, not a host mechanism)
defaults an absent mode to 'workspace' rather than throwing `got unset` — which
would have made every derived codex seat unspawnable.

Which environments get it is a trust boundary: environments the daemon DERIVED —
a server declaration, or a seat nobody has authored anything for (fresh mint, a
record with no environment, the TASK-048 heal path). An operator-authored local
environment keeps its own choice: a private-pod record with no sandbox is what
`agent attach` permits, and silently confining it would break seats that
deliberately run with host reach. `withDefaultSandbox` replaces an explicitly
disengaged `mode: 'none'` rather than honouring it, because on this path 'none'
and absent are the same thing to the spawn.

`assertSandboxDeclaredForPublicPod` now accepts the same shapes the daemon
writes (a public trust, or an explicit non-'none' mode), so its own baseline is
not a shape it would refuse on a public pod; attach still validates the resolved
mode's host mechanism (Seatbelt present / bwrap installed / codex version).

Tests: 6 new in default-environment.test.mjs (including "stores NO mode"), 5 in
daemon-supervisor.test.mjs, 3 in adapters.claude.environment.test.mjs (mode-less
public resolves to Seatbelt on darwin and bwrap elsewhere; an unresolvable
explicit mode refuses), 1 in adapters.codex.test.mjs, 2 in
public-pod-sandbox-gate.test.mjs. Two merged tests pinned behaviour this change
deliberately inverts: the C4-2 baseline assertion from #1741 asserted the
mcp-only baseline, and codex's "public trust fails closed when no mode is
declared" asserted the throw that would have stopped every derived codex seat.
Cli suite 39 suites / 550 passed / 10 skipped; lint:cli clean.

Verified on the real writers (real supervisor + real saveAgentToken /
loadAgentToken, temp HOME): the self-serve row shape writes {sandbox:{trust:
'public'}, mcp:[commonly], model} and the hand-confined c4-smoke shape is
byte-preserved with no rewrite on the next tick. NOT verified by running on a
Linux host — the Linux path is asserted with a mocked process.platform plus the
adapter's own bwrap branch.

Confinement holds for claude and codex. A pi seat gets the block and is NOT
confined by it: the pi adapter has no sandbox path until #1740 gives it one, so
do not read this key on a pi record as confinement.

Cli version 0.1.48 → 0.1.49 (main took 0.1.48 with #1726; Wave's #1744 slots
this in the cli chain). Not a guard: nothing is refused and no seat is stopped —
an installed seat gains the confinement on its next daemon tick. Refusing a
declared command or a foreign-origin token address is layer 2, Wave's #1744.

* fix(cli): the Linux public seat gets the same tool floor as the macOS one (TASK-052)

Wren 69586 + Vera 69578, folded in before the PR opens.

"public" was one contract implemented twice, and the Linux half was half of
it. `buildPublicClaudePolicyArgs` — settings off, permission mode dontAsk,
the denied reads and tools, strict MCP config — was only reachable inside the
Seatbelt branch, so a derived Linux seat ran inside bwrap with Bash, Write and
WebFetch intact and the operator's own Claude settings loaded. The jail bounds
the filesystem and the policy bounds the tools; a jail alone is half a floor.

Now a public trust that resolves to bwrap gets the same policy args inside the
namespace, and a bwrap seat with no public trust still gets only the jail —
the policy is the public contract, not a property of wrapping. Asserted both
ways.

The mechanism is also verified where the mode is chosen: a public seat on a
host without bwrap now fails its derivation with an actionable message
(`bwrap not found on PATH. Install bubblewrap: ...`) instead of every spawn
failing with a bare ENOENT. On macOS it reports the macOS-only message, the
same one the wrapper would have thrown later. `ctx._detectBwrap` is a test
seam, alongside the existing `_spawnImpl`.

Tests: the derived-Linux test now asserts the policy args are inside the
namespace, and two cases cover a host with no bwrap (refused, and nothing
spawned) and a trust-less bwrap seat (no policy args). cli 39 suites / 553
passed / 10 skipped, lint clean.

* fix(cli): a trust value no adapter reads is refused, and a stored one resolves toward confinement (TASK-059)

Wren 69585, on Vera's 69592 premise correction, folded into this PR because
the runtime half of it IS this PR's resolver: `internal` resolves as `public`,
and a public declaration is then confined or refuses to derive. Landing the
enum change alone would have made a legacy record *look* public while still
falling through to the bare spawn.

Two halves:

1. Validation refuses `trust: 'internal'` for new declarations, and the message
   names what does something — `public`, or omitting the field for your own
   seat. The value was in ALLOWED_SANDBOX_TRUST and read by no adapter: on the
   attach path it could only name a mode the platform cannot resolve, and on
   the daemon path it was silently inert — claude fell through to an unconfined
   spawn and codex took `--dangerously-bypass-approvals-and-sandbox`. A
   declaration that reads as "confine me, not as a public agent" meant the
   opposite of what it said.

2. A record that already carries it is resolved TOWARD confinement
   (`normalizeSandboxTrust`/`isLegacySandboxTrust` in environment.js), with one
   line in the spawn log saying what was read and why. On macOS a legacy
   internal+workspace seat now gets Seatbelt; on Linux that same record refuses
   to derive rather than running bare, and codex gets the public permission
   profile instead of the bypass flag. Nothing resolves toward the bare spawn.

The claude adapter resolves it ONCE at the top of spawn and threads it to both
consumers, because my first cut normalized inside the argv builder only, and
the seat then died with "public Claude state was not prepared before argv
construction" — the state preparation and the argv builder had read the same
declaration independently. That is the bug this whole change is about, in
miniature.

Tests: validation rejects `internal` and accepts `public` or an omitted field;
the legacy resolution is unit-tested (and leaves every other declaration, and
an absent sandbox block, untouched); claude confines a legacy internal+workspace
seat under Seatbelt; codex gives it the public permission profile and never the
bypass flag. cli 39 suites / 559 passed / 10 skipped, lint clean.

* fix(cli): a pi seat is not handed the sandbox its own adapter refuses (TASK-052)

The sandbox half of the daemon baseline was written for every adapter that
consumes mcp[] — claude, codex AND pi — because it piggybacked on
ADAPTERS_WITH_DEFAULT_MCP. pi cannot enforce a sandbox, and since #1727
`sandbox.trust: 'public'` makes it fail closed BEFORE pi starts
(`assertNoSandboxDeclared`, adapters/pi.js). So the default did not merely fail
to confine a pi seat: it made every daemon-derived pi seat unspawnable, with
`pi adapter: public-trust seats are not supported`. The seat would have been
born dead rather than born unconfined, which is the failure that hides best —
the daemon writes the record, the spawn throws, and the record looks correct.

The predicate was the bug: "consumes mcp[]" is not "can enforce a sandbox".
`ADAPTERS_WITH_DEFAULT_SANDBOX` (claude, codex) is now the set the sandbox half
uses, so a pi seat gets the mcp half only, exactly as it did before this
branch. The stale claim in the doc comment ("a pi seat gets the block and is not
confined by it") was the opposite of the truth and is replaced with the
mechanism.

The C4-2 fixture in daemon-supervisor.test.mjs is a pi row and pinned the
buggy both-halves shape; it now asserts the mcp half and runs the derived value
through pi's own guard, because the invariant is not "no sandbox key" but "the
spec this adapter receives is one this adapter accepts". Mutation-proven:
reverting the guard to ADAPTERS_WITH_DEFAULT_MCP reds exactly two tests, the
new seatBaseline case and that fixture. cli 39 suites / 567 passed / 10
skipped, lint:cli clean.

Residual, stated rather than implied: a derived pi seat still runs unconfined,
because refusing it is a policy choice (fail closed, as #1727 does when a
sandbox IS declared) and not something this default gets to decide by writing a
declaration pi rejects. Raised with the pod rather than settled here.
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.

1 participant