Skip to content

Adopt a terminal Claude in no project: adopt, relaunch paths, session mark, audit, fakerelay #586

Closed

Description

@barelyworkingcode

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: true when its registry tests pass.
  • The probe keeps adoptable: false and no_project on Pi and Codex rows in no project.
  • POST /api/sessions/adopt on that Claude row answers 201 with unsandboxed: true and 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_unusable when the folder is missing or is not a directory, and the process keeps running.
  • The adopt refuses with 409 changed when 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/sessions lists the adopted session with unsandboxed: true and projectId: "".
  • GET /api/terminals lists the session's drop-in terminal with unsandboxed: true.
  • No session or terminal that runs in a project carries unsandboxed.
  • The session_adopt, session_interrupt, session_swap and session_message rows of the session carry no_project: true and relay_sandbox: false, with no project_id.
  • The session_launch and session_resume rows of the session carry sandbox: false and no_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/messages to the session answers 202 before the drop-in and after the hand back.
  • POST /api/sessions/{id}/resume of the dormant session answers 200, and the session runs unsandboxed in the same folder.
  • A resume or drop-in refuses with 409 directory_unusable when 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 reason unsandboxed.
  • The session host refuses a local claude launch that has no project and no unsandboxed mark.
  • 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: GateAdoption gives no_project to Pi and Codex rows only.
  • Adopt: the no-project path, the folder check, the fresh project re-match, the audit args, unsandboxed in the 201 body.
  • New authorizeUnsandboxedLaunch in cmd/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 unsandboxed on 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 mode on 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 (the G1.50 claim and a new row G1.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_project from the session_adopt intent args, confirm TestAdoptNoProjectAuditRows goes 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 403 caller_not_authorized, as AuthorizeLaunch.
  • A resume names a dormant record with Unsandboxed, else 403 session_not_resumable.
  • usableDirectory fails: 409 directory_unusable.
  • No project lookup, no allowed-models check, no template allow-list, no model key, no sandbox profile, no launch identity.
  • KindPTY takes the console's claude-code template from config.EffectiveTerminalTemplates. None: 403 template_not_allowed.
  • Claude env passthrough as for any local Claude. Token per parent open question 2.
  • Spec.Unsandboxed = true. Kind claude: ledger record Unsandboxed: true, ProjectID: "". AuditFields.NoProject = true, Sandbox = false.
  • Only adopt, resume and drop-in call it. No request decoder reaches it. AuthorizeLaunch is unchanged and still refuses project_required.

Other shapes:

  • outsidesessions.GateAdoption(s *Session, onHost, inProject bool, agent string): no_project only when agent is pi or codex.
  • sessionLaunchAuditFields.NoProject bool; sessionLaunchAuditArgs.NoProject bool json:"no_project,omitempty".
  • ledger.Record.Unsandboxed bool json:"unsandboxed,omitempty".
  • hostapi.LaunchRequest.Unsandboxed bool json:"unsandboxed,omitempty"; session.CreateSpec.Unsandboxed; session.Summary.Unsandboxed bool json:"unsandboxed,omitempty"; the terminal create spec and terminal list item the same.
  • swapArgs.NoProject bool json:"no_project,omitempty" and RelaySandbox *bool json:"relay_sandbox,omitempty", set from the record. swapIntent takes the record's mark.
  • Rebind: reason unsandboxed, judged where project_gone is judged today, for a record with Unsandboxed.

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.go
  • cmd/relay/outside_session_ops.go
  • cmd/relay/session_adopt.go
  • cmd/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.go
  • cmd/relay/session_swap.go
  • cmd/relay/session_interrupt.go
  • cmd/relay/session_chief_of_staff.go (sessionMessageArgs only)
  • cmd/relay/session_rebind.go
  • internal/sessions/ledger/ledger.go
  • internal/sessions/hostapi/types.go
  • internal/sessions/hostapi/dispatch.go
  • internal/sessions/hostapi/server.go (the launchTerminal create call only; amendment 2026-10-22)
  • docs/events.md (the session.rebind and session.resume reason lists only; amendment 2026-10-22)
  • internal/sessions/session/manager.go
  • internal/sessions/types/session.go
  • internal/sessions/terminal/manager.go
  • internal/sessions/terminal/session.go (token only)
  • internal/sessions/provider/claude.go (token only)
  • internal/sessions/provider/shimspawn.go (token only)
  • fakerelay/internal/world/validate.go
  • fakerelay/internal/sessions/adopt.go
  • fakerelay/internal/sessions/session.go
  • fakerelay/internal/sessions/dropin.go
  • fakerelay/internal/sessions/terminal.go
  • fakerelay/internal/sessions/register.go (the launchTarget.unsandboxed field 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 the no project case and TestAdoptReasonsOnRows expectations, which change by this contract. The PR names both as an approved behaviour change.
  • fakerelay/prooftest/adopt_test.go
  • e2e/fakes/fakeoutside/: a folder outside every project, only if the fake needs it
  • cmd/devboxverify/journey_adopt.go: the new journey session-adopt-unsandboxed beside session-adopt
  • cmd/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 revision that 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_back event (ok) after the terminal's exit, then live: true in the list.
  • Relay restart: the bundle's ready signal on the new relay, then the session.rebind event.
  • Journey: the outside Claude's reply is model output with no signal, so it is a bounded read of the transcript, as session-adopt does. 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

  1. barelyworkingcode commented on Oct 22, 2026

    @barelyworkingcode
    OwnerAuthor

    Coordinator approval: inherits the approved epic's listed batches, 2026-10-22

  2. barelyworkingcode commented on Oct 22, 2026

    @barelyworkingcode
    OwnerAuthor

    Contract amendment (planner, 2026-10-22): add internal/sessions/hostapi/server.go, the launchTerminal create call only; wire item 8 needs it.

  3. barelyworkingcode commented on Oct 22, 2026

    @barelyworkingcode
    OwnerAuthor

    Contract amendment (planner, 2026-10-22): add docs/events.md, the session.rebind and session.resume reason lists only.

  4. barelyworkingcode commented on Oct 22, 2026

    @barelyworkingcode
    OwnerAuthor

    Contract amendment (planner, 2026-10-22): add fakerelay/internal/sessions/register.go, the launchTarget.unsandboxed field only.

  5. barelyworkingcode commented on Oct 22, 2026

    @barelyworkingcode
    OwnerAuthor

    Decision (coordinator, 2026-10-22): (a) With no claude_token_file, relay session mode restarts an unsandboxed session in place, as planner risk 2 implies. Only a per-launch token forces resume_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.

  6. barelyworkingcode commented on Oct 22, 2026

    @barelyworkingcode
    OwnerAuthor

    Merged to the epic branch in #592 (fa33abf). The close child #587 closes this one by hand.

  7. barelyworkingcode commented on Oct 22, 2026

    @barelyworkingcode
    OwnerAuthor

    Done in #592, merged to epic/585-projectless-adopt.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesttaskOne batch of an epic

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions