diff --git a/CHANGELOG.md b/CHANGELOG.md index 50bef781..fac05d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ new version heading in the same commit. ## [Unreleased] +## [0.462.1] - 2026-10-13 +### Fixed +- **Adding a Claude runtime account from the console no longer hangs on "the authorization link appears + here in a moment…".** The guided login ran the bare `claude` TUI, whose pre-browser screens depend on + the config dir: a dir left over from a removed account (on macOS the login is in the Keychain, so there + is no `.credentials.json` and the "already holds a login" guard passed) was already onboarded, so the + CLI skipped straight to the folder-trust dialog and never printed a URL. It now runs `claude auth login + --claudeai`, which prints the link immediately whatever the dir holds, wrapped in a retry loop that keeps + the fresh-link-after-a-rejected-code recovery; any non-empty leftover dir is archived aside (never + reused, never deleted on cancel — which would have taken its run history with it); and a rejected + attempt's link can no longer be re-offered or its error counted twice. + **For admins:** signing in a new Claude account under Settings → Runtime accounts shows its link right away again, including when you reuse an old account's name. + ## [0.462.0] - 2026-10-13 ### Security - **An agent's shell can no longer read the tenant DB, the connector bag, the vault key or other diff --git a/package-lock.json b/package-lock.json index 5891492f..83ce64e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.462.0", + "version": "0.462.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.462.0", + "version": "0.462.1", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index f6dc4959..63151933 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.462.0", + "version": "0.462.1", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/scripts/runtime-login-test.cjs b/scripts/runtime-login-test.cjs index 90f469c3..10e883d2 100644 --- a/scripts/runtime-login-test.cjs +++ b/scripts/runtime-login-test.cjs @@ -52,7 +52,10 @@ pane = THEME; let s = m.start('claude-code', 'overflow-1'); assert(s.phase === 'starting' && spawned.length === 1, 'start() spawns the runtime with its config dir pointed at a fresh dir'); assert(spawned[0].env.CLAUDE_CONFIG_DIR === path.join(accountsDir, 'claude-code', 'overflow-1'), 'the credential dir is inside the tenant home', JSON.stringify(spawned[0].env)); -assert(spawned[0].argv.join(' ') === 'claude', 'it runs the vendor CLI alone — no launch script, no task, no gate env', spawned[0].argv.join(' ')); +assert(spawned[0].argv[0] === 'sh' && spawned[0].argv.slice(-4).join(' ') === 'claude auth login --claudeai', + 'it runs the vendor CLI\'s dedicated login command alone — no launch script, no task, no gate env', spawned[0].argv.join(' ')); +assert(/Press Enter to retry/.test(spawned[0].argv[2]) && /clear-history/.test(spawned[0].argv[2]), + 'wrapped in a retry loop that re-prints the retry prompt on a wiped pane (auth login exits on a bad code)', spawned[0].argv[2]); assert(spawned[0].cols >= 600, 'the pane is spawned wide so the CLI does not hard-wrap the authorize URL', String(spawned[0].cols)); assert(fs.existsSync(spawned[0].env.CLAUDE_CONFIG_DIR), 'the dir exists before the CLI needs it'); @@ -103,6 +106,9 @@ s = m.poll(s.id); const beforeSubmit = typed.length; m.submitCode(s.id, 'nope'); assert(typed[beforeSubmit].enterPresses === 1, 'the code is submitted with exactly ONE Enter — a second lands on the retry prompt and re-arms the flow'); +pane = 'Login failed: Request failed with status code 400'; // caught before the retry prompt printed +let early = m.poll(s.id); +assert(early.phase === 'exchanging' && early.codeAttempts === 0, 'an error caught before its retry prompt waits a poll instead of re-arming without the Enter', JSON.stringify(early)); pane = 'OAuth error: Request failed with status code 400\n Press Enter to retry.'; const beforeRetry = typed.length; s = m.poll(s.id); @@ -111,11 +117,18 @@ assert(/rejected/.test(s.notice || ''), 'and says so, telling the human the earl assert(s.codeAttempts === 1, 'the rejection is counted'); assert(typed.length === beforeRetry + 1 && typed[beforeRetry].enterPresses === 1, 'exactly one Enter is pressed on the retry prompt'); assert(audits.some((a) => a.type === 'runtime.account.login.code.rejected'), 'the rejection is audited'); +s = m.poll(s.id); // the error is still on screen while the retry spins up +assert(s.codeAttempts === 1 && s.phase === 'starting', 'an error still on screen is not counted again — only a submitted code can be rejected', JSON.stringify(s)); // The retry prints a FRESH url — that, not the stale one, is what the console must show. const URL2 = URL.replace('state=xyz', 'state=fresh2'); pane = `Browser didn't open? Use the url below to sign in (c to copy)\n${URL2}\n Paste code here if prompted >`; s = m.poll(s.id); assert(s.phase === 'awaiting-code' && s.url === URL2, 'the fresh link replaces the dead one', JSON.stringify(s)); +{ // a retry that leaves the dead attempt in scrollback: the LAST url wins, and the old error is not re-counted + const { extractAuthorizeUrl } = require(path.join(ROOT, 'dist/edge/runtime-login.js')); + const stacked = `${URL_SCREEN}\nLogin failed: Request failed with status code 400\nPress Enter to retry\n${URL2}\n Paste code here if prompted >`; + assert(extractAuthorizeUrl(stacked) === URL2, 'with two links on screen, the newest is the one surfaced'); +} m.submitCode(s.id, 'nope2'); assert(!m.poll(s.id).notice, 'submitting again clears the stale notice'); // …but it does not loop forever: a code that keeps failing ends with the manual fallback. @@ -203,6 +216,18 @@ assert(archived.length === 1 && fs.existsSync(path.join(accountsDir, 'claude-cod assert(audits.some((a) => a.type === 'runtime.account.login.orphan.archived'), 'and the move is audited'); m.cancel(orphanStart.id); +// macOS keeps the login in the Keychain, so a leftover dir has NO credential file — only the CLI's +// `.claude.json` (onboarded) and run history. Reusing it is what hung instapods on the folder-trust dialog +// (2026-10-13), and cancelling then would have DELETED that history. It must be archived like any orphan. +fs.mkdirSync(path.join(accountsDir, 'claude-code', 'kcorphan', 'projects'), { recursive: true }); +fs.writeFileSync(path.join(accountsDir, 'claude-code', 'kcorphan', '.claude.json'), JSON.stringify({ hasCompletedOnboarding: true })); +const kcStart = m.start('claude-code', 'kcorphan'); +assert(kcStart.phase === 'starting' && !fs.existsSync(path.join(accountsDir, 'claude-code', 'kcorphan', '.claude.json')), + 'a leftover dir with no credential file (Keychain login) is not reused — the CLI gets a clean dir'); +assert(fs.readdirSync(path.join(accountsDir, 'claude-code')).some((d) => d.startsWith('kcorphan.orphan-')), + 'and the leftover is archived, not deleted'); +m.cancel(kcStart.id); + assert(m.supported('codex').ok === false, 'a runtime whose login flow has not been walked is not offered'); assert(m.supported('claude-code').ok === true, 'claude-code is offered'); diff --git a/src/edge/runtime-login.ts b/src/edge/runtime-login.ts index 64fd804f..5635f7cb 100644 --- a/src/edge/runtime-login.ts +++ b/src/edge/runtime-login.ts @@ -64,6 +64,8 @@ interface Login extends LoginState { tmux: string; /** Prompts already answered, so a poll can't press Enter twice on the same screen. */ answered: Set; + /** URLs of attempts the runtime already rejected — their PKCE challenge is spent. */ + deadUrls: Set; codeAt?: number; /** When we first saw an authorize URL that wasn't yet whole — lets us wait out a mid-render capture * before deciding the link is genuinely clipped. */ @@ -95,7 +97,9 @@ const looksWhole = (u: string): boolean => /[?&]state=[^&\s]+/.test(u); * whether it's complete enough to show, so a still-partial reassembly is treated as "not ready yet". */ export function extractAuthorizeUrl(pane: string): string | undefined { const lines = pane.split('\n'); - const start = lines.findIndex((l) => URL_START_RE.test(l)); + // The LAST one: after a retry the pane can still hold the previous attempt's (now dead) URL above it. + let start = -1; + for (let i = lines.length - 1; i >= 0; i--) if (URL_START_RE.test(lines[i])) { start = i; break; } if (start < 0) return undefined; let url = lines[start].match(URL_RE)?.[0]; if (!url) return undefined; @@ -181,7 +185,10 @@ export class RuntimeLoginManager { // box to delete a directory — the exact class of "the easy path silently doesn't work" this surface // exists to remove. Move it aside instead and continue into a clean dir. Moved, never deleted: the // dir also holds the transcripts of every run made under that account. - if (fs.existsSync(path.join(dir, spec.credentialEnv.configDirFile))) { + // "Holds a login" is judged by the dir having ANY content, not by the credential file: on macOS the + // login lives in the Keychain, so an old dir has no `.credentials.json` — and reusing it handed the CLI + // a config that was already onboarded (and an expired Keychain entry), which is not a fresh sign-in. + if (dirHasContent(dir)) { const owner = this.deps.accounts.list().find((a) => a.configDir && path.resolve(a.configDir) === path.resolve(dir)); if (owner) throw new Error(`${dir} is the credential dir of the "${owner.name}" account — remove that account first, or use another name`); const aside = `${dir}.orphan-${Date.now()}`; @@ -198,7 +205,14 @@ export class RuntimeLoginManager { this.deps.backend.spawn('', { sessionId: id, agent: 'runtime-login', tmuxName: tmux, env: { [spec.credentialEnv.configDirVar]: dir }, - argv: [spec.bin], + // `auth login` exits on a rejected code instead of offering a retry the way the TUI did, so wrap it in + // a loop that restores that contract: same "Press Enter to retry" prompt, and the re-run mints a fresh + // authorize URL — exactly what the BAD_CODE_RE branch in poll() expects. Exits once a login succeeds. + // Each pass starts on a WIPED pane (`tmux clear-history`, run from inside it): poll() reads the whole + // scrollback, so the previous attempt's "Login failed" and dead URL would otherwise still be there. + argv: spec.loginArgs + ? ['sh', '-c', 'while :; do clear; tmux clear-history 2>/dev/null; "$0" "$@" && exit 0; printf "\\nPress Enter to retry\\n"; read _; done', spec.bin, ...spec.loginArgs] + : [spec.bin], // Wide pane on purpose: nobody attaches to this one, and the authorize URL is ~400 chars. A TUI // hard-wraps to its terminal width and emits real newlines, which `capture-pane -J` will not // rejoin — at the default 203 columns the URL came back truncated at the wrap point (caught by a @@ -206,7 +220,7 @@ export class RuntimeLoginManager { // needs no reassembly heuristic. cols: 900, }); - const login: Login = { id, runtime, name: clean, phase: 'starting', startedAt: Date.now(), dir, tmux, answered: new Set(), codeAttempts: 0 }; + const login: Login = { id, runtime, name: clean, phase: 'starting', startedAt: Date.now(), dir, tmux, answered: new Set(), deadUrls: new Set(), codeAttempts: 0 }; this.logins.set(id, login); this.deps.audit('runtime.account.login.started', { runtime, name: clean, dir }); return toState(login); @@ -238,7 +252,13 @@ export class RuntimeLoginManager { // the human a fresh link, rather than telling them to start over while the pane quietly re-arms with a // challenge the link in their browser no longer matches. That mismatch is what made a second attempt // fail as reliably as the first. - if (BAD_CODE_RE.test(pane)) { + // Only after a code was actually submitted: a rejection can follow nothing else, and checking in other + // phases let a previous attempt's error still on screen be counted again on every poll. + if (l.phase === 'exchanging' && BAD_CODE_RE.test(pane)) { + // The error and the retry prompt are separate writes; a poll can land between them. Re-arming then + // would skip the Enter, leaving the CLI parked on the retry prompt and the dead URL on screen — wait + // for the prompt instead (EXCHANGE_GRACE_MS still bounds it). + if (!RETRY_PROMPT_RE.test(pane) && l.codeAttempts < MAX_CODE_ATTEMPTS) return toState(l); if (l.codeAttempts >= MAX_CODE_ATTEMPTS) { return toState(this.fail(l, `the runtime rejected ${l.codeAttempts} codes — sign in on the box with CLAUDE_CONFIG_DIR=${l.dir} claude, then add that dir by path`)); } @@ -246,8 +266,9 @@ export class RuntimeLoginManager { this.deps.audit('runtime.account.login.code.rejected', { runtime: l.runtime, name: l.name, attempt: l.codeAttempts }); // Re-arm: press Enter on the retry prompt, drop the stale URL, and go back to waiting for the CLI to // print the next one. `answered` is reset because the retry replays the same pre-browser prompts. - if (RETRY_PROMPT_RE.test(pane)) this.deps.backend.injectText('', l.tmux, '', true, true, 1); + this.deps.backend.injectText('', l.tmux, '', true, true, 1); l.phase = 'starting'; + if (l.url) l.deadUrls.add(l.url); l.url = undefined; l.urlSeenAt = undefined; l.codeAt = undefined; @@ -275,7 +296,8 @@ export class RuntimeLoginManager { } const url = extractAuthorizeUrl(pane); - if (url && l.phase === 'starting' && l.url === undefined) { + // A rejected attempt's URL can linger on screen for a beat after the retry Enter — never re-offer it. + if (url && l.phase === 'starting' && l.url === undefined && !l.deadUrls.has(url)) { if (looksWhole(url)) { l.phase = 'awaiting-code'; l.url = url; @@ -377,6 +399,10 @@ export class RuntimeLoginManager { } } +const dirHasContent = (dir: string): boolean => { + try { return fs.readdirSync(dir).length > 0; } catch { return false; } +}; + const toState = (l: Login): LoginState => ({ id: l.id, runtime: l.runtime, name: l.name, phase: l.phase, url: l.url, error: l.error, notice: l.notice, codeAttempts: l.codeAttempts, startedAt: l.startedAt, diff --git a/src/types.ts b/src/types.ts index 6a1fb4ca..8b32444c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1495,6 +1495,13 @@ export interface CodingRuntimeSpec { * on prompts it recognises, so claiming it for an unverified CLI would strand operators mid-flow. * False → the console still accepts a credential dir added by path. */ guidedLogin: boolean; + /** Arguments after `bin` that run the runtime's DEDICATED login command for the guided flow. Bare `claude` + * is the TUI, and which screens it shows before the browser step depends on the config dir's state: a + * dir that was ever onboarded skips theme + login-method and lands on the folder-trust dialog, never + * printing a URL — the guided flow sat on "the link appears in a moment" forever (instapods, + * 2026-10-13). `claude auth login` prints the URL and the paste prompt straight away, whatever the dir + * holds. Omitted → the bare binary, with the prompt answering in `runtime-login.ts` as the fallback. */ + loginArgs?: readonly string[]; /** A few known-good model ids, offered as suggestions in the console. NOT an allowlist — a custom or * newer id must still be settable, so validation only rejects ids that clearly belong to ANOTHER * runtime (see `foreignModel`). */ @@ -1521,6 +1528,7 @@ export const CODING_RUNTIMES: Readonly