Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 26 additions & 1 deletion scripts/runtime-login-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -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');

Expand Down
40 changes: 33 additions & 7 deletions src/edge/runtime-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/** URLs of attempts the runtime already rejected — their PKCE challenge is spent. */
deadUrls: Set<string>;
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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()}`;
Expand All @@ -198,15 +205,22 @@ 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
// live run against the real CLI, not by the stubbed test). Giving the pane room is the fix that
// 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);
Expand Down Expand Up @@ -238,16 +252,23 @@ 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`));
}
l.codeAttempts++;
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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`). */
Expand All @@ -1521,6 +1528,7 @@ export const CODING_RUNTIMES: Readonly<Record<CodingRuntimeId, CodingRuntimeSpec
// left out for the same reason it was never proven: an unverified kind must not be silently selectable.
liveCredentialKinds: ['oauth'],
guidedLogin: true,
loginArgs: ['auth', 'login', '--claudeai'],
suggestedModels: ['claude-opus-4-8', 'claude-sonnet-4-8', 'claude-haiku-4-5'],
foreignModel: /^(gpt|o[0-9]|codex|glm|kimi|deepseek)\b/i,
capabilities: {
Expand Down
Loading