@bridge4dev/runner 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-auth.d.ts +29 -0
- package/dist/agent-auth.js +136 -0
- package/dist/auth-relay.d.ts +29 -1
- package/dist/auth-relay.js +228 -13
- package/dist/environment.d.ts +15 -0
- package/dist/environment.js +25 -1
- package/dist/index.js +146 -10
- package/dist/protocol.d.ts +5 -5
- package/dist/recipe-schema.d.ts +1 -1
- package/dist/self-update.d.ts +43 -2
- package/dist/self-update.js +137 -43
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare function agentAuthPath(): string;
|
|
2
|
+
/** The token we hold for Claude, or null once it is too old to trust. */
|
|
3
|
+
export declare function storedClaudeToken(): string | null;
|
|
4
|
+
export declare function storeClaudeToken(token: string): void;
|
|
5
|
+
export declare function clearStoredClaudeToken(): void;
|
|
6
|
+
/**
|
|
7
|
+
* Put a stored token into this process's environment, unless the operator
|
|
8
|
+
* already set one.
|
|
9
|
+
*
|
|
10
|
+
* Their variable wins on purpose: an explicitly exported
|
|
11
|
+
* `CLAUDE_CODE_OAUTH_TOKEN` in the unit or in `environment.d` is a deliberate
|
|
12
|
+
* choice, and silently overriding it with something we captured months ago is
|
|
13
|
+
* the kind of surprise this file exists to prevent.
|
|
14
|
+
*
|
|
15
|
+
* Returns true when it applied one.
|
|
16
|
+
*/
|
|
17
|
+
export declare function applyStoredClaudeToken(): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* The OAuth token `claude setup-token` printed, reassembled out of pty output.
|
|
20
|
+
*
|
|
21
|
+
* Written against how the output actually arrives rather than how it looks in a
|
|
22
|
+
* terminal: the pty wraps at its width, so a ~100-character token is routinely
|
|
23
|
+
* split across lines. Continuation lines are joined only while they consist
|
|
24
|
+
* ENTIRELY of token characters — prose ("Store this token securely") contains
|
|
25
|
+
* spaces or punctuation and stops the join, which is what keeps the reassembly
|
|
26
|
+
* from swallowing the sentence after it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractOauthToken(text: string): string | null;
|
|
29
|
+
//# sourceMappingURL=agent-auth.d.ts.map
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { configDir } from './paths.js';
|
|
4
|
+
/**
|
|
5
|
+
* A Claude login this runner owns, for the one CLI that cannot store one.
|
|
6
|
+
*
|
|
7
|
+
* Background, because this file only makes sense with it. The dashboard's
|
|
8
|
+
* sign-in button relays a real OAuth flow to the machine — and until 0.27.0 it
|
|
9
|
+
* relayed `claude setup-token`, which is not a login at all. Measured against
|
|
10
|
+
* the 2.1.220 binary and confirmed by Anthropic's own docs: `setup-token` mints
|
|
11
|
+
* a long-lived INFERENCE-ONLY token, prints it, and «does not save the token
|
|
12
|
+
* anywhere». The runner threw the printed token away, then judged the login by
|
|
13
|
+
* reading `~/.claude/.credentials.json` — a file that command never writes. So
|
|
14
|
+
* the flow completed, the panel re-probed within a second, and answered «No
|
|
15
|
+
* Claude login on this server» underneath a success screen. That is the bug
|
|
16
|
+
* behind every «I signed in and it still says signed out».
|
|
17
|
+
*
|
|
18
|
+
* The main path is now `claude auth login`, which persists properly and needs
|
|
19
|
+
* nothing from this module. This exists for the fallback: a CLI old enough not
|
|
20
|
+
* to have `auth login` still has `setup-token`, and rather than leave that
|
|
21
|
+
* machine with no way in, we capture the token it prints and keep it ourselves.
|
|
22
|
+
* `CLAUDE_CODE_OAUTH_TOKEN` is already on the session env allowlist, so a stored
|
|
23
|
+
* token reaches the agent exactly like an operator-configured one.
|
|
24
|
+
*
|
|
25
|
+
* Stored on the machine, 0600, in the runner's own config directory. DevBridge
|
|
26
|
+
* never receives it: the token travels from the provider into a pty on this
|
|
27
|
+
* host and onto this host's disk.
|
|
28
|
+
*/
|
|
29
|
+
const FILE_MODE = 0o600;
|
|
30
|
+
export function agentAuthPath() {
|
|
31
|
+
return path.join(configDir(), 'agent-auth.json');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* How long a captured token is believed without further evidence.
|
|
35
|
+
*
|
|
36
|
+
* `claude setup-token` mints a one-year credential and there is no way to ask
|
|
37
|
+
* whether it is still good without spending a request. A stored token that
|
|
38
|
+
* never ages out is a green light nobody can turn off: the panel would keep
|
|
39
|
+
* saying «signed in» about a revoked account for as long as the file exists.
|
|
40
|
+
* Eleven months keeps it inside the token's own life while still expiring.
|
|
41
|
+
*/
|
|
42
|
+
const TOKEN_MAX_AGE_MS = 334 * 24 * 60 * 60 * 1000;
|
|
43
|
+
function read() {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(fs.readFileSync(agentAuthPath(), 'utf8'));
|
|
46
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
47
|
+
return {};
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The token we hold for Claude, or null once it is too old to trust. */
|
|
55
|
+
export function storedClaudeToken() {
|
|
56
|
+
const file = read();
|
|
57
|
+
const value = file.claudeOauthToken;
|
|
58
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
59
|
+
return null;
|
|
60
|
+
const stamped = typeof file.updatedAt === 'string' ? Date.parse(file.updatedAt) : NaN;
|
|
61
|
+
if (Number.isFinite(stamped) && Date.now() - stamped > TOKEN_MAX_AGE_MS)
|
|
62
|
+
return null;
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
export function storeClaudeToken(token) {
|
|
66
|
+
const dir = configDir();
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
68
|
+
const file = agentAuthPath();
|
|
69
|
+
// Same write-then-rename as the config: a torn file here reads as «signed
|
|
70
|
+
// out» and sends somebody through the whole login again.
|
|
71
|
+
const tmp = path.join(dir, `.agent-auth.${process.pid}.tmp`);
|
|
72
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ claudeOauthToken: token, updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: FILE_MODE });
|
|
73
|
+
fs.renameSync(tmp, file);
|
|
74
|
+
fs.chmodSync(file, FILE_MODE);
|
|
75
|
+
}
|
|
76
|
+
export function clearStoredClaudeToken() {
|
|
77
|
+
try {
|
|
78
|
+
fs.rmSync(agentAuthPath(), { force: true });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* nothing to clear */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Put a stored token into this process's environment, unless the operator
|
|
86
|
+
* already set one.
|
|
87
|
+
*
|
|
88
|
+
* Their variable wins on purpose: an explicitly exported
|
|
89
|
+
* `CLAUDE_CODE_OAUTH_TOKEN` in the unit or in `environment.d` is a deliberate
|
|
90
|
+
* choice, and silently overriding it with something we captured months ago is
|
|
91
|
+
* the kind of surprise this file exists to prevent.
|
|
92
|
+
*
|
|
93
|
+
* Returns true when it applied one.
|
|
94
|
+
*/
|
|
95
|
+
export function applyStoredClaudeToken() {
|
|
96
|
+
if (process.env['CLAUDE_CODE_OAUTH_TOKEN'])
|
|
97
|
+
return false;
|
|
98
|
+
const token = storedClaudeToken();
|
|
99
|
+
if (!token)
|
|
100
|
+
return false;
|
|
101
|
+
process.env['CLAUDE_CODE_OAUTH_TOKEN'] = token;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The OAuth token `claude setup-token` printed, reassembled out of pty output.
|
|
106
|
+
*
|
|
107
|
+
* Written against how the output actually arrives rather than how it looks in a
|
|
108
|
+
* terminal: the pty wraps at its width, so a ~100-character token is routinely
|
|
109
|
+
* split across lines. Continuation lines are joined only while they consist
|
|
110
|
+
* ENTIRELY of token characters — prose ("Store this token securely") contains
|
|
111
|
+
* spaces or punctuation and stops the join, which is what keeps the reassembly
|
|
112
|
+
* from swallowing the sentence after it.
|
|
113
|
+
*/
|
|
114
|
+
export function extractOauthToken(text) {
|
|
115
|
+
const lines = text.split('\n').map((line) => line.trim());
|
|
116
|
+
for (let i = 0; i < lines.length; i++) {
|
|
117
|
+
const line = lines[i] ?? '';
|
|
118
|
+
const start = line.match(/sk-ant-oat[0-9]*-[A-Za-z0-9_-]*$/);
|
|
119
|
+
// The token must run to the end of its line; anything after it on the same
|
|
120
|
+
// line means this is prose mentioning a token, not the token itself.
|
|
121
|
+
if (!start)
|
|
122
|
+
continue;
|
|
123
|
+
let token = start[0];
|
|
124
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
125
|
+
const next = lines[j] ?? '';
|
|
126
|
+
if (!/^[A-Za-z0-9_-]+$/.test(next))
|
|
127
|
+
break;
|
|
128
|
+
token += next;
|
|
129
|
+
}
|
|
130
|
+
// Long enough to be real, short enough not to be a runaway join.
|
|
131
|
+
if (token.length >= 40 && token.length <= 400)
|
|
132
|
+
return token;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=agent-auth.js.map
|
package/dist/auth-relay.d.ts
CHANGED
|
@@ -24,18 +24,46 @@ export interface AgentAuthStatus {
|
|
|
24
24
|
expiresAt?: string;
|
|
25
25
|
detail?: string;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Rejoin a secret the pty split across lines, so the masker can see it.
|
|
29
|
+
*
|
|
30
|
+
* `maskString` matches `sk-ant-[A-Za-z0-9_-]{8,}`, which a line break inside
|
|
31
|
+
* the token defeats — and the pty wraps at its width, so a ~100-character
|
|
32
|
+
* token arrives in pieces as a matter of course. Measured: the head gets
|
|
33
|
+
* masked and the tail is printed verbatim into an error detail that travels to
|
|
34
|
+
* the dashboard. Rejoining first costs nothing and closes it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function rejoinWrappedSecrets(text: string): string;
|
|
27
37
|
/** Strip ANSI/OSC control sequences so text matching sees plain output. */
|
|
28
38
|
export declare function stripControl(raw: string): string;
|
|
29
39
|
export declare function extractLoginUrl(agent: RelayAgent, raw: string): string | null;
|
|
30
40
|
export declare function extractDeviceCode(raw: string): string | null;
|
|
41
|
+
export interface AuthRelayDeps {
|
|
42
|
+
/**
|
|
43
|
+
* How we find out whether Claude is signed in, after the CLI says it is.
|
|
44
|
+
* Injected so a test can exercise the contract without an OAuth provider.
|
|
45
|
+
*/
|
|
46
|
+
claudeStatus?: () => Promise<AgentAuthStatus>;
|
|
47
|
+
}
|
|
31
48
|
export declare class AuthRelay {
|
|
32
49
|
private readonly commands;
|
|
50
|
+
private readonly deps;
|
|
33
51
|
private active;
|
|
34
|
-
constructor(commands?: Record<RelayAgent, string
|
|
52
|
+
constructor(commands?: Record<RelayAgent, string>, deps?: AuthRelayDeps);
|
|
35
53
|
/** Start (or restart) a login flow and wait until the sign-in URL appears. */
|
|
36
54
|
start(agent: RelayAgent): Promise<LoginStartResult>;
|
|
55
|
+
private startWith;
|
|
37
56
|
/** Paste the confirmation code back into the waiting CLI (Claude flow). */
|
|
38
57
|
submitCode(agent: RelayAgent, code: string): Promise<LoginCodeResult>;
|
|
58
|
+
/**
|
|
59
|
+
* The CLI exited 0 — but is the machine actually signed in?
|
|
60
|
+
*
|
|
61
|
+
* Asked rather than assumed. Reporting a success the panel then contradicts
|
|
62
|
+
* one second later is worse than reporting a failure: it sends the person
|
|
63
|
+
* looking for a permissions problem that does not exist, which is exactly
|
|
64
|
+
* what happened on axon-prod-01.
|
|
65
|
+
*/
|
|
66
|
+
private confirmSignedIn;
|
|
39
67
|
cancel(): void;
|
|
40
68
|
}
|
|
41
69
|
/**
|
package/dist/auth-relay.js
CHANGED
|
@@ -5,6 +5,8 @@ import path from 'node:path';
|
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { log } from './log.js';
|
|
7
7
|
import { maskString } from './policy.js';
|
|
8
|
+
import { runnerIdentity, whichExecutable } from './environment.js';
|
|
9
|
+
import { applyStoredClaudeToken, clearStoredClaudeToken, extractOauthToken, storeClaudeToken, storedClaudeToken, } from './agent-auth.js';
|
|
8
10
|
import { adoptLoginResult, discardStagingHome, prepareStagingHome, repairCodexAuth, stagingCodexHomePath, } from './adapters/codex-home.js';
|
|
9
11
|
const execFileAsync = promisify(execFile);
|
|
10
12
|
/* eslint-disable no-control-regex -- this module parses raw pty output, so
|
|
@@ -18,6 +20,18 @@ const URL_PATTERNS = {
|
|
|
18
20
|
claude: /https:\/\/(?:claude\.com|claude\.ai)\/[^\s\x07\x1b"']+/,
|
|
19
21
|
codex: /https:\/\/[^\s\x07\x1b"']+/,
|
|
20
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Rejoin a secret the pty split across lines, so the masker can see it.
|
|
25
|
+
*
|
|
26
|
+
* `maskString` matches `sk-ant-[A-Za-z0-9_-]{8,}`, which a line break inside
|
|
27
|
+
* the token defeats — and the pty wraps at its width, so a ~100-character
|
|
28
|
+
* token arrives in pieces as a matter of course. Measured: the head gets
|
|
29
|
+
* masked and the tail is printed verbatim into an error detail that travels to
|
|
30
|
+
* the dashboard. Rejoining first costs nothing and closes it.
|
|
31
|
+
*/
|
|
32
|
+
export function rejoinWrappedSecrets(text) {
|
|
33
|
+
return text.replace(/sk-ant-[A-Za-z0-9_-]*(?:\n[A-Za-z0-9_-]+)+/g, (match) => match.replace(/\n/g, ''));
|
|
34
|
+
}
|
|
21
35
|
/** Strip ANSI/OSC control sequences so text matching sees plain output. */
|
|
22
36
|
export function stripControl(raw) {
|
|
23
37
|
return raw
|
|
@@ -38,18 +52,132 @@ export function extractDeviceCode(raw) {
|
|
|
38
52
|
// Device-auth user codes look like XXXX-XXXX (letters/digits).
|
|
39
53
|
return stripControl(raw).match(/\b[A-Z0-9]{4,8}-[A-Z0-9]{4,8}\b/)?.[0] ?? null;
|
|
40
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Did the CLI reject the subcommand itself, rather than fail the login?
|
|
57
|
+
*
|
|
58
|
+
* Commander prints «unknown command» / «error: unknown option» and exits
|
|
59
|
+
* before any network call. Anything else — a refused grant, no subscription, a
|
|
60
|
+
* DNS failure — is a real answer and must NOT be retried on the legacy command,
|
|
61
|
+
* or we would quietly downgrade a healthy machine to an inference-only token.
|
|
62
|
+
*/
|
|
63
|
+
function looksLikeUnsupportedSubcommand(output) {
|
|
64
|
+
// Anchored to Commander's own usage-error wording. The loose version matched
|
|
65
|
+
// anywhere in the relayed CLI output, so a genuine login failure that merely
|
|
66
|
+
// mentioned an unknown option would silently downgrade a healthy machine to
|
|
67
|
+
// an inference-only token — the very thing this release stops doing.
|
|
68
|
+
return /error:\s*unknown (?:command|option)\b|unrecognized subcommand/i.test(output);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* What «sign in» actually is, per agent.
|
|
72
|
+
*
|
|
73
|
+
* `claude auth login` and NOT `claude setup-token`, and the difference is the
|
|
74
|
+
* whole of ticket «Sign in does nothing». Measured against the 2.1.220 binary
|
|
75
|
+
* and stated outright in Anthropic's docs: `setup-token` mints a long-lived
|
|
76
|
+
* INFERENCE-ONLY token, prints it to the terminal, and «does not save the token
|
|
77
|
+
* anywhere». It never touches `~/.claude/.credentials.json` — the file
|
|
78
|
+
* `claudeAuthStatus()` below reads to decide whether this machine is signed in.
|
|
79
|
+
* So the relay completed a real OAuth flow, the CLI exited 0, we reported
|
|
80
|
+
* success, and the panel's immediate re-probe answered «No Claude login on this
|
|
81
|
+
* server». Nobody was wrong; the two halves were simply about different things.
|
|
82
|
+
*
|
|
83
|
+
* `claude auth login --claudeai` is the same OAuth flow through the same pty
|
|
84
|
+
* harness — verified live: it prints the authorize URL and then waits on stdin
|
|
85
|
+
* with «Paste code here if prompted >» — except it persists the credential and
|
|
86
|
+
* asks for the full subscription scope rather than inference alone.
|
|
87
|
+
*
|
|
88
|
+
* `--claudeai` is explicit so the CLI never stops to ask «subscription or
|
|
89
|
+
* Console?»: a menu waiting for an arrow key is indistinguishable, from here,
|
|
90
|
+
* from a login that hung.
|
|
91
|
+
*/
|
|
41
92
|
const LOGIN_COMMANDS = {
|
|
42
|
-
claude: 'claude
|
|
93
|
+
claude: 'claude auth login --claudeai',
|
|
43
94
|
codex: 'codex login --device-auth',
|
|
44
95
|
};
|
|
96
|
+
/**
|
|
97
|
+
* The pre-0.27.0 command, kept for one job only: a `claude` old enough to have
|
|
98
|
+
* no `auth login` subcommand. There the CLI exits immediately with a usage
|
|
99
|
+
* error, and leaving that machine with no way to sign in at all would be a
|
|
100
|
+
* worse regression than the bug this replaced. On that path we capture the
|
|
101
|
+
* printed token ourselves (see `agent-auth.ts`) so the outcome is still a
|
|
102
|
+
* login the panel can see.
|
|
103
|
+
*/
|
|
104
|
+
const CLAUDE_LEGACY_LOGIN = 'claude setup-token';
|
|
105
|
+
/**
|
|
106
|
+
* A pty on a headless server must not try to launch a browser.
|
|
107
|
+
*
|
|
108
|
+
* `claude auth login` prints «Opening browser to sign in…» and calls the
|
|
109
|
+
* platform opener first. On a server that is merely noise, but on a machine
|
|
110
|
+
* with a desktop session it pops a window in front of whoever is sitting there
|
|
111
|
+
* — for a sign-in they did not start, on a host they may not own. The URL is
|
|
112
|
+
* printed regardless, which is the only part this flow uses.
|
|
113
|
+
*/
|
|
114
|
+
function relayEnv(extra = {}) {
|
|
115
|
+
const env = { ...process.env, ...extra };
|
|
116
|
+
env['BROWSER'] = 'true';
|
|
117
|
+
delete env['DISPLAY'];
|
|
118
|
+
delete env['WAYLAND_DISPLAY'];
|
|
119
|
+
return env;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Is the command this relay is about to run actually present?
|
|
123
|
+
*
|
|
124
|
+
* Split out because the answer differs by shape: a bare name is looked up on
|
|
125
|
+
* PATH, while an absolute path (what the tests inject, and what a hand-written
|
|
126
|
+
* config could hold) is checked where it points. Without this the missing-CLI
|
|
127
|
+
* case surfaced as «login exited before printing a sign-in URL: script:
|
|
128
|
+
* command not found» — an error about the pty helper, on a machine whose real
|
|
129
|
+
* problem was that nobody had installed the agent for that user. That was
|
|
130
|
+
* axon-prod-01 exactly: a dedicated user, an empty home, no `claude` anywhere.
|
|
131
|
+
*/
|
|
132
|
+
function commandExists(command) {
|
|
133
|
+
const binary = command.trim().split(/\s+/)[0] ?? '';
|
|
134
|
+
if (!binary)
|
|
135
|
+
return false;
|
|
136
|
+
if (binary.includes(path.sep)) {
|
|
137
|
+
try {
|
|
138
|
+
fs.accessSync(binary, fs.constants.X_OK);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return whichExecutable(binary) !== null;
|
|
146
|
+
}
|
|
45
147
|
export class AuthRelay {
|
|
46
148
|
commands;
|
|
149
|
+
deps;
|
|
47
150
|
active = null;
|
|
48
|
-
constructor(commands = LOGIN_COMMANDS) {
|
|
151
|
+
constructor(commands = LOGIN_COMMANDS, deps = {}) {
|
|
49
152
|
this.commands = commands;
|
|
153
|
+
this.deps = deps;
|
|
50
154
|
}
|
|
51
155
|
/** Start (or restart) a login flow and wait until the sign-in URL appears. */
|
|
52
156
|
async start(agent) {
|
|
157
|
+
this.cancel();
|
|
158
|
+
const command = this.commands[agent] ?? '';
|
|
159
|
+
if (!commandExists(command)) {
|
|
160
|
+
const binary = command.trim().split(/\s+/)[0] ?? agent;
|
|
161
|
+
throw new Error(`\`${binary}\` is not installed for ${runnerIdentity().user} on this server — ` +
|
|
162
|
+
'install the agent CLI for that user (or use the installer’s --user mode, which does it) and try again');
|
|
163
|
+
}
|
|
164
|
+
if (!whichExecutable('script')) {
|
|
165
|
+
// util-linux, and the only reason a pty exists here at all.
|
|
166
|
+
throw new Error('the `script` command (util-linux) is missing on this server — the sign-in needs it to run the agent CLI on a terminal');
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return await this.startWith(agent, this.commands[agent] ?? '', false);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// Only the one recoverable shape: a `claude` too old to have `auth
|
|
173
|
+
// login`. Everything else is the answer, not a reason to try again.
|
|
174
|
+
if (agent !== 'claude' || !looksLikeUnsupportedSubcommand(String(error)))
|
|
175
|
+
throw error;
|
|
176
|
+
log.warn('auth-relay: this claude has no `auth login` — falling back to setup-token');
|
|
177
|
+
return this.startWith(agent, CLAUDE_LEGACY_LOGIN, true);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async startWith(agent, command, captureToken) {
|
|
53
181
|
this.cancel();
|
|
54
182
|
// Codex logs into a THROWAWAY home and is promoted only on success. The
|
|
55
183
|
// old flow deleted the shared credential link up front, so abandoning the
|
|
@@ -57,10 +185,10 @@ export class AuthRelay {
|
|
|
57
185
|
// with no way back except restarting the daemon — and writing through the
|
|
58
186
|
// link would have overwritten the host user's own account (QA-100 MINOR-5).
|
|
59
187
|
const stagingHome = agent === 'codex' ? prepareStagingHome() : null;
|
|
60
|
-
const proc = spawn('script', ['-qec',
|
|
188
|
+
const proc = spawn('script', ['-qec', command, '/dev/null'], {
|
|
61
189
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
62
190
|
// Codex must log in to a home WE control, never the host user's ~/.codex.
|
|
63
|
-
|
|
191
|
+
env: relayEnv(stagingHome ? { CODEX_HOME: stagingHome } : {}),
|
|
64
192
|
});
|
|
65
193
|
const relay = {
|
|
66
194
|
agent,
|
|
@@ -68,6 +196,8 @@ export class AuthRelay {
|
|
|
68
196
|
buffer: '',
|
|
69
197
|
exited: false,
|
|
70
198
|
exitCode: null,
|
|
199
|
+
command,
|
|
200
|
+
captureToken,
|
|
71
201
|
killTimer: setTimeout(() => this.cancel(), RELAY_MAX_LIFETIME_MS),
|
|
72
202
|
};
|
|
73
203
|
relay.killTimer.unref();
|
|
@@ -121,7 +251,7 @@ export class AuthRelay {
|
|
|
121
251
|
return { url, ...(code ? { code } : {}), expectsCode: agent === 'claude' };
|
|
122
252
|
}
|
|
123
253
|
if (relay.exited) {
|
|
124
|
-
const tail = maskString(stripControl(relay.buffer)).slice(-400);
|
|
254
|
+
const tail = maskString(rejoinWrappedSecrets(stripControl(relay.buffer))).slice(-400);
|
|
125
255
|
this.cancel();
|
|
126
256
|
throw new Error(`${agent} login exited before printing a sign-in URL: ${tail}`);
|
|
127
257
|
}
|
|
@@ -148,21 +278,79 @@ export class AuthRelay {
|
|
|
148
278
|
while (Date.now() < deadline) {
|
|
149
279
|
const fresh = stripControl(relay.buffer.slice(bufferMark));
|
|
150
280
|
if (relay.exited) {
|
|
281
|
+
const raw = relay.buffer;
|
|
151
282
|
this.cancel();
|
|
152
|
-
if (relay.exitCode
|
|
153
|
-
return {
|
|
154
|
-
|
|
283
|
+
if (relay.exitCode !== 0) {
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
detail: maskString(rejoinWrappedSecrets(fresh)).slice(-400) || 'Login failed',
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// Exit 0 is the CLI's opinion. Ours has to be «is this machine signed
|
|
290
|
+
// in now», because those two came apart before and nobody noticed for
|
|
291
|
+
// weeks: `setup-token` exits 0 over a credential it never stored.
|
|
292
|
+
return this.confirmSignedIn(relay, raw);
|
|
155
293
|
}
|
|
156
294
|
if (/invalid|error|failed|expired/i.test(fresh)) {
|
|
157
295
|
// The CLI usually re-prompts after a bad code; surface it and keep
|
|
158
296
|
// the relay alive so the user can retry with a corrected code.
|
|
159
|
-
return { ok: false, detail: maskString(fresh).trim().slice(-400) };
|
|
297
|
+
return { ok: false, detail: maskString(rejoinWrappedSecrets(fresh)).trim().slice(-400) };
|
|
160
298
|
}
|
|
161
299
|
await sleep(300);
|
|
162
300
|
}
|
|
163
301
|
this.cancel();
|
|
164
302
|
return { ok: false, detail: 'Timed out waiting for the login to complete' };
|
|
165
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* The CLI exited 0 — but is the machine actually signed in?
|
|
306
|
+
*
|
|
307
|
+
* Asked rather than assumed. Reporting a success the panel then contradicts
|
|
308
|
+
* one second later is worse than reporting a failure: it sends the person
|
|
309
|
+
* looking for a permissions problem that does not exist, which is exactly
|
|
310
|
+
* what happened on axon-prod-01.
|
|
311
|
+
*/
|
|
312
|
+
async confirmSignedIn(relay, raw) {
|
|
313
|
+
if (relay.agent !== 'claude')
|
|
314
|
+
return { ok: true };
|
|
315
|
+
// Legacy `setup-token`: the token exists only in the output. Keep it, or
|
|
316
|
+
// the whole flow was for nothing.
|
|
317
|
+
if (relay.captureToken) {
|
|
318
|
+
const token = extractOauthToken(stripControl(raw));
|
|
319
|
+
if (!token) {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
detail: 'the sign-in finished but this older Claude CLI printed no usable token — ' +
|
|
323
|
+
'update the Claude CLI on the server and try again',
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
storeClaudeToken(token);
|
|
328
|
+
applyStoredClaudeToken();
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
return { ok: false, detail: `could not store the token on this server: ${String(error)}` };
|
|
332
|
+
}
|
|
333
|
+
log.info('auth-relay: stored a long-lived Claude token for this runner');
|
|
334
|
+
clearAgentAuthFailure('claude');
|
|
335
|
+
return { ok: true, detail: 'signed in with a long-lived token stored on this server' };
|
|
336
|
+
}
|
|
337
|
+
// `claude auth login` writes the credential just before it exits; give the
|
|
338
|
+
// filesystem a couple of beats rather than racing it.
|
|
339
|
+
const probe = this.deps.claudeStatus ?? claudeAuthStatus;
|
|
340
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
341
|
+
const status = await probe();
|
|
342
|
+
if (status.status === 'ok') {
|
|
343
|
+
clearAgentAuthFailure('claude');
|
|
344
|
+
return { ok: true };
|
|
345
|
+
}
|
|
346
|
+
await sleep(300);
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
detail: 'the sign-in completed but no login was stored on this server — ' +
|
|
351
|
+
'check that the user the runner runs as can write its own ~/.claude directory',
|
|
352
|
+
};
|
|
353
|
+
}
|
|
166
354
|
cancel() {
|
|
167
355
|
const relay = this.active;
|
|
168
356
|
if (!relay)
|
|
@@ -224,6 +412,25 @@ export async function claudeAuthStatus(homedir = os.homedir()) {
|
|
|
224
412
|
if (process.env['CLAUDE_CODE_OAUTH_TOKEN']) {
|
|
225
413
|
return { status: 'ok', detail: 'CLAUDE_CODE_OAUTH_TOKEN is configured' };
|
|
226
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* A token this runner captured itself (the `setup-token` fallback) is the
|
|
417
|
+
* LAST word, never the first.
|
|
418
|
+
*
|
|
419
|
+
* It used to short-circuit ahead of the credentials file, and that was three
|
|
420
|
+
* bugs in one line: a real `/login` afterwards could never show through, the
|
|
421
|
+
* post-exchange re-probe could not fail (so `confirmSignedIn` always agreed
|
|
422
|
+
* with itself), and there was no way to get back to «signed out» short of
|
|
423
|
+
* deleting a file nobody documents. Read below, after the file has had its
|
|
424
|
+
* say — and read from disk rather than from the environment, so `doctor` (a
|
|
425
|
+
* different process, which never applied it) gives the same verdict as the
|
|
426
|
+
* daemon.
|
|
427
|
+
*/
|
|
428
|
+
const fallbackToken = () => storedClaudeToken()
|
|
429
|
+
? {
|
|
430
|
+
status: 'ok',
|
|
431
|
+
detail: 'signed in with a long-lived token stored on this server',
|
|
432
|
+
}
|
|
433
|
+
: null;
|
|
227
434
|
const file = path.join(homedir, '.claude', '.credentials.json');
|
|
228
435
|
let raw;
|
|
229
436
|
try {
|
|
@@ -236,7 +443,7 @@ export async function claudeAuthStatus(homedir = os.homedir()) {
|
|
|
236
443
|
// sends the user re-authenticating a credential that is sitting right
|
|
237
444
|
// there (the same mistake #121 is about, one layer down).
|
|
238
445
|
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
239
|
-
return { status: 'missing', detail: 'No Claude login on this server' };
|
|
446
|
+
return fallbackToken() ?? { status: 'missing', detail: 'No Claude login on this server' };
|
|
240
447
|
}
|
|
241
448
|
// No `log.warn` here: this probe is on a 60-second timer since #121, and a
|
|
242
449
|
// machine with EACCES on that file would write the same line forever.
|
|
@@ -273,7 +480,7 @@ export async function claudeAuthStatus(homedir = os.homedir()) {
|
|
|
273
480
|
// Nothing recognisable in the blob at all — no token, not even a date. That
|
|
274
481
|
// is «never signed in here», and it must offer the button that fixes it.
|
|
275
482
|
if (!hasAccess && !hasRefresh && accessExpiry === undefined) {
|
|
276
|
-
return { status: 'missing', detail: 'No subscription login found' };
|
|
483
|
+
return fallbackToken() ?? { status: 'missing', detail: 'No subscription login found' };
|
|
277
484
|
}
|
|
278
485
|
// A date we can read outranks the token beside it (the pre-#121 contract, and
|
|
279
486
|
// the reason a dated-but-token-less fixture still reads as expired); with no
|
|
@@ -292,11 +499,11 @@ export async function claudeAuthStatus(homedir = os.homedir()) {
|
|
|
292
499
|
const effectiveExpiry = refreshLive ? refreshExpiry : accessExpiry;
|
|
293
500
|
const expiresAt = effectiveExpiry === undefined ? undefined : new Date(effectiveExpiry).toISOString();
|
|
294
501
|
if (!accessLive && !refreshLive) {
|
|
295
|
-
return {
|
|
502
|
+
return (fallbackToken() ?? {
|
|
296
503
|
status: 'expired',
|
|
297
504
|
...(expiresAt ? { expiresAt } : {}),
|
|
298
505
|
detail: 'the stored login has expired',
|
|
299
|
-
};
|
|
506
|
+
});
|
|
300
507
|
}
|
|
301
508
|
return {
|
|
302
509
|
status: 'ok',
|
|
@@ -324,6 +531,14 @@ const authFailures = new Map();
|
|
|
324
531
|
export function noteAgentAuthFailure(agent) {
|
|
325
532
|
authFailures.set(agent, Date.now());
|
|
326
533
|
log.warn('auth-relay: agent sign-in refused during a session', { agent });
|
|
534
|
+
// A refusal is the only authority on a revoked credential, and a token we
|
|
535
|
+
// captured ourselves has no other expiry we can see. Keeping it would let a
|
|
536
|
+
// dead login outlive the evidence: the failure marker times out after 15
|
|
537
|
+
// minutes and the panel would go green again over the same dead token.
|
|
538
|
+
if (agent === 'claude' && storedClaudeToken()) {
|
|
539
|
+
clearStoredClaudeToken();
|
|
540
|
+
log.warn('auth-relay: discarded the stored Claude token after a refusal');
|
|
541
|
+
}
|
|
327
542
|
}
|
|
328
543
|
/** The agent just worked — whatever was wrong with the sign-in is not. */
|
|
329
544
|
export function clearAgentAuthFailure(agent) {
|
package/dist/environment.d.ts
CHANGED
|
@@ -77,6 +77,20 @@ export interface AgentConfigContour {
|
|
|
77
77
|
plugins: boolean;
|
|
78
78
|
codexDir: boolean;
|
|
79
79
|
codexConfig: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* MCP servers an agent session in this home will actually see, and the ones
|
|
82
|
+
* that are configured but invisible to it.
|
|
83
|
+
*
|
|
84
|
+
* Both numbers, because the difference IS the bug. `claude mcp add` defaults
|
|
85
|
+
* to LOCAL scope, which stores the server under
|
|
86
|
+
* `~/.claude.json → projects["<cwd>"].mcpServers` — keyed by the directory it
|
|
87
|
+
* was added from. Runner sessions work in a per-session git worktree, so that
|
|
88
|
+
* key never matches and the servers are simply absent. On a live machine this
|
|
89
|
+
* read as «the agent lost Playwright and Context7», and the only cure was
|
|
90
|
+
* moving them to user scope (the top-level `mcpServers`).
|
|
91
|
+
*/
|
|
92
|
+
mcpUserScope: number;
|
|
93
|
+
mcpProjectScope: number;
|
|
80
94
|
}
|
|
81
95
|
export declare function agentConfigContour(home?: string): AgentConfigContour;
|
|
82
96
|
/**
|
|
@@ -116,6 +130,7 @@ export interface ToolCheck {
|
|
|
116
130
|
/** Set when the tool is there but this user cannot use it. */
|
|
117
131
|
problem?: string;
|
|
118
132
|
}
|
|
133
|
+
export declare function whichExecutable(name: string): string | null;
|
|
119
134
|
/**
|
|
120
135
|
* Node, as THIS user sees it.
|
|
121
136
|
*
|
package/dist/environment.js
CHANGED
|
@@ -114,6 +114,27 @@ export async function addSafeDirectory(repoPath) {
|
|
|
114
114
|
timeout: 10_000,
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Count MCP servers in the CLI's own config file, by scope.
|
|
119
|
+
*
|
|
120
|
+
* Reads `~/.claude.json` directly rather than shelling out to `claude mcp
|
|
121
|
+
* list`: this is a diagnostic that must work when the CLI is missing, and it
|
|
122
|
+
* must not spend ~300 MB and a second of a doctor run to answer.
|
|
123
|
+
*/
|
|
124
|
+
function countMcpServers(home) {
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'));
|
|
127
|
+
const user = Object.keys(parsed.mcpServers ?? {}).length;
|
|
128
|
+
let project = 0;
|
|
129
|
+
for (const entry of Object.values(parsed.projects ?? {})) {
|
|
130
|
+
project += Object.keys(entry?.mcpServers ?? {}).length;
|
|
131
|
+
}
|
|
132
|
+
return { user, project };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { user: 0, project: 0 };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
117
138
|
function countAllowRules(file) {
|
|
118
139
|
try {
|
|
119
140
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -152,6 +173,7 @@ export function agentConfigContour(home = os.homedir()) {
|
|
|
152
173
|
const localSettings = path.join(claudeDir, 'settings.local.json');
|
|
153
174
|
const commandsDir = path.join(claudeDir, 'commands');
|
|
154
175
|
const codexDir = path.join(home, '.codex');
|
|
176
|
+
const mcp = countMcpServers(home);
|
|
155
177
|
return {
|
|
156
178
|
home,
|
|
157
179
|
claudeDir: fs.existsSync(claudeDir),
|
|
@@ -166,6 +188,8 @@ export function agentConfigContour(home = os.homedir()) {
|
|
|
166
188
|
plugins: fs.existsSync(path.join(claudeDir, 'plugins')),
|
|
167
189
|
codexDir: fs.existsSync(codexDir),
|
|
168
190
|
codexConfig: fs.existsSync(path.join(codexDir, 'config.toml')),
|
|
191
|
+
mcpUserScope: mcp.user,
|
|
192
|
+
mcpProjectScope: mcp.project,
|
|
169
193
|
};
|
|
170
194
|
}
|
|
171
195
|
/**
|
|
@@ -266,7 +290,7 @@ export function knownWorkspacePaths() {
|
|
|
266
290
|
return [];
|
|
267
291
|
}
|
|
268
292
|
}
|
|
269
|
-
function whichExecutable(name) {
|
|
293
|
+
export function whichExecutable(name) {
|
|
270
294
|
for (const dir of (process.env['PATH'] ?? '').split(path.delimiter).filter(Boolean)) {
|
|
271
295
|
const candidate = path.join(dir, name);
|
|
272
296
|
try {
|