Repository navigation
Description
Child of #585. Read the parent's Contract first: it holds the wire, merge order and joint close task. Security story: this child waits with the parent for the owner's approval.
User story
As the owner, I want relay to adopt and keep running a terminal Claude session that is in no project, so that I can drive it like any adopted session.
Acceptance criteria
- The probe marks a live console Claude row in no project
adoptable: truewhen its registry tests pass. - The probe keeps
adoptable: falseandno_projecton Pi and Codex rows in no project. POST /api/sessions/adopton that Claude row answers 201 withunsandboxed: trueand the row's conversation id.- The adopted session runs in the row's folder with no sandbox profile and no launch identity.
- The adopted session's settings carry no project permission policy, and relay runs no allowed-models check on it.
- The adopt refuses with 409
directory_unusablewhen the folder is missing or is not a directory, and the process keeps running. - The adopt refuses with 409
changedwhen a local project holds the folder at adopt time, and the process keeps running. - The adopt with auditing off refuses with 503
audit_unavailable, and the process keeps running. - Interrupt and adopt of a busy row in no project interrupts, adopts unsandboxed and sends the recovery message, as for a project row.
GET /api/sessionslists the adopted session withunsandboxed: trueandprojectId: "".GET /api/terminalslists the session's drop-in terminal withunsandboxed: true.- No session or terminal that runs in a project carries
unsandboxed. - The
session_adopt,session_interrupt,session_swapandsession_messagerows of the session carryno_project: trueandrelay_sandbox: false, with noproject_id. - The
session_launchandsession_resumerows of the session carrysandbox: falseandno_project: true. - Drop in on the session answers 201 and runs
claude --resume <conversation id>in the same folder with no sandbox profile. - A send while the drop-in terminal holds the session answers 409
dropped_in. - Closing the drop-in terminal resumes the session headless, with
unsandboxed: true, in the same folder. POST /api/chief-of-staff/messagesto the session answers 202 before the drop-in and after the hand back.POST /api/sessions/{id}/resumeof the dormant session answers 200, and the session runs unsandboxed in the same folder.- A resume or drop-in refuses with 409
directory_unusablewhen the folder is gone, and the session state does not change. - After a relay restart the session keeps
unsandboxed: true, and its rebind audit row names the reasonunsandboxed. - The session host refuses a local
claudelaunch that has no project and nounsandboxedmark. - The adopt door still refuses the Chief of Staff scope with 403.
- fakerelay serves the new row rule, the adopt answer, the list field and the terminal field as relay does.
What I'd notice
An Adopt button on a terminal Claude row in no project (through eve). After adopt, relay session list --json shows unsandboxed: true on the session.
Tasks
- Probe gate:
GateAdoptiongivesno_projectto Pi and Codex rows only. - Adopt: the no-project path, the folder check, the fresh project re-match, the audit args,
unsandboxedin the 201 body. - New
authorizeUnsandboxedLaunchincmd/relay/session_unsandboxed.go: the one launch builder for a session in no project. - Ledger field
unsandboxed; resume branches on it. - Drop in and hand back on an unsandboxed record.
- Swap, interrupt and message audit rows.
- Rebind reason
unsandboxed. - Session host: take
unsandboxedon the launch, keep it on the session, list it on sessions and terminals, refuse an unmarked no-project Claude launch. - Claude token over fd 5 to an unsandboxed local Claude, per parent open question 2. Check that
relay session modeon such a session behaves as on a sandboxed one (a restart cannot reuse a per-launch token). - fakerelay: world rule, adopt answer, list and terminal fields;
docs/fakerelay.md. - Docs:
session-adoption.md,session-host.md,outside-sessions.md,cli.md,routes.md,audit-log.md,FEATURES.md(theG1.50claim and a new rowG1.54),THREAT-MODEL.md(3f and promise 1). - Tests (test writer, from the criteria and this contract): the e2e feature test, the fakerelay prooftest, the devboxverify journey
session-adopt-unsandboxed. - Break and restore: drop
no_projectfrom thesession_adoptintent args, confirmTestAdoptNoProjectAuditRowsgoes red, restore byte for byte. - Review on the default model: the diff touches a threat-model boundary.
Contract
Interfaces
The wire is items 1 to 10 of the parent. The Go shapes:
// cmd/relay/session_unsandboxed.go (new)
type unsandboxedLaunch struct {
Caller LaunchCaller
SessionID string // "" for an adopt; the record's id for a resume
Resume bool
Kind string // KindClaude, or KindPTY for a drop-in terminal
Directory string // the original folder, absolute
Name string
Model string // KindClaude only
ClientSettings json.RawMessage // KindClaude only: {"headless":true,"agent":true} on adopt, the stored settings on resume
Cols, Rows int // KindPTY only
}
func authorizeUnsandboxedLaunch(store config.SettingsStore, sessions *ledger.Ledger, req unsandboxedLaunch) (*LaunchResult, *LaunchRefusal)
// usableDirectory: absolute, exists, a directory after symlinks resolve. Returns the resolved path.
func usableDirectory(dir string) (string, bool)Rules of authorizeUnsandboxedLaunch:
- The caller holds
execute, else 403caller_not_authorized, asAuthorizeLaunch. - A resume names a dormant record with
Unsandboxed, else 403session_not_resumable. usableDirectoryfails: 409directory_unusable.- No project lookup, no allowed-models check, no template allow-list, no model key, no sandbox profile, no launch identity.
KindPTYtakes the console'sclaude-codetemplate fromconfig.EffectiveTerminalTemplates. None: 403template_not_allowed.- Claude env passthrough as for any local Claude. Token per parent open question 2.
Spec.Unsandboxed = true. Kind claude: ledger recordUnsandboxed: true,ProjectID: "".AuditFields.NoProject = true,Sandbox = false.- Only adopt, resume and drop-in call it. No request decoder reaches it.
AuthorizeLaunchis unchanged and still refusesproject_required.
Other shapes:
outsidesessions.GateAdoption(s *Session, onHost, inProject bool, agent string):no_projectonly whenagentispiorcodex.sessionLaunchAuditFields.NoProject bool;sessionLaunchAuditArgs.NoProject booljson:"no_project,omitempty".ledger.Record.Unsandboxed booljson:"unsandboxed,omitempty".hostapi.LaunchRequest.Unsandboxed booljson:"unsandboxed,omitempty";session.CreateSpec.Unsandboxed;session.Summary.Unsandboxed booljson:"unsandboxed,omitempty"; the terminal create spec and terminal list item the same.swapArgs.NoProject booljson:"no_project,omitempty"andRelaySandbox *booljson:"relay_sandbox,omitempty", set from the record.swapIntenttakes the record's mark.- Rebind: reason
unsandboxed, judged whereproject_goneis judged today, for a record withUnsandboxed.
Adopt order for a Claude row in no project: audit ready, host ready, the row, a fresh candidate, then no project: re-match the folder against current local projects (a match: 409 changed), usableDirectory (409 directory_unusable), managed, idle check, model, authorizeUnsandboxedLaunch, the durable intent, interrupt, take over, launch, commit, recovery message. Nothing is signalled before the intent is durable.
File ownership
Dev:
internal/outsidesessions/probe.gocmd/relay/outside_session_ops.gocmd/relay/session_adopt.gocmd/relay/session_unsandboxed.go(new)cmd/relay/session_launch.go(audit fields and args only)cmd/relay/session_routes.go(the resume branch only)cmd/relay/session_dropin.gocmd/relay/session_swap.gocmd/relay/session_interrupt.gocmd/relay/session_chief_of_staff.go(sessionMessageArgsonly)cmd/relay/session_rebind.gointernal/sessions/ledger/ledger.gointernal/sessions/hostapi/types.gointernal/sessions/hostapi/dispatch.gointernal/sessions/hostapi/server.go(thelaunchTerminalcreate call only; amendment 2026-10-22)docs/events.md(thesession.rebindandsession.resumereason lists only; amendment 2026-10-22)internal/sessions/session/manager.gointernal/sessions/types/session.gointernal/sessions/terminal/manager.gointernal/sessions/terminal/session.go(token only)internal/sessions/provider/claude.go(token only)internal/sessions/provider/shimspawn.go(token only)fakerelay/internal/world/validate.gofakerelay/internal/sessions/adopt.gofakerelay/internal/sessions/session.gofakerelay/internal/sessions/dropin.gofakerelay/internal/sessions/terminal.gofakerelay/internal/sessions/register.go(thelaunchTarget.unsandboxedfield only; amendment 2026-10-22)docs/session-adoption.md,docs/session-host.md,docs/outside-sessions.md,docs/cli.md,docs/routes.md,docs/audit-log.md,docs/FEATURES.md,docs/THREAT-MODEL.md,docs/fakerelay.md
Test writer:
e2e/features/g1_outside_adopt_unsandboxed_test.go(new)e2e/features/g1_outside_adopt_test.go: only theno projectcase andTestAdoptReasonsOnRowsexpectations, which change by this contract. The PR names both as an approved behaviour change.fakerelay/prooftest/adopt_test.goe2e/fakes/fakeoutside/: a folder outside every project, only if the fake needs itcmd/devboxverify/journey_adopt.go: the new journeysession-adopt-unsandboxedbesidesession-adoptcmd/devboxverify/README.md: that journey's entry
docs/FEATURES.md: a new row G1.54 "Adopt an outside Claude session that is in no project". Simple door: Eve > Chief of Staff > Agents rail > Adopt. Power door: n/a (the adopt body, as G1.50). Doors: http:POST /api/sessions/adopt, http:POST /api/sessions/{id}/resume, http:POST /api/sessions/{id}/drop-in, cli:relay drop-in, cli:relay session resume. Test: the e2e names below and journey:session-adopt-unsandboxed. The G1.50 claim drops no_project from the Claude reasons and points to G1.54. The run report lists that claim edit under gate changes.
Paths
Simple and power: as in the parent. This child adds no CLI verb or flag.
Acceptance mapping and test surface
| Criterion | Test (e2e unless named) |
|---|---|
Row adoptable; Pi and Codex keep no_project |
TestAdoptNoProjectRowAdoptable, TestAdoptNoProjectPiCodexKeepReason |
| 201, same conversation, folder, no sandbox, identity or policy | TestAdoptNoProjectResumesUnsandboxed |
directory_unusable on adopt |
TestAdoptNoProjectDirectoryUnusable |
changed when a project holds the folder |
TestAdoptNoProjectFolderNowInProject |
| Auditing off | TestAdoptNoProjectAuditOff |
| Interrupt and adopt | TestAdoptNoProjectInterrupt |
| List and terminal marks, none on project sessions | TestAdoptNoProjectListMark |
| Audit rows | TestAdoptNoProjectAuditRows |
Drop in, dropped_in, hand back |
TestAdoptNoProjectDropInAndHandBack |
| Chief of Staff send before and after | TestAdoptNoProjectChiefOfStaffSend; journey session-adopt-unsandboxed |
Resume of the dormant session; directory_unusable on resume and drop-in |
TestAdoptNoProjectResumeDormant |
| Relay restart keeps the mark; rebind reason | TestAdoptNoProjectRelayRestart |
| Host refuses an unmarked no-project launch | no door reaches the host's internal API from e2e; the reviewer checks it and the PR says so |
| Scope refused | TestAdoptNoProjectScopeRefused |
| fakerelay | fakerelay/prooftest/adopt_test.go cases for wire 1 to 4 |
The journey session-adopt-unsandboxed (api): a terminal Claude in a scratch folder outside every project on the stack, adopted by conversation id; a reply in the same transcript; a Chief of Staff send (202); the session_adopt rows with no_project: true. It uses the stack's local model endpoint, as session-adopt does, and is NOTRUN off a stack. |
Waits:
- e2e boot: the test bundle's ready signal, as the existing adopt tests use.
- The outside row: the inventory snapshot
revisionthat lists the row. - Adopt, resume, drop-in: the HTTP answer. Audit rows are durable before the answer, so the test reads them after it.
- The outside process exit: the fake's exit watch (
PIDGone), as the existing tests use. - The hand back: the
session.hand_backevent (ok) after the terminal's exit, thenlive: truein the list. - Relay restart: the bundle's ready signal on the new relay, then the
session.rebindevent. - Journey: the outside Claude's reply is model output with no signal, so it is a bounded read of the transcript, as
session-adoptdoes. Everything else waits on the HTTP answers and the CLI process exits.
Budget
About 34 production, fakerelay and doc files, 1,400 to 1,900 changed lines (about 400 of them docs). Tests and journey: 6 files, 1,000 to 1,300 lines. Past either figure, stop and ask the planner.
Amendment 1 (2026-10-22): the dev list adds internal/sessions/hostapi/server.go, the launchTerminal create call only. Wire item 8 needs it. It also adds docs/events.md, the session.rebind and session.resume reason lists only, to document wire item 10 and the resume refusal. It also adds fakerelay/internal/sessions/register.go, the launchTarget.unsandboxed field only, for wire items 3 and 4.
Activity
barelyworkingcode commentedon Oct 22, 2026
Coordinator approval: inherits the approved epic's listed batches, 2026-10-22
barelyworkingcode commentedon Oct 22, 2026
Contract amendment (planner, 2026-10-22): add internal/sessions/hostapi/server.go, the launchTerminal create call only; wire item 8 needs it.
barelyworkingcode commentedon Oct 22, 2026
Contract amendment (planner, 2026-10-22): add docs/events.md, the session.rebind and session.resume reason lists only.
barelyworkingcode commentedon Oct 22, 2026
Contract amendment (planner, 2026-10-22): add fakerelay/internal/sessions/register.go, the launchTarget.unsandboxed field only.
barelyworkingcode commentedon Oct 22, 2026
Decision (coordinator, 2026-10-22): (a) With no
claude_token_file,relay session moderestarts an unsandboxed session in place, as planner risk 2 implies. Only a per-launch token forcesresume_required. (b) The unsandboxed drop-in terminal gets the Claude token and no model key. A model key is project-scoped, and the session has no project. No criteria change.barelyworkingcode commentedon Oct 22, 2026
Merged to the epic branch in #592 (fa33abf). The close child #587 closes this one by hand.
barelyworkingcode commentedon Oct 22, 2026
Done in #592, merged to epic/585-projectless-adopt.