diff --git a/cmd/devboxverify/README.md b/cmd/devboxverify/README.md index 0d5f0e16..a358dc2c 100644 --- a/cmd/devboxverify/README.md +++ b/cmd/devboxverify/README.md @@ -1270,6 +1270,33 @@ holds the intent row and a completion with outcome `ok`. kills the tmux session, removes the transcript (only a file named for the conversation id that holds the run's tag) and the folder. +**session-adopt-unsandboxed** (api, so a `--stack` run drives it). The same +adopt for an interactive Claude that is in no project: the terminal's folder is a +scratch folder `verify--adopt-uns-*` under the OS temp directory, which no +registered project holds. Relay adopts it with no project, no sandbox and no +permission policy. The setup is `session-adopt`'s: a detached tmux session on a +socket of its own, `claude --model haiku --session-id `, the stack's local +model endpoint from `stack.json`, the trust prompt answered. NOTRUN off a stack +or with no `claude_base_url`. PASS when the door answers 201 with `unsandboxed: +true` for that conversation; the terminal's claude process is gone; `relay +session message` gets a reply holding `verify--adopt-uns-2` that is in the +same `.jsonl`; a Chief of Staff `POST /api/chief-of-staff/messages` (a +`proxy` credential the journey seeds into `settings.json`, as +`session-swap-back` does, and drops after; a stack run has no console for a +presence-gated mint) answers 202; and +every `relay audit --event session_adopt` row for the conversation carries +`"no_project": true`, with the intent row and a completion with outcome `ok`. +- Waits: as `session-adopt`. The outside reply is model output: a bounded read of + the transcript, 120 s; the registry `idle` re-read, 60 s; the trust prompt, pane + capture, 30 s. The settings import has no event: the seeded credential's scoped read is retried, + bounded, as in `session-swap-back`. The send then waits on its HTTP answer. +- Lives in: `cmd/relay/session_adopt.go`, `cmd/relay/session_unsandboxed.go`. +- Reached by: `POST /api/sessions/adopt` and `POST /api/chief-of-staff/messages` + on the frontend socket, `relay session message`, `relay audit`. +- Traps: as `session-adopt`, plus a temp directory that lies inside a registered + project, which the door refuses as `changed`. Cleanup also removes the scratch + folder (only a folder whose name starts with `verify-`). + **session-host-restart** (screen). The admin op `service.restart {"id":"relaysessions"}` answers without error, and within 15 s `relaysessions` is STATE `running` with a `relay-sessions` pid it did not diff --git a/cmd/devboxverify/journey_adopt.go b/cmd/devboxverify/journey_adopt.go index 2dcacdf4..07087977 100644 --- a/cmd/devboxverify/journey_adopt.go +++ b/cmd/devboxverify/journey_adopt.go @@ -4,6 +4,9 @@ import ( "bufio" "bytes" "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -27,6 +30,7 @@ import ( const ( adoptID = "session-adopt" + adoptUnsID = "session-adopt-unsandboxed" adoptTrustWait = 30 * time.Second adoptMarkerWait = 120 * time.Second adoptIdleWait = 60 * time.Second @@ -37,6 +41,7 @@ const ( var adoptJourneys = []journey{ {adoptID, []string{"sessions", "audit"}, []string{"project:acme"}, phaseAPI, 240 * time.Second, runAdopt}, + {adoptUnsID, []string{"sessions", "audit"}, nil, phaseAPI, 300 * time.Second, runAdoptUnsandboxed}, } var adoptTrustPrompt = regexp.MustCompile(`(?i)trust (this|the files in this) folder|Do you trust|Yes, I trust`) @@ -50,6 +55,11 @@ type adoptRun struct { Reply string ReplyInFile bool Audit []audit.AuditEvent + + // Unsandboxed journey only. + Unsandboxed bool + SendStatus int + SendDetail string } // runAdopt drives the simple path of the adopt door: an interactive Claude @@ -62,43 +72,70 @@ type adoptRun struct { // journey is NOTRUN, so it never runs claude against a hosted model. The // token is read by the shell at exec from its file; it is in no argv, file // or detail. -func runAdopt(ctx context.Context, e env) result { +func runAdopt(ctx context.Context, e env) result { return adoptFlow(ctx, e, false) } + +// runAdoptUnsandboxed is runAdopt for a terminal Claude in a scratch folder +// outside every project: relay adopts it with no project, no sandbox and no +// permission policy, and the Chief of Staff can still send to it. +func runAdoptUnsandboxed(ctx context.Context, e env) result { return adoptFlow(ctx, e, true) } + +func adoptFlow(ctx context.Context, e env, unsandboxed bool) result { + id, suffix := adoptID, "adopt" + if unsandboxed { + id, suffix = adoptUnsID, "adopt-uns" + } baseURL, model, tokenFile, err := stackClaude(e) if err != nil { - return blocked(adoptID, err.Error()) + return blocked(id, err.Error()) } if baseURL == "" { - return result{adoptID, stateNotRun, "needs a stack's local model endpoint (claude_base_url in stack.json)"} + return result{id, stateNotRun, "needs a stack's local model endpoint (claude_base_url in stack.json)"} } - acme, err := e.World.project("acme") - if err != nil { - return blocked(adoptID, err.Error()) - } - folder, err := filepath.EvalSymlinks(acme.Folder) - if err != nil { - return blocked(adoptID, acme.Name+" folder missing") + tag := "verify-" + e.Nonce + "-" + suffix + var dir string + if !unsandboxed { + acme, err := e.World.project("acme") + if err != nil { + return blocked(id, err.Error()) + } + folder, err := filepath.EvalSymlinks(acme.Folder) + if err != nil { + return blocked(id, acme.Name+" folder missing") + } + dir = filepath.Join(folder, tag) } token, err := adoptCredential(e.CredentialFile) if err != nil { - return blocked(adoptID, err.Error()) + return blocked(id, err.Error()) } tmux := adoptTmux() claude, cerr := cmdlookup.Local(ctx, "claude") switch { case tmux == "": - return blocked(adoptID, "tmux is not installed") + return blocked(id, "tmux is not installed") case cerr != nil: - return blocked(adoptID, "no claude on this machine's login-shell PATH") + return blocked(id, "no claude on this machine's login-shell PATH") } - tag := "verify-" + e.Nonce + "-adopt" - dir := filepath.Join(folder, tag) + if unsandboxed { + // Deliberate: the folder is under the OS temp directory, which no + // registered project holds, so relay finds the row in no project. It is + // made after every BLOCKED check above, so none leaves it behind. + tmp, err := os.MkdirTemp("", tag+"-") + if err != nil { + return blocked(id, "scratch folder: "+err.Error()) + } + if dir, err = filepath.EvalSymlinks(tmp); err != nil { + _ = os.RemoveAll(tmp) + return blocked(id, "scratch folder: "+err.Error()) + } + } convID := uuid.NewString() - sess := &adoptCleanup{tmux: tmux, name: tag, dir: dir, convID: convID, tag: tag, e: e} + sess := &adoptCleanup{tmux: tmux, name: tag, dir: dir, convID: convID, tag: tag, e: e, id: id} defer sess.run(context.WithoutCancel(ctx)) if err := writeAdoptSettings(dir); err != nil { - return blocked(adoptID, "scratch folder: "+err.Error()) + return blocked(id, "scratch folder: "+err.Error()) } line := adoptShellPrefix(baseURL, model, tokenFile) + "exec env -u CLAUDE_CODE_ENTRYPOINT -u CLAUDECODE " + shellQuote(claude) + " --model haiku --session-id " + convID + " --permission-mode default " + shellQuote("Reply with exactly: "+tag+"-1") @@ -108,7 +145,7 @@ func runAdopt(ctx context.Context, e env) result { // entrypoint "sdk-cli", which the door refuses as not_command_line. create.Env = adoptCleanEnv(os.Environ()) if out, err := create.CombinedOutput(); err != nil { - return blocked(adoptID, "tmux new-session: "+firstLine(string(out))) + return blocked(id, "tmux new-session: "+firstLine(string(out))) } sess.started = true @@ -118,7 +155,7 @@ func runAdopt(ctx context.Context, e env) result { // Waits: none possible for the outside reply: model output; a bounded read // of the transcript for the marker record, 120 s. if !pollUntil(ctx, adoptMarkerWait, func() bool { return adoptAssistantHas(convID, tag+"-1") }) { - return blocked(adoptID, "the outside claude did not reply "+tag+"-1 within 120s (model output)") + return blocked(id, "the outside claude did not reply "+tag+"-1 within 120s (model output)") } // Waits: the registry status change to idle, re-read, 60 s. var pid int @@ -126,17 +163,17 @@ func runAdopt(ctx context.Context, e env) result { pid = adoptPID(ctx, convID) return pid > 0 && adoptRegistryStatus(pid) == "idle" }) { - return blocked(adoptID, "the outside claude's registry entry did not read idle within 60s") + return blocked(id, "the outside claude's registry entry did not read idle within 60s") } - if id, err := claudeSessionForPID(pid); err != nil || id != convID { - return blocked(adoptID, "the registry entry does not carry the conversation id the journey set") + if got, err := claudeSessionForPID(pid); err != nil || got != convID { + return blocked(id, "the registry entry does not carry the conversation id the journey set") } r := adoptRun{ClaudeID: convID} body, _ := json.Marshal(map[string]any{"claudeSessionId": convID, "interrupt": false, "model": "haiku"}) r.Status, r.Body, err = adoptPost(ctx, e.FrontendSocket, token, body) if err != nil { - return blocked(adoptID, "frontend socket unreachable") + return blocked(id, "frontend socket unreachable") } var ok struct { SessionID string `json:"sessionId"` @@ -155,7 +192,14 @@ func runAdopt(ctx context.Context, e env) result { } r.ReplyInFile = adoptAssistantHas(convID, tag+"-2") } + if unsandboxed && r.Status == http.StatusCreated && ok.SessionID != "" { + r.Unsandboxed = true + r.SendStatus, r.SendDetail = adoptCosSend(ctx, e, ok.SessionID, "Reply with exactly: "+tag+"-3") + } r.Audit = adoptAuditRows(ctx, e, convID) + if unsandboxed { + return classifyAdoptUnsandboxed(r, tag) + } return classifyAdopt(r, tag) } @@ -199,6 +243,84 @@ func classifyAdopt(r adoptRun, tag string) result { return result{adoptID, statePass, "idle terminal Claude adopted by conversation id: 201, terminal process gone, the resumed headless session replied in the same transcript, session_adopt intent and ok completion audited"} } +func classifyAdoptUnsandboxed(r adoptRun, tag string) result { + fail := func(d string) result { return result{adoptUnsID, stateFail, d} } + base := classifyAdopt(r, tag) + if base.State != statePass { + base.ID = adoptUnsID + return base + } + var got struct { + Unsandboxed bool `json:"unsandboxed"` + } + _ = json.Unmarshal(r.Body, &got) + var intent, done bool + for _, row := range r.Audit { + var args struct { + NoProject bool `json:"no_project"` + } + if json.Unmarshal(row.Args, &args) != nil || !args.NoProject { + return fail("a session_adopt row lacks no_project: true (" + row.Phase + ")") + } + intent = intent || row.Phase == "intent" + done = done || row.Phase == "completion" && row.Outcome == "ok" + } + switch { + case !got.Unsandboxed: + return fail("201 body lacks unsandboxed: true") + case r.SendStatus != http.StatusAccepted: + return fail(fmt.Sprintf("Chief of Staff send answered %d, want 202: %s", r.SendStatus, r.SendDetail)) + case !intent || !done: + return fail("session_adopt rows with no_project: true lack the intent or the ok completion") + } + return result{adoptUnsID, statePass, "terminal Claude in a folder outside every project adopted unsandboxed: 201 with unsandboxed true, terminal process gone, the headless session replied in the same transcript, a Chief of Staff send answered 202, session_adopt rows audited with no_project true"} +} + +// adoptCosSend sends one message to the session as the Chief of Staff, with a +// proxy credential seeded into settings.json for the call and dropped after +// it. Deliberate: a presence-gated mint needs the console, which a stack run +// has not; the seeded record is how session-swap-back and session-detached-host +// get theirs. The scope needs class proxy only. The token goes into no file, +// log or detail beyond the hash settings.json keeps. +func adoptCosSend(ctx context.Context, e env, sessionID, text string) (status int, detail string) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return 0, "credential: " + err.Error() + } + token := hex.EncodeToString(secret) + hash := sha256.Sum256([]byte(token)) + credID := uuid.NewString() + cred := map[string]any{ + "id": credID, "name": "verify-" + e.Nonce + "-adopt-cos", "hash": hex.EncodeToString(hash[:]), + "classes": []string{"proxy"}, "created": time.Now().UTC().Format(time.RFC3339), + "expires": time.Now().UTC().Add(time.Hour).Format(time.RFC3339), + } + if err := editSettings(e.ConfigDir, func(top map[string]json.RawMessage) error { + return appendRecords(top, map[string]any{"api_credentials": cred}) + }); err != nil { + return 0, "seeding the credential: " + err.Error() + } + defer func() { + if err := editSettings(e.ConfigDir, func(top map[string]json.RawMessage) error { + dropRecords(top, "api_credentials", credID) + return nil + }); err != nil { + detail += "; teardown: " + err.Error() + } + }() + // Waits: none possible for the settings import; relay's file watch emits + // no event. The seeded credential answers 200 once the import has run, so + // the scoped read is retried, bounded. + if !pollUntil(ctx, detachedStartTries, func() bool { + return cosRequest(ctx, e, token, true, http.MethodGet, "/api/sessions", nil, frontendRequestTimeout).Status == http.StatusOK + }) { + return 0, "the seeded credential was not imported within " + detachedStartTries.String() + } + resp := cosRequest(ctx, e, token, true, http.MethodPost, "/api/chief-of-staff/messages", + jsonBody(map[string]string{"sessionId": sessionID, "text": text}), frontendRequestTimeout) + return resp.Status, firstLine(resp.Error) +} + // adoptCredential reads the execute credential the way the other journeys do. func adoptCredential(file string) (string, error) { fi, err := os.Stat(file) @@ -406,6 +528,7 @@ type adoptCleanup struct { tmux, name, dir, convID, tag, sessionID string started bool e env + id string } func (c *adoptCleanup) run(ctx context.Context) { @@ -418,7 +541,7 @@ func (c *adoptCleanup) run(ctx context.Context) { if pid := adoptPID(ctx, c.convID); pid > 0 { _ = syscall.Kill(pid, syscall.SIGTERM) } - removeTranscript(adoptID, c.convID, c.tag, false, func(ctx context.Context) string { + removeTranscript(c.id, c.convID, c.tag, false, func(ctx context.Context) string { if adoptPID(ctx, c.convID) > 0 { return "process still running" } diff --git a/cmd/relay/outside_session_ops.go b/cmd/relay/outside_session_ops.go index 735a576a..e740b4ad 100644 --- a/cmd/relay/outside_session_ops.go +++ b/cmd/relay/outside_session_ops.go @@ -234,7 +234,7 @@ func outsideMachine(store config.SettingsStore, deps sessionRouteDeps, h *config id := project.ID o.Session.ProjectID = &id } - outsidesessions.GateAdoption(&o.Session, h != nil, project != nil) + outsidesessions.GateAdoption(&o.Session, h != nil, project != nil, o.Agent) if o.Session.TmuxSession != nil { if h == nil || h.ShowAllTmuxSessions { o.Session.Viewable = true diff --git a/cmd/relay/session_adopt.go b/cmd/relay/session_adopt.go index d399d432..662f5451 100644 --- a/cmd/relay/session_adopt.go +++ b/cmd/relay/session_adopt.go @@ -52,6 +52,7 @@ const ( adoptStopFailed = "stop_failed" adoptLaunchFailed = "launch_failed" adoptAuditOff = "audit_unavailable" + adoptDirectoryGone = refusalDirectoryUnusable ) var outsideIDPattern = regexp.MustCompile(`^outside:[0-9a-f]{32}$`) @@ -71,6 +72,8 @@ type adoptResponse struct { ConversationID string `json:"conversationId,omitempty"` Interrupted bool `json:"interrupted"` RecoverySent bool `json:"recoverySent"` + // Unsandboxed is true for a session adopted in no project. + Unsandboxed bool `json:"unsandboxed,omitempty"` } // AdoptRequest is what the door hands the core. Caller is the resolved @@ -89,6 +92,7 @@ type AdoptResult struct { ClaudeSessionID string Interrupted bool RecoverySent bool + Unsandboxed bool } // AdoptRefusal is an adopt the core did not carry out. Status is the HTTP @@ -102,7 +106,8 @@ func (r *AdoptRefusal) Error() string { return r.Message } // adoptOps is the one core behind the adopt door: it ends an outside Claude // process a person started in a terminal and resumes its conversation as a -// headless relay session, under the project's sandbox and policy. An outside +// headless relay session, under the project's sandbox and policy, or in its own +// folder with no sandbox when it is in no project. An outside // codex thread is not ended: relay attaches to it through the codex daemon and // changes none of its settings. type adoptOps struct { @@ -156,7 +161,7 @@ func (a *adoptOps) handleAdopt(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusCreated, adoptResponse{ SessionID: res.SessionID, ClaudeSessionID: res.ClaudeSessionID, Agent: res.Agent, ConversationID: res.ConversationID, - Interrupted: res.Interrupted, RecoverySent: res.RecoverySent, + Interrupted: res.Interrupted, RecoverySent: res.RecoverySent, Unsandboxed: res.Unsandboxed, }) } @@ -262,7 +267,8 @@ func (s *adoptState) endEvent(ev *logging.Event, refusal *AdoptRefusal) { } // adoptOutside judges, audits, stops and resumes, in this order: audit ready, -// the row, a fresh match of it against the agent's registry, the project, the +// the row, a fresh match of it against the agent's registry, the project (a +// Claude row in no project: the folder re-match and the directory check), the // managed list, the idle check, the model, launch authorization, the adopt // intent, the interrupt (when the session is busy), the stop, the launch and // the recovery message. Nothing is signalled before AuthorizeLaunch passed and @@ -308,14 +314,26 @@ func (a *adoptOps) adoptOutside(ctx context.Context, req AdoptRequest) (res *Ado } var err error + // A Claude row in no project is adopted unsandboxed in its own folder. Pi + // and Codex rows need a project, and a Claude row whose project is gone or + // remote is not "in no project": it keeps the refusal. + noProject := agent == "claude" && row.ProjectID == nil var proj *config.Project - if row.ProjectID != nil { - proj, _ = config.FindProjectByID(config.FreshSettings(d.store), *row.ProjectID) - } - if proj == nil || proj.IsRemote() || proj.IsHosted() { - return nil, refuse(http.StatusConflict, adopt.ReasonNoProject, "the session is not in a registered local project") + var folder string + if noProject { + var refusal *AdoptRefusal + if folder, refusal = a.noProjectFolder(ctx, row.Directory); refusal != nil { + return nil, refusal + } + } else { + if row.ProjectID != nil { + proj, _ = config.FindProjectByID(config.FreshSettings(d.store), *row.ProjectID) + } + if proj == nil || proj.IsRemote() || proj.IsHosted() { + return nil, refuse(http.StatusConflict, adopt.ReasonNoProject, "the session is not in a registered local project") + } + st.projectID = proj.ID } - st.projectID = proj.ID if agent == "pi" { _, process, e := d.host().resolve() @@ -382,11 +400,20 @@ func (a *adoptOps) adoptOutside(ctx context.Context, req AdoptRequest) (res *Ado return nil, refuse(http.StatusConflict, adoptModelUnknown, "the model of the session is unknown; send model") } - result, lr := AuthorizeLaunch(d.store, d.modelKeys, d.sessions, LaunchRequest{ - Caller: req.Caller, ProjectID: proj.ID, Kind: agent, Directory: row.Directory, - Name: row.Name, Model: model, offer: d.offerFor(ctx), - ClientSettings: json.RawMessage(`{"headless":true,"agent":true}`), - }) + var result *LaunchResult + var lr *LaunchRefusal + if noProject { + result, lr = authorizeUnsandboxedLaunch(d.store, d.sessions, unsandboxedLaunch{ + Caller: req.Caller, Kind: KindClaude, Directory: folder, Name: row.Name, Model: model, + ClientSettings: json.RawMessage(`{"headless":true,"agent":true}`), + }) + } else { + result, lr = AuthorizeLaunch(d.store, d.modelKeys, d.sessions, LaunchRequest{ + Caller: req.Caller, ProjectID: proj.ID, Kind: agent, Directory: row.Directory, + Name: row.Name, Model: model, offer: d.offerFor(ctx), + ClientSettings: json.RawMessage(`{"headless":true,"agent":true}`), + }) + } if lr != nil { d.auditor.Record(lr.Audit) return nil, refuse(lr.Status, lr.Code, lr.Message) @@ -415,7 +442,12 @@ func (a *adoptOps) adoptOutside(ctx context.Context, req AdoptRequest) (res *Ado } actor := callerAuditActor(req.Caller) base := map[string]any{ - "outside_id": row.ID, "claude_session_id": obs.ClaudeID, "project_id": proj.ID, + "outside_id": row.ID, "claude_session_id": obs.ClaudeID, + } + if noProject { + base["no_project"], base["relay_sandbox"] = true, false + } else { + base["project_id"] = proj.ID } if agent == "pi" || agent == "codex" { delete(base, "claude_session_id") @@ -554,10 +586,29 @@ func (a *adoptOps) adoptOutside(ctx context.Context, req AdoptRequest) (res *Ado return "" }(), SessionID: result.SessionID, ClaudeSessionID: obs.ClaudeID, - Interrupted: st.interrupted, RecoverySent: recoverySent, + Interrupted: st.interrupted, RecoverySent: recoverySent, Unsandboxed: noProject, }, nil } +// noProjectFolder judges the folder of a Claude row that is in no project, at +// adopt time and before anything is signalled: a local project that holds it +// now answers `changed` so the person adopts again from the refreshed row, +// sandboxed, and a folder that is not an existing directory answers +// `directory_unusable`. Relay never picks unsandboxed over a project for them. +// It returns the folder with its symlinks resolved. +func (a *adoptOps) noProjectFolder(ctx context.Context, directory string) (string, *AdoptRefusal) { + settings := config.FreshSettings(a.deps.store) + resolved := resolveOutsideProjectPaths(ctx, nil, settings.Projects) + if matchOutsideProject(settings.Projects, resolved, nil, directory) != nil { + return "", &AdoptRefusal{Status: http.StatusConflict, Code: adoptChanged, Message: "a project now holds the session's folder; adopt it again"} + } + folder, ok := usableDirectory(directory) + if !ok { + return "", &AdoptRefusal{Status: http.StatusConflict, Code: adoptDirectoryGone, Message: "the session's folder is not a usable directory"} + } + return folder, nil +} + // resolveRow finds the live console row the request names. A selector that // matches an ended row answers `ended`; one that matches nothing answers 404. func (a *adoptOps) resolveRow(ctx context.Context, req AdoptRequest) (outsidesessions.Observation, *AdoptRefusal) { diff --git a/cmd/relay/session_chief_of_staff.go b/cmd/relay/session_chief_of_staff.go index 82fec26f..eeea54fb 100644 --- a/cmd/relay/session_chief_of_staff.go +++ b/cmd/relay/session_chief_of_staff.go @@ -181,6 +181,11 @@ func (d sessionRouteDeps) sessionMessageArgs(sessionID, body, origin string, wit "origin": origin, "text_bytes": len(body), } + if d.sessions != nil { + if rec, ok := d.sessions.Get(sessionID); ok && rec.Unsandboxed { + args["no_project"], args["relay_sandbox"] = true, false + } + } if withText && d.auditor.LogArgs() { text, truncated := d.auditor.CapText(body) args["text"] = text diff --git a/cmd/relay/session_dropin.go b/cmd/relay/session_dropin.go index 6f493d9b..33f321d9 100644 --- a/cmd/relay/session_dropin.go +++ b/cmd/relay/session_dropin.go @@ -138,7 +138,17 @@ func (d sessionRouteDeps) dropIn(ctx context.Context, req DropInRequest) (res *D DropInAgent: rec.Kind, } - result, lr := AuthorizeLaunch(d.store, d.modelKeys, d.sessions, launchReq) + var result *LaunchResult + var lr *LaunchRefusal + if rec.Unsandboxed { + // Before the handoff: a folder that is gone refuses here, with the + // session still running. + result, lr = authorizeUnsandboxedLaunch(d.store, d.sessions, unsandboxedLaunch{ + Caller: req.Caller, Kind: KindPTY, Directory: rec.Directory, Name: launchReq.Name, Cols: cols, Rows: rows, + }) + } else { + result, lr = AuthorizeLaunch(d.store, d.modelKeys, d.sessions, launchReq) + } if lr != nil { d.auditor.Record(lr.Audit) return nil, &DropInRefusal{Status: lr.Status, Code: lr.Code, Message: lr.Message} @@ -148,14 +158,14 @@ func (d sessionRouteDeps) dropIn(ctx context.Context, req DropInRequest) (res *D // The intent row is durable before anything stops; a swap relay cannot // record does not start. actor := callerAuditActor(req.Caller) - swapID, err := d.swapIntent(actor, swapToTerminal, req.SessionID, rec.ProjectID, hostID, swapAgent(rec.Kind)) + swapID, err := d.swapIntent(actor, swapToTerminal, req.SessionID, rec.ProjectID, hostID, swapAgent(rec.Kind), rec.Unsandboxed) if err != nil { d.discardLaunch(result) return nil, &DropInRefusal{Status: http.StatusServiceUnavailable, Code: dropInAuditOff, Message: "auditing is off or cannot record; the session was not taken over"} } complete := func(outcome, code string) { - d.swapCompletion(swapID, swapToTerminal, req.SessionID, terminalID, swapAgent(rec.Kind), outcome, code) + d.swapCompletion(swapID, swapToTerminal, req.SessionID, terminalID, swapAgent(rec.Kind), outcome, code, rec.Unsandboxed) } link := swapLink{SessionID: req.SessionID, TerminalID: terminalID, Host: host, Caller: req.Caller} diff --git a/cmd/relay/session_launch.go b/cmd/relay/session_launch.go index 02bbd6dc..ce2311dc 100644 --- a/cmd/relay/session_launch.go +++ b/cmd/relay/session_launch.go @@ -276,6 +276,9 @@ type sessionLaunchAuditFields struct { HostID string Entry string Command string + // NoProject is set on the launch of a session that runs in no project, with + // no sandbox: the row says no project policy applied. + NoProject bool ReadOnlyProjects bool ReadRoots int @@ -296,6 +299,7 @@ type sessionLaunchAuditArgs struct { HostID string `json:"host_id,omitempty"` Entry string `json:"entry,omitempty"` Command string `json:"command,omitempty"` + NoProject bool `json:"no_project,omitempty"` ReadOnlyProjects bool `json:"read_only_projects,omitempty"` ReadRoots int `json:"read_roots,omitempty"` // GrantWarnings are the template folders that reach a whole home @@ -322,7 +326,7 @@ func newSessionLaunchAuditEvent(f sessionLaunchAuditFields, outcome, errMsg stri Directory: directory, DirectoryTruncated: directory != f.Directory, Sandbox: f.Sandbox, Origin: f.Origin, PromptBytes: f.PromptBytes, HostID: f.HostID, Entry: capAuditText(f.Entry, maxRefusalNameRunes), Command: capAuditText(f.Command, maxAuditDirectoryRunes), - ReadOnlyProjects: f.ReadOnlyProjects, ReadRoots: f.ReadRoots, + NoProject: f.NoProject, ReadOnlyProjects: f.ReadOnlyProjects, ReadRoots: f.ReadRoots, GrantWarnings: cappedAuditTexts(f.GrantWarnings, maxAuditDirectoryRunes), }) return audit.AuditEvent{ diff --git a/cmd/relay/session_rebind.go b/cmd/relay/session_rebind.go index 522b2742..17b52466 100644 --- a/cmd/relay/session_rebind.go +++ b/cmd/relay/session_rebind.go @@ -25,6 +25,7 @@ import ( const ( rebindNotLive = "not_live" rebindProjectGone = "project_gone" + rebindUnsandboxed = "unsandboxed" rebindNoRootRecord = "no_root_record" rebindRunnerMismatch = "runner_mismatch" rebindRootGone = "root_gone" @@ -144,6 +145,11 @@ func (r *appRouter) rebindRoot(req bridge.SessionRebindRequest, rec ledger.Recor if !held || rec.State != ledger.StateLive || rec.Kind != KindClaude || rec.Detached != nil { return nil, rebindNotLive } + // An unsandboxed session has no launch identity to bind, and its record has + // no project to be gone: it keeps its own reason. + if rec.Unsandboxed { + return nil, rebindUnsandboxed + } if r.store == nil || r.launches == nil { return nil, rebindProjectGone } diff --git a/cmd/relay/session_routes.go b/cmd/relay/session_routes.go index 5cdca9b1..4370fa66 100644 --- a/cmd/relay/session_routes.go +++ b/cmd/relay/session_routes.go @@ -644,14 +644,24 @@ func (d sessionRouteDeps) resumeSessionInner(ctx context.Context, ev *logging.Ev return resumeError(http.StatusInternalServerError, "corrupt session record", "corrupt_record") } - req := LaunchRequest{ - Caller: caller, SessionID: id, Resume: true, - ProjectID: rec.ProjectID, Kind: rec.Kind, Directory: rec.Directory, - Name: sessionReq.Name, Model: sessionReq.Model, ClientSettings: sessionReq.Settings, - SystemPrompt: sessionReq.SystemPrompt, AppendClaudeMd: sessionReq.AppendClaudeMd, - Entry: rec.Entry, offer: d.offerFor(ctx), + var result *LaunchResult + var refusal *LaunchRefusal + if rec.Unsandboxed { + // A record the adopt marked runs in its own folder with no project. The + // mark comes from the ledger, never from a caller. + result, refusal = authorizeUnsandboxedLaunch(d.store, d.sessions, unsandboxedLaunch{ + Caller: caller, SessionID: id, Resume: true, Kind: rec.Kind, Directory: rec.Directory, + Name: sessionReq.Name, Model: sessionReq.Model, ClientSettings: sessionReq.Settings, + }) + } else { + result, refusal = AuthorizeLaunch(d.store, d.modelKeys, d.sessions, LaunchRequest{ + Caller: caller, SessionID: id, Resume: true, + ProjectID: rec.ProjectID, Kind: rec.Kind, Directory: rec.Directory, + Name: sessionReq.Name, Model: sessionReq.Model, ClientSettings: sessionReq.Settings, + SystemPrompt: sessionReq.SystemPrompt, AppendClaudeMd: sessionReq.AppendClaudeMd, + Entry: rec.Entry, offer: d.offerFor(ctx), + }) } - result, refusal := AuthorizeLaunch(d.store, d.modelKeys, d.sessions, req) if refusal != nil { d.auditor.Record(resumeAuditEvent(refusal.Audit)) endEvent(ev, refusal) @@ -711,6 +721,9 @@ func resumeAuditEvent(ev audit.AuditEvent) audit.AuditEvent { // not -- is identical. func (d sessionRouteDeps) auditResume(actor audit.AuditActor, sessionID, projectID, kind, outcome, errMsg string) { fields := sessionLaunchAuditFields{Actor: actor, ProjectID: projectID, SessionID: sessionID, Kind: kind} + if rec, ok := d.sessions.Get(sessionID); ok && rec.Unsandboxed { + fields.NoProject = true + } d.auditor.Record(resumeAuditEvent(newSessionLaunchAuditEvent(fields, outcome, errMsg))) } diff --git a/cmd/relay/session_swap.go b/cmd/relay/session_swap.go index f245b4c0..baa371a3 100644 --- a/cmd/relay/session_swap.go +++ b/cmd/relay/session_swap.go @@ -122,6 +122,20 @@ type swapArgs struct { TerminalID string `json:"terminal_id,omitempty"` // Agent is set for Pi and Codex swaps; a Claude swap's rows carry none. Agent string `json:"agent,omitempty"` + // NoProject and RelaySandbox are set on the swap of a session that runs in no + // project, so the row says no project policy or sandbox applied. + NoProject bool `json:"no_project,omitempty"` + RelaySandbox *bool `json:"relay_sandbox,omitempty"` +} + +// unsandboxedSwapArgs fills the two fields a swap row of an unsandboxed +// session carries. +func unsandboxedSwapArgs(a swapArgs, unsandboxed bool) swapArgs { + if unsandboxed { + off := false + a.NoProject, a.RelaySandbox = true, &off + } + return a } // swapAgent is the agent a swap row names: empty for Claude. @@ -134,11 +148,11 @@ func swapAgent(kind string) string { // swapIntent writes the swap's intent row and answers only once it is // durable. A swap that cannot be recorded does not start. -func (d sessionRouteDeps) swapIntent(actor audit.AuditActor, dir swapDirection, sessionID, projectID, hostID, agent string) (string, error) { +func (d sessionRouteDeps) swapIntent(actor audit.AuditActor, dir swapDirection, sessionID, projectID, hostID, agent string, unsandboxed bool) (string, error) { if !d.auditor.Ready() { return "", errors.New(swapAuditUnavailable) } - args, _ := json.Marshal(swapArgs{SessionID: sessionID, Direction: string(dir), ProjectID: projectID, HostID: hostID, Agent: agent}) + args, _ := json.Marshal(unsandboxedSwapArgs(swapArgs{SessionID: sessionID, Direction: string(dir), ProjectID: projectID, HostID: hostID, Agent: agent}, unsandboxed)) id := audit.NewAuditID() if err := d.auditor.RecordDurable(audit.AuditEvent{ ID: id, TS: d.swaps.clock(), Event: audit.AuditEventSessionSwap, Phase: audit.AuditPhaseIntent, @@ -152,8 +166,8 @@ func (d sessionRouteDeps) swapIntent(actor audit.AuditActor, dir swapDirection, // swapCompletion writes the completion row that pairs with swapIntent's id. It // is durable before the caller's event ends, so a reader of the event finds it. -func (d sessionRouteDeps) swapCompletion(auditID string, dir swapDirection, sessionID, terminalID, agent, outcome, code string) { - args, _ := json.Marshal(swapArgs{SessionID: sessionID, Direction: string(dir), TerminalID: terminalID, Agent: agent}) +func (d sessionRouteDeps) swapCompletion(auditID string, dir swapDirection, sessionID, terminalID, agent, outcome, code string, unsandboxed bool) { + args, _ := json.Marshal(unsandboxedSwapArgs(swapArgs{SessionID: sessionID, Direction: string(dir), TerminalID: terminalID, Agent: agent}, unsandboxed)) now := d.swaps.clock() ev := audit.AuditEvent{ ID: auditID, TS: now, Event: audit.AuditEventSessionSwap, Phase: audit.AuditPhaseCompletion, @@ -218,14 +232,14 @@ func (d sessionRouteDeps) handBackResume(ctx context.Context, l swapLink) { ev.End(logging.OutcomeDenied, "session_not_found", errors.New("session_not_found")) return } - auditID, err := d.swapIntent(callerAuditActor(l.Caller), swapToHeadless, l.SessionID, rec.ProjectID, d.swapHostID(rec.ProjectID), swapAgent(rec.Kind)) + auditID, err := d.swapIntent(callerAuditActor(l.Caller), swapToHeadless, l.SessionID, rec.ProjectID, d.swapHostID(rec.ProjectID), swapAgent(rec.Kind), rec.Unsandboxed) if err != nil { ev.End(logging.OutcomeDenied, swapAuditUnavailable, err) return } out := d.resumeSession(ctx, l.Caller, l.SessionID) if out.Status == http.StatusOK { - d.swapCompletion(auditID, swapToHeadless, l.SessionID, l.TerminalID, swapAgent(rec.Kind), audit.AuditOutcomeOK, "") + d.swapCompletion(auditID, swapToHeadless, l.SessionID, l.TerminalID, swapAgent(rec.Kind), audit.AuditOutcomeOK, "", rec.Unsandboxed) ev.End(logging.OutcomeOK, "", nil) return } @@ -235,6 +249,6 @@ func (d sessionRouteDeps) handBackResume(ctx context.Context, l swapLink) { if out.Status >= http.StatusInternalServerError { auditOutcome = audit.AuditOutcomeError } - d.swapCompletion(auditID, swapToHeadless, l.SessionID, l.TerminalID, swapAgent(rec.Kind), auditOutcome, code) + d.swapCompletion(auditID, swapToHeadless, l.SessionID, l.TerminalID, swapAgent(rec.Kind), auditOutcome, code, rec.Unsandboxed) ev.End(outcome, reason, errors.New(code)) } diff --git a/cmd/relay/session_unsandboxed.go b/cmd/relay/session_unsandboxed.go new file mode 100644 index 00000000..e04eca33 --- /dev/null +++ b/cmd/relay/session_unsandboxed.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + + "github.com/barelyworkingcode/relay/internal/config" + "github.com/barelyworkingcode/relay/internal/sessions/hostapi" + "github.com/barelyworkingcode/relay/internal/sessions/ledger" +) + +const refusalDirectoryUnusable = "directory_unusable" + +// unsandboxedLaunch is what adopt, resume and drop-in hand the one launch +// builder for a Claude session that is in no project. No request decoder fills +// it: the person never asks for a launch with no project, they adopt a session +// that already runs outside every project. +type unsandboxedLaunch struct { + Caller LaunchCaller + // SessionID is empty for an adopt and for a drop-in terminal, and the + // record's id for a resume. + SessionID string + Resume bool + // Kind is KindClaude, or KindPTY for a drop-in terminal. + Kind string + Directory string + Name string + // Model and ClientSettings apply to KindClaude only: the settings are + // {"headless":true,"agent":true} on an adopt and the stored ones on a resume. + Model string + ClientSettings json.RawMessage + // Cols and Rows apply to KindPTY only. + Cols, Rows int +} + +// usableDirectory resolves dir and reports whether it is an absolute path to +// an existing directory. It returns the path with its symlinks resolved. +func usableDirectory(dir string) (string, bool) { + if !filepath.IsAbs(dir) { + return "", false + } + resolved, err := filepath.EvalSymlinks(dir) + if err != nil { + return "", false + } + info, err := os.Stat(resolved) + if err != nil || !info.IsDir() { + return "", false + } + return resolved, true +} + +// authorizeUnsandboxedLaunch builds the launch of a Claude session, or of its +// drop-in terminal, that runs in no project: no project lookup, no allowed- +// models check, no template allow-list, no model key, no sandbox profile and +// no launch identity. The session runs as the account that runs relay, in the +// folder the person started it in. AuthorizeLaunch is unchanged and still +// refuses a launch with no project; only adopt, resume and drop-in reach this. +func authorizeUnsandboxedLaunch(store config.SettingsStore, sessions *ledger.Ledger, req unsandboxedLaunch) (*LaunchResult, *LaunchRefusal) { + settings := config.FreshSettings(store) + fields := sessionLaunchAuditFields{ + Actor: callerAuditActor(req.Caller), Kind: req.Kind, NoProject: true, + } + if !callerGrantsExecute(req.Caller) { + return nil, forbidden("caller_not_authorized", "caller does not hold execute on the frontend socket", fields) + } + if req.Kind != KindClaude && req.Kind != KindPTY { + return nil, invalidRequest("invalid_kind", "only a claude session or its drop-in terminal runs with no project", fields) + } + if req.SessionID != "" && !req.Resume { + return nil, forbidden("session_id_not_allowed", "a fresh launch may not name a session id", fields) + } + if req.Resume && req.Kind != KindClaude { + return nil, invalidRequest("invalid_kind", "only a claude session resumes", fields) + } + + var origin string + if req.Resume { + rec, ok := sessions.Get(req.SessionID) + if !ok || rec.State != ledger.StateDormant || !rec.Unsandboxed || rec.ProjectID != "" || rec.Kind != KindClaude { + return nil, forbidden("session_not_resumable", "session is not a dormant session that runs with no project", fields) + } + origin = rec.Origin + fields.Origin = origin + } + + directory, ok := usableDirectory(req.Directory) + if !ok { + return nil, deniedRefusal(http.StatusConflict, refusalDirectoryUnusable, "the session's folder is not a usable directory", nil, fields) + } + fields.Directory = directory + + var tmpl *config.TerminalTemplate + if req.Kind == KindPTY { + t, found := config.GetTerminalTemplate(settings, kindTemplateIDs[KindClaude]) + if !found { + return nil, forbidden("template_not_allowed", "the claude-code terminal template is not available", fields) + } + tmpl = &t + fields.TemplateID = t.ID + } + + // Read at every launch and resume, before anything is minted or written, so + // a refusal leaves nothing behind. The same token a sandboxed Claude gets, + // over the same fd 5; the process runs as the same account and could read + // the file by path, so it gains nothing. + claudeToken, err := loadClaudeToken(settings) + if err != nil { + return nil, invalidRequest("claude_token_unavailable", err.Error(), fields) + } + + var claudeEnv map[string]string + var sessionRequest json.RawMessage + if req.Kind == KindClaude { + readOnly, err := readOnlyProjectsSetting(req.ClientSettings) + if err != nil { + return nil, invalidRequest("invalid_settings", err.Error(), fields) + } + if readOnly { + return nil, invalidRequest("read_only_needs_sandbox", "readOnlyProjects needs a sandbox; this session has none", fields) + } + if claudeEnv, err = resolveClaudeEnvPassthrough(settings); err != nil { + return nil, invalidRequest("invalid_claude_env", err.Error(), fields) + } + // A nil project: mergePermissionSettings applies no project policy. + var refusal *LaunchRefusal + sessionRequest, refusal = buildSessionRequest(LaunchRequest{ + Kind: KindClaude, Name: req.Name, Model: req.Model, ClientSettings: req.ClientSettings, + }, nil, directory, fields) + if refusal != nil { + return nil, refusal + } + } + + sessionID := req.SessionID + if sessionID == "" { + sessionID = uuid.NewString() + } + fields.SessionID = sessionID + + spec := hostapi.LaunchRequest{ + V: 1, + SessionID: sessionID, + Kind: req.Kind, + Resume: req.Resume, + Directory: directory, + Name: req.Name, + IdleTimeoutSec: 86400, + SessionRequest: sessionRequest, + ClaudeToken: claudeToken, + ClaudeEnv: claudeEnv, + Origin: origin, + Unsandboxed: true, + } + + if req.Kind == KindPTY { + env, err := resolveTemplateEnv(*tmpl) + if err != nil { + return nil, invalidRequest("model_endpoint_unavailable", err.Error(), fields) + } + spec.Argv = resolveArgv(*tmpl, "", "") + spec.Env = env + spec.TemplateID = tmpl.ID + if tmpl.IdleTimeout > 0 { + spec.IdleTimeoutSec = tmpl.IdleTimeout * 60 + } + cols, rows := req.Cols, req.Rows + if cols <= 0 || rows <= 0 { + cols, rows = 120, 40 + } + spec.PTY = &hostapi.PTYSpec{Cols: cols, Rows: rows} + } + + var record *ledger.Record + if req.Kind == KindClaude { + record = &ledger.Record{ + SessionID: sessionID, + Kind: KindClaude, + Directory: directory, + Created: time.Now().UTC(), + Origin: origin, + State: ledger.StateLive, + SessionRequest: sessionRequest, + Unsandboxed: true, + } + } + return &LaunchResult{SessionID: sessionID, Spec: spec, Ledger: record, AuditFields: fields}, nil +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 4654400c..d68b5679 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -185,15 +185,16 @@ Areas: sessions, sandbox, templates, audit, presence. | `G1.42` | Read events from a cursor | Events after a cursor print in order with the next cursor; two calls chained by cursor print no event twice and miss none. A local runner session is replayable; a chat or `ssh -T` session exits 1 with `replay_unavailable`. | none | `relay session events` | `cli:relay session events` `bridge:admin_op:session.events` | none | `event:session.events=ok#session_id` `code:cli:relay session events#1` | `e2e:TestSessionEventsAfterCursor` `e2e:TestSessionEventsChainedByCursor` `e2e:TestSessionEventsReplayUnavailable` | | `G1.43` | Sandboxed Claude does not self-update | A sandboxed `claude` session and a sandboxed `claude-code` terminal launch start with `DISABLE_AUTOUPDATER=1`, because the sandbox grants Claude's install folder read-only. A value the template's own `env` sets wins. An unsandboxed launch and an SSH host launch do not change. | Settings > Templates | `settings.json` `terminal_templates[].env` | `http:POST /api/sessions` `http:POST /api/terminals` `cli:relay session start` `cli:relay terminal start` | none | `event:session.launch=ok#session_id` | `e2e:TestBug181SandboxedClaudeSessionUpdaterOff` `e2e:TestBug181SandboxedClaudeTerminalUpdaterOff` `e2e:TestBug181TemplateEnvWins` `e2e:TestBug181UnsandboxedLaunchUnchanged` | | `G1.44` | Sandboxed Claude keeps its own login | A sandboxed `claude` session and a sandboxed `claude-code` terminal launch start with `CLAUDE_CONFIG_DIR` set to one relay-owned folder, `/relay/sandbox-claude`, which the profile grants read-write. Relay creates it (0700) and links the person's `~/.claude` settings, instructions, skills, agents, commands, projects and sessions into it, never credentials. When the folder has no `.claude.json` entry, relay writes one authored seed (0600), never a copy of the person's file: `hasCompletedOnboarding` and one `hasTrustDialogAccepted` entry for the launch directory with symlinks resolved, none for `/`, the home folder, a parent of it or an unresolvable path. The first start skips the theme picker, the security notes and the folder-trust prompt. A folder that already has a `.claude.json` is not changed. A sandboxed `claude-code` terminal also gets `CLAUDE_CODE_SANDBOXED=1` when its folder passes the same check, so Claude skips its trust check and persists nothing (measured on Claude Code 2.1.304, not in Claude's public docs); the `claude` session gets none. The login lives in that folder, so a token refresh survives the sandbox's refused keychain write. Each person runs `/login` once inside a sandboxed Claude. A value the template's own `env` sets wins. A folder path that is not a real directory refuses the launch. An unsandboxed launch and an SSH host launch do not change. | none | `settings.json` `terminal_templates[].env` | `http:POST /api/sessions` `http:POST /api/terminals` `cli:relay session start` `cli:relay terminal start` | none | `event:session.launch=ok#session_id` `code:http:POST /api/sessions#400` | `e2e:TestBug181SandboxedClaudeSessionConfigDir` `e2e:TestBug181SandboxedClaudeTerminalConfigDir` `e2e:TestBug181UnsandboxedClaudeNoConfigDir` `e2e:TestBug181TemplateConfigDirWins` `e2e:TestBug181SandboxClaudeDirWritable` `e2e:TestBug181SandboxClaudeTerminalDirWritable` `e2e:TestBug181SandboxClaudeDirSeeded` `e2e:TestBug181SandboxClaudeDirKeepsOwnEntries` `e2e:TestBug181SandboxClaudeDirNotADirRefused` `e2e:TestBug506SandboxClaudeDirOnboardingSeeded` `e2e:TestBug506SandboxClaudeDirKeepsOwnClaudeJSON` `e2e:TestBug506SandboxClaudeDirKeepsLinkedClaudeJSON` `e2e:TestBug506SandboxClaudeDirTrustsResolvedLink` `e2e:TestBug506SandboxClaudeDirHomeNotTrusted` `e2e:TestSandboxClaudeTrustTerminalEnv` `e2e:TestSandboxClaudeTrustHomeNotTrusted` `e2e:TestSandboxClaudeTrustTemplateEnvWins` `e2e:TestSandboxClaudeTrustUnsandboxedUnset` `e2e:TestSandboxClaudeTrustSessionUnchanged` `journey:session-drop-in` | -| `G1.45` | Sandboxed Claude gets a long-term token on a descriptor | While `claude_token_file` names a file, a sandboxed `claude` session and a sandboxed `claude-code` terminal launch start logged in: relay reads the token at each launch and resume, the host passes it on fd 5, and `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR=5` names it. The token is never in a child's environment, argv, a log line, an audit row, an event or an API answer. Every sandbox profile denies the file by path. Relay's descriptor value wins over a template's while the setting is set. A missing, unreadable, empty, oversized or non-regular file refuses the launch with `sandbox_unavailable`, naming the path. With the setting empty, nothing changes; an unsandboxed launch and an SSH host launch never get the token. | none | `settings.json` `claude_token_file` | `http:POST /api/sessions` `http:POST /api/terminals` `cli:relay session start` `cli:relay terminal start` | none | `event:session.launch=ok#session_id` `code:http:POST /api/sessions#400` | `e2e:TestClaudeTokenUnsetLaunchUnchanged` `e2e:TestClaudeTokenSessionDescriptor` `e2e:TestClaudeTokenTerminalDescriptor` `e2e:TestClaudeTokenReadAtEachLaunch` `e2e:TestClaudeTokenNeverDisclosed` `e2e:TestClaudeTokenFileDeniedToSession` `e2e:TestClaudeTokenFileDeniedToShellTerminal` `e2e:TestClaudeTokenMissingFileRefused` `e2e:TestClaudeTokenUnreadableFileRefused` `e2e:TestClaudeTokenRelayDescriptorWins` `e2e:TestClaudeTokenUnsandboxedNoDescriptor` | +| `G1.45` | Sandboxed Claude gets a long-term token on a descriptor | While `claude_token_file` names a file, a sandboxed `claude` session and a sandboxed `claude-code` terminal launch start logged in: relay reads the token at each launch and resume, the host passes it on fd 5, and `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR=5` names it. The token is never in a child's environment, argv, a log line, an audit row, an event or an API answer. Every sandbox profile denies the file by path. Relay's descriptor value wins over a template's while the setting is set. A missing, unreadable, empty, oversized or non-regular file refuses the launch with `sandbox_unavailable`, naming the path. With the setting empty, nothing changes; an unsandboxed terminal and an SSH host launch never get the token; only a Claude adopted in no project gets it unsandboxed (`G1.54`). | none | `settings.json` `claude_token_file` | `http:POST /api/sessions` `http:POST /api/terminals` `cli:relay session start` `cli:relay terminal start` | none | `event:session.launch=ok#session_id` `code:http:POST /api/sessions#400` | `e2e:TestClaudeTokenUnsetLaunchUnchanged` `e2e:TestClaudeTokenSessionDescriptor` `e2e:TestClaudeTokenTerminalDescriptor` `e2e:TestClaudeTokenReadAtEachLaunch` `e2e:TestClaudeTokenNeverDisclosed` `e2e:TestClaudeTokenFileDeniedToSession` `e2e:TestClaudeTokenFileDeniedToShellTerminal` `e2e:TestClaudeTokenMissingFileRefused` `e2e:TestClaudeTokenUnreadableFileRefused` `e2e:TestClaudeTokenRelayDescriptorWins` `e2e:TestClaudeTokenUnsandboxedNoDescriptor` | | `G1.46` | Recently ended outside sessions | An outside Claude session whose transcript file was modified inside the window (3 days) stays in the scoped list as a read-only `ended` row with `endedAt` and `claudeSessionId` (its transcript file's stem; every row carries `claudeSessionId`, live ones too), newest first, at most 50, and its detail shows the last turn (at most 8192 characters). A session that is live is never also listed as ended. Only folders under a registered project, or the console's own project folders, are scanned; a symlink, a missing or an unreadable file drops that row and never the list. Transcript text grants no authority. | Eve > Chief of Staff > Agents rail | `settings.json` `outside_sessions.ended_window_days` (0 to 30 days; 0 turns it off) | `http:GET /api/chief-of-staff/outside-sessions` `http:GET /api/chief-of-staff/outside-sessions/{id}` `http:GET /ws/outside-sessions` | none | `out:http:GET /api/chief-of-staff/outside-sessions#.sessions[]` | `e2e:TestOutsideEndedRowListed` `e2e:TestOutsideEndedWindowDefault` `e2e:TestOutsideEndedWindowSetting` `e2e:TestOutsideEndedCapAndOrder` `e2e:TestOutsideEndedDetailLastTurn` `e2e:TestOutsideEndedNotListedWhileLive` `e2e:TestOutsideEndedTailBounds` `e2e:TestOutsideEndedScanScope` `e2e:TestOutsideEndedBadFileDropsRow` `e2e:TestOutsideEndedRowGrantsNoAuthority` `e2e:TestOutsideEndedSubfolderCwd` `e2e:TestOutsideEndedCapSkipsNoCwd` `e2e:TestOutsideEndedListCarriesClaudeSessionID` `e2e:TestOutsideEndedDetailAndWSCarryClaudeSessionID` `e2e:TestOutsideLiveRowCarriesClaudeSessionID` `e2e:TestOutsideResumedSessionShowsOnceWithClaudeSessionID` | | `G1.47` | Pass chosen Claude Code settings to Claude sessions | While `claude_env_passthrough` lists `CLAUDE_CODE_*` names, a local `claude` session starts with each listed name that relay's environment sets, at relay's value. An unlisted name stays dropped, and the list is empty by default. A listed name outside `CLAUDE_CODE_[A-Z0-9_]+`, one with a credential segment, or one relay reserves refuses every local claude launch with 400 `invalid_claude_env`, naming the entry and never a value. Terminals, pi, codex, chat and SSH host sessions do not change. | none | `settings.json` `claude_env_passthrough` | `http:POST /api/sessions` `cli:relay session start` | none | `event:session.launch=ok#session_id` `code:http:POST /api/sessions#400` | `e2e:TestClaudeEnvPassthroughListedReachesSession` `e2e:TestClaudeEnvPassthroughEmptyByDefault` `e2e:TestClaudeEnvPassthroughRefusedNames` `e2e:TestClaudeEnvPassthroughOtherKindsUntouched` | | `G1.48` | Runner event lines | The runner appends one line to `events.jsonl` per state change: `turn_started`, `turn_ended`, `asking` and `exited`. Each line carries the byte offset in `out.jsonl` of the line that caused it, and precedes that line. The agent's stderr is a pipe the runner copies to `stderr.log`, never a file the agent holds. | n/a | n/a | `bg:session runner` | none | `event:session.state=ok#to` | `e2e:TestRunnerWritesEventLines` `e2e:TestCodexPiRunnerEventLines` `e2e:TestCodexPiSendReturnsAfterReceipt` `e2e:TestRunnerAgentStderrIsPipe` | | `G1.49` | Local session survives a relay restart | A local Claude session keeps its agent when relay restarts: the next `relay session message` is answered with no resume and no second agent, and a turn running during the restart finishes. Its permission hook still reaches relay's check, and a caller outside the agent's process tree is refused. Its relay tools come back on their own (`G1.51`). A session whose agent exited while relay was down reads dormant and resumes on its conversation. The runner directory is outside every sandbox grant. | none (automatic in eve) | n/a | `cli:relay session message` `cli:relay session resume` | none | `event:session.state=ok#to` | `e2e:TestLocalRunnerSurvivesRelayRestart` `e2e:TestLocalRunnerExitedReadsDormant` `e2e:TestLocalRunnerHookRootAfterRestart` `e2e:TestLocalRunnerAskingEventLine` `e2e:TestLocalRunnerDirOutsideSandbox` | -| `G1.50` | Adopt an outside Claude session | The owner adopts an interactive Claude started in a terminal on this Mac, in a registered local project: the process ends and the same conversation resumes as a headless relay session in the same folder, under the project's sandbox and policy. A row lists its `claudeSessionId` and whether it is `adoptable`, with the reason when not (`ended`, `host_unsupported`, `no_project`, `not_registered`, `invalid_id`, `start_mismatch`, `not_interactive`, `not_command_line`, `ambiguous`). A busy session is refused (409 `busy`) unless the caller asks to interrupt; an interrupt sends SIGINT, then SIGTERM, then SIGKILL, each after a bounded wait on relay's clock, and the first message to the resumed session names the open tool call and asks the agent to check what it touched. A transcript model that the broker serves adopts with the Claude alias that relay's environment maps to its modelMap key. A match whose process start time differs from the registry entry is never adopted. Each adopt, interrupt and recovery message writes an audit row before it acts, and with auditing off nothing is signalled. The Chief of Staff scope cannot reach the door. | Eve > Chief of Staff > Agents rail > Adopt | n/a (the body's `claudeSessionId`, `interrupt` and `model`) | `http:POST /api/sessions/adopt` | none | `event:session.adopt=ok#session_id` `event:session.adopt=denied/busy` `audit:session_adopt=ok#session_id` `code:http:POST /api/sessions/adopt#409` `code:http:POST /api/sessions/adopt#403` | `e2e:TestAdoptRowListsConversationAndState` `e2e:TestAdoptReasonsOnRows` `e2e:TestAdoptIdleResumesSameConversation` `e2e:TestAdoptByClaudeSessionID` `e2e:TestAdoptBusyRefusedWithoutInterrupt` `e2e:TestAdoptInterruptLadder` `e2e:TestAdoptRecoveryMessageNamesOpenTool` `e2e:TestAdoptStartMismatchNeverAdopted` `e2e:TestAdoptAuditRowsBeforeAct` `e2e:TestAdoptRefusedWithAuditingOff` `e2e:TestAdoptAmbiguousNeverAdopted` `e2e:TestAdoptScopeRefused` `e2e:TestAdoptChildVetoInLongTranscript` `e2e:TestAdoptIdleIgnoresTurnHelper` `e2e:TestAdoptBrokerModelAdoptsWithAlias` `e2e:TestAdoptBrokerModelUnknownRefused` `e2e:TestAdoptIdleIgnoresLateLongLivedChild` `e2e:TestAdoptToolChildOfLastTurnVetoes` `journey:session-adopt` | +| `G1.50` | Adopt an outside Claude session | The owner adopts an interactive Claude started in a terminal on this Mac, in a registered local project: the process ends and the same conversation resumes as a headless relay session in the same folder, under the project's sandbox and policy. A row lists its `claudeSessionId` and whether it is `adoptable`, with the reason when not (`ended`, `host_unsupported`, `not_registered`, `invalid_id`, `start_mismatch`, `not_interactive`, `not_command_line`, `ambiguous`); a Claude in no project is adoptable and runs unsandboxed (`G1.54`). A busy session is refused (409 `busy`) unless the caller asks to interrupt; an interrupt sends SIGINT, then SIGTERM, then SIGKILL, each after a bounded wait on relay's clock, and the first message to the resumed session names the open tool call and asks the agent to check what it touched. A transcript model that the broker serves adopts with the Claude alias that relay's environment maps to its modelMap key. A match whose process start time differs from the registry entry is never adopted. Each adopt, interrupt and recovery message writes an audit row before it acts, and with auditing off nothing is signalled. The Chief of Staff scope cannot reach the door. | Eve > Chief of Staff > Agents rail > Adopt | n/a (the body's `claudeSessionId`, `interrupt` and `model`) | `http:POST /api/sessions/adopt` | none | `event:session.adopt=ok#session_id` `event:session.adopt=denied/busy` `audit:session_adopt=ok#session_id` `code:http:POST /api/sessions/adopt#409` `code:http:POST /api/sessions/adopt#403` | `e2e:TestAdoptRowListsConversationAndState` `e2e:TestAdoptReasonsOnRows` `e2e:TestAdoptIdleResumesSameConversation` `e2e:TestAdoptByClaudeSessionID` `e2e:TestAdoptBusyRefusedWithoutInterrupt` `e2e:TestAdoptInterruptLadder` `e2e:TestAdoptRecoveryMessageNamesOpenTool` `e2e:TestAdoptStartMismatchNeverAdopted` `e2e:TestAdoptAuditRowsBeforeAct` `e2e:TestAdoptRefusedWithAuditingOff` `e2e:TestAdoptAmbiguousNeverAdopted` `e2e:TestAdoptScopeRefused` `e2e:TestAdoptChildVetoInLongTranscript` `e2e:TestAdoptIdleIgnoresTurnHelper` `e2e:TestAdoptBrokerModelAdoptsWithAlias` `e2e:TestAdoptBrokerModelUnknownRefused` `e2e:TestAdoptIdleIgnoresLateLongLivedChild` `e2e:TestAdoptToolChildOfLastTurnVetoes` `journey:session-adopt` | | `G1.51` | Relay tools rebind after a relay restart | After a relay restart, a surviving local Claude session's relay tools work again under the same project, with no stop and no resume. Relay binds only the root process it recorded at launch, after it checks the runner and finds the root in the runner's process tree. A caller other than relay-sessions is refused, and so is a root outside that tree or a rebind whose intent row cannot be written. Each rebind writes a `session_rebind` intent row and a completion row. | none (automatic in eve) | n/a | `bridge:SessionRebind` | none | `event:session.rebind=ok#session_id` `event:session.rebind=denied/root_not_in_tree` `audit:session_rebind=ok#session_id` | `e2e:TestLocalRunnerRelayToolsAfterRestart` `e2e:TestLocalRunnerRebindRefusesOutsideRoot` `e2e:TestLocalRunnerRebindRefusesStranger` `e2e:TestLocalRunnerRebindNeedsAudit` | | `G1.52` | Adopt an outside Pi session | An idle console Pi in a registered project resumes headless on the exact UUID and transcript, under existing project grants. Process cwd/start and the default transcript match uniquely. No first message means no row. Busy or ambiguous sessions, stale evidence, missing transcript grants and failed authorization or audit stop nothing. Adopted managed interrupt records a durable intent before RPC abort; audit failure leaves the turn running. | Eve > Agents rail > Adopt | `POST /api/sessions/adopt` with `outsideId` and optional `model` | `http:POST /api/sessions/adopt` `bridge:SessionInterrupt` | none | `event:session.adopt=ok#session_id` `event:session.interrupt=ok#session_id` `audit:session_adopt=ok#session_id` `audit:session_interrupt=pending` | `e2e:TestPiAdoptInventoryIdentity` `e2e:TestPiAdoptSameFileResume` `e2e:TestPiAdoptBusyRefused` `e2e:TestPiAdoptNoFirstTurn` `e2e:TestPiAdoptAmbiguousRefused` `e2e:TestPiAdoptStaleEvidenceRefused` `e2e:TestPiAdoptAuthorizationAndAuditRefused` `e2e:TestPiAdoptManagedAbortAudited` `e2e:TestPiAdoptSelectedLeafReadiness` `e2e:TestPiAdoptWorkingChildRefused` `e2e:TestPiAdoptFailedSuccessorPreservesTranscript` `e2e:TestPiAdoptDeleteOnlyExactFile` `e2e:TestPiAdoptAuditWriteFailureRefused` `e2e:TestPiAdoptManagedAbortAuditFailureRefused` `e2e:TestPiAdoptTitleErasedIdentity` | | `G1.53` | Attach to an outside codex thread | An idle thread of the per-user codex daemon, started from a codex terminal or the codex desktop app in a registered local project, shows as an outside row and attaches through the daemon's control socket. Attach ends no process and sends the thread no sandbox, approval, model, folder or instruction setting: it keeps the policy it already runs under, and relay claims no project sandbox over it. A thread with no turn shows no row. A busy thread is refused (409 `busy`), also with `interrupt:true`, and a changed, ambiguous or already managed thread is never attached. Relay reads only the default control socket and only when this user alone owns it, never asks the daemon to stop, restart, update, delete or archive, and declines each approval request on a turn it started and answers none for a turn it did not start. Stop sends `turn/interrupt` after a durable `session_interrupt` intent row, and a durable `session_adopt` intent row precedes the attach; with auditing off relay sends neither. Delete of the attached session ends only relay's connection. A dormant attached session resumes only by attaching again. | Eve > Agents rail > Adopt | `POST /api/sessions/adopt` with `outsideId` | `http:POST /api/sessions/adopt` `bridge:SessionInterrupt` | none | `event:session.adopt=ok#session_id` `event:session.adopt=denied/busy` `event:session.interrupt=ok#session_id` `audit:session_adopt=ok#session_id` `audit:session_interrupt=pending` `code:http:POST /api/sessions/adopt#409` | `e2e:TestCodexAttachRowListsThread` `e2e:TestCodexAttachRowNoEnvironmentsUsesCwd` `e2e:TestCodexAttachRowEnvironmentsWin` `e2e:TestCodexAttachNoTurnNoRow` `e2e:TestCodexAttachIdleNoKill` `e2e:TestCodexAttachSendsNoOverride` `e2e:TestCodexAttachNeverStopsDaemon` `e2e:TestCodexAttachBusyRefused` `e2e:TestCodexAttachChangedOrAmbiguousRefused` `e2e:TestCodexAttachManagedRefused` `e2e:TestCodexAttachSocketUnsafeNoRows` `e2e:TestCodexAttachAuditBeforeAttach` `e2e:TestCodexAttachAuditFailureSendsNothing` `e2e:TestCodexAttachInterruptAudited` `e2e:TestCodexAttachInterruptAuditFailureKeepsTurn` `e2e:TestCodexAttachStateFollowsStatus` `e2e:TestCodexAttachApprovalsDeclined` `e2e:TestCodexAttachDeleteDetachesOnly` `e2e:TestCodexAttachDormantReattaches` `e2e:TestCodexAttachResumeRefused` `e2e:TestCodexAttachResumeAuditFailureSendsNothing` `e2e:TestCodexAttachTerminalTurnApprovalUnanswered` `e2e:TestCodexAttachClearRefused` `e2e:TestCodexAttachScopeRefused` `e2e:TestCodexAttachModelRefused` `e2e:TestCodexAttachEphemeralThreadKeepsOtherRows` (real-app check: the terminal and daemon keep running, Task C of the child) | +| `G1.54` | Adopt an outside Claude session that is in no project | The owner adopts an interactive Claude started in a terminal in a folder no registered local project holds. The process ends and the same conversation resumes headless in the same folder, unsandboxed: no sandbox profile, no project permission policy, no allowed-models check and no launch identity, so relay tools are absent. A tool call that needs approval still asks the owner through relay's permission hook. The 201 answer, the session in `GET /api/sessions` and its drop-in terminal in `GET /api/terminals` carry `unsandboxed: true`; no other session or terminal does. A project that holds the folder at adopt time refuses 409 `changed`, and a folder that is not an existing directory refuses 409 `directory_unusable`, both with the process still running. Drop in, the hand back, a resume and a Chief of Staff send keep working; a resume or drop-in with the folder gone refuses 409 `directory_unusable` and changes nothing. With `claude_token_file` set the session gets the token on fd 5. The adopt, interrupt, swap, message, launch and resume audit rows record `no_project: true` and no sandbox. Pi and Codex rows in no project stay not adoptable (`no_project`). With auditing off nothing is signalled. The Chief of Staff scope cannot reach the door. | Eve > Chief of Staff > Agents rail > Adopt | n/a (the adopt body, as `G1.50`) | `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` | none | `event:session.adopt=ok#session_id` `audit:session_adopt=ok#session_id` `code:http:POST /api/sessions/adopt#409` `code:http:POST /api/sessions/adopt#403` | `e2e:TestAdoptNoProjectRowAdoptable` `e2e:TestAdoptNoProjectPiCodexKeepReason` `e2e:TestAdoptNoProjectResumesUnsandboxed` `e2e:TestAdoptNoProjectDirectoryUnusable` `e2e:TestAdoptNoProjectFolderNowInProject` `e2e:TestAdoptNoProjectAuditOff` `e2e:TestAdoptNoProjectInterrupt` `e2e:TestAdoptNoProjectListMark` `e2e:TestAdoptNoProjectAuditRows` `e2e:TestAdoptNoProjectDropInAndHandBack` `e2e:TestAdoptNoProjectChiefOfStaffSend` `e2e:TestAdoptNoProjectResumeDormant` `e2e:TestAdoptNoProjectRelayRestart` `e2e:TestAdoptNoProjectScopeRefused` `e2e:TestAdoptNoProjectTokenDescriptor` `journey:session-adopt-unsandboxed` | ### G2 · Give an agent access to one project and nothing else — must-have Intent: grant a project its folder, mail account and chosen tools, and nothing wider. @@ -537,9 +538,10 @@ a promise there updates this table in the same PR. | `TM3b.3` | 3b A read-only-projects session | A | A project change ends the sessions that hold one | `G1.27` | | `TM3c.1` | 3c Outside tmux viewer | A | An outside viewer cannot send input, resize, kill or take control; host opt-in is fresh and defaults off | `G1.34` | | `TM3d.1` | 3d Outside transcript reader | A, D | It reads only the inventory's derived transcript, at most 256 KiB per request, writes nothing, and the scope cannot reach it | `G1.36` | -| `TM3f.1` | 3f A caller adopting an outside session | A | Only an eligible session in a registered project is adopted: a console session or a Pi session, or an idle Codex thread of the user's own daemon; a start-time mismatch (Claude, Pi), a changed daemon identity (Codex) or an ambiguous match never is | `G1.50` `G1.52` `G1.53` | -| `TM3f.2` | 3f A caller adopting an outside session | C | An adopt or interrupt that cannot be recorded sends no signal | `G1.50` `G1.52` `G1.53` | +| `TM3f.1` | 3f A caller adopting an outside session | A | Only an eligible session is adopted: a console Claude session in a registered project or in none, a Pi session in a registered project, or an idle Codex thread of the user's own daemon; a start-time mismatch (Claude, Pi), a changed daemon identity (Codex) or an ambiguous match never is | `G1.50` `G1.52` `G1.53` `G1.54` | +| `TM3f.2` | 3f A caller adopting an outside session | C | An adopt or interrupt that cannot be recorded sends no signal | `G1.50` `G1.52` `G1.53` `G1.54` | | `TM3f.3` | 3f A caller adopting an outside session | A | An attached codex thread gets no setting change from relay and no process ends; relay claims no sandbox over it and approves nothing on it and answers no request for a turn it did not start | `G1.53` | +| `TM3f.4` | 3f A caller adopting an outside session | A | A Claude adopted in no project runs only in its own existing folder, never in a registered project's, and is marked `unsandboxed` in the session and terminal lists | `G1.54` | | `TM4.1` | 4 A peer on the network or in the browser | A, B | It gets no tool call or config change without a credential | `G9.04` | | `TM4.2` | 4 A peer on the network or in the browser | B | It gets no enrolment without the operator's approval | `G10.06` | | `TM4.3` | 4 A peer on the network or in the browser | B | It can trigger no prompt on the operator's screen | `G9.04` `G7.03` `G10.03` | diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index ab150a26..6205eda8 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -23,7 +23,8 @@ outside it is not a relay bug, however real. loopback in place of a credential, obtain or keep a credential it wasn't issued, or act without the audit trail recording it. When `claude_token_file` is set, a sandboxed Claude launch is issued the Claude - token on a descriptor, no other launch is, and no sandboxed process can open + token on a descriptor, and so is a Claude session adopted in no project (3f), + which is not sandboxed; no other launch is, and no sandboxed process can open the file by path. An attached codex thread (3f) is not a sandboxed session: relay did not start it and confines nothing in it. Examples: #53 (a planted symlink widens a later session's read grants), #104 (renaming a socket-deny directory to connect). @@ -147,14 +148,20 @@ outside it is not a relay bug, however real. `POST /api/sessions/adopt`. Only an eligible entry is adopted: an interactive, command-line Claude with a UUID `sessionId` whose registry `procStart` equals the process's kernel start time, in a registered local - project. An ambiguous match (two live entries, one `sessionId`), a session + project or in none. An ambiguous match (two live entries, one `sessionId`), a session relay already manages and a session on an SSH host are never adopted. Relay writes a durable intent row before any signal and signals nothing with auditing off; before each signal it re-reads the pid's start time and sends nothing on a mismatch. The registry and the transcript choose no path and grant nothing: the transcript path is derived and symlinks are refused. The resumed session gets the project's sandbox, allowed models and - permission policy and the ordinary launch identity and token path. The + permission policy and the ordinary launch identity and token path. A Claude + in no project resumes in its own folder with no sandbox, no policy and no + launch identity: the owner chose that, and it runs with the account's full + reach. A project that holds the folder at adopt time refuses the adopt, so + relay never picks unsandboxed over a project, and Pi and Codex in no project + are not adopted. `bypassPermissions` on an unsandboxed session lets every tool + run and asks nothing, which is the owner's accepted risk. The recovery message after an interrupt quotes at most 300 characters of the session's own last prompt and 500 of the open tool call's input, under the `relay-recovery` origin. The chief-of-staff scope cannot reach the door. diff --git a/docs/audit-log.md b/docs/audit-log.md index 9fd722d2..0bacd192 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -944,6 +944,18 @@ With auditing off, or when the intent row cannot be written, relay sends the daemon no `thread/resume`. Each audit row of an attach, the `session_launch` row included, records `sandbox:false`. +A Claude adopted in no project (see +[session-adoption.md](session-adoption.md#a-claude-session-in-no-project)) +writes the same rows with no `project_id` and two fields that say no project +policy or sandbox applied: the `session_adopt` intent and completion, the +`session_interrupt` row, the `session_swap` pairs and the `session_message` +rows of the session carry `"no_project": true, "relay_sandbox": false`, and its +`session_launch` and `session_resume` rows carry `"sandbox": false, +"no_project": true` with an empty actor `project_id`. A refusal before the +intent (`409 changed`, `409 directory_unusable`) writes no adopt row; +`503 audit_unavailable` signals nothing. A relay restart writes the +`session_rebind` pair with the reason `unsandboxed`. + A resume of a dormant attached codex session writes the same intent and completion with `method:"reattach"` and `session_id` in the args, before `thread/resume` goes to the daemon. A refusal before the intent (`409 busy`, @@ -971,7 +983,8 @@ Relay writes a durable `intent` row before it checks the claim and a durable `root_pid` (from the ledger); `outcome: pending`. - `session_rebind` completion: the same args. `outcome` is `ok`; `denied` with `error` one of `not_live`, `project_gone`, `no_root_record`, `runner_mismatch`, - `root_gone`, `root_not_in_tree`, `already_bound`; or `error` with `error` + `root_gone`, `root_not_in_tree`, `already_bound`, `unsandboxed` (the session + has no launch identity to bind); or `error` with `error` `bind_failed`. - A caller that is not the bound relay-sessions identity writes one row with no phase: actor `unknown`, `auth: none`, args `session_id`, `outcome: diff --git a/docs/cli.md b/docs/cli.md index 3563a8f6..3e8e8dda 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2666,6 +2666,7 @@ when set. The text form is `started session SESSION-ID`. | `sessions[].folder` | string, optional | A grouping label. | | `sessions[].headless` | boolean, optional | `true` for a session with no terminal attached. | | `sessions[].origin` | string, optional | Who started it, when not a person at a screen. | +| `sessions[].unsandboxed` | boolean, optional | `true` only for a session adopted in no project, which runs with no sandbox; its `projectId` is `""`. Absent on every other session. | | `sessions[].host` | object, optional | `{"id","name"}` of the SSH host of a host project. | | `sessions[].attention` | object, optional | Why the session needs someone. `exit` (`code`, `stderr[]`) is present on an `errored` session whose provider exited non-zero. | @@ -2777,8 +2778,10 @@ session is deleted, not left dormant: it cannot be resumed. A resume applies to a dormant session. Every session is dormant after a relay restart, except a detached host session whose agent still runs (below); an -unknown id exits `1` (`404`). Text form: `resumed session ID` or -`session ID is already live`. +unknown id exits `1` (`404`). A session adopted in no project resumes +unsandboxed in its own folder. When the folder is gone it exits `1` and prints +the refusal message ("the session's folder is not a usable directory"); the code +`directory_unusable` is the `reason` of the `session.resume` event. Text form: `resumed session ID` or `session ID is already live`. A detached host session ([ssh-hosts.md](ssh-hosts.md)) whose agent still runs on the host is attached and no second agent starts. A host that cannot @@ -2794,7 +2797,10 @@ finds the agent running and attaches it. permission mode through the session host. A local claude session relay launched answers `resume_required` for any mode, valid or not, which is exit `1`; the session stays live. Only a session on an SSH host changes mode in -place, and there an unknown mode is refused. `--json` prints: +place, and there an unknown mode is refused. An unsandboxed session (adopted in +no project) answers `resume_required` when `claude_token_file` is set, because +its per-launch token cannot be reused by a restart in place; with no token it +restarts in place. `--json` prints: | Field | JSON type | Meaning | |---|---|---| @@ -2867,6 +2873,7 @@ The text form is `started terminal TERMINAL-ID`. | `terminals[].exitCode` | number, optional | The exit code of an ended terminal, when it is not 0. Absent while the terminal runs and absent after it exits 0, so a `state` of `stopped` with no `exitCode` means exit 0. A signalled exit is 128 plus the signal. | | `terminals[].host` | object, optional | `{"id","name"}` of the SSH host. | | `terminals[].origin` | string, optional | Who started it, when not a person at a screen. | +| `terminals[].unsandboxed` | boolean, optional | `true` only on the drop-in terminal of a session adopted in no project. Absent otherwise. | Text form: the line `N terminals`, then one `IDNAME` line per terminal. @@ -3099,6 +3106,11 @@ On a host with tmux 3.0 or later, the terminal shows the session's tmux pane session keeps one name. `tmux attach -t relay-agent-` on the host shows the same pane. Closing the terminal swaps the pane back to the runner. +A session adopted in no project drops in unsandboxed, in its own folder, and +the hand back resumes it the same way; both refuse when that folder is gone +(`directory_unusable`; the CLI prints the message, and the code is the `reason` +of the `session.resume` or `session.drop_in` event). + No JSON form: the terminal belongs to the agent. Exit codes: the agent's own exit status after a hand-over; `1` for any refusal below (`error: relay drop-in needs an interactive terminal on stdin and stdout` when stdin or stdout is not a diff --git a/docs/events.md b/docs/events.md index 01635853..2673a2de 100644 --- a/docs/events.md +++ b/docs/events.md @@ -411,7 +411,7 @@ prompt and an unavailable audit log use the shared `presence_*` and | `session.list` | In `handleProxyList`, `listViaHost` | `GET /api/sessions`, `relay session list` | `count` | | `terminal.list` | In `handleProxyList`, `listViaHost` | `GET /api/terminals`, `relay terminal list` | `count` | | `session.launch` | In `launchWithEvent` | `POST /api/sessions`, `POST /api/terminals`, `relay session start`, `relay terminal start` | `session_id`, `project_id`, `kind`, and when the start named an agent `entry` and `command` (the stored absolute path; absent when the entry has none). On a refusal `reason` is the launch refusal code, and `model` (the requested model, capped) is set for `model_not_offered` and `models_unavailable`, only on a start with no entry | -| `session.resume` | In `resumeSession` | `POST /api/sessions/{id}/resume`, `relay session resume` | `session_id`, `project_id` | +| `session.resume` | In `resumeSession` | `POST /api/sessions/{id}/resume`, `relay session resume` | `session_id`, `project_id`. A refusal is `denied` or `error` with `reason` the refusal code; `directory_unusable` is the reason when a session adopted in no project has lost its folder | | `session.drop_in` | In `dropIn` | `POST /api/sessions/{id}/drop-in`, bridge `drop_in_attach` (`relay drop-in`) | `session_id`, `host`, `terminal_id` | | `session.adopt` | In `adoptOutside`, one per request | `POST /api/sessions/adopt` | `outside_id`, `claude_session_id` for Claude or `agent` (`pi` or `codex`) and `conversation_id` for Pi or Codex, `project_id`, `session_id` (set on success), `interrupted`. A refusal's `reason` is its code (`busy`, `changed`, `start_mismatch`, `audit_unavailable`, …); a body that fails to parse ends `denied` with reason `invalid_body` or `body_too_large` | | `session.hand_back` | When `handBackResume` returns after a drop-in terminal ends (background, relay) | none (background) | `session_id`, `terminal_id`, `host` (`console` for this Mac). The outcome is `ok`, or the resume refusal's code as `reason`; `audit_unavailable` when the swap could not be recorded and the session stays dormant | @@ -421,7 +421,7 @@ prompt and an unavailable audit log use the shared `presence_*` and | `session.persistent.kill` | At the end of `PersistentSessionOps.Kill` | `DELETE /api/projects/{id}/persistent-sessions/{name}`, `relay terminal persistent-kill` | `project_id` | | `session.interrupt` | At the end of authenticated `SessionInterrupt` | private bridge `SessionInterrupt` | `session_id`; OK certifies durable Pi or Codex interrupt intent, not delivery | | `session.exited` | At the end of `appRouter.SessionExited` | bridge `session_exited` | `session_id` | -| `session.rebind` | At the end of `appRouter.SessionRebind`, after the completion row is on disk | bridge `SessionRebind` | `session_id`, `project_id`. `denied` with `reason` `unauthorized` or one of `not_live`, `project_gone`, `no_root_record`, `runner_mismatch`, `root_gone`, `root_not_in_tree`, `already_bound`; `error` with `reason` `audit_unavailable` or `bind_failed` | +| `session.rebind` | At the end of `appRouter.SessionRebind`, after the completion row is on disk | bridge `SessionRebind` | `session_id`, `project_id`. `denied` with `reason` `unauthorized` or one of `not_live`, `project_gone`, `no_root_record`, `runner_mismatch`, `root_gone`, `root_not_in_tree`, `already_bound`, `unsandboxed` (a session adopted in no project has no launch identity to bind); `error` with `reason` `audit_unavailable` or `bind_failed` | | `session.delete` | In relay-sessions `HandleDeleteSession` / `HandleSessionMessageSync` | forwarded `DELETE /api/sessions/{id}`, `POST /api/sessions/{id}/message`, `relay session stop`, `relay session message` | `session_id` | | `session.send` | In relay-sessions `handleSessionSend` | `relay session send` | `session_id` (a link that stays down ends the event `error` with reason `send_failed`) | | `session.wait` | In relay-sessions `handleSessionWait` | `relay session wait` | `session_id` | diff --git a/docs/fakerelay.md b/docs/fakerelay.md index c9c9341b..b35b05d1 100644 --- a/docs/fakerelay.md +++ b/docs/fakerelay.md @@ -1224,7 +1224,9 @@ match answers, and nothing changes before step 11: | 4 | `adopt.refuse` `unavailable` | 502 | `unavailable` | `the session host is not available` | | 4 | `adopt.refuse` `adopt_in_progress` | 409 | `adopt_in_progress` | `an adopt of this conversation is already running` | | 4 | `adopt.refuse` `changed` | 409 | `changed` | `the process or its registry entry changed` | -| 5 | `project_id` names no world project, or a host or remote project | 409 | `no_project` | `the session is not in a registered local project` | +| 5 | `project_id` is set and names no world project, or a host or remote project | 409 | `no_project` | `the session is not in a registered local project` | +| 5 | no `project_id` and a local world project's path holds the row's `directory` (cleaned path text; fakerelay reads no disk) | 409 | `changed` | `a project now holds the session's folder; adopt it again` | +| 5 | no `project_id` and `adopt.refuse` `directory_unusable` | 409 | `directory_unusable` | `the session's folder is not a usable directory` | | 6 | an earlier adopt made a session that holds the conversation id | 409 | `managed` | `relay already holds this conversation` | | 7 | `adopt.busy` is set and `interrupt` is not true | 409 | `busy` | `the session is not idle (); ask to interrupt to take it over anyway` | | 8 | no `model` in the body and no `adopt.model_family` | 409 | `model_unknown` | `the model of the session is unknown; send model` | @@ -1232,7 +1234,9 @@ match answers, and nothing changes before step 11: | 10 | `adopt.refuse` `stop_failed`; the row stays | 502 | `stop_failed` | `the process did not exit after SIGKILL; nothing was launched` | | 10 | `adopt.refuse` `launch_failed`; the row becomes `ended` | 502 | `launch_failed` | `the process ended but relay could not start the session; the conversation is intact and can be resumed` | -Step 9 uses `authorize(project, row directory, model, "claude")`, with the +A row with no `project_id` skips step 9: no project, directory or model check +applies, and the launch runs unsandboxed (step 11). Step 9 otherwise uses +`authorize(project, row directory, model, "claude")`, with the codes renamed as the Chief of Staff start renames them. A `launch_failed` row keeps `state:"ended"`, `ended_at` (the instance clock's now, RFC 3339 UTC, seconds) and its `claude_session_id`, loses `adopt` and reads @@ -1253,7 +1257,14 @@ Success (step 11), in this order: sent as a turn with origin `relay-recovery`: the turn is recorded before the answer and runs after it. `recoverySent` is true when the turn was accepted. 4. The answer is 201 `{"sessionId","claudeSessionId","interrupted","recoverySent"}`, - exactly these four keys. + exactly these four keys, plus `"unsandboxed": true` for a row with no + `project_id` (absent otherwise). That session has `projectId` `""` and + `unsandboxed: true` on its `GET /api/sessions` row, and the terminal of its + drop-in (the `claude-code` console template, no project allow-list) has + `unsandboxed: true` on its `GET /api/terminals` row. No other session or + terminal carries the key. A resume and a hand back keep the session as it is. + fakerelay checks no folder on a resume or a drop-in, so those never answer + `directory_unusable`. The recovery text is relay's, filled from the row's `transcript`. The prompt is the newest `user` entry's text. The open call is the newest `tool` entry @@ -2374,12 +2385,12 @@ refused as an invalid outside fixture (`PUT` 400; a world file fails the load at | Key | Shape | Rule | |---|---|---| -| `adoptable` | bool, default `false` | `true` needs: `state` not `ended`; `host_id` null; `project_id` set; `id` matching `^outside:[0-9a-f]{32}$`; `claude_session_id` a UUID; no `adopt_reason` | -| `adopt_reason` | `ended`, `host_unsupported`, `no_project`, `not_registered`, `invalid_id`, `start_mismatch`, `not_interactive`, `not_command_line` or `ambiguous` | only when `adoptable` is false. Where the row is ended, has a `host_id` or has no `project_id` (in that order) it must equal that code; otherwise one of the last six. Absent: that first rule, else `not_registered` | +| `adoptable` | bool, default `false` | `true` needs: `state` not `ended`; `host_id` null; `id` matching `^outside:[0-9a-f]{32}$`; `claude_session_id` a UUID; no `adopt_reason` | +| `adopt_reason` | `ended`, `host_unsupported`, `not_registered`, `invalid_id`, `start_mismatch`, `not_interactive`, `not_command_line` or `ambiguous` | only when `adoptable` is false. Where the row is ended or has a `host_id` (in that order) it must equal that code; otherwise one of the last five. `no_project` is refused: it is the reason of a Pi or Codex row in no project, and a world holds only Claude rows, which are adoptable in no project. Absent: that first rule, else `not_registered` | | `adopt` | `{busy?, model_family?, refuse?}`, unknown keys refused | only with `adoptable: true` | | `adopt.busy` | `registry_busy`, `status_stale`, `open_tool_call` or `child_process` | the idle check's veto. Absent: idle | | `adopt.model_family` | `haiku`, `sonnet` or `opus` | the family relay derives from the transcript. Absent: none derivable | -| `adopt.refuse` | `audit_unavailable`, `unavailable`, `adopt_in_progress`, `changed`, `stop_failed` or `launch_failed` | the refusal relay would reach from process or host state. Absent: the adopt succeeds | +| `adopt.refuse` | `audit_unavailable`, `unavailable`, `adopt_in_progress`, `changed`, `stop_failed`, `launch_failed` or `directory_unusable` (only on a row with no `project_id`) | the refusal relay would reach from process or host state. Absent: the adopt succeeds | A fixture row with none of these keys reads `adoptable:false` with the first rule that applies as its reason, so existing fixtures stay valid. A test diff --git a/docs/outside-sessions.md b/docs/outside-sessions.md index 30006d3a..6579de56 100644 --- a/docs/outside-sessions.md +++ b/docs/outside-sessions.md @@ -235,12 +235,15 @@ serve ended ids. `chief-of-staff` scope) is the one door that acts on an outside session. It ends the process of an interactive Claude the owner started in a terminal on this Mac and resumes the same conversation as an ordinary headless relay -session, in the same folder, in the registered local project that holds it. +session, in the same folder, in the registered local project that holds it. A +Claude in no registered project is adopted too, unsandboxed +([A Claude session in no project](session-adoption.md#a-claude-session-in-no-project)). Sessions on SSH hosts are not adopted. - **Eligibility** is static and shown on the row: `adoptable`, else `adoptReason`, the first that applies of `ended`, `host_unsupported`, - `no_project`, `not_registered` (no registry entry), `invalid_id` (the + `no_project` (a Pi or Codex row outside every project; a Claude row there is + adoptable), `not_registered` (no registry entry), `invalid_id` (the `sessionId` is not a UUID), `start_mismatch`, `not_interactive`, `not_command_line`, `ambiguous` (two live entries hold one `sessionId`). Relay reads `~/.claude/sessions/.json` in Go: at most 512 regular files diff --git a/docs/routes.md b/docs/routes.md index 3b2a9a29..b7ec489c 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -435,6 +435,8 @@ The trailing-slash form of `POST /api/terminals`: same contract. `resumed` is `false` when the session was already live. - **Errors:** `404` unknown session; `409` a resume of the same session is in progress; `403` or `400` the launch is refused; `502` the session host failed. + A dormant session adopted in no project resumes unsandboxed in its own + folder and answers `409 directory_unusable` when the folder is gone. A dormant session attached to a codex thread resumes only by attaching again: the route runs the adopt checks and answers `409 busy|changed|ended|ambiguous|managed|no_project|model_unknown`, `502 unavailable` (before the audit intent), `502 launch_failed` (after it) or @@ -463,7 +465,10 @@ A Pi or Codex session in an SSH host project is refused with `409 not_local`. `session_not_found`, `409` `not_claude`, `not_local` or a handoff conflict, `502` `unavailable` (for an attached Codex thread, the codex daemon is not reachable) or `launch_failed`, `503` `audit_unavailable` (auditing is off or cannot record; - nothing was stopped), or the launch refusal's own status. + nothing was stopped), or the launch refusal's own status. A session adopted + in no project drops in unsandboxed in its own folder (`claude-code` + template, no sandbox) and answers `409 directory_unusable` when the folder is + gone, before it stops. - **Event:** `session.drop_in`; `session.hand_back` when the terminal ends (background). **Audit rows:** `session_launch`, and `session_swap` (an intent row before the session stops, then a completion row; a second pair @@ -483,13 +488,18 @@ daemon is attached instead: no process ends. See [Adopting a session](outside-se `outsideId` (`outside:<32 hex>`) and `claudeSessionId` (a UUID), plus optional `interrupt` (default false) and `model`. - **Response `201`:** `sessionId`, `claudeSessionId`, `interrupted`, - `recoverySent`. + `recoverySent`, and `unsandboxed: true` for a Claude row in no project + (the session runs with no sandbox; see + [session-adoption.md](session-adoption.md#a-claude-session-in-no-project)). - **Errors:** `{"error": "", "message": ""}`. `400` `invalid_body`; `413` `body_too_large`; `404` `outside_session_not_found`; `409` a reason code (`ended`, `host_unsupported`, `no_project`, `not_registered`, `invalid_id`, `start_mismatch`, `not_interactive`, `not_command_line`, - `ambiguous`), `managed`, `busy`, `model_unknown`, `adopt_in_progress` or - `changed` or `transcript_not_granted`; `403`/`400` the launch refusal's own code (nothing is signalled); + `ambiguous`; `no_project` is a Pi or Codex row outside every project), + `managed`, `busy`, `model_unknown`, `adopt_in_progress`, `changed` (also: a + project now holds the folder of a Claude row in no project), + `directory_unusable` (that folder is not an existing directory) or + `transcript_not_granted`; `403`/`400` the launch refusal's own code (nothing is signalled); `502` `unavailable` (relay-sessions is down, the registry or the idle check could not be read, or the process could not be signalled), `stop_failed` (the process outlived SIGKILL's wait) or `launch_failed`; `503` @@ -1513,6 +1523,7 @@ Also relay's own door `GET /api/sessions` (section 2.1), which forwards here. | `host` | object | SSH host chip; absent for this Mac | | `headless` | boolean | Present for headless agents | | `origin` | string | Set when someone other than the person started it (`chief-of-staff`) | + | `unsandboxed` | boolean | `true` only on a session adopted in no project (its `projectId` is `""`); absent otherwise | | `attention` | object | `{"state", "since"}` (plus `"exit": {"code", "stderr"}` when a provider exit left the session `errored`) for a live tracked session; `state` is `starting`, `running`, `idle`, `asking`, `errored`, `stalled` | - **Event:** `session.list` (relay's door writes it). @@ -1543,7 +1554,9 @@ Sends a message and waits for the whole reply. Also relay's own door `GET /api/terminals`. - **Response `200`:** `{"terminals": [{"id", "templateId", "name", "directory", - "state", "exitCode", "host", "origin"}]}`. `state` is the terminal's process state. + "state", "exitCode", "host", "origin", "unsandboxed"}]}`. `state` is the terminal's process state. + `unsandboxed` is `true` only on the drop-in terminal of a session adopted in no + project; absent otherwise. - **Event:** `terminal.list`. ### proxy:GET /api/terminals/{id}/log diff --git a/docs/session-adoption.md b/docs/session-adoption.md index c8fd8100..210334f7 100644 --- a/docs/session-adoption.md +++ b/docs/session-adoption.md @@ -368,6 +368,64 @@ MCP and subagent threads, ephemeral threads, and every thread source other than UI opened inside a relay terminal session that joins the daemon lists like any daemon thread. After attach, Eve shows turns from the attach on. +### A Claude session in no project + +A live console Claude row whose folder no registered local project holds is +adoptable. The adopt resumes the same conversation headless in that folder, and +the session runs **unsandboxed**. It is the owner's choice: the owner sees the +Adopt button and takes no extra confirmation. Pi and Codex rows in no project +stay `adoptable:false` with `adoptReason:"no_project"`: Pi needs a project for +its model key and its transcript grant, and a Codex attach needs one for launch +authorization on attach and reattach. + +What an unsandboxed session has and lacks: + +- No sandbox profile, no project permission policy, no allowed-models check, no + template allow-list, no model key and no launch identity. It runs as the + account that runs relay, with that account's reach. +- A tool call that needs approval still asks the owner through relay's + permission hook. The hook finds the session by its runner's process tree, not + by a launch identity. +- Relay tools are absent: the session has no launch identity and no + `useRelayTools` setting, so a relay tool call fails closed. +- It gets the `claude_token_file` token, when set, on fd 5 through the shim + (the shim runs with no profile), as a sandboxed Claude does. The process + could read the file by path, so it gains no reach. `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR=5` + names the descriptor; the token is never in the environment. +- `bypassPermissions` on it lets every tool run and asks nothing. That is the + owner's accepted risk. +- `relay session mode` answers `resume_required` for it when `claude_token_file` + is set, as for a sandboxed session: the token is per launch and cannot be + reused by a restart in place. With no token set it restarts in place. + +Adopt order for such a row: audit ready (`503 audit_unavailable`), host ready, +the row, a fresh candidate, then the folder is matched against the registered +local projects again (a match is `409 changed`: adopt again from the refreshed +row, sandboxed; relay never picks unsandboxed over a project), then the folder +must be an existing directory after symlinks resolve (`409 directory_unusable`, +no further trust rule, so the home folder or `/` is allowed), then the managed +list, the idle check, the model, launch authorization, the durable +`session_adopt` intent, the interrupt, the take over, the launch and the +recovery message. Nothing is signalled before the intent is durable. The `201` +body adds `"unsandboxed": true`. + +The ledger record carries `unsandboxed: true` and an empty `project_id`, and the +session host keeps the mark on the session across a relay-sessions restart. +`GET /api/sessions` lists the session with `unsandboxed: true` and `projectId:""`, +and `GET /api/terminals` lists its drop-in terminal with `unsandboxed: true`; no +other session or terminal carries the field. A resume of the dormant session, a +drop-in and the hand-back resume run unsandboxed in the same folder, and each +refuses with `409 directory_unusable` when the folder is gone, before anything +stops. A project registered for the folder later does not change the record: it +stays unsandboxed and keeps its chip; delete the session and adopt again to +sandbox it. After a relay restart the rebind of such a session is refused with +the reason `unsandboxed` (it has no launch identity to bind). + +Audit rows of the session record that no project policy or sandbox applied: +`session_adopt`, `session_interrupt`, `session_swap` and `session_message` carry +`no_project:true` and `relay_sandbox:false` with no `project_id`; +`session_launch` and `session_resume` carry `sandbox:false` and `no_project:true`. + ### The idle check Several signals combine. A signal can veto idle. Only the agent's own state can @@ -414,7 +472,7 @@ with the recovery message. - Adopt, interrupt and swap are owner actions in this design. The Chief of Staff gains no new authority. Whether it may adopt on its own is an open question in the epic. -- Only sessions in a registered project are adopted. A project's policy applies from the resume on to a session relay takes over, not to an attached codex thread. +- A Claude or Pi session is adopted in a registered project, a Claude session also in no project (see [A Claude session in no project](#a-claude-session-in-no-project)). A project's policy applies from the resume on to a session relay takes over, not to an attached codex thread. - Every adopt, interrupt, swap and recovery message writes an audit row before it acts, as every other launch and send does. - Transcript and registry text is untrusted display data and grants no diff --git a/docs/session-host.md b/docs/session-host.md index 8b6cc0ab..0eacc232 100644 --- a/docs/session-host.md +++ b/docs/session-host.md @@ -287,6 +287,8 @@ Error codes C5 names explicitly, each mapped from a manager error: | 502 | `identity_refused` | the shim's Hello did not bind | | 500 | `spawn_failed` | the target process could not be started (a target that starts and exits at once is not this: the launch is `201` and the exit is reported like any other) | +`LaunchRequest.unsandboxed` (`"unsandboxed": true`) marks a launch that runs with no project and no sandbox. The host takes it only on a local `claude` launch, or a `pty` drop-in terminal of the `claude-code` template (`drop_in_for` set), with no project, no sandbox, no identity, no host, no launcher, no entry and no model key; any other launch that carries it is `400 invalid_spec`. A local `claude` launch with no project and no mark is `400 invalid_spec` too, so a launch that lost its project by mistake fails closed. A marked launch may carry `claude_token`, which the shim hands to the target on fd 5 with no sandbox profile. The mark is saved with the session (`unsandboxed` in the session file), so it survives a relay-sessions restart; a resume sets it from the launch. `GET /api/sessions` and `GET /api/terminals` rows carry `"unsandboxed": true` only for a marked session or drop-in terminal. A restart in place of such a launch's Claude (`SetPermissionMode`) is refused with `resume_required` when the launch carries a token, because the token pipe is spent; with no token it restarts in place. + ### Launcher: an agent entry's program A start by agent entry may carry a launcher: `LaunchRequest.launcher`, JSON diff --git a/e2e/features/g1_outside_adopt_test.go b/e2e/features/g1_outside_adopt_test.go index d91a531a..6c4e0893 100644 --- a/e2e/features/g1_outside_adopt_test.go +++ b/e2e/features/g1_outside_adopt_test.go @@ -575,6 +575,8 @@ func TestAdoptReasonsOnRows(t *testing.T) { h.transcript(t, osPrompt("acme hello"), osAnswer("acme hi")) s, frame := a.probeFrame() + // A Claude row in no project is adoptable (an empty reason); the others + // carry their reason. cases := []struct { name, reason string row *osRow @@ -582,7 +584,7 @@ func TestAdoptReasonsOnRows(t *testing.T) { }{ {"kind bg", "not_interactive", s.liveAt(bg.dir), ""}, {"entrypoint sdk-cli", "not_command_line", s.liveAt(sdk.dir), ""}, - {"no project", "no_project", s.liveAt(loose.dir), ""}, + {"no project", "", s.liveAt(loose.dir), ""}, {"ended", "ended", s.endedAt(edir), ""}, {"host", "host_unsupported", s.liveAt(h.dir), ""}, } @@ -591,6 +593,18 @@ func TestAdoptReasonsOnRows(t *testing.T) { t.Fatalf("%s: no row in %+v", c.name, s) } wire := osRowByID(frame, c.row.ID) + if c.reason == "" { + if wire["adoptable"] != true { + t.Fatalf("%s: row = %v, want adoptable true", c.name, wire) + } + if _, has := wire["adoptReason"]; has { + t.Fatalf("%s: an adoptable row carries adoptReason: %v", c.name, wire) + } + if got := a.detailSession(c.row.ID); got["adoptable"] != true { + t.Fatalf("%s: detail = %v, want adoptable true", c.name, got) + } + continue + } if wire["adoptable"] != false || wire["adoptReason"] != c.reason { t.Fatalf("%s: row = %v, want adoptable false and adoptReason %s", c.name, wire, c.reason) } diff --git a/e2e/features/g1_outside_adopt_unsandboxed_test.go b/e2e/features/g1_outside_adopt_unsandboxed_test.go new file mode 100644 index 00000000..63bcbfd4 --- /dev/null +++ b/e2e/features/g1_outside_adopt_unsandboxed_test.go @@ -0,0 +1,730 @@ +package features + +import ( + "encoding/json" + "net/http" + "os" + "strings" + "testing" + + "relaye2e/harness" +) + +// These tests adopt a fake outside Claude that runs in a folder no registered +// project holds (docs/session-adoption.md, "A Claude session in no project"). +// The adopted session runs unsandboxed: no sandbox profile, so a launch gets no +// CLAUDE_CONFIG_DIR, which only a sandboxed Claude is given. The helpers of +// g1_outside_adopt_test.go start the fake and the instance. + +// nsAdopted is one adopted session in no project. +type nsAdopted struct { + a *adRig + f *adFake + sid string + body map[string]any +} + +// nsLoose starts a fake outside Claude in a new folder under no project. +func nsLoose(a *adRig, ignore ...string) *adFake { + a.t.Helper() + return adSpawn(a.t, osDir(a.t, a.t.TempDir(), "loose"), adUUID(), ignore...) +} + +// nsRow returns the wire row of the fake after a probe. +func nsRow(t *testing.T, a *adRig, f *adFake) map[string]any { + t.Helper() + s, frame := a.probeFrame() + live := s.liveAt(f.dir) + if live == nil { + t.Fatalf("no live row for %s; snapshot %+v", f.dir, s) + } + return osRowByID(frame, live.ID) +} + +// nsAdoptIdle adopts an idle fake in no project and waits for it to end. The +// HTTP answer is the signal for the adopt; the fake's exit is the signal for +// the process. +func nsAdoptIdle(t *testing.T, a *adRig) *nsAdopted { + t.Helper() + f := nsLoose(a) + f.idle(a.i.Home, adModelSn) + row := nsRow(t, a, f) + resp := a.adopt(map[string]any{"outsideId": row["id"], "interrupt": false}) + res := adCreated(t, resp) + var body map[string]any + resp.JSON(t, &body) + f.signalAt(f.expect("signal TERM"), "TERM") + f.gone() + g1cKillRuns(t, a.i, res.SessionID) + return &nsAdopted{a: a, f: f, sid: res.SessionID, body: body} +} + +func nsSessionList(t *testing.T, i *harness.Instance) []map[string]any { + t.Helper() + var out struct { + Sessions []map[string]any `json:"sessions"` + } + i.MustCLI("session", "list", "--json").JSON(t, &out) + return out.Sessions +} + +func nsSessionRow(t *testing.T, i *harness.Instance, sid string) map[string]any { + t.Helper() + for _, s := range nsSessionList(t, i) { + if s["id"] == sid { + return s + } + } + t.Fatalf("session list holds no %s", sid) + return nil +} + +func nsTerminalRow(t *testing.T, i *harness.Instance, tid string) map[string]any { + t.Helper() + var out struct { + Terminals []map[string]any `json:"terminals"` + } + i.MustCLI("terminal", "list", "--json").JSON(t, &out) + for _, tr := range out.Terminals { + if tr["id"] == tid { + return tr + } + } + t.Fatalf("terminal list holds no %s", tid) + return nil +} + +// nsCallerDo posts as the owner: the socket and the execute credential. +func nsCallerDo(a *adRig, method, path string, body any) harness.Response { + a.t.Helper() + return a.i.SocketHTTP(a.i.Credential("cos")).Do(method, path, body, harness.ReqOpts{Trace: harness.NewTrace(a.t)}) +} + +// nsSend sends one turn so the headless agent has started. +func nsSend(t *testing.T, ad *nsAdopted, text string) { + t.Helper() + g1sSendText(t, ad.a.i, ad.sid, text, "echo: "+text) +} + +// nsStarts are the start lines of the instance's fake claude, in order. +func nsStarts(i *harness.Instance) []harness.AgentCall { + var out []harness.AgentCall + for _, c := range i.FakeAgentCalls("claude") { + if c.Event == "start" && (len(c.Argv) == 0 || (c.Argv[0] != "debug" && c.Argv[0] != "--list-models")) { + out = append(out, c) + } + } + return out +} + +func nsResumeStarts(i *harness.Instance, conv string) int { + n := 0 + for _, c := range nsStarts(i) { + if adHasFlag(c.Argv, "--resume", conv) { + n++ + } + } + return n +} + +// nsNoSandbox fails t when a start line carries the config folder a sandboxed +// Claude is given. +func nsNoSandbox(t *testing.T, i *harness.Instance) { + t.Helper() + for _, c := range nsStarts(i) { + if v, has := c.Env["CLAUDE_CONFIG_DIR"]; has { + t.Fatalf("a claude start %v carries CLAUDE_CONFIG_DIR=%q, which only a sandboxed launch gets", c.Argv, v) + } + } +} + +// nsDropIn drops in on sid and returns the terminal id. Waits: none possible +// for the terminal's claude to start: relay writes no event then, and the fake +// claude writes nothing to the pty, so the call log is polled to its bound. +func nsDropIn(t *testing.T, a *adRig, sid, conv string, starts int) string { + t.Helper() + resp := nsCallerDo(a, "POST", "/api/sessions/"+sid+"/drop-in", map[string]any{}) + var out struct { + ClaudeSessionID string `json:"claudeSessionId"` + Terminal struct { + TerminalID string `json:"terminalId"` + } `json:"terminal"` + } + resp.JSON(t, &out) + if resp.Status != http.StatusCreated || out.Terminal.TerminalID == "" || out.ClaudeSessionID != conv { + t.Fatalf("drop-in answered %d with terminal %q and conversation %q, want 201, a terminal and %s: %s", + resp.Status, out.Terminal.TerminalID, out.ClaudeSessionID, conv, resp.Body) + } + g11dUntil(t, "the drop-in terminal to start claude with --resume "+conv, func() bool { + return nsResumeStarts(a.i, conv) >= starts + }) + return out.Terminal.TerminalID +} + +// nsHandBack closes the terminal and waits for the hand back's event. +func nsHandBack(t *testing.T, a *adRig, sid, tid string) { + t.Helper() + if del := nsCallerDo(a, "DELETE", "/api/terminals/"+tid, nil); del.Status != http.StatusNoContent { + t.Fatalf("DELETE /api/terminals/%s answered %d, want 204: %s", tid, del.Status, del.Body) + } + a.i.WaitEvent(harness.EventQuery{Key: "session.hand_back", + Fields: map[string]any{"status": "ok", "session_id": sid}}, g1tEventDeadline) +} + +// AC: a live console Claude row in no project is adoptable with no reason, +// on the snapshot row and on the detail. A registry reason still applies. +func TestAdoptNoProjectRowAdoptable(t *testing.T) { + t.Parallel() + a := adStart(t) + idle := nsLoose(a, "INT", "TERM") + idle.idle(a.i.Home, adModelSn) + busy := nsLoose(a, "INT", "TERM") + busy.busy(a.i.Home, adModelSn) + bg := nsLoose(a, "INT", "TERM") + bg.idle(a.i.Home, adModelSn) + bg.register(a.i.Home, "idle", idle.start, func(e map[string]any) { e["kind"] = "bg" }) + + s, frame := a.probeFrame() + for _, c := range []struct { + name string + f *adFake + }{{"idle", idle}, {"busy", busy}} { + live := s.liveAt(c.f.dir) + if live == nil { + t.Fatalf("%s: no live row for %s; snapshot %+v", c.name, c.f.dir, s) + } + row := osRowByID(frame, live.ID) + if row["projectId"] != nil || row["adoptable"] != true || row[osClaudeIDKey] != c.f.sid { + t.Fatalf("%s: row = %v, want projectId null, adoptable true and %s %s", c.name, row, osClaudeIDKey, c.f.sid) + } + if _, has := row["adoptReason"]; has { + t.Fatalf("%s: an adoptable row carries adoptReason: %v", c.name, row) + } + if d := a.detailSession(live.ID); d["adoptable"] != true { + t.Fatalf("%s: detail = %v, want adoptable true", c.name, d) + } else if _, has := d["adoptReason"]; has { + t.Fatalf("%s: an adoptable detail carries adoptReason: %v", c.name, d) + } + } + live := s.liveAt(bg.dir) + if live == nil { + t.Fatalf("no live row for %s; snapshot %+v", bg.dir, s) + } + if row := osRowByID(frame, live.ID); row["adoptable"] != false || row["adoptReason"] != "not_interactive" { + t.Fatalf("a bg row in no project = %v, want adoptable false and adoptReason not_interactive", row) + } + adNoSignals(t, idle, busy, bg) +} + +// Pi and Codex rows in no project keep adoptable false and no_project, and the +// door refuses them. +func TestAdoptNoProjectPiCodexKeepReason(t *testing.T) { + t.Parallel() + t.Run("pi", func(t *testing.T) { + t.Parallel() + r := paStart(t, nil) + loose := *r + loose.root = osDir(t, t.TempDir(), "pi-loose") + f := loose.fake("loose") + f.idle() + row := r.row(f) + if row["projectId"] != nil || row["adoptable"] != false || row["adoptReason"] != "no_project" { + t.Fatalf("Pi row in no project = %v, want projectId null, adoptable false and adoptReason no_project", row) + } + otRefusal(t, r.adopt(row["id"].(string), true), http.StatusConflict, "no_project") + f.untouched() + if n := len(adSessions(t, r.i)); n != 0 { + t.Fatalf("a refused adopt left %d sessions", n) + } + }) + t.Run("codex", func(t *testing.T) { + t.Parallel() + r := ccStart(t) + d := r.daemon() + th := r.thread("unused") + th.Dir = osDir(t, t.TempDir(), "codex-loose") + d.add(th) + row := r.mustRow(th) + if row["projectId"] != nil || row["adoptable"] != false || row["adoptReason"] != "no_project" { + t.Fatalf("Codex row in no project = %v, want projectId null, adoptable false and adoptReason no_project", row) + } + otRefusal(t, r.attach(row["id"].(string)), http.StatusConflict, "no_project") + r.untouched(d) + }) +} + +// The 201 answer names the conversation and unsandboxed; the session runs in +// the row's folder with no project and no sandbox. +func TestAdoptNoProjectResumesUnsandboxed(t *testing.T) { + t.Parallel() + a := adStart(t) + ad := nsAdoptIdle(t, a) + + if ad.body["unsandboxed"] != true || ad.body["claudeSessionId"] != ad.f.sid || ad.body["interrupted"] != false { + t.Fatalf("201 body = %v, want unsandboxed true, claudeSessionId %s and interrupted false", ad.body, ad.f.sid) + } + row := nsSessionRow(t, a.i, ad.sid) + if row["live"] != true || row["headless"] != true || row["directory"] != ad.f.dir || row["projectId"] != "" || row["unsandboxed"] != true { + t.Fatalf("session = %v, want a live headless session in %s with projectId \"\" and unsandboxed true", row, ad.f.dir) + } + argv := adStartLine(t, a.i, ad.sid, "acme next") + if !adHasFlag(argv, "--resume", ad.f.sid) || !adHasFlag(argv, "--model", "sonnet") { + t.Fatalf("start line argv = %v, want --resume %s and --model sonnet", argv, ad.f.sid) + } + nsNoSandbox(t, a.i) + if _, err := os.Lstat(b181Dir(a.i)); !os.IsNotExist(err) { + t.Fatalf("the sandbox Claude folder after an unsandboxed adopt: Lstat error %v, want not exist", err) + } + a.i.WaitEvent(harness.EventQuery{Key: "session.adopt", Fields: map[string]any{ + "status": "ok", "claude_session_id": ad.f.sid, "session_id": ad.sid, + }}, osBound) +} + +// A folder that is gone refuses 409 directory_unusable and the process lives. +func TestAdoptNoProjectDirectoryUnusable(t *testing.T) { + t.Parallel() + a := adStart(t) + f := nsLoose(a, "INT", "TERM") + f.idle(a.i.Home, adModelSn) + row := nsRow(t, a, f) + if row["adoptable"] != true { + t.Fatalf("row = %v, want adoptable true", row) + } + if err := os.RemoveAll(f.dir); err != nil { + t.Fatalf("removing %s: %v", f.dir, err) + } + + otRefusal(t, a.adopt(map[string]any{"outsideId": row["id"], "interrupt": true}), http.StatusConflict, "directory_unusable") + adNoSignals(t, f) + if n := len(adSessions(t, a.i)); n != 0 { + t.Fatalf("a refused adopt left %d sessions", n) + } +} + +// A project that holds the folder at adopt time refuses 409 changed and the +// process lives; the person adopts again from the refreshed row. +func TestAdoptNoProjectFolderNowInProject(t *testing.T) { + t.Parallel() + a := adStart(t) + f := nsLoose(a, "INT", "TERM") + f.idle(a.i.Home, adModelSn) + row := nsRow(t, a, f) + if row["projectId"] != nil || row["adoptable"] != true { + t.Fatalf("row = %v, want projectId null and adoptable true", row) + } + body, _ := json.Marshal(map[string]any{"name": "Acme late", "path": f.dir, "allowed_templates": []string{"claude-code"}}) + if res := a.i.CLIWith(harness.CLIOpts{Stdin: body}, "project", "create", "--file", "-", "--json"); res.Code != 0 { + t.Fatalf("project create exited %d\nstderr: %s", res.Code, res.Stderr) + } + + otRefusal(t, a.adopt(map[string]any{"outsideId": row["id"], "interrupt": true}), http.StatusConflict, "changed") + adNoSignals(t, f) + if n := len(adSessions(t, a.i)); n != 0 { + t.Fatalf("a refused adopt left %d sessions", n) + } +} + +// With auditing off an adoptable row in no project still refuses 503 and +// nothing is signalled. +func TestAdoptNoProjectAuditOff(t *testing.T) { + t.Parallel() + r := osStart(t, false, func(o *harness.Options) { + if o.Settings == nil { + o.Settings = map[string]json.RawMessage{} + } + o.Settings["audit"] = json.RawMessage(`{"enabled": false}`) + }) + a := &adRig{osRig: r} + f := nsLoose(a, "INT", "TERM") + f.idle(r.i.Home, adModelSn) + row := nsRow(t, a, f) + if row["adoptable"] != true { + t.Fatalf("row = %v, want adoptable true", row) + } + + otRefusal(t, r.adopt(map[string]any{"outsideId": row["id"], "interrupt": true}), http.StatusServiceUnavailable, "audit_unavailable") + adNoSignals(t, f) + if n := len(adSessions(t, r.i)); n != 0 { + t.Fatalf("a refused adopt left %d sessions", n) + } +} + +// Interrupt and adopt of a busy row in no project interrupts, adopts +// unsandboxed and sends the recovery message, as for a project row. +func TestAdoptNoProjectInterrupt(t *testing.T) { + t.Parallel() + a := adStart(t) + f := nsLoose(a) + f.openTool(a.i.Home, adModelSn, "busy") + row := nsRow(t, a, f) + + resp := a.adopt(map[string]any{"outsideId": row["id"], "interrupt": true}) + res := adCreated(t, resp) + var body map[string]any + resp.JSON(t, &body) + g1cKillRuns(t, a.i, res.SessionID) + if !res.Interrupted || !res.RecoverySent || body["unsandboxed"] != true { + t.Fatalf("201 body = %v, want interrupted true, recoverySent true and unsandboxed true", body) + } + f.signalAt(f.expect("signal INT"), "INT") + a.i.MustCLI("session", "wait", "--id", res.SessionID, "--json") + if in := adInputs(a.i); len(in) != 1 { + t.Fatalf("the agent has %d user inputs, want the one recovery message", len(in)) + } + rows := g4Wait(t, a.i, g4Has(func(r g4Row) bool { + return r.obj("args").str("origin") == "relay-recovery" && r.str("phase") == "completion" + }), "--event", "session_message") + if n := len(g4Where(rows, func(r g4Row) bool { return r.obj("args").str("session_id") == res.SessionID })); n == 0 { + t.Fatalf("no recovery session_message row names %s", res.SessionID) + } + if got := nsSessionRow(t, a.i, res.SessionID); got["unsandboxed"] != true || got["directory"] != f.dir { + t.Fatalf("session = %v, want unsandboxed true in %s", got, f.dir) + } +} + +// The session and its drop-in terminal carry unsandboxed; a session adopted in +// a project, its adopt answer and its terminal do not. +func TestAdoptNoProjectListMark(t *testing.T) { + t.Parallel() + a := adStart(t) + ad := nsAdoptIdle(t, a) + + pf := a.fake("app") + pf.idle(a.i.Home, adModelSn) + prow := nsRow(t, a, pf) + presp := a.adopt(map[string]any{"outsideId": prow["id"], "interrupt": false}) + pres := adCreated(t, presp) + var pbody map[string]any + presp.JSON(t, &pbody) + pf.signalAt(pf.expect("signal TERM"), "TERM") + pf.gone() + g1cKillRuns(t, a.i, pres.SessionID) + if _, has := pbody["unsandboxed"]; has { + t.Fatalf("the adopt answer of a project row carries unsandboxed: %v", pbody) + } + + nsSend(t, ad, "acme hello") + g1sSendText(t, a.i, pres.SessionID, "acme hello", "echo: acme hello") + looseTerm := nsDropIn(t, a, ad.sid, ad.f.sid, 2) + projTerm := nsDropIn(t, a, pres.SessionID, pf.sid, 2) + + for _, s := range nsSessionList(t, a.i) { + _, has := s["unsandboxed"] + switch s["id"] { + case ad.sid: + if s["unsandboxed"] != true || s["projectId"] != "" { + t.Fatalf("the session in no project = %v, want unsandboxed true and projectId \"\"", s) + } + default: + if has { + t.Fatalf("a session in a project carries unsandboxed: %v", s) + } + } + } + if tr := nsTerminalRow(t, a.i, looseTerm); tr["unsandboxed"] != true { + t.Fatalf("the drop-in terminal in no project = %v, want unsandboxed true", tr) + } + if tr := nsTerminalRow(t, a.i, projTerm); tr["unsandboxed"] != nil { + t.Fatalf("the drop-in terminal in a project carries unsandboxed: %v", tr) + } +} + +// nsNoProjectArgs fails t unless the row records no_project and no relay +// sandbox, with no project_id. +func nsNoProjectArgs(t *testing.T, r g4Row, what string) { + t.Helper() + args := r.obj("args") + sandbox, has := args["relay_sandbox"] + if args["no_project"] != true || !has || sandbox != false { + t.Fatalf("%s row args = %v, want no_project true and relay_sandbox false", what, args) + } + if _, has := args["project_id"]; has { + t.Fatalf("%s row carries project_id: %v", what, args) + } +} + +// nsLaunchArgs fails t unless the launch or resume row records no sandbox and +// no project. +func nsLaunchArgs(t *testing.T, r g4Row, what string) { + t.Helper() + args := r.obj("args") + sandbox, has := args["sandbox"] + if args["no_project"] != true || !has || sandbox != false { + t.Fatalf("%s row args = %v, want no_project true and sandbox false", what, args) + } + if p := r.actorProject(); p != "" { + t.Fatalf("%s row names actor project %q, want none", what, p) + } +} + +// The adopt, interrupt, swap and message rows record no_project and +// relay_sandbox false; the launch and resume rows record sandbox false and +// no_project. +func TestAdoptNoProjectAuditRows(t *testing.T) { + t.Parallel() + a := adStart(t) + f := nsLoose(a) + f.openTool(a.i.Home, adModelSn, "busy") + row := nsRow(t, a, f) + res := adCreated(t, a.adopt(map[string]any{"outsideId": row["id"], "interrupt": true})) + g1cKillRuns(t, a.i, res.SessionID) + f.signalAt(f.expect("signal INT"), "INT") + a.i.MustCLI("session", "wait", "--id", res.SessionID, "--json") + ad := &nsAdopted{a: a, f: f, sid: res.SessionID} + tid := nsDropIn(t, a, ad.sid, f.sid, 2) + nsHandBack(t, a, ad.sid, tid) + + for _, ev := range []string{"session_adopt", "session_interrupt"} { + rows := g4Wait(t, a.i, g4Has(func(r g4Row) bool { return r.str("phase") == "completion" }), "--event", ev) + if len(rows) < 2 { + t.Fatalf("%s: %d rows, want an intent and a completion", ev, len(rows)) + } + for _, r := range rows { + nsNoProjectArgs(t, r, ev+" "+r.str("phase")) + } + } + swaps := g4Wait(t, a.i, func(rows []g4Row) bool { + return len(g4Where(rows, func(r g4Row) bool { return r.str("phase") == "completion" })) >= 2 + }, "--event", "session_swap") + if len(swaps) != 4 { + t.Fatalf("%d session_swap rows, want 4: %v", len(swaps), swaps) + } + for _, r := range swaps { + nsNoProjectArgs(t, r, "session_swap "+r.str("phase")) + } + msgs := g4Wait(t, a.i, g4Has(func(r g4Row) bool { + return r.obj("args").str("origin") == "relay-recovery" && r.str("phase") == "completion" + }), "--event", "session_message") + named := g4Where(msgs, func(r g4Row) bool { return r.obj("args").str("session_id") == ad.sid }) + if len(named) == 0 { + t.Fatalf("no session_message row names %s", ad.sid) + } + for _, r := range named { + nsNoProjectArgs(t, r, "session_message "+r.str("phase")) + } + launches := g4Wait(t, a.i, func(rows []g4Row) bool { + kinds := map[string]bool{} + for _, r := range rows { + if r.obj("args").str("directory") == f.dir { + kinds[r.obj("args").str("session_kind")] = true + } + } + return kinds["claude"] && kinds["pty"] + }, "--event", "session_launch") + for _, r := range launches { + if r.obj("args").str("directory") == f.dir { + nsLaunchArgs(t, r, "session_launch "+r.obj("args").str("session_kind")) + } + } + resumes := g4Wait(t, a.i, g4Has(func(r g4Row) bool { return r.obj("args").str("directory") == f.dir }), "--event", "session_resume") + for _, r := range resumes { + if r.obj("args").str("directory") == f.dir { + nsLaunchArgs(t, r, "session_resume") + } + } +} + +// Drop in opens an unsandboxed terminal on the same conversation in the same +// folder; a send while it holds answers dropped_in; closing it resumes the +// session headless, unsandboxed, in the same folder. +func TestAdoptNoProjectDropInAndHandBack(t *testing.T) { + t.Parallel() + a := adStart(t) + ad := nsAdoptIdle(t, a) + nsSend(t, ad, "acme hello") + + tid := nsDropIn(t, a, ad.sid, ad.f.sid, 2) + tr := nsTerminalRow(t, a.i, tid) + if tr["unsandboxed"] != true || tr["directory"] != ad.f.dir { + t.Fatalf("terminal = %v, want unsandboxed true in %s", tr, ad.f.dir) + } + res := a.i.CLI("session", "message", "--id", ad.sid, "--text", "while held", "--json") + if res.Code != 1 || g1vErrCode(res) != "dropped_in" { + t.Fatalf("session message while held exited %d with code %q, want 1 and dropped_in\nstdout: %s\nstderr: %s", + res.Code, g1vErrCode(res), res.Stdout, res.Stderr) + } + + nsHandBack(t, a, ad.sid, tid) + row := nsSessionRow(t, a.i, ad.sid) + if row["live"] != true || row["headless"] != true || row["unsandboxed"] != true || row["directory"] != ad.f.dir || row["projectId"] != "" { + t.Fatalf("session after the hand back = %v, want live headless unsandboxed in %s with projectId \"\"", row, ad.f.dir) + } + nsSend(t, ad, "acme again") + if n := nsResumeStarts(a.i, ad.f.sid); n != 3 { + t.Fatalf("%d claude starts carry --resume %s, want 3: the adopt, the terminal and the hand back", n, ad.f.sid) + } + nsNoSandbox(t, a.i) +} + +// A Chief of Staff send reaches the session before a drop-in and after the +// hand back. +func TestAdoptNoProjectChiefOfStaffSend(t *testing.T) { + t.Parallel() + a := adStart(t) + ad := nsAdoptIdle(t, a) + send := func(text string) { + t.Helper() + resp := a.i.SocketHTTP(a.i.Credential("cos")).Do("POST", "/api/chief-of-staff/messages", + map[string]any{"sessionId": ad.sid, "text": text}, harness.ReqOpts{Header: g1tScope, Trace: harness.NewTrace(t)}) + if resp.Status != http.StatusAccepted { + t.Fatalf("a Chief of Staff send answered %d, want 202: %s", resp.Status, resp.Body) + } + a.i.WaitEvent(harness.EventQuery{Key: "chief_of_staff.send", Trace: resp.Trace, + Fields: map[string]any{"session_id": ad.sid}}, g1tEventDeadline) + } + send("acme before") + tid := nsDropIn(t, a, ad.sid, ad.f.sid, 2) + nsHandBack(t, a, ad.sid, tid) + send("acme after") +} + +// A resume of the dormant session runs unsandboxed in the same folder; with +// the folder gone a resume or a drop-in refuses 409 directory_unusable and the +// session does not change. +func TestAdoptNoProjectResumeDormant(t *testing.T) { + t.Parallel() + a := adStart(t) + ad := nsAdoptIdle(t, a) + nsSend(t, ad, "acme hello") + g1lEndAgentWhileDown(t, a.i, ad.sid) + g1lRestart(t, a.i) + + if row := nsSessionRow(t, a.i, ad.sid); row["live"] == true || row["unsandboxed"] != true { + t.Fatalf("dormant session = %v, want live false and unsandboxed true", row) + } + if err := os.Rename(ad.f.dir, ad.f.dir+".gone"); err != nil { + t.Fatalf("moving %s away: %v", ad.f.dir, err) + } + // The resume door's refusal body carries the message as error, so the code + // is read from the session.resume event, whose reason is the refusal code. + resp := nsCallerDo(a, "POST", "/api/sessions/"+ad.sid+"/resume", nil) + if resp.Status != http.StatusConflict { + t.Fatalf("resume with the folder gone answered %d, want 409: %s", resp.Status, resp.Body) + } + a.i.WaitEvent(harness.EventQuery{Key: "session.resume", Trace: resp.Trace, + Fields: map[string]any{"status": "denied", "reason": "directory_unusable", "session_id": ad.sid}}, g1tEventDeadline) + if row := nsSessionRow(t, a.i, ad.sid); row["live"] == true { + t.Fatalf("a refused resume left the session live: %v", row) + } + if err := os.Rename(ad.f.dir+".gone", ad.f.dir); err != nil { + t.Fatalf("restoring %s: %v", ad.f.dir, err) + } + + resp = nsCallerDo(a, "POST", "/api/sessions/"+ad.sid+"/resume", nil) + var out struct { + SessionID string `json:"session_id"` + Resumed bool `json:"resumed"` + } + resp.JSON(t, &out) + if resp.Status != http.StatusOK || !out.Resumed || out.SessionID != ad.sid { + t.Fatalf("resume answered %d %+v, want 200 and resumed true: %s", resp.Status, out, resp.Body) + } + g1sSendText(t, a.i, ad.sid, "acme again", "echo: acme again") + row := nsSessionRow(t, a.i, ad.sid) + if row["live"] != true || row["unsandboxed"] != true || row["directory"] != ad.f.dir { + t.Fatalf("resumed session = %v, want live and unsandboxed in %s", row, ad.f.dir) + } + if n := nsResumeStarts(a.i, ad.f.sid); n != 2 { + t.Fatalf("%d claude starts carry --resume %s, want 2: the adopt and the resume", n, ad.f.sid) + } + nsNoSandbox(t, a.i) + + // With the live session's folder gone a drop-in refuses before it stops. + if err := os.Rename(ad.f.dir, ad.f.dir+".gone"); err != nil { + t.Fatalf("moving %s away: %v", ad.f.dir, err) + } + otRefusal(t, nsCallerDo(a, "POST", "/api/sessions/"+ad.sid+"/drop-in", map[string]any{}), http.StatusConflict, "directory_unusable") + if row := nsSessionRow(t, a.i, ad.sid); row["live"] != true { + t.Fatalf("a refused drop-in changed the session: %v", row) + } + if err := os.Rename(ad.f.dir+".gone", ad.f.dir); err != nil { + t.Fatalf("restoring %s: %v", ad.f.dir, err) + } +} + +// After a relay restart the session keeps unsandboxed, and its rebind names the +// reason unsandboxed, not project_gone. +func TestAdoptNoProjectRelayRestart(t *testing.T) { + t.Parallel() + a := adStart(t) + ad := nsAdoptIdle(t, a) + nsSend(t, ad, "acme hello") + + since := g1rRestart(t, a.i) + ev := g1rRebindEvent(a.i, ad.sid, since) + if ev["reason"] != "unsandboxed" { + t.Fatalf("session.rebind line = %v, want reason unsandboxed", ev) + } + done := g1rRebindRows(t, a.i, ad.sid, "completion") + if len(done) != 1 || done[0].str("error") != "unsandboxed" { + t.Fatalf("session_rebind completion rows = %v, want one with error unsandboxed", done) + } + if row := nsSessionRow(t, a.i, ad.sid); row["unsandboxed"] != true || row["projectId"] != "" { + t.Fatalf("session after the restart = %v, want unsandboxed true and projectId \"\"", row) + } +} + +// The Chief of Staff scope cannot reach the adopt door on a row in no project, +// and a read-only credential is refused; the process lives. +func TestAdoptNoProjectScopeRefused(t *testing.T) { + t.Parallel() + a := adStart(t) + f := nsLoose(a, "INT", "TERM") + f.idle(a.i.Home, adModelSn) + row := nsRow(t, a, f) + body := map[string]any{"outsideId": row["id"], "interrupt": true} + + scoped := a.i.SocketHTTP(a.i.Credential("cos")).Do("POST", adDoor, body, harness.ReqOpts{Header: g1tScope, Trace: harness.NewTrace(t)}) + if scoped.Status != http.StatusForbidden { + t.Fatalf("the chief-of-staff scope got %d, want 403: %s", scoped.Status, scoped.Body) + } + reader := a.i.SocketHTTP(a.i.Credential("reader")).Do("POST", adDoor, body, harness.ReqOpts{Trace: harness.NewTrace(t)}) + if reader.Status != http.StatusForbidden { + t.Fatalf("a read-only credential got %d, want 403: %s", reader.Status, reader.Body) + } + adNoSignals(t, f) + if n := len(adSessions(t, a.i)); n != 0 { + t.Fatalf("refused adopts left %d sessions", n) + } +} + +// With claude_token_file set the unsandboxed session gets the token on fd 5 and +// never in its environment or argv; relay session mode then refuses, because a +// restart in place cannot reuse the per-launch token. +func TestAdoptNoProjectTokenDescriptor(t *testing.T) { + t.Parallel() + r := osStart(t, false, func(o *harness.Options) { + if o.Settings == nil { + o.Settings = map[string]json.RawMessage{} + } + o.Settings["claude_token_file"] = ctJSON(t, ctSetting) + }) + a := &adRig{osRig: r} + token := ctNewToken(t) + ctWriteToken(t, r.i, token, 0o600) + ad := nsAdoptIdle(t, a) + adStartLine(t, r.i, ad.sid, "acme next") + + var start *harness.AgentCall + for _, c := range nsStarts(r.i) { + if c.Env["RELAY_SESSION_ID"] == ad.sid { + c := c + start = &c + } + } + if start == nil { + t.Fatalf("no claude start line carries RELAY_SESSION_ID %s", ad.sid) + } + ctWantToken(t, "unsandboxed adopted session", *start, token) + ctScan(t, "the start line's environment", strings.Join(start.Environ, "\n"), token) + ctScan(t, "the start line's argv", strings.Join(start.Argv, "\n"), token) + nsNoSandbox(t, r.i) + + res := r.i.CLI("session", "mode", "--id", ad.sid, "--mode", "plan", "--json") + if res.Code != 1 { + t.Fatalf("session mode on the unsandboxed session exited %d, want 1 (resume_required)\nstdout: %s\nstderr: %s", res.Code, res.Stdout, res.Stderr) + } + if row := nsSessionRow(t, r.i, ad.sid); row["live"] != true { + t.Fatalf("the session is not live after the refused mode change: %v", row) + } +} diff --git a/fakerelay/internal/sessions/adopt.go b/fakerelay/internal/sessions/adopt.go index e11632d5..96f2b2c9 100644 --- a/fakerelay/internal/sessions/adopt.go +++ b/fakerelay/internal/sessions/adopt.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "path" "strconv" "strings" "unicode" @@ -102,14 +103,25 @@ func (s *svc) adopt(w http.ResponseWriter, r *http.Request) { var project world.Project var projectOK bool - if row.ProjectID != nil { + noProject := row.ProjectID == nil + if noProject { + // A row in no project is adopted unsandboxed in its own folder. + if s.localProjectHolds(row.Directory) { + late(http.StatusConflict, "changed", "a project now holds the session's folder; adopt it again") + return + } + if adopt.Refuse == "directory_unusable" { + late(http.StatusConflict, "directory_unusable", "the session's folder is not a usable directory") + return + } + } else { s.State.Read(func(m *state.Model) { project, projectOK = lookupProject(m, *row.ProjectID) }) + if !projectOK || project.HostID != "" || project.Kind == "remote" { + late(http.StatusConflict, "no_project", "the session is not in a registered local project") + return + } + projectID = project.ID } - if !projectOK || project.HostID != "" || project.Kind == "remote" { - late(http.StatusConflict, "no_project", "the session is not in a registered local project") - return - } - projectID = project.ID if s.holdsConversation(row.ClaudeSessionID) { late(http.StatusConflict, "managed", "relay already holds this conversation") @@ -127,10 +139,16 @@ func (s *svc) adopt(w http.ResponseWriter, r *http.Request) { late(http.StatusConflict, "model_unknown", "the model of the session is unknown; send model") return } - t, ref := s.authorize(project.ID, row.Directory, model, "claude") - if ref != nil { - late(ref.status, cosCodeFor(ref.code), ref.msg) - return + var t launchTarget + if noProject { + t = launchTarget{dir: row.Directory, unsandboxed: true} + } else { + var ref *refusal + t, ref = s.authorize(project.ID, row.Directory, model, "claude") + if ref != nil { + late(ref.status, cosCodeFor(ref.code), ref.msg) + return + } } switch adopt.Refuse { case "stop_failed": @@ -166,6 +184,9 @@ func (s *svc) adopt(w http.ResponseWriter, r *http.Request) { Set("session_id", sessionID).Set("interrupted", interrupted) ev.End("ok", "", nil) created := map[string]any{"sessionId": x.id, "claudeSessionId": row.ClaudeSessionID, "interrupted": interrupted, "recoverySent": recovery != nil} + if noProject { + created["unsandboxed"] = true + } writeCreated(w, created) if recovery != nil { go s.runTurn(recovery, recoveryText, recoveryOrigin, events.TraceFrom(r.Context())) @@ -269,6 +290,25 @@ func (s *svc) resolveOutside(b adoptBody) (row world.OutsideFixture, status int, return notFound() } +// localProjectHolds reports whether a local world project's folder holds dir, +// matched on the cleaned path text: fakerelay reads no disk. +func (s *svc) localProjectHolds(dir string) bool { + dir = path.Clean(dir) + held := false + s.State.Read(func(m *state.Model) { + for _, p := range m.Projects { + if p.HostID != "" || p.Kind == "remote" || p.Path == "" { + continue + } + root := strings.TrimSuffix(path.Clean(p.Path), "/") + if dir == root || strings.HasPrefix(dir, root+"/") { + held = true + } + } + }) + return held +} + // holdsConversation: a session made by an adopt carries the conversation id. func (s *svc) holdsConversation(id string) bool { s.mu.Lock() diff --git a/fakerelay/internal/sessions/dropin.go b/fakerelay/internal/sessions/dropin.go index db89c41d..fe11699c 100644 --- a/fakerelay/internal/sessions/dropin.go +++ b/fakerelay/internal/sessions/dropin.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/barelyworkingcode/relay/fakerelay/internal/server" + "github.com/barelyworkingcode/relay/fakerelay/internal/world" ) // dropInTemplates is the terminal template a drop-in launches for each kind, @@ -37,8 +38,10 @@ func (s *svc) dropIn(w http.ResponseWriter, r *http.Request) { x := s.sessions[id] var kind, projectID, dir, name string var host map[string]any + unsandboxed := false if x != nil { kind, projectID, dir, host, name = x.kind, x.projectID, x.directory, x.host, x.askedName + unsandboxed = x.unsandboxed } s.mu.Unlock() hostName := "console" @@ -58,8 +61,16 @@ func (s *svc) dropIn(w http.ResponseWriter, r *http.Request) { return } templateID := dropInTemplates[kind] - target, ref := s.authorize(projectID, "", "", "terminal") - tpl := s.findTemplate(target, templateID) + var target launchTarget + var ref *refusal + var tpl *world.Template + if unsandboxed { + target = launchTarget{unsandboxed: true} + tpl = s.consoleTemplate(templateID) + } else { + target, ref = s.authorize(projectID, "", "", "terminal") + tpl = s.findTemplate(target, templateID) + } if ref == nil && tpl == nil { ref = &refusal{http.StatusForbidden, "template_unavailable", templateUnavailable(templateID)} } diff --git a/fakerelay/internal/sessions/register.go b/fakerelay/internal/sessions/register.go index 692baedd..73000e67 100644 --- a/fakerelay/internal/sessions/register.go +++ b/fakerelay/internal/sessions/register.go @@ -120,6 +120,9 @@ type launchTarget struct { project world.Project dir string host map[string]any + // unsandboxed marks a launch in no project: adopt builds it, and the + // drop-in of such a session inherits it. + unsandboxed bool } // authorize resolves a launch. model is checked only when it is not empty. diff --git a/fakerelay/internal/sessions/session.go b/fakerelay/internal/sessions/session.go index 1ae55406..0c21b149 100644 --- a/fakerelay/internal/sessions/session.go +++ b/fakerelay/internal/sessions/session.go @@ -17,7 +17,7 @@ import ( type session struct { id, projectID, name, directory, folder, model, kind, origin, permMode string - headless, agentOn, hidden, held, dropped bool + headless, agentOn, hidden, held, dropped, unsandboxed bool createdAt, lastMsg, since time.Time host map[string]any history []map[string]any @@ -108,7 +108,7 @@ type launcher struct { func (s *svc) launch(t launchTarget, b launchBody, kind, origin string, headless bool) *session { x := &session{id: newUUID(), projectID: t.project.ID, name: b.Name, directory: t.dir, folder: b.Folder, model: b.Model, kind: kind, origin: origin, headless: headless, askedName: b.Name, createdAt: s.now(), host: t.host, live: true, state: "idle", stats: newStats(), - viewers: map[*conn]bool{}} + viewers: map[*conn]bool{}, unsandboxed: t.unsandboxed} x.since = x.createdAt x.agentOn, _ = b.Settings["agent"].(bool) // Chief of Staff headless sessions stay listed; a client's headless launch @@ -144,6 +144,9 @@ func (s *svc) row(x *session) map[string]any { if x.origin != "" { v["origin"] = x.origin } + if x.unsandboxed { + v["unsandboxed"] = true + } if (x.live || x.dropped || x.exit != nil) && x.tracked() { att := map[string]any{"state": x.state, "since": stamp(x.since)} if x.exit != nil { diff --git a/fakerelay/internal/sessions/terminal.go b/fakerelay/internal/sessions/terminal.go index 51cd4758..62e4c238 100644 --- a/fakerelay/internal/sessions/terminal.go +++ b/fakerelay/internal/sessions/terminal.go @@ -59,6 +59,7 @@ type terminal struct { exitCode int readOnly, outside bool dropIn string // the session this terminal holds + unsandboxed bool // the drop-in of a session that runs in no project } func (t *terminal) row() map[string]any { @@ -66,6 +67,9 @@ func (t *terminal) row() map[string]any { if t.readOnly { v["readOnly"], v["outside"] = true, t.outside } + if t.unsandboxed { + v["unsandboxed"] = true + } if t.stopped { v["state"] = "stopped" if t.exitCode != 0 { @@ -213,6 +217,21 @@ func templateUnavailable(id string) string { // findTemplate resolves a terminal template for a launch target: the host's // for a host project, else a console template the project allows. +// consoleTemplate is a console template by id, with no project allow-list: the +// drop-in of an unsandboxed session has no project to allow it. +func (s *svc) consoleTemplate(id string) *world.Template { + var tpl *world.Template + s.State.Read(func(m *state.Model) { + for i := range m.Templates { + if m.Templates[i].ID == id { + x := m.Templates[i] + tpl = &x + } + } + }) + return tpl +} + func (s *svc) findTemplate(t launchTarget, id string) *world.Template { var tpl *world.Template s.State.Read(func(m *state.Model) { @@ -238,7 +257,7 @@ func allowedTemplate(list []string, id string) bool { return allowed(list, id) } func (s *svc) newTerminal(tpl *world.Template, b terminalBody, t launchTarget, origin string) *terminal { x := &terminal{id: newUUID(), templateID: tpl.ID, name: b.Name, directory: t.dir, origin: origin, cols: b.Cols, rows: b.Rows, - host: t.host, viewers: map[*conn]bool{}} + host: t.host, viewers: map[*conn]bool{}, unsandboxed: t.unsandboxed} if x.name == "" { x.name = tpl.Name } diff --git a/fakerelay/internal/world/validate.go b/fakerelay/internal/world/validate.go index 2354bbfc..5a6d337e 100644 --- a/fakerelay/internal/world/validate.go +++ b/fakerelay/internal/world/validate.go @@ -387,7 +387,7 @@ var ( adoptReasonsOther = map[string]bool{"not_registered": true, "invalid_id": true, "start_mismatch": true, "not_interactive": true, "not_command_line": true, "ambiguous": true} adoptBusyVetoes = map[string]bool{"registry_busy": true, "status_stale": true, "open_tool_call": true, "child_process": true} adoptModelFamilies = map[string]bool{"haiku": true, "sonnet": true, "opus": true} - adoptRefusals = map[string]bool{"audit_unavailable": true, "unavailable": true, "adopt_in_progress": true, "changed": true, "stop_failed": true, "launch_failed": true} + adoptRefusals = map[string]bool{"audit_unavailable": true, "unavailable": true, "adopt_in_progress": true, "changed": true, "stop_failed": true, "launch_failed": true, "directory_unusable": true} ) // derivedAdoptReason is the first of relay's reasons that applies to the @@ -398,8 +398,6 @@ func derivedAdoptReason(o OutsideFixture) string { return "ended" case o.HostID != nil: return "host_unsupported" - case o.ProjectID == nil: - return "no_project" } return "" } @@ -427,8 +425,6 @@ func validateOutsideAdopt(path string, o OutsideFixture) error { return bad(path+".adoptable", "an ended row is not adoptable") case o.HostID != nil: return bad(path+".adoptable", "a host row is not adoptable") - case o.ProjectID == nil: - return bad(path+".adoptable", "requires project_id") case !outsideIDShape.MatchString(o.ID): return bad(path+".adoptable", "requires an id of outside: and 32 hex digits") case !conversationUUID.MatchString(o.ClaudeSessionID): @@ -447,6 +443,9 @@ func validateOutsideAdopt(path string, o OutsideFixture) error { if a.Refuse != "" && !adoptRefusals[a.Refuse] { return bad(path+".adopt.refuse", "invalid refusal") } + if a.Refuse == "directory_unusable" && o.ProjectID != nil { + return bad(path+".adopt.refuse", "directory_unusable applies only to a row with no project_id") + } } return nil } @@ -456,6 +455,9 @@ func validateOutsideAdopt(path string, o OutsideFixture) error { if o.AdoptReason == "" { return nil } + if o.AdoptReason == "no_project" { + return bad(path+".adopt_reason", "no_project applies to Pi and Codex rows, which a world cannot hold") + } if d := derivedAdoptReason(o); d != "" { if o.AdoptReason != d { return bad(path+".adopt_reason", "must be %s for this row", d) diff --git a/fakerelay/prooftest/adopt_test.go b/fakerelay/prooftest/adopt_test.go index 650f142f..a5bdac12 100644 --- a/fakerelay/prooftest/adopt_test.go +++ b/fakerelay/prooftest/adopt_test.go @@ -140,8 +140,7 @@ func TestAdoptRowFieldsServed(t *testing.T) { ended := outsideEnded("outside:gone", "2026-01-01T01:00:00Z") host := outsideFixture("") host["id"], host["directory"], host["host_id"], host["machine"] = "outside:onhost", "/work/onhost", "h_box", "testbox" - noProject := outsideFixture("") - noProject["id"], noProject["directory"] = "outside:noproject", "/work/noproject" + noProject := adoptRow(3, "/work/noproject", "project_id", nil) unregistered := outsideFixture("") unregistered["id"], unregistered["directory"], unregistered["project_id"] = "outside:unreg", "/work/unreg", "p_acme" reasoned := adoptRow(2, e.dir, "adoptable", false, "adopt_reason", "not_interactive") @@ -158,12 +157,12 @@ func TestAdoptRowFieldsServed(t *testing.T) { claudeID string } wants := map[string]want{ - adoptID(1): {true, "", adoptUUID(1)}, - "outside:gone": {false, "ended", ""}, - "outside:onhost": {false, "host_unsupported", ""}, - "outside:noproject": {false, "no_project", ""}, - "outside:unreg": {false, "not_registered", ""}, - adoptID(2): {false, "not_interactive", adoptUUID(2)}, + adoptID(1): {true, "", adoptUUID(1)}, + "outside:gone": {false, "ended", ""}, + "outside:onhost": {false, "host_unsupported", ""}, + adoptID(3): {true, "", adoptUUID(3)}, + "outside:unreg": {false, "not_registered", ""}, + adoptID(2): {false, "not_interactive", adoptUUID(2)}, } check := func(door, id string, row map[string]any) { t.Helper() @@ -209,26 +208,29 @@ func TestAdoptFixtureRefused(t *testing.T) { valid := func(kv ...any) J { return adoptRow(1, e.dir, kv...) } putRows(t, e.in, valid("adopt", J{"busy": "registry_busy", "model_family": "opus", "refuse": "changed"})) putRows(t, e.in, valid("adoptable", false, "adopt_reason", "invalid_id")) + // A Claude row in no project is adoptable, and its folder may be unusable. + putRows(t, e.in, valid("project_id", nil, "adopt", J{"refuse": "directory_unusable"})) cases := map[string]J{ - "adoptable on an ended row": valid("state", "ended", "ended_at", "2026-01-01T00:00:00Z"), - "adoptable on a host row": valid("host_id", "h_box", "machine", "testbox"), - "adoptable without a project": valid("project_id", nil), - "adoptable with a short id": valid("id", "outside:acme"), - "adoptable with an upper id": valid("id", "outside:"+strings.Repeat("A", 32)), - "adoptable with a stem id": valid("claude_session_id", "stem-id"), - "adoptable without a stem id": valid("claude_session_id", nil), - "adoptable with a reason": valid("adopt_reason", "not_interactive"), - "unknown reason": valid("adoptable", false, "adopt_reason", "bogus"), - "ended row with another reason": valid("adoptable", false, "state", "ended", "ended_at", "2026-01-01T00:00:00Z", "adopt_reason", "not_interactive"), - "host row with another reason": valid("adoptable", false, "host_id", "h_box", "machine", "testbox", "adopt_reason", "no_project"), - "no project with a late reason": valid("adoptable", false, "project_id", nil, "adopt_reason", "not_registered"), - "first-rule reason on live row": valid("adoptable", false, "adopt_reason", "ended"), - "adopt without adoptable": valid("adoptable", false, "adopt_reason", "not_interactive", "adopt", J{}), - "bad busy": valid("adopt", J{"busy": "bogus"}), - "bad model family": valid("adopt", J{"model_family": "gpt"}), - "bad refuse": valid("adopt", J{"refuse": "bogus"}), - "unknown adopt key": valid("adopt", J{"idle": true}), + "adoptable on an ended row": valid("state", "ended", "ended_at", "2026-01-01T00:00:00Z"), + "adoptable on a host row": valid("host_id", "h_box", "machine", "testbox"), + "no_project reason on a Claude row": valid("adoptable", false, "project_id", nil, "adopt_reason", "no_project"), + "no_project reason in a project": valid("adoptable", false, "adopt_reason", "no_project"), + "directory_unusable in a project": valid("adopt", J{"refuse": "directory_unusable"}), + "adoptable with a short id": valid("id", "outside:acme"), + "adoptable with an upper id": valid("id", "outside:"+strings.Repeat("A", 32)), + "adoptable with a stem id": valid("claude_session_id", "stem-id"), + "adoptable without a stem id": valid("claude_session_id", nil), + "adoptable with a reason": valid("adopt_reason", "not_interactive"), + "unknown reason": valid("adoptable", false, "adopt_reason", "bogus"), + "ended row with another reason": valid("adoptable", false, "state", "ended", "ended_at", "2026-01-01T00:00:00Z", "adopt_reason", "not_interactive"), + "host row with another reason": valid("adoptable", false, "host_id", "h_box", "machine", "testbox", "adopt_reason", "no_project"), + "first-rule reason on live row": valid("adoptable", false, "adopt_reason", "ended"), + "adopt without adoptable": valid("adoptable", false, "adopt_reason", "not_interactive", "adopt", J{}), + "bad busy": valid("adopt", J{"busy": "bogus"}), + "bad model family": valid("adopt", J{"model_family": "gpt"}), + "bad refuse": valid("adopt", J{"refuse": "bogus"}), + "unknown adopt key": valid("adopt", J{"idle": true}), } for name, row := range cases { if got := e.in.ctl(t, "PUT", "/v1/outside-sessions", []J{row}); got.Status != 400 { @@ -489,7 +491,7 @@ func TestAdoptRefusals(t *testing.T) { return []J{adoptRow(1, e.dir), adoptRow(2, e.dir, "claude_session_id", adoptUUID(1))} }, body: func(adoptEnv) any { return J{"claudeSessionId": adoptUUID(1)} }, status: 409, code: "ambiguous", msg: "more than one live process holds this conversation"}, {name: "host", rows: oneRow("adoptable", false, "host_id", "h_box", "machine", "testbox"), body: idBody(nil), status: 409, code: "host_unsupported", msg: "sessions on an SSH host cannot be adopted"}, - {name: "no project", rows: oneRow("adoptable", false, "project_id", nil), body: idBody(nil), status: 409, code: "no_project", msg: "the session cannot be adopted: no_project"}, + {name: "no project row keeps its reason", rows: oneRow("adoptable", false, "project_id", nil, "adopt_reason", "not_interactive"), body: idBody(nil), status: 409, code: "not_interactive", msg: "the session cannot be adopted: not_interactive"}, {name: "not registered", rows: oneRow("adoptable", false), body: idBody(nil), status: 409, code: "not_registered", msg: "the session cannot be adopted: not_registered"}, {name: "audit off", rows: oneRow("adopt", J{"refuse": "audit_unavailable"}), body: idBody(nil), status: 503, code: "audit_unavailable", msg: "auditing is off; no session can be adopted"}, {name: "host down", rows: oneRow("adopt", J{"refuse": "unavailable"}), body: idBody(nil), status: 502, code: "unavailable", msg: "the session host is not available"}, @@ -567,3 +569,75 @@ func TestAdoptDoorClass(t *testing.T) { detail(t, e.in, adoptID(1)).is(t, 200) eq(t, len(sessionRows(t, e.in)), 0, "sessions after the refused doors") } + +func unsandboxedWorld(string) any { + w := adoptWorld("").(J) + w["terminal_templates"] = []J{{"id": "claude-code", "name": "Claude", "command": "/bin/sh"}} + return w +} + +// Regression: a Claude row in no project is refused, adopted with a project, or answers without the +// unsandboxed mark; the mark leaks onto project sessions or terminals; a project that holds the folder, +// or an unusable folder, is not refused with relay's code. +func TestAdoptNoProjectUnsandboxed(t *testing.T) { + t.Parallel() + in := startInstance(t, unsandboxedWorld) + dir := in.api(t, "GET", "/api/projects/p_acme", nil).is(t, 200).obj(t)["path"].(string) + free := adoptRow(1, "/work/free-acme", "project_id", nil, "adopt", J{"model_family": "haiku"}) + inside := adoptRow(2, dir+"/sub", "project_id", nil, "adopt", J{"model_family": "haiku"}) + unusable := adoptRow(3, "/work/gone-acme", "project_id", nil, "adopt", J{"model_family": "haiku", "refuse": "directory_unusable"}) + member := adoptRow(4, dir, "adopt", J{"model_family": "haiku"}) + putRows(t, in, free, inside, unusable, member) + + for _, id := range []int{1, 2, 3} { + row := detail(t, in, adoptID(id)).is(t, 200).obj(t)["session"].(map[string]any) + eq[any](t, row["adoptable"], true, "adoptable") + if r, has := row["adoptReason"]; has { + t.Errorf("row %d carries adoptReason %v", id, r) + } + } + + changed := adoptCall(t, in, J{"outsideId": adoptID(2)}, "adopt-np-0001").is(t, 409).obj(t) + eq[any](t, changed["error"], "changed", "changed code") + gone := adoptCall(t, in, J{"outsideId": adoptID(3)}, "adopt-np-0002").is(t, 409).obj(t) + eq[any](t, gone["error"], "directory_unusable", "directory_unusable code") + eq(t, len(sessionRows(t, in)), 0, "sessions after the refusals") + detail(t, in, adoptID(2)).is(t, 200) + detail(t, in, adoptID(3)).is(t, 200) + + got := adoptCall(t, in, J{"outsideId": adoptID(1)}, "adopt-np-0003").is(t, 201).obj(t) + eq[any](t, got["unsandboxed"], true, "adopt answer") + proj := adoptCall(t, in, J{"outsideId": adoptID(4)}, "adopt-np-0004").is(t, 201).obj(t) + if v, has := proj["unsandboxed"]; has { + t.Errorf("project adopt answer carries unsandboxed %v", v) + } + + marks := map[string]any{} + for _, s := range sessionRows(t, in) { + marks[s["id"].(string)] = s["unsandboxed"] + if s["id"] == got["sessionId"] { + eq[any](t, s["projectId"], "", "projectId") + } + } + eq(t, marks, map[string]any{got["sessionId"].(string): true, proj["sessionId"].(string): nil}, "unsandboxed on GET /api/sessions") + + held := map[string]any{} + for _, sid := range []any{got["sessionId"], proj["sessionId"]} { + d := in.api(t, "POST", "/api/sessions/"+sid.(string)+"/drop-in", nil).is(t, 201).obj(t) + held[d["terminal"].(map[string]any)["terminalId"].(string)] = sid + } + for _, v := range in.api(t, "GET", "/api/terminals", nil).is(t, 200).obj(t)["terminals"].([]any) { + row := v.(map[string]any) + sid, ok := held[row["id"].(string)] + if !ok { + continue + } + if sid == got["sessionId"] { + eq[any](t, row["unsandboxed"], true, "unsandboxed drop-in terminal") + } else if m, has := row["unsandboxed"]; has { + t.Errorf("project drop-in terminal carries unsandboxed %v", m) + } + delete(held, row["id"].(string)) + } + eq(t, len(held), 0, "drop-in terminals not listed") +} diff --git a/internal/outsidesessions/probe.go b/internal/outsidesessions/probe.go index 5a03a8c9..3f76c61b 100644 --- a/internal/outsidesessions/probe.go +++ b/internal/outsidesessions/probe.go @@ -216,13 +216,16 @@ func markAmbiguous(obs []Observation) { } // GateAdoption applies the two reasons that outrank the registry's: a session -// on an SSH host, then one outside every registered project. A row they do not -// touch keeps the reason observe gave it. -func GateAdoption(s *Session, onHost, inProject bool) { +// on an SSH host, then a Pi or Codex session outside every registered project. +// A Claude session outside every project is adoptable: relay resumes it in its +// own folder with no sandbox. Pi needs a project for its model key and its +// transcript grant, and a Codex attach needs one for launch authorization. A +// row they do not touch keeps the reason observe gave it. +func GateAdoption(s *Session, onHost, inProject bool, agent string) { switch { case onHost: s.AdoptReason = AdoptReasonHost - case !inProject: + case !inProject && (agent == "pi" || agent == "codex"): s.AdoptReason = AdoptReasonNoProject default: return diff --git a/internal/sessions/hostapi/dispatch.go b/internal/sessions/hostapi/dispatch.go index e22808b1..4c04d10a 100644 --- a/internal/sessions/hostapi/dispatch.go +++ b/internal/sessions/hostapi/dispatch.go @@ -67,6 +67,61 @@ func checkLaunchOrigin(origin string) error { return nil } +// claudeCodeTemplateID mirrors cmd/relay's kindTemplateIDs[KindClaude]: the +// template an unsandboxed drop-in terminal runs. +const claudeCodeTemplateID = "claude-code" + +// hasProject reports whether the launch names a project, in project or in the +// session request. The literal null counts as none. +func hasProject(req LaunchRequest, sr sessionRequestBody) bool { + return (len(req.Project) > 0 && string(req.Project) != "null") || sr.ProjectID != "" +} + +// checkUnsandboxedTerminal allows the unsandboxed mark on a local drop-in +// terminal of the claude-code template only, with no project, no sandbox and +// no identity. Fail closed: any other shape that carries it is refused. +func checkUnsandboxedTerminal(req LaunchRequest, host *sessionstypes.HostSpec) error { + if !req.Unsandboxed { + return nil + } + if req.Kind != kindPTY || req.DropInFor == "" || req.TemplateID != claudeCodeTemplateID || host != nil || + req.Sandbox != nil || req.Identity != nil || hasProject(req, sessionRequestBody{}) || + req.Outside || req.ReadOnly || req.ViewTarget != nil || req.ModelKey != "" { + return errors.New("hostapi: unsandboxed applies only to a local drop-in terminal with no project, sandbox or identity") + } + return nil +} + +// checkUnsandboxedSession allows the unsandboxed mark on a local claude launch +// only, with no project, sandbox, identity, launcher, entry or model key, and +// refuses a local claude launch with no project and no mark. Deliberate: relay +// authorizes every claude launch against a project, so one that arrives +// without a project is a request relay never makes unless the mark says so. +func checkUnsandboxedSession(req LaunchRequest, sr sessionRequestBody, host *sessionstypes.HostSpec) error { + noProject := !hasProject(req, sr) + if !req.Unsandboxed { + if req.Kind == session.KindClaude && host == nil && noProject { + return errors.New("hostapi: a local claude launch needs a project or the unsandboxed mark") + } + return nil + } + if req.Kind != session.KindClaude || host != nil || !noProject || req.Sandbox != nil || req.Identity != nil || + req.Launcher != nil || sr.Entry != "" || req.ModelKey != "" || req.AttachCodexThread != "" { + return errors.New("hostapi: unsandboxed applies only to a local claude launch with no project, sandbox or identity") + } + return nil +} + +// createTerminal starts the terminal the launch describes. The mark reaches the +// manager here, not in the spec, so a terminal listed as unsandboxed is one +// this function checked. +func createTerminal(m *terminal.Manager, req LaunchRequest, spec terminal.CreateSpec) (*terminal.Session, error) { + if req.Unsandboxed { + return m.CreateUnsandboxed(spec) + } + return m.Create(spec) +} + // buildTerminalSpec translates a "pty" LaunchRequest into // terminal.CreateSpec, field by field: the two types were designed // independently (C5's wire contract vs. this package's own Go shape) and @@ -89,6 +144,9 @@ func buildTerminalSpec(req LaunchRequest) (terminal.CreateSpec, error) { if err != nil { return terminal.CreateSpec{}, err } + if err := checkUnsandboxedTerminal(req, host); err != nil { + return terminal.CreateSpec{}, err + } spec := terminal.CreateSpec{ ReadOnly: req.ReadOnly, Outside: req.Outside, @@ -170,8 +228,11 @@ func buildSessionSpec(req LaunchRequest) (session.CreateSpec, error) { if err != nil { return session.CreateSpec{}, err } - if req.ClaudeToken != "" && (req.Kind != session.KindClaude || req.Sandbox == nil || host != nil) { - return session.CreateSpec{}, errors.New("hostapi: claude token requires a sandboxed local claude launch") + if req.ClaudeToken != "" && (req.Kind != session.KindClaude || (req.Sandbox == nil && !req.Unsandboxed) || host != nil) { + return session.CreateSpec{}, errors.New("hostapi: claude token requires a sandboxed or unsandboxed local claude launch") + } + if err := checkUnsandboxedSession(req, sr, host); err != nil { + return session.CreateSpec{}, err } if err := checkResumeConversation(req, host); err != nil { return session.CreateSpec{}, err @@ -210,6 +271,7 @@ func buildSessionSpec(req LaunchRequest) (session.CreateSpec, error) { ModelKey: req.ModelKey, Resume: req.Resume, Origin: req.Origin, + Unsandboxed: req.Unsandboxed, Launcher: req.Launcher, Entry: sr.Entry, ClaudeToken: req.ClaudeToken, diff --git a/internal/sessions/hostapi/server.go b/internal/sessions/hostapi/server.go index a5b7dc77..dfd4ab2c 100644 --- a/internal/sessions/hostapi/server.go +++ b/internal/sessions/hostapi/server.go @@ -713,7 +713,7 @@ func (s *Server) launchTerminal(w http.ResponseWriter, req LaunchRequest) { return } } - sess, err := s.terminals.Create(spec) + sess, err := createTerminal(s.terminals, req, spec) if err != nil { if req.DropInFor != "" { s.takeDropIn(req.SessionID) diff --git a/internal/sessions/hostapi/types.go b/internal/sessions/hostapi/types.go index d27c47d6..1f082dde 100644 --- a/internal/sessions/hostapi/types.go +++ b/internal/sessions/hostapi/types.go @@ -80,8 +80,9 @@ type LaunchRequest struct { Identity *IdentitySpec `json:"identity"` ModelKey string `json:"model_key,omitempty"` // ClaudeToken is the token relay read from claude_token_file for this - // launch. Set only for a sandboxed claude launch or a sandboxed pty launch - // of the claude-code template. Never logged and never in an error message. + // launch. Set only for a sandboxed or unsandboxed local claude launch, or a + // pty launch of the claude-code template. Never logged and never in an + // error message. ClaudeToken string `json:"claude_token,omitempty"` // ClaudeEnv holds the claude_env_passthrough values relay resolved for this // launch. Local claude launches only; every name is re-checked here. @@ -107,6 +108,10 @@ type LaunchRequest struct { // omitted when the agent entry names no command; the host refuses one on a // pty or chat launch. Launcher *sessionstypes.Launcher `json:"launcher,omitempty"` + // Unsandboxed marks a local claude launch, or a pty drop-in terminal, that + // runs with no project, no sandbox and no identity. The host takes it on + // nothing else, keeps it on the session and lists it. + Unsandboxed bool `json:"unsandboxed,omitempty"` // ViewTarget names the tmux session an outside viewer attaches to. The // host builds the attach argv from it; an outside launch carries no argv. ViewTarget *ViewTarget `json:"view_target,omitempty"` diff --git a/internal/sessions/ledger/ledger.go b/internal/sessions/ledger/ledger.go index f46eb48e..26c2a99d 100644 --- a/internal/sessions/ledger/ledger.go +++ b/internal/sessions/ledger/ledger.go @@ -66,6 +66,9 @@ type Record struct { // CodexThread is the codex daemon thread an attached session drives; a // dormant session keeps it to attach again. CodexThread string `json:"codex_thread,omitempty"` + // Unsandboxed marks a Claude session relay runs with no project and no + // sandbox; its ProjectID is empty. A resume carries the mark back to the host. + Unsandboxed bool `json:"unsandboxed,omitempty"` } // RootRef names a process by its kernel identity. It is not a credential: diff --git a/internal/sessions/provider/claude.go b/internal/sessions/provider/claude.go index 4eb2ca23..e448b325 100644 --- a/internal/sessions/provider/claude.go +++ b/internal/sessions/provider/claude.go @@ -81,7 +81,7 @@ type ClaudeConfig struct { Identity *sessionsmcp.IdentitySpec // ClaudeToken is this launch's Claude long-term OAuth token, handed to the - // child on shim.TokenFD. Sandboxed local launches only; never cached across + // child on shim.TokenFD. Local launches only, sandboxed or unsandboxed; never cached across // calls, same rule as Identity, and never logged. ClaudeToken string @@ -1382,12 +1382,13 @@ func (p *ClaudeProvider) Alive() bool { } func (p *ClaudeProvider) SetPermissionMode(mode string) error { - if p.cfg.Identity != nil || p.cfg.SandboxProfile != "" { + if p.cfg.Identity != nil || p.cfg.SandboxProfile != "" || p.cfg.ClaudeToken != "" { // This launch's identity is single-use (a second Hello is refused), // and relay mints the identity and sandbox profile per launch, not // per spawn, so the Kill-then-Start restart below cannot reuse - // them. The caller must drive a real resume instead, which mints - // both fresh. + // them. The token is read from claude_token_file per launch and its + // pipe is spent after one spawn, so it cannot be reused either. The + // caller must drive a real resume instead, which mints all of them fresh. return ErrRestartNeedsResume } switch mode { diff --git a/internal/sessions/provider/shimspawn.go b/internal/sessions/provider/shimspawn.go index 96f7e48f..a0851ce3 100644 --- a/internal/sessions/provider/shimspawn.go +++ b/internal/sessions/provider/shimspawn.go @@ -52,10 +52,13 @@ type shimSpec struct { } // wanted reports whether spec asks for a shim-wrapped launch at all — a -// launch with neither a sandbox profile nor an identity to present has -// nothing the shim would do differently than a direct spawn, so Start skips -// it entirely rather than adding a needless hop. -func (s shimSpec) wanted() bool { return s.SandboxProfile != "" || s.Identity != nil } +// launch with no sandbox profile, no identity to present and no token to hand +// on has nothing the shim would do differently than a direct spawn, so Start +// skips it entirely rather than adding a needless hop. The token alone wants +// the shim: only the shim can put it on fd 5 without the environment. +func (s shimSpec) wanted() bool { + return s.SandboxProfile != "" || s.Identity != nil || s.ClaudeToken != "" +} // buildShimCmd wraps name+args as the target argv of a `relay-sessions exec` // invocation, mirroring internal/sessions/mcp's buildShimCommand and diff --git a/internal/sessions/session/manager.go b/internal/sessions/session/manager.go index 8937356e..aafcc8d2 100644 --- a/internal/sessions/session/manager.go +++ b/internal/sessions/session/manager.go @@ -299,6 +299,10 @@ type CreateSpec struct { Model string // Origin is who asked for the session, empty for the person. Origin string + // Unsandboxed marks a claude session with no project and no sandbox. The + // host saves it with the session and lists it; hostapi has already checked + // it fits no other shape. + Unsandboxed bool Settings json.RawMessage SystemPrompt string @@ -516,6 +520,9 @@ func (m *Manager) resolveSessionForCreate(spec CreateSpec, reused *sessionstypes if err != nil { return nil, fmt.Errorf("session: resume %s: not found on disk: %w", spec.SessionID, err) } + // Deliberate: the launch decides the mark, so a session never keeps + // "unsandboxed" from a saved file when relay resumes it in a project. + sess.Unsandboxed = spec.Unsandboxed return sess, nil } return buildNewSession(spec, m.cfg.clockOrDefault().Now()), nil @@ -587,6 +594,7 @@ func buildNewSession(spec CreateSpec, now time.Time) *sessionstypes.Session { Headless: parsed.Headless, Agent: parsed.Agent, Origin: spec.Origin, + Unsandboxed: spec.Unsandboxed, PermissionMode: mode, Policy: parsed.PermissionPolicy, Host: spec.Host, @@ -1841,6 +1849,8 @@ type Summary struct { Headless bool `json:"headless,omitempty"` // Origin is set when someone other than the person started the session. Origin string `json:"origin,omitempty"` + // Unsandboxed is set only on a session relay runs with no project and no sandbox. + Unsandboxed bool `json:"unsandboxed,omitempty"` Attention *attention.Attention `json:"attention,omitempty"` } @@ -1863,6 +1873,7 @@ func summarize(sess *sessionstypes.Session) Summary { Host: sess.Host.Chip(), Headless: sess.Headless, Origin: sess.Origin, + Unsandboxed: sess.Unsandboxed, } } diff --git a/internal/sessions/terminal/manager.go b/internal/sessions/terminal/manager.go index 7f4c6e21..7a390581 100644 --- a/internal/sessions/terminal/manager.go +++ b/internal/sessions/terminal/manager.go @@ -40,6 +40,9 @@ type Summary struct { ExitCode int `json:"exitCode,omitempty"` Host map[string]string `json:"host,omitempty"` Origin string `json:"origin,omitempty"` + // Unsandboxed is set only on a drop-in terminal of a session that runs with + // no project and no sandbox. + Unsandboxed bool `json:"unsandboxed,omitempty"` } // sessionSlot is one entry in Manager's table, including the window between @@ -58,6 +61,9 @@ type sessionSlot struct { // the table entry has been resolved. done chan struct{} session *Session // nil until the launch succeeds + // unsandboxed marks a terminal started by CreateUnsandboxed. It lives on + // the slot so it leaves the table with the terminal. + unsandboxed bool } // RootPID returns s's shim pid (SH §4.2's session root), or 0 if the shim @@ -119,12 +125,24 @@ func (m *Manager) SetOutputHandler(fn func(id string, data []byte)) { // under the same lock acquisition, so two concurrent Creates for the same // id can never both pass the check. func (m *Manager) Create(spec CreateSpec) (*Session, error) { + return m.create(spec, false) +} + +// CreateUnsandboxed starts a terminal with no sandbox profile, no launch +// identity and no host that may still carry the Claude token, and lists it as +// unsandboxed. The caller (hostapi) has already checked the launch is a +// drop-in terminal of an unsandboxed session. +func (m *Manager) CreateUnsandboxed(spec CreateSpec) (*Session, error) { + return m.create(spec, true) +} + +func (m *Manager) create(spec CreateSpec, unsandboxed bool) (*Session, error) { m.mu.Lock() if _, exists := m.sessions[spec.SessionID]; exists { m.mu.Unlock() return nil, fmt.Errorf("%w: %s", ErrSessionExists, spec.SessionID) } - slot := &sessionSlot{launching: true, done: make(chan struct{})} + slot := &sessionSlot{launching: true, done: make(chan struct{}), unsandboxed: unsandboxed} m.sessions[spec.SessionID] = slot m.mu.Unlock() @@ -144,7 +162,7 @@ func (m *Manager) Create(spec CreateSpec) (*Session, error) { m.mu.Lock() onOutput := m.onOutput m.mu.Unlock() - s, err := startSession(spec, m.cfg, onExit, onIdle, onOutput) + s, err := startSession(spec, m.cfg, unsandboxed, onExit, onIdle, onOutput) m.mu.Lock() stopping := slot.stopping @@ -237,6 +255,8 @@ func (m *Manager) ListSummary() []Summary { ExitCode: exitCode, Host: s.Host.Chip(), Origin: s.Origin, + + Unsandboxed: slot.unsandboxed, }) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) diff --git a/internal/sessions/terminal/session.go b/internal/sessions/terminal/session.go index eb10dff6..fe2d10ec 100644 --- a/internal/sessions/terminal/session.go +++ b/internal/sessions/terminal/session.go @@ -131,8 +131,14 @@ var ErrNoBridgeSocket = errors.New("terminal: identity requires a configured bri // before Manager.Create would otherwise have had a chance to set // session.onExit/onOutput, a real data race this signature closes by // construction rather than by convention. -func startSession(spec CreateSpec, cfg Config, onExit func(id string, exitCode int), onIdle func(id string), onOutput func(id string, data []byte)) (*Session, error) { - if err := spec.validate(); err != nil { +func startSession(spec CreateSpec, cfg Config, unsandboxed bool, onExit func(id string, exitCode int), onIdle func(id string), onOutput func(id string, data []byte)) (*Session, error) { + checked := spec + if unsandboxed && spec.Sandbox == nil && spec.Identity == nil && spec.Host == nil { + // Deliberate: the Claude token needs a sandbox only for a launch that is + // not unsandboxed, and CreateUnsandboxed's caller has marked this one. + checked.ClaudeToken = "" + } + if err := checked.validate(); err != nil { return nil, err } if spec.Identity != nil && cfg.BridgeSocket == "" { diff --git a/internal/sessions/types/session.go b/internal/sessions/types/session.go index 81a61687..44cc6218 100644 --- a/internal/sessions/types/session.go +++ b/internal/sessions/types/session.go @@ -26,7 +26,11 @@ type Session struct { Agent bool `json:"agent,omitempty"` // listed and state-tracked although headless // Origin names who started the session when it was not the person at a // frontend (OriginChiefOfStaff). - Origin string `json:"origin,omitempty"` + Origin string `json:"origin,omitempty"` + // Unsandboxed marks a Claude session that runs with no project and no + // sandbox. It is saved with the session, so it survives a relay-sessions + // restart. + Unsandboxed bool `json:"unsandboxed,omitempty"` ThinkingLevel string `json:"thinkingLevel,omitempty"` // pi-only: off/minimal/low/medium/high/xhigh // Project-scoped MCP tokens are NOT stored on the session. Relay is the // sole token authority: providers resolve the token just-in-time from