Skip to content
27 changes: 27 additions & 0 deletions cmd/devboxverify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<nonce>-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 <uuid>`, 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-<nonce>-adopt-uns-2` that is in the
same `<uuid>.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
Expand Down
171 changes: 147 additions & 24 deletions cmd/devboxverify/journey_adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"bufio"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand All @@ -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
Expand All @@ -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`)
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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

Expand All @@ -118,25 +155,25 @@ 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
if !pollUntil(ctx, adoptIdleWait, func() bool {
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"`
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/relay/outside_session_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading