diff --git a/CHANGELOG.md b/CHANGELOG.md index 19df612c..25404004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ new version heading in the same commit. ## [Unreleased] +## [0.469.3] - 2026-11-10 +### Fixed +- **A resumed session no longer comes up "Not logged in" or as a blank conversation after a runtime account + is re-added.** Three gaps, one incident (instawp: an expired account was re-added under the same name and + every session that had run on it broke): + 1. *The conversation was stranded.* Re-adding moved the old credential dir aside to `.orphan-`, + taking every transcript made under it, and nothing pointed there afterwards. `claude --resume` found no + conversation and the launcher fell back to a fresh one under the same id (a 4-day, 102-turn session + re-opened blank). Archived dirs are now transcript roots, and every resume copies the conversation into + the dir it will run under when it isn't already there. It never overwrites a copy in place and writes + only into a pool dir or the box default. + 2. *The resume stayed on a dead account.* Credentials bind at launch, and every resurrect replayed the + same `CLAUDE_CONFIG_DIR` even after that account left the pool, was disabled or lost its login. Two + healthy accounts sat idle meanwhile. A resume now moves to a usable pool account first (rewriting the + launch env so later resurrects follow, audited `runtime.account.rotated` with `reason: resume: …`). + 3. *An empty login read as healthy.* Claude keeps MCP OAuth in `.credentials.json` too, so a re-added + account whose sign-in never finished had a credentials file holding no sign-in. That now counts as + dead, so the launch and resume pre-flights and the account picker stop treating it as usable. + Both resume steps run in one fail-open loopback call made by every `RESUME=1` launch before the credential + pre-flight (`POST /api/resume-prepare`, session secret), so browser reattach, Reload and server-launched + resumes are all covered. Audits `session.transcript.staged` / `session.transcript.missing`. Pinned by + `scripts/resume-transcript-stage-test.cjs`. + **For users:** Re-opening an old session after an admin re-signs-in a Claude account now picks up your conversation on a working account, instead of asking you to /login or starting over. + ## [0.469.1] - 2026-10-29 ### Fixed - **Talking to an unattended run no longer gets it closed under you.** A task- or automation-dispatched diff --git a/package-lock.json b/package-lock.json index 0f856780..6183aec5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.469.1", + "version": "0.469.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.469.1", + "version": "0.469.3", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 2b7a7dc0..d5199011 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.469.1", + "version": "0.469.3", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -27,7 +27,7 @@ "check-deps": "bash scripts/install-deps.sh --check", "dev": "ts-node src/cli.ts serve", "demo:dev": "ts-node src/demo.ts", - "test:governance": "node scripts/version-sync-test.cjs && node scripts/router-dispatch-test.cjs && node scripts/prompt-credential-guard-test.cjs && node scripts/standing-instructions-test.cjs && node scripts/session-trust-test.cjs && node scripts/video-gen-mapping-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/cron-supersede-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/connector-auth-stub-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-pool-signed-out-test.cjs && node scripts/login-expiry-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/unblock-run-dedupe-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/session-search-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/launch-window-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/protected-read-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/goal-heartbeat-test.cjs && node scripts/question-durability-test.cjs && node scripts/ask-block-link-test.cjs && node scripts/answer-pinning-test.cjs && node scripts/bets-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", + "test:governance": "node scripts/version-sync-test.cjs && node scripts/router-dispatch-test.cjs && node scripts/prompt-credential-guard-test.cjs && node scripts/standing-instructions-test.cjs && node scripts/session-trust-test.cjs && node scripts/video-gen-mapping-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/cron-supersede-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/connector-auth-stub-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-pool-signed-out-test.cjs && node scripts/login-expiry-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/resume-transcript-stage-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/unblock-run-dedupe-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/session-search-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/launch-window-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/protected-read-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/goal-heartbeat-test.cjs && node scripts/question-durability-test.cjs && node scripts/ask-block-link-test.cjs && node scripts/answer-pinning-test.cjs && node scripts/bets-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", "test:alert-staleness": "node scripts/alert-staleness-test.cjs", "test:deps": "node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-login-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs", "test:dm-continuity": "node scripts/dm-continuity-test.cjs", diff --git a/scripts/resume-transcript-stage-test.cjs b/scripts/resume-transcript-stage-test.cjs new file mode 100644 index 00000000..4bbd7ff8 --- /dev/null +++ b/scripts/resume-transcript-stage-test.cjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/* Resume-preparation test — a resume must land on an account that can sign in, and find its conversation + * wherever it ended up on disk. + * + * Live incident (instawp, 2026-11-10): a pooled account's login expired, the operator re-added it under the + * same name, and the guided login moved the old credential dir aside to `.orphan-` — taking the + * transcript of a 4-day, 102-turn session with it. `claude --resume` only looks under + * `$CLAUDE_CONFIG_DIR/projects/`, nothing pointed at the archived dir, so the launcher fell back to + * `--session-id` and the session re-opened as a BLANK conversation under its own id. + * + * Pins: archived dirs are transcript roots; staging copies the conversation into the dir the resume runs + * under; it never overwrites a copy already there; it never writes outside dirs the server vouches for; + * and the launcher stages before it resumes. Isolated home; no tmux, no network. */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-stage-test-')); +process.env.AGENT_OS_HOME = HOME; +process.env.AGENT_OS_TENANT = 'testco'; +process.env.AOS_NO_TTYD = '1'; +delete process.env.AGENT_OS_SECRET_KEY; +// Keep the reader (and any write) away from the developer's real ~/.claude. +process.env.CLAUDE_CONFIG_DIR = path.join(HOME, 'box-claude'); + +let pass = 0, fail = 0; +const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d ? ' — ' + d : ''}`)); + +const ACCTS = path.join(HOME, 'runtime-accounts', 'claude-code'); +const PROJECT = '-home-agent-os-agents-ceo'; +const CLAUDE_ID = '22222222-0000-4000-8000-000000000001'; +const REAL = ['{"type":"user","message":{"role":"user","content":"turn 1"}}', '{"type":"assistant","message":{"role":"assistant","content":"reply"}}', '{"type":"user","message":{"role":"user","content":"turn 2"}}'].join('\n') + '\n'; + +const credDir = (dir) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '.credentials.json'), JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-oat01-x', refreshToken: 'r', expiresAt: Date.now() + 864e5 } })); + return dir; +}; +const transcriptAt = (dir) => path.join(dir, 'projects', PROJECT, `${CLAUDE_ID}.jsonl`); + +// The incident's disk state, built BEFORE the server boots: the old `vikas` dir holding the real +// conversation has been archived, and a fresh `vikas` dir (the re-add) holds a login but no transcript. +const archived = path.join(ACCTS, 'vikas.orphan-1794293493060'); +fs.mkdirSync(path.dirname(transcriptAt(archived)), { recursive: true }); +fs.writeFileSync(transcriptAt(archived), REAL); +const dirV = credDir(path.join(ACCTS, 'vikas')); + +const { loadAgentOS } = require(path.join(ROOT, 'dist/kernel.js')); +const { TerminalManager } = require(path.join(ROOT, 'dist/terminal.js')); +const { findTranscript } = require(path.join(ROOT, 'dist/edge/conversation.js')); + +const aos = loadAgentOS(); +aos.runtimeAccounts.add({ runtime: 'claude-code', name: 'vikas', kind: 'oauth', configDir: dirV }); +const tm = new TerminalManager(aos, 'http://127.0.0.1:0', path.join(HOME, 'tmux.sock')); + +aos.db.prepare("INSERT INTO term_sessions (id,agent,title,task,tmux,status,headless,resident,spawned_by,claude_session_id,runtime_account,created_at,updated_at) VALUES ('ses_1','ceo','t','x','aos-ses_1','crashed',0,0,'m_alice',?,'vikas',?,?)") + .run(CLAUDE_ID, Date.now(), Date.now()); +aos.db.prepare("INSERT INTO term_sessions (id,agent,title,task,tmux,status,headless,resident,spawned_by,claude_session_id,created_at,updated_at) VALUES ('ses_2','ceo','t','x','aos-ses_2','crashed',0,0,'m_alice','33333333-0000-4000-8000-000000000009',?,?)") + .run(Date.now(), Date.now()); +const auditTypes = () => aos.db.prepare("SELECT type FROM audit_events WHERE type LIKE 'session.transcript.%'").all().map((r) => r.type); + +console.log('\n\x1b[1m1) an archived credential dir is still a transcript root\x1b[0m'); +const found = findTranscript(CLAUDE_ID); +assert(found && found.startsWith(archived), 'findTranscript reaches the copy inside `.orphan-`', String(found)); + +console.log('\n\x1b[1m2) staging carries the conversation into the dir the resume runs under\x1b[0m'); +assert(!fs.existsSync(transcriptAt(dirV)), 'precondition: the re-added dir has no transcript (the blank-resume state)'); +assert(tm.stageResumeTranscript('ses_1', dirV) === 'staged', 'stage → staged'); +assert(fs.existsSync(transcriptAt(dirV)) && fs.readFileSync(transcriptAt(dirV), 'utf8') === REAL, 'the full conversation is now where `claude --resume` looks'); +assert(fs.readFileSync(transcriptAt(archived), 'utf8') === REAL, 'copied, not moved: the archived copy is untouched'); +assert(auditTypes().includes('session.transcript.staged'), 'audited session.transcript.staged'); + +console.log('\n\x1b[1m3) a copy already in the target is never overwritten\x1b[0m'); +fs.appendFileSync(transcriptAt(dirV), '{"type":"user","message":{"role":"user","content":"turn 3, made after staging"}}\n'); +const before = fs.readFileSync(transcriptAt(dirV), 'utf8'); +assert(tm.stageResumeTranscript('ses_1', dirV) === 'present', 'second stage → present'); +assert(fs.readFileSync(transcriptAt(dirV), 'utf8') === before, 'the newer turn survives'); + +console.log('\n\x1b[1m4) never writes outside a dir the server vouches for\x1b[0m'); +const rogue = path.join(HOME, 'somewhere-else'); +assert(tm.stageResumeTranscript('ses_1', rogue) === 'skipped', 'a path that is neither a pool dir nor the box default → skipped'); +assert(!fs.existsSync(path.join(rogue, 'projects')), 'nothing written there'); +assert(tm.stageResumeTranscript('ses_1', '') === 'staged', 'empty configDir = the box default, which IS vouched for'); +assert(fs.existsSync(transcriptAt(process.env.CLAUDE_CONFIG_DIR)), 'staged into the box default dir'); + +console.log('\n\x1b[1m5) nothing to stage is said out loud\x1b[0m'); +assert(tm.stageResumeTranscript('ses_2', dirV) === 'missing', 'a conversation found nowhere → missing'); +assert(auditTypes().includes('session.transcript.missing'), 'audited session.transcript.missing'); +assert(tm.stageResumeTranscript('ses_nope', dirV) === 'skipped', 'unknown session → skipped'); + +console.log('\n\x1b[1m6) a resume pinned to an account that can no longer sign in moves to one that can\x1b[0m'); +const { credentialReadiness } = require(path.join(ROOT, 'dist/edge/runtime-account-check.js')); +const dirT = credDir(path.join(ACCTS, 'tools')); +aos.runtimeAccounts.add({ runtime: 'claude-code', name: 'tools', kind: 'oauth', configDir: dirT }); +// The incident's re-added dir: a `.credentials.json` holding only MCP-server OAuth — no sign-in at all. +const dirHalf = path.join(ACCTS, 'half'); +fs.mkdirSync(dirHalf, { recursive: true }); +fs.writeFileSync(path.join(dirHalf, '.credentials.json'), JSON.stringify({ mcpOAuth: { 'some-server': { accessToken: 'x' } } })); +const r0 = credentialReadiness('claude-code', dirHalf); +assert(!r0.ok && r0.reason === 'expired', 'a credentials file with no access/refresh token is NOT a usable login', JSON.stringify(r0)); +assert(credentialReadiness('claude-code', dirT).ok, 'a real login still reads as usable'); +const envFile = (id) => path.join(aos.paths.connectors, `session-${id}.env`); +const mkResumable = (id, dir, account) => { + aos.db.prepare("INSERT INTO term_sessions (id,agent,title,task,tmux,status,headless,resident,spawned_by,claude_session_id,runtime_account,created_at,updated_at) VALUES (?,'ceo','t','x',?,'crashed',0,0,'m_alice',?,?,?,?)") + .run(id, 'aos-' + id, CLAUDE_ID, account, Date.now(), Date.now()); + fs.mkdirSync(aos.paths.connectors, { recursive: true }); + fs.writeFileSync(envFile(id), `export CLAUDE_SESSION_ID='${CLAUDE_ID}'\nexport CLAUDE_CONFIG_DIR='${dir}'\n`); +}; +// (a) pinned to a dir no pool account owns any more +const gone = path.join(ACCTS, 'gone'); +credDir(gone); +mkResumable('ses_gone', gone, 'gone'); +const moved = tm.repinResumeAccount('ses_gone', gone); +assert(moved && fs.realpathSync(moved) !== fs.realpathSync(gone), 'an account removed from the pool → moved', String(moved)); +assert(fs.readFileSync(envFile('ses_gone'), 'utf8').includes(moved), 'the persisted launch env now names the new dir (so the NEXT resurrect lands there too)'); +assert(aos.db.prepare("SELECT runtime_account a FROM term_sessions WHERE id='ses_gone'").get().a !== 'gone', 'the row names the new account'); +const rot = aos.db.prepare("SELECT data FROM audit_events WHERE run_id='ses_gone' AND type='runtime.account.rotated'").get(); +assert(rot && JSON.parse(rot.data).reason === 'resume: not in the account pool', 'audited runtime.account.rotated with the reason', rot && rot.data); +// (b) a pool account whose dir holds no sign-in +aos.runtimeAccounts.add({ runtime: 'claude-code', name: 'half', kind: 'oauth', configDir: dirHalf }); +mkResumable('ses_half', dirHalf, 'half'); +const moved2 = tm.repinResumeAccount('ses_half', dirHalf); +assert(moved2 && fs.realpathSync(moved2) !== fs.realpathSync(dirHalf), 'a pool account with no sign-in in its dir → moved', String(moved2)); +// (c) healthy and box-default resumes are left alone +mkResumable('ses_ok', dirT, 'tools'); +assert(tm.repinResumeAccount('ses_ok', dirT) === null, 'a healthy pinned account → left alone'); +assert(fs.readFileSync(envFile('ses_ok'), 'utf8').includes(dirT), 'its env is untouched'); +assert(tm.repinResumeAccount('ses_ok', '') === null, 'the box default (no dir) → left alone'); + +console.log('\n\x1b[1m7) the launcher prepares before the pre-flight and before it resumes, on both lanes\x1b[0m'); +const sh = fs.readFileSync(path.join(ROOT, 'terminal/claude-launch.sh'), 'utf8'); +const call = sh.indexOf('then prepare_resume; fi'); +const preflight = sh.indexOf('! preflight_credentials; then'); +assert(call > 0 && call < preflight, 'prepare_resume runs before the credential pre-flight (it can change the dir)'); +const lanes = sh.indexOf('if [ "${RESIDENT:-}" = "1" ] || [ "${UNATTENDED:-}" = "1" ]; then'); +const firstResume = sh.search(/^\s+claude --resume "\$CLAUDE_SESSION_ID"/m); +assert(call > 0 && call < lanes, 'prepare_resume runs before the unattended/interactive split'); +assert(call < firstResume, 'prepare_resume runs before any `claude --resume`'); +const srv = fs.readFileSync(path.join(ROOT, 'src/server.ts'), 'utf8'); +const route = srv.indexOf("p === '/api/resume-prepare'"); +assert(route > 0 && srv.slice(route, route + 600).includes('sessionSecretOk(session)'), '/api/resume-prepare is session-secret gated'); + +fs.rmSync(HOME, { recursive: true, force: true }); +console.log(`\n${fail ? '\x1b[31m' : '\x1b[32m'}${pass} passed, ${fail} failed\x1b[0m`); +process.exit(fail ? 1 : 0); diff --git a/src/edge/conversation.ts b/src/edge/conversation.ts index 88d1bc56..68fe5634 100644 --- a/src/edge/conversation.ts +++ b/src/edge/conversation.ts @@ -83,7 +83,8 @@ export interface Conversation { found: boolean; } -const claudeConfigDir = (): string => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); +/** The config dir the SERVER's own claude would use — the box default a run with no pooled account writes under. */ +export const claudeConfigDir = (): string => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); /** Extra config dirs to search beyond the server's own. A session launched under a ROTATION-POOL account * runs with `CLAUDE_CONFIG_DIR=`, so claude writes its transcript under THAT dir's @@ -98,6 +99,18 @@ export function registerTranscriptRoot(dir: string | undefined | null): void { if (dir) extraRoots.add(dir); } +/** The copy of `.jsonl` under ONE root's `projects/`, if it has one. */ +export function transcriptInRoot(root: string, claudeSessionId: string): string | undefined { + const projects = path.join(root, 'projects'); + let dirs: string[]; + try { dirs = fs.readdirSync(projects); } catch { return undefined; } + for (const d of dirs) { + const candidate = path.join(projects, d, `${claudeSessionId}.jsonl`); + try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* not in this project dir */ } + } + return undefined; +} + /** * Locate `.jsonl`. The filename is unique per root, so cwd escaping is irrelevant — but * the SAME id can exist in SEVERAL roots, and that is the whole subtlety here. diff --git a/src/edge/runtime-account-check.ts b/src/edge/runtime-account-check.ts index 31c33b9c..74147890 100644 --- a/src/edge/runtime-account-check.ts +++ b/src/edge/runtime-account-check.ts @@ -285,6 +285,12 @@ function isDeadCredential(runtime: CodingRuntimeId, dir: string, now = Date.now( if (runtime !== 'claude-code') return null; const rec = readCredentialRecord(dir); const o = rec?.claudeAiOauth ?? rec; + // A record that carries NO login at all — neither an access nor a refresh token — is dead, not "no stated + // expiry". Claude also keeps MCP-server OAuth in this file, so an account dir whose sign-in never finished + // (re-added, abandoned at the browser step) still has a `.credentials.json`, holding only `mcpOAuth`. + // Reading that as healthy let every resume pinned to it come up "Not logged in · Please run /login" + // (instawp, 2026-11-10). 0 = "no token in it", which expiryPhrase already words correctly. + if (rec && typeof rec === 'object' && !o?.accessToken && !o?.refreshToken) return 0; const exp = o?.expiresAt; if (typeof exp !== 'number' || exp > now) return null; // no stated expiry, or still valid // `exp` is returned as-is and may be 0 — the live instawp box default literally carried `expiresAt: 0` diff --git a/src/server.ts b/src/server.ts index 5403b4ba..5c14ff0d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1985,6 +1985,19 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const blocked = tm.checkResumeCredentials(session, String(b.configDir || ''), runtime); return sendJson(res, 200, blocked ? { ok: false, ...blocked } : { ok: true }); } + // Prepare a resume (claude-launch.sh, every RESUME=1, BEFORE the credential check): move it off a + // credential dir that can no longer sign in (`repinResumeAccount`), then stage the transcript into the dir + // it will run under (`stageResumeTranscript`). A returned `configDir` is the launcher's new + // CLAUDE_CONFIG_DIR. Fails OPEN: any non-answer resumes exactly as before. + if (method === 'POST' && p === '/api/resume-prepare') { + const b = await readBody(req); + const session = String(b.session || ''); + if (!tm.hasSession(session)) return sendJson(res, 404, { error: 'unknown session' }); + if (!sessionSecretOk(session)) return sendJson(res, 403, { error: 'bad session secret' }); + const moved = tm.repinResumeAccount(session, String(b.configDir || '')); + const transcript = tm.stageResumeTranscript(session, moved ?? String(b.configDir || '')); + return sendJson(res, 200, { ok: true, transcript, ...(moved ? { configDir: moved } : {}) }); + } if (method === 'POST' && p === '/api/resumed') { const b = await readBody(req); const session = String(b.session || ''); diff --git a/src/terminal.ts b/src/terminal.ts index f1362592..808f2a4f 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -53,7 +53,7 @@ import { readSessionCost } from './edge/session-cost'; import { readTranscriptEnd } from './edge/outcome'; import { pendingBackgroundWork, BACKGROUND_GRACE_MS, UNATTENDED_TURN_BRIEF, WAITING_BRIEF } from './edge/background-work'; import { findCodexRollout, readCodexCost, readCodexConversation } from './edge/codex-transcript'; -import { readConversation, readConversationBounded, findTranscript, registerTranscriptRoot, Conversation } from './edge/conversation'; +import { readConversation, readConversationBounded, findTranscript, registerTranscriptRoot, transcriptInRoot, claudeConfigDir, Conversation } from './edge/conversation'; import { digestConversation, sessionConsoleUrl, toSessionFtsQuery, SESSION_TEXT_READ_HEAD, SESSION_TEXT_READ_MAX, SESSION_TEXT_REREAD_MS } from './edge/session-search'; // Video render tuning: a submitted job renders async. The in-call path polls briefly for the fast case; @@ -1245,6 +1245,99 @@ export class TerminalManager { * invisible for the lifetime of the process. Cheap (one small query) and idempotent. */ private refreshTranscriptRoots(): void { try { for (const dir of this.os.runtimeAccounts.configDirs()) registerTranscriptRoot(dir); } catch { /* pool is optional */ } + // ARCHIVED credential dirs too. Re-adding an account under a name whose dir is still on disk moves that + // dir aside to `.orphan-` (runtime-login.ts) — and with it every transcript of every run made + // under the old login. No pool row points there any more, so without this those conversations became + // unreachable: instawp, 2026-11-10, a 4-day, 102-turn session re-opened as a blank conversation under + // its own id because its transcript had been moved aside one minute earlier. + if (!this.os.paths) return; + const base = path.join(this.os.paths.home, 'runtime-accounts'); + let runtimes: string[]; + try { runtimes = fs.readdirSync(base); } catch { return; } + for (const rt of runtimes) { + let names: string[]; + try { names = fs.readdirSync(path.join(base, rt)); } catch { continue; } + for (const n of names) if (/\.orphan-\d+$/.test(n)) registerTranscriptRoot(path.join(base, rt, n)); + } + } + + /** + * Move a resume off a credential dir that can no longer sign in. Credentials bind ONCE, at launch: + * `session-.env` pins `CLAUDE_CONFIG_DIR` to the account the run started on, and every resurrect + * replays that file. So an account removed from the pool, disabled, or re-added and never finished kept + * receiving its old sessions — each one coming up "Not logged in · Please run /login" while two healthy + * accounts sat idle (instawp, 2026-11-10). Called from the same `RESUME=1` hook as staging, BEFORE the + * credential pre-flight, so a resume lands on a usable account instead of being refused or stranded. + * + * Leaves alone: the box default (no dir), a dir whose pool account is healthy, and a locked keychain + * (box-wide — no other account would fare better; the pre-flight reports it). Returns the new dir, having + * rewritten the launch env and the row, or null to resume as-is. + */ + repinResumeAccount(sessionId: string, configDir: string): string | null { + try { + if (!configDir) return null; + const agent = this.sessionAgent(sessionId); + if (!agent) return null; + const manifest = this.os.agents.get(agent); + const runtime: CodingRuntimeId = isCodingRuntime(manifest?.runtime) ? manifest!.runtime : 'claude-code'; + const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } }; + const owner = this.os.runtimeAccounts.list().find((a) => a.runtime === runtime && a.kind === 'oauth' && a.configDir && real(a.configDir) === real(configDir)); + const state = credentialReadiness(runtime, configDir); + if (!state.ok && state.reason === 'keychain_locked') return null; + const why = !owner ? 'not in the account pool' : !owner.enabled ? 'account disabled' : !state.ok ? `credential ${state.reason}` : null; + if (!why) return null; + const acct = this.pickUsableAccount(runtime, { kinds: ['oauth'], exclude: owner ? [owner.name] : [] }, sessionId, agent); + if (!acct?.configDir || real(acct.configDir) === real(configDir)) return null; + const resolved = this.credentialEnvFor(acct, runtime, sessionId, agent); + if (!resolved || !this.rewriteLaunchEnv(sessionId, runtime, resolved.vars)) return null; + const from = this.db.prepare('SELECT runtime_account a FROM term_sessions WHERE id = ?').get<{ a: string | null }>(sessionId)?.a ?? null; + this.db.prepare('UPDATE term_sessions SET runtime_account = ? WHERE id = ?').run(acct.name, sessionId); + this.audit(sessionId, agent, 'runtime.account.rotated', { runtime, from, to: acct.name, kind: acct.kind, via: resolved.varName, reason: `resume: ${why}` }); + return acct.configDir; + } catch { + return null; + } + } + + /** + * Make sure a resume finds its conversation. Called by `claude-launch.sh` (loopback, session secret) on + * EVERY `RESUME=1` launch — browser reattach, Reload, a server-launched resume — right before + * `claude --resume`, because the transcript lives under `$CLAUDE_CONFIG_DIR/projects/` and nothing else + * guarantees the dir the resume will run under is the one that holds it. When it doesn't, claude finds + * "no conversation", the launcher falls back to `--session-id` and the human gets a BLANK conversation + * re-seeded with the original prompt — under the same id, so it looks like a resume that lost its memory. + * + * Copies the newest copy from any known root (pool dirs, archived dirs, the box default) into the target. + * Never overwrites a copy the target already has, and only ever writes into a dir the server itself + * vouches for (a pool account's dir or the box default): the route is reachable with a session secret an + * agent holds, so a client-supplied path must never become a write target. + */ + stageResumeTranscript(sessionId: string, configDir: string): 'present' | 'staged' | 'missing' | 'skipped' { + try { + const row = this.db.prepare('SELECT agent, claude_session_id FROM term_sessions WHERE id = ?') + .get<{ agent: string; claude_session_id: string | null }>(sessionId); + if (!row?.claude_session_id) return 'skipped'; + const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } }; + const target = configDir || claudeConfigDir(); + let pool: string[] = []; + try { pool = this.os.runtimeAccounts.configDirs(); } catch { /* pool is optional */ } + if (![claudeConfigDir(), ...pool].some((d) => real(d) === real(target))) return 'skipped'; + if (transcriptInRoot(target, row.claude_session_id)) return 'present'; + this.refreshTranscriptRoots(); + const src = findTranscript(row.claude_session_id); + if (!src) { + this.audit(sessionId, row.agent, 'session.transcript.missing', { claudeSessionId: row.claude_session_id, dir: target }); + return 'missing'; + } + const destDir = path.join(target, 'projects', path.basename(path.dirname(src))); + fs.mkdirSync(destDir, { recursive: true }); + fs.copyFileSync(src, path.join(destDir, `${row.claude_session_id}.jsonl`)); + registerTranscriptRoot(target); + this.audit(sessionId, row.agent, 'session.transcript.staged', { from: src, to: destDir }); + return 'staged'; + } catch { + return 'skipped'; + } } /** The launcher "space" (member-uid identity) a session runs in: the spawning member, or a shared diff --git a/terminal/claude-launch.sh b/terminal/claude-launch.sh index 71ae7945..158fc7e9 100755 --- a/terminal/claude-launch.sh +++ b/terminal/claude-launch.sh @@ -396,6 +396,37 @@ preflight_credentials() { dim "Fix the credential (Settings → Runtime → Runtime accounts), then reopen this session." return 1 } +# RESUME preparation — BEFORE the credential pre-flight, because it can change which credential dir we +# run under. Two ways a resume used to go wrong with nothing in the server's path to notice (attach.sh +# resurrects in pure shell): +# 1. ACCOUNT. Credentials bind once, at launch: the persisted env pins CLAUDE_CONFIG_DIR to the account +# the run started on. Remove, disable or re-add that account and every resume still went there and +# came up "Not logged in · Please run /login". The server moves it to a usable pool account and hands +# back the new dir. +# 2. TRANSCRIPT. `claude --resume` only looks under $CLAUDE_CONFIG_DIR/projects/. If the conversation is +# elsewhere (a re-added account's old dir is moved aside to `.orphan-*`, or the run moved +# accounts), claude finds nothing, we fall back to `--session-id` below and the human gets a BLANK +# conversation under the same id. The server copies it into place. +# Fails open: no AOS_URL, an unreachable server or any odd answer resumes exactly as before. +prepare_resume() { + [ -n "${AOS_URL:-}" ] && [ -n "${SESSION:-}" ] || return 0 + local body out dir + body=$(node -e 'console.log(JSON.stringify({session:process.argv[1],configDir:process.argv[2]||""}))' \ + "$SESSION" "${CLAUDE_CONFIG_DIR:-}" 2>/dev/null) || return 0 + out=$(curl -s -m 15 -X POST "$AOS_URL/api/resume-prepare" -H 'content-type: application/json' \ + -H "x-aos-secret: ${AOS_SECRET:-}" -H "x-aos-tenant: ${AOS_TENANT:-}" -d "$body" 2>/dev/null) || return 0 + dir=$(printf '%s' "$out" | node -e ' + let s = ""; + process.stdin.on("data", (d) => { s += d; }); + process.stdin.on("end", () => { try { const j = JSON.parse(s); if (j && typeof j.configDir === "string") process.stdout.write(j.configDir); } catch {} });' 2>/dev/null) || return 0 + if [ -n "$dir" ] && [ -d "$dir" ]; then + export CLAUDE_CONFIG_DIR="$dir" + dim "runtime account: the session's login is unusable — resuming on $(basename "$dir")" + fi + return 0 +} +if [ "${RESUME:-}" = "1" ] && [ -n "${CLAUDE_SESSION_ID:-}" ]; then prepare_resume; fi + if [ "$RESUMED_FROM_ENV" = "1" ] && ! preflight_credentials; then # Hold the pane open rather than exiting: ttyd re-dials the instant a pane dies, which would spin this # same refusal in a loop. A shell lets the human read the reason and close the tab.