@bridge4dev/runner 0.22.1 → 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.
@@ -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
@@ -24,26 +24,84 @@ 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
  /**
42
- * Claude: the subscription token's expiry is recorded in the CLI's own
43
- * credentials file. The RUNNER reads it (its own host user's file) — the
44
- * agent itself is still denied this path by layer-1 policy.
70
+ * Claude: the subscription login is recorded in the CLI's own credentials file.
71
+ * The RUNNER reads it (its own host user's file) — the agent itself is still
72
+ * denied this path by layer-1 policy.
73
+ *
74
+ * `expiresAt` is NOT the login. It is the expiry of a short-lived access token
75
+ * (~8 hours on a live file), and next to it sits `refreshToken` with
76
+ * `refreshTokenExpiresAt` ~26 days out, which the CLI spends silently on its
77
+ * next run. Judging the login by `expiresAt` alone is why every server nobody
78
+ * had touched since the morning reported "login expired — re-login needed"
79
+ * over a login that was good for another three weeks (#121). Codex has carried
80
+ * exactly this guard since day one (`readCodexCredential`); Claude did not.
81
+ *
82
+ * Deliberately NOT asking the CLI. `claude auth status --json` looks like an
83
+ * arbiter and is not one: measured live (SDK binary 2.1.218), it answers
84
+ * `loggedIn: true` for a credential whose access token has expired AND which
85
+ * carries no refresh token at all — i.e. for a genuinely dead login. It never
86
+ * leaves the machine, so it cannot see a server-side revocation either. It
87
+ * would have cost a ~800 ms / ~300 MB subprocess per poll under `MemoryMax=2G`
88
+ * (gotcha #100) and echoed the account's e-mail and org name to every member of
89
+ * the organization, in exchange for no truth at all. A revoked login is caught
90
+ * instead by `noteAgentAuthFailure` below — from a real refusal, not a guess.
45
91
  */
46
92
  export declare function claudeAuthStatus(homedir?: string): Promise<AgentAuthStatus>;
93
+ /** A session just failed to authenticate as this agent. */
94
+ export declare function noteAgentAuthFailure(agent: RelayAgent): void;
95
+ /** The agent just worked — whatever was wrong with the sign-in is not. */
96
+ export declare function clearAgentAuthFailure(agent: RelayAgent): void;
97
+ /**
98
+ * Is a refusal still being held against this agent?
99
+ *
100
+ * Exported so the wiring in `supervisor.ts` can be pinned by a test without
101
+ * shelling out to the agent CLIs: this predicate IS the mechanism the panel's
102
+ * demotion reads, and three unguarded lines were carrying it (QA-117 M6).
103
+ */
104
+ export declare function agentAuthFailureActive(agent: RelayAgent): boolean;
47
105
  /**
48
106
  * Codex reports its own login state via an exit code (0 signed in / 1 not).
49
107
  * Probed against the RUNNER's home: the host user can be signed in while our