@bridge4dev/runner 0.66.0 → 0.68.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/account-commands.d.ts +27 -8
- package/dist/account-commands.js +103 -13
- package/dist/adapters/codex-home.d.ts +144 -21
- package/dist/adapters/codex-home.js +626 -77
- package/dist/adapters/codex.d.ts +25 -3
- package/dist/adapters/codex.js +149 -11
- package/dist/agent-auth.d.ts +13 -0
- package/dist/agent-auth.js +57 -33
- package/dist/auth-relay.d.ts +7 -1
- package/dist/auth-relay.js +162 -67
- package/dist/claude-homes.d.ts +6 -7
- package/dist/claude-homes.js +20 -26
- package/dist/codex-accounts.d.ts +79 -0
- package/dist/codex-accounts.js +386 -0
- package/dist/config.d.ts +3 -3
- package/dist/config.js +4 -1
- package/dist/index.js +26 -10
- package/dist/login-marks.d.ts +11 -0
- package/dist/login-marks.js +23 -0
- package/dist/supervisor.d.ts +30 -0
- package/dist/supervisor.js +97 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { type CodexHome } from './codex-home.js';
|
|
2
2
|
import { AppServerClient, type ServerRequest } from './codex-protocol.js';
|
|
3
3
|
import { type AgentAdapter, type AgentQuestion, type AgentSession, type EffortOption, type SessionSpec } from './types.js';
|
|
4
|
+
/** One entry of `/`, as `writableRootsFor` needs it. */
|
|
5
|
+
export interface RootEntry {
|
|
6
|
+
path: string;
|
|
7
|
+
/** Where a link leads; null when it leads nowhere. */
|
|
8
|
+
realPath: string | null;
|
|
9
|
+
isDirectory: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Lists `/`; throws when it cannot. Replaced only by tests. */
|
|
12
|
+
export type RootLister = () => RootEntry[];
|
|
13
|
+
/**
|
|
14
|
+
* The top-level directories a widened sandbox may write to, or null when `/`
|
|
15
|
+
* could not be read.
|
|
16
|
+
*
|
|
17
|
+
* Links are followed to where they lead and counted once (`/bin` → `/usr/bin`
|
|
18
|
+
* adds nothing `/usr` does not); a directory inside another root is dropped for
|
|
19
|
+
* the same reason; files (a swap file) and dangling links are skipped.
|
|
20
|
+
*/
|
|
21
|
+
export declare function topLevelWritableRoots(listRoot?: RootLister): string[] | null;
|
|
4
22
|
export interface CodexAdapterDeps {
|
|
5
23
|
/** Injected in tests to drive a scripted app-server. */
|
|
6
24
|
spawnClient?: (options: {
|
|
@@ -28,11 +46,15 @@ export interface CodexAdapterDeps {
|
|
|
28
46
|
repairHome?: () => CodexHome;
|
|
29
47
|
/**
|
|
30
48
|
* How this runner's CODEX_HOME is authenticated (`[codex] auth` in
|
|
31
|
-
* config.toml)
|
|
32
|
-
* repair that
|
|
33
|
-
* runner's credential separate
|
|
49
|
+
* config.toml) – ONLY when the owner wrote it. Every repair honours it, and a
|
|
50
|
+
* repair that defaulted to `link` would undo both an owner's decision to keep
|
|
51
|
+
* the runner's credential separate and a switch to a saved login (#422 S4 item
|
|
52
|
+
* 2). Production leaves it unset: the daemon records the configured mode once
|
|
53
|
+
* (`configureCodexAuth`), and the home reads it from there.
|
|
34
54
|
*/
|
|
35
55
|
authMode?: 'link' | 'own';
|
|
56
|
+
/** What `/` holds, for the widened sandbox roots – a test seam (`codex-auto-dev-null`). */
|
|
57
|
+
listRoot?: RootLister;
|
|
36
58
|
/** #382: how often live helpers are re-checked against Codex – a test seam. */
|
|
37
59
|
subagentReconcileMs?: number;
|
|
38
60
|
/** #382: how long a just-started helper's `idle` is disbelieved – a test seam. */
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { AsyncQueue } from '../async-queue.js';
|
|
2
4
|
import { log } from '../log.js';
|
|
3
5
|
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
4
6
|
import { memoryDeathSentence, sessionMemoryEnv, sessionMemoryPromptLine } from '../session-cage.js';
|
|
5
7
|
import { RUNNER_VERSION } from '../version.js';
|
|
6
8
|
import { repairCodexAuth } from './codex-home.js';
|
|
9
|
+
import { codexSessionAccount, noteCodexSessionAccount, noteCodexUsage, releaseCodexSessionAccount, } from '../codex-accounts.js';
|
|
7
10
|
import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
|
|
8
11
|
import { truncate } from './claude.js';
|
|
9
12
|
import { AgentTaskTray } from './agent-tasks.js';
|
|
@@ -87,11 +90,79 @@ const MODE_POLICY = {
|
|
|
87
90
|
*
|
|
88
91
|
* Read on every turn rather than captured at launch, because the setting can be
|
|
89
92
|
* switched while the session runs.
|
|
93
|
+
*
|
|
94
|
+
* **Never `/` itself** (gotcha 541, plan `codex-auto-dev-null`). Codex mounts a
|
|
95
|
+
* writable root without devices, and `/` laid over its own `/dev` left
|
|
96
|
+
* `/dev/null` visible but unopenable: every `bash -lc` printed «Permission
|
|
97
|
+
* denied» and `git status` exited 128. The widened roots are the machine's real
|
|
98
|
+
* top-level directories instead, minus the kernel's own (`/dev`, `/proc`,
|
|
99
|
+
* `/sys`) – checked on codex-cli 0.154.0 through `command/exec`: `/dev/null`
|
|
100
|
+
* opens, `git status` passes, a write outside the project is allowed, and, unlike
|
|
101
|
+
* `/`, a list of directories leaves no empty `.git`/`.codex`/`.agents` behind
|
|
102
|
+
* (17.09.2026, on throwaway folders – never on this production host's `/`).
|
|
90
103
|
*/
|
|
91
|
-
function writableRootsFor(spec) {
|
|
104
|
+
function writableRootsFor(spec, listRoot = listRootEntries) {
|
|
92
105
|
if (spec.trustMode === 'STRICT')
|
|
93
106
|
return [spec.cwd];
|
|
94
|
-
|
|
107
|
+
if (spec.gitPolicy?.agentAllowOutsideFolder !== true)
|
|
108
|
+
return [spec.cwd];
|
|
109
|
+
const roots = topLevelWritableRoots(listRoot);
|
|
110
|
+
if (roots === null || roots.length === 0) {
|
|
111
|
+
// Unknown means confined (ADR 0007, «polarity»): a step outside asks, as it
|
|
112
|
+
// did before the permission existed.
|
|
113
|
+
return [spec.cwd];
|
|
114
|
+
}
|
|
115
|
+
return roots;
|
|
116
|
+
}
|
|
117
|
+
/** The kernel's own file systems – never a place a sandbox should bind over. */
|
|
118
|
+
const NEVER_WRITABLE_ROOTS = ['/dev', '/proc', '/sys'];
|
|
119
|
+
function listRootEntries() {
|
|
120
|
+
// `withFileTypes`, so a top-level entry is judged by what `readdir` already
|
|
121
|
+
// knows. A `stat` per entry would reach INTO every mount point, and a dead
|
|
122
|
+
// network mount at the top level would then block the daemon's only thread on
|
|
123
|
+
// every turn (found by the independent check of S4). Only a symbolic link is
|
|
124
|
+
// resolved, and those are few (`/bin`, `/lib`, …).
|
|
125
|
+
return fs.readdirSync('/', { withFileTypes: true }).map((entry) => {
|
|
126
|
+
const full = path.join('/', entry.name);
|
|
127
|
+
if (!entry.isSymbolicLink()) {
|
|
128
|
+
return { path: full, realPath: full, isDirectory: entry.isDirectory() };
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const realPath = fs.realpathSync(full);
|
|
132
|
+
return { path: full, realPath, isDirectory: fs.statSync(realPath).isDirectory() };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { path: full, realPath: null, isDirectory: false };
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The top-level directories a widened sandbox may write to, or null when `/`
|
|
141
|
+
* could not be read.
|
|
142
|
+
*
|
|
143
|
+
* Links are followed to where they lead and counted once (`/bin` → `/usr/bin`
|
|
144
|
+
* adds nothing `/usr` does not); a directory inside another root is dropped for
|
|
145
|
+
* the same reason; files (a swap file) and dangling links are skipped.
|
|
146
|
+
*/
|
|
147
|
+
export function topLevelWritableRoots(listRoot = listRootEntries) {
|
|
148
|
+
let entries;
|
|
149
|
+
try {
|
|
150
|
+
entries = listRoot();
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
log.warn('codex: could not read the top-level folders – the sandbox stays on the project folder', {
|
|
154
|
+
error: String(error).slice(0, 200),
|
|
155
|
+
});
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const kernel = (dir) => NEVER_WRITABLE_ROOTS.some((root) => dir === root || dir.startsWith(`${root}/`));
|
|
159
|
+
const unique = [
|
|
160
|
+
...new Set(entries
|
|
161
|
+
.filter((entry) => entry.isDirectory && entry.realPath !== null)
|
|
162
|
+
.map((entry) => path.resolve(entry.realPath))
|
|
163
|
+
.filter((dir) => dir !== '/' && !kernel(dir))),
|
|
164
|
+
].sort();
|
|
165
|
+
return unique.filter((dir) => !unique.some((other) => other !== dir && dir.startsWith(`${other}/`)));
|
|
95
166
|
}
|
|
96
167
|
/**
|
|
97
168
|
* DevBridge's own rules — composed per session since session 18, and kept
|
|
@@ -214,6 +285,15 @@ class CodexSession {
|
|
|
214
285
|
queuedInput = [];
|
|
215
286
|
/** Null when the home was injected — an injected home is the whole truth. */
|
|
216
287
|
repairHome;
|
|
288
|
+
/**
|
|
289
|
+
* The login this process started under (#422 S4), kept to its end: the machine
|
|
290
|
+
* may be switched while it runs, and its limits, its card and a refusal it meets
|
|
291
|
+
* all belong to THIS account (R14). Codex itself keeps a running session on the
|
|
292
|
+
* account it started with – it will not refresh into another account's file.
|
|
293
|
+
*/
|
|
294
|
+
account;
|
|
295
|
+
accountId;
|
|
296
|
+
listRoot;
|
|
217
297
|
/** Open elicitations, oldest first — Codex may have several in flight. */
|
|
218
298
|
questions = new Map();
|
|
219
299
|
threadId = null;
|
|
@@ -279,6 +359,12 @@ class CodexSession {
|
|
|
279
359
|
this.model = spec.model;
|
|
280
360
|
this.effort = spec.effort;
|
|
281
361
|
this.repairHome = deps.repairHome ?? (deps.codexHome ? null : repairCodexAuth);
|
|
362
|
+
this.listRoot = deps.listRoot;
|
|
363
|
+
this.account = codexSessionAccount(home);
|
|
364
|
+
this.accountId = this.account.id;
|
|
365
|
+
// Released in `finish`, and only by THIS session: «forget» refuses a login a
|
|
366
|
+
// live process refreshes into.
|
|
367
|
+
noteCodexSessionAccount(spec.sessionId, this.accountId, this);
|
|
282
368
|
const wiring = {
|
|
283
369
|
// The cage's two numbers ride along (#398 S5). Built here rather than
|
|
284
370
|
// inside `scrubbedEnv`, which has no access to the session — and merged
|
|
@@ -346,6 +432,13 @@ class CodexSession {
|
|
|
346
432
|
throw new Error(`codex is using an unexpected CODEX_HOME (${reportedHome ?? 'not reported'}) — refusing to start the session`);
|
|
347
433
|
}
|
|
348
434
|
this.client.notify('initialized', {});
|
|
435
|
+
// The app-server reads `auth.json` while it starts, so the login it ended
|
|
436
|
+
// up with is the one the link points at NOW, not the one this object read
|
|
437
|
+
// before the process existed. A switch in that window is rare and would
|
|
438
|
+
// otherwise put the previous account's name on this session's card and its
|
|
439
|
+
// limits (found by the independent check of S4). The row it is noted under
|
|
440
|
+
// moves with it, so «forget» refuses the right one.
|
|
441
|
+
this.rereadAccount();
|
|
349
442
|
// The helper set is per PROCESS (#113, #382): a session relaunched after a
|
|
350
443
|
// runner restart must not go on showing the helpers of its previous life,
|
|
351
444
|
// and nothing else would say so until the next one starts or ends.
|
|
@@ -675,7 +768,7 @@ class CodexSession {
|
|
|
675
768
|
input: [{ type: 'text', text, text_elements: [] }],
|
|
676
769
|
approvalPolicy: policy.approvalPolicy,
|
|
677
770
|
// #418: the roots live here and only here — see `writableRootsFor`.
|
|
678
|
-
sandboxPolicy: sandboxPolicyFor(policy.sandbox, this.spec),
|
|
771
|
+
sandboxPolicy: sandboxPolicyFor(policy.sandbox, this.spec, this.listRoot),
|
|
679
772
|
...(this.model ? { model: this.model } : {}),
|
|
680
773
|
// "Override the reasoning effort for this turn and subsequent turns" —
|
|
681
774
|
// the same sticky-override channel the model uses (there is still no
|
|
@@ -1835,6 +1928,13 @@ class CodexSession {
|
|
|
1835
1928
|
this.emitRateLimits();
|
|
1836
1929
|
}
|
|
1837
1930
|
emitRateLimits(blocked = null) {
|
|
1931
|
+
// Whose figures these are (#422 §8): the subscription this session started
|
|
1932
|
+
// under. Codex reports the windows of the account its process is signed in
|
|
1933
|
+
// with, and that is the account captured at the start – so the signature is
|
|
1934
|
+
// true for the whole life of the session. No key (an API-key login) – no
|
|
1935
|
+
// signature, and the panel draws as before #422 (R12).
|
|
1936
|
+
const orgId = this.account.orgId;
|
|
1937
|
+
noteCodexUsage(orgId, this.rateLimitWindows);
|
|
1838
1938
|
// An empty snapshot is «no plan», not «nothing happened»: Codex sends this
|
|
1839
1939
|
// for API-key accounts too, and the popup has to be able to say so.
|
|
1840
1940
|
this.emit({
|
|
@@ -1845,6 +1945,7 @@ class CodexSession {
|
|
|
1845
1945
|
planType: this.ratePlanType,
|
|
1846
1946
|
measuredAt: new Date().toISOString(),
|
|
1847
1947
|
windows: this.rateLimitWindows,
|
|
1948
|
+
...(orgId ? { orgId } : {}),
|
|
1848
1949
|
},
|
|
1849
1950
|
});
|
|
1850
1951
|
}
|
|
@@ -2194,6 +2295,27 @@ class CodexSession {
|
|
|
2194
2295
|
this.capabilitiesInFlight = false;
|
|
2195
2296
|
});
|
|
2196
2297
|
}
|
|
2298
|
+
/** Who the home belongs to now – after the app-server has read its credential. */
|
|
2299
|
+
rereadAccount() {
|
|
2300
|
+
if (!this.repairHome)
|
|
2301
|
+
return;
|
|
2302
|
+
try {
|
|
2303
|
+
const now = codexSessionAccount(this.repairHome());
|
|
2304
|
+
if (now.id === this.account.id)
|
|
2305
|
+
return;
|
|
2306
|
+
log.info('codex: the machine changed its Codex login while this session was starting', {
|
|
2307
|
+
sessionId: this.spec.sessionId,
|
|
2308
|
+
from: this.account.id,
|
|
2309
|
+
to: now.id,
|
|
2310
|
+
});
|
|
2311
|
+
this.account = now;
|
|
2312
|
+
this.accountId = now.id;
|
|
2313
|
+
noteCodexSessionAccount(this.spec.sessionId, now.id, this);
|
|
2314
|
+
}
|
|
2315
|
+
catch (error) {
|
|
2316
|
+
log.warn('codex: could not re-read the account after start', { error: describe(error) });
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2197
2319
|
async publishCapabilities() {
|
|
2198
2320
|
// Every probe carries its own catch: an unknown method on a newer or older
|
|
2199
2321
|
// codex must degrade a picker, not kill the session.
|
|
@@ -2218,7 +2340,7 @@ class CodexSession {
|
|
|
2218
2340
|
currentMode: this.mode,
|
|
2219
2341
|
...(currentModel ? { currentModel } : {}),
|
|
2220
2342
|
...(currentEffort ? { currentEffort } : {}),
|
|
2221
|
-
|
|
2343
|
+
account: this.sessionAccountCard(account),
|
|
2222
2344
|
mcpServers,
|
|
2223
2345
|
interrupt: true,
|
|
2224
2346
|
};
|
|
@@ -2358,6 +2480,21 @@ class CodexSession {
|
|
|
2358
2480
|
}
|
|
2359
2481
|
return options;
|
|
2360
2482
|
}
|
|
2483
|
+
/**
|
|
2484
|
+
* The card's account: what the live process says about itself (`account/read`),
|
|
2485
|
+
* under the row and the subscription the session started with (#422 §8) – the
|
|
2486
|
+
* `id` a «forget» is refused for, the `orgId` the limits panel matches against.
|
|
2487
|
+
*/
|
|
2488
|
+
sessionAccountCard(live) {
|
|
2489
|
+
const plan = live?.plan ?? (this.account.plan ? `ChatGPT ${this.account.plan}` : undefined);
|
|
2490
|
+
const email = live?.email ?? this.account.email;
|
|
2491
|
+
return {
|
|
2492
|
+
id: this.account.id,
|
|
2493
|
+
...(email ? { email } : {}),
|
|
2494
|
+
...(plan ? { plan } : {}),
|
|
2495
|
+
...(this.account.orgId ? { orgId: this.account.orgId } : {}),
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2361
2498
|
async readAccount() {
|
|
2362
2499
|
const result = asRecord(await this.client.request('account/read', {}, 15_000));
|
|
2363
2500
|
const account = asRecord(result['account']);
|
|
@@ -2505,6 +2642,7 @@ class CodexSession {
|
|
|
2505
2642
|
return home.auth === 'missing' ? 'missing' : 'expired';
|
|
2506
2643
|
}
|
|
2507
2644
|
finish() {
|
|
2645
|
+
releaseCodexSessionAccount(this.spec.sessionId, this);
|
|
2508
2646
|
this.stopped = true;
|
|
2509
2647
|
this.clearLimitSettle();
|
|
2510
2648
|
this.subagents.close();
|
|
@@ -2514,7 +2652,7 @@ class CodexSession {
|
|
|
2514
2652
|
}
|
|
2515
2653
|
class ResumeFailed extends Error {
|
|
2516
2654
|
}
|
|
2517
|
-
function sandboxPolicyFor(mode, spec) {
|
|
2655
|
+
function sandboxPolicyFor(mode, spec, listRoot) {
|
|
2518
2656
|
// turn/start takes the structured SandboxPolicy, while thread/start takes the
|
|
2519
2657
|
// CLI-style SandboxMode string. Same intent, two shapes — and only this one
|
|
2520
2658
|
// can name the writable roots, which is why #418 lives here.
|
|
@@ -2526,7 +2664,7 @@ function sandboxPolicyFor(mode, spec) {
|
|
|
2526
2664
|
default:
|
|
2527
2665
|
return {
|
|
2528
2666
|
type: 'workspaceWrite',
|
|
2529
|
-
writableRoots: writableRootsFor(spec),
|
|
2667
|
+
writableRoots: writableRootsFor(spec, listRoot),
|
|
2530
2668
|
// Unchanged by #418, on purpose: the permission is about the file
|
|
2531
2669
|
// system, and letting the network out with it would be a second
|
|
2532
2670
|
// boundary nobody asked to move. A network command still fails in the
|
|
@@ -2796,11 +2934,11 @@ export class CodexAdapter {
|
|
|
2796
2934
|
// Re-assert rather than reuse: `repairCodexAuth` is a couple of lstat calls
|
|
2797
2935
|
// and it puts a credential link back if something removed it since boot.
|
|
2798
2936
|
//
|
|
2799
|
-
// The configured mode has to travel with it
|
|
2800
|
-
// would silently re-link the host user's credential
|
|
2801
|
-
// explicitly asked to keep isolated
|
|
2802
|
-
//
|
|
2803
|
-
const repair = () => repairCodexAuth({ auth: this.deps.authMode
|
|
2937
|
+
// The configured mode has to travel with it – and only a configured one.
|
|
2938
|
+
// Repairing with a default would silently re-link the host user's credential
|
|
2939
|
+
// into a home the owner explicitly asked to keep isolated, and undo a switch
|
|
2940
|
+
// to a saved login at every session start (#422 S4 item 2).
|
|
2941
|
+
const repair = () => repairCodexAuth(this.deps.authMode ? { auth: this.deps.authMode } : {});
|
|
2804
2942
|
const home = this.deps.codexHome ?? repair();
|
|
2805
2943
|
return new CodexSession(spec, home, {
|
|
2806
2944
|
...this.deps,
|
package/dist/agent-auth.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface ClaudeAccountRecord {
|
|
|
29
29
|
*/
|
|
30
30
|
loginExpiredAt?: string;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* One saved Codex login of this machine (#422 S4) – the same shape as a Claude
|
|
34
|
+
* record. `lastSeenIdentity.orgId` carries `tokens.account_id` of its `auth.json`:
|
|
35
|
+
* the key of a Codex subscription (plan §8), one subscription – one row.
|
|
36
|
+
*/
|
|
37
|
+
export type CodexAccountRecord = ClaudeAccountRecord;
|
|
32
38
|
/** What the runner remembers about the machine row itself – it has no record of its own. */
|
|
33
39
|
export interface ClaudeMachineRecord {
|
|
34
40
|
lastSeenIdentity?: ClaudeIdentity;
|
|
@@ -52,6 +58,13 @@ export interface AgentAuthFile {
|
|
|
52
58
|
/** Absent means the machine login – the pre-#422 file reads exactly as before. */
|
|
53
59
|
claudeActiveAccount?: string;
|
|
54
60
|
claudeMachine?: ClaudeMachineRecord;
|
|
61
|
+
/**
|
|
62
|
+
* The saved Codex logins of this machine (#422 S4). Their files live in the
|
|
63
|
+
* runner's CODEX_HOME (`accounts/<id>/auth.json`); WHICH one is active is not
|
|
64
|
+
* here but in the home itself – the `auth.json` link and its mark – because
|
|
65
|
+
* that is what the CLI reads.
|
|
66
|
+
*/
|
|
67
|
+
codexAccounts?: CodexAccountRecord[];
|
|
55
68
|
}
|
|
56
69
|
/** Everything this runner keeps in `agent-auth.json`, read forgivingly. */
|
|
57
70
|
export declare function readAgentAuth(): AgentAuthFile;
|
package/dist/agent-auth.js
CHANGED
|
@@ -65,6 +65,29 @@ function readIdentity(value) {
|
|
|
65
65
|
at,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
+
/** A list of account records, each read on its own: one unreadable entry drops only itself. */
|
|
69
|
+
function parseRecords(entries) {
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
return entries.flatMap((entry) => {
|
|
72
|
+
if (!isObject(entry))
|
|
73
|
+
return [];
|
|
74
|
+
const id = shortString(entry['id'], 64);
|
|
75
|
+
const addedAt = isoString(entry['addedAt']);
|
|
76
|
+
if (!id || !addedAt || seen.has(id))
|
|
77
|
+
return [];
|
|
78
|
+
seen.add(id);
|
|
79
|
+
const identity = readIdentity(entry['lastSeenIdentity']);
|
|
80
|
+
const expiredAt = isoString(entry['loginExpiredAt']);
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
id,
|
|
84
|
+
addedAt,
|
|
85
|
+
...(identity ? { lastSeenIdentity: identity } : {}),
|
|
86
|
+
...(expiredAt ? { loginExpiredAt: expiredAt } : {}),
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
});
|
|
90
|
+
}
|
|
68
91
|
/**
|
|
69
92
|
* The file as it was written, field by field.
|
|
70
93
|
*
|
|
@@ -81,28 +104,10 @@ function parse(raw) {
|
|
|
81
104
|
file.claudeOauthToken = raw['claudeOauthToken'];
|
|
82
105
|
if (typeof raw['updatedAt'] === 'string')
|
|
83
106
|
file.updatedAt = raw['updatedAt'];
|
|
84
|
-
if (Array.isArray(raw['claudeAccounts']))
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return [];
|
|
89
|
-
const id = shortString(entry['id'], 64);
|
|
90
|
-
const addedAt = isoString(entry['addedAt']);
|
|
91
|
-
if (!id || !addedAt || seen.has(id))
|
|
92
|
-
return [];
|
|
93
|
-
seen.add(id);
|
|
94
|
-
const identity = readIdentity(entry['lastSeenIdentity']);
|
|
95
|
-
const expiredAt = isoString(entry['loginExpiredAt']);
|
|
96
|
-
return [
|
|
97
|
-
{
|
|
98
|
-
id,
|
|
99
|
-
addedAt,
|
|
100
|
-
...(identity ? { lastSeenIdentity: identity } : {}),
|
|
101
|
-
...(expiredAt ? { loginExpiredAt: expiredAt } : {}),
|
|
102
|
-
},
|
|
103
|
-
];
|
|
104
|
-
});
|
|
105
|
-
}
|
|
107
|
+
if (Array.isArray(raw['claudeAccounts']))
|
|
108
|
+
file.claudeAccounts = parseRecords(raw['claudeAccounts']);
|
|
109
|
+
if (Array.isArray(raw['codexAccounts']))
|
|
110
|
+
file.codexAccounts = parseRecords(raw['codexAccounts']);
|
|
106
111
|
const active = shortString(raw['claudeActiveAccount'], 64);
|
|
107
112
|
if (active)
|
|
108
113
|
file.claudeActiveAccount = active;
|
|
@@ -122,6 +127,7 @@ const KNOWN_KEYS = new Set([
|
|
|
122
127
|
'claudeAccounts',
|
|
123
128
|
'claudeActiveAccount',
|
|
124
129
|
'claudeMachine',
|
|
130
|
+
'codexAccounts',
|
|
125
131
|
]);
|
|
126
132
|
const RECORD_KEYS = new Set(['id', 'addedAt', 'lastSeenIdentity', 'loginExpiredAt']);
|
|
127
133
|
const MACHINE_KEYS = new Set(['lastSeenIdentity', 'loginExpiredAt']);
|
|
@@ -142,6 +148,28 @@ function mergeObject(raw, known, keys, identityRaw) {
|
|
|
142
148
|
merged['lastSeenIdentity'] = { ...unknownFields(identityRaw, IDENTITY_KEYS), ...identity };
|
|
143
149
|
return merged;
|
|
144
150
|
}
|
|
151
|
+
/** The lists of account records – both agents keep theirs in the same shape (#422 S4). */
|
|
152
|
+
const RECORD_LISTS = new Set(['claudeAccounts', 'codexAccounts']);
|
|
153
|
+
/**
|
|
154
|
+
* One list of records as it is to be written: every record this runner knows,
|
|
155
|
+
* over the unknown fields the same record had on disk, followed by the records it
|
|
156
|
+
* could not read at all. `undefined` – the list is not written.
|
|
157
|
+
*/
|
|
158
|
+
function mergeRecordList(original, list, known) {
|
|
159
|
+
const rawAccounts = Array.isArray(original[list]) ? original[list] : [];
|
|
160
|
+
const rawById = new Map(rawAccounts.filter(isObject).map((entry) => [String(entry['id']), entry]));
|
|
161
|
+
const readable = new Set((parse(original)[list] ?? []).map((record) => record.id));
|
|
162
|
+
const accounts = (known ?? []).map((record) => {
|
|
163
|
+
const rawEntry = rawById.get(record.id);
|
|
164
|
+
return mergeObject(rawEntry, record, RECORD_KEYS, isObject(rawEntry) ? rawEntry['lastSeenIdentity'] : undefined);
|
|
165
|
+
});
|
|
166
|
+
const unreadable = rawAccounts.filter((entry) => !isObject(entry) || !readable.has(String(entry['id'])));
|
|
167
|
+
if (accounts.length + unreadable.length === 0)
|
|
168
|
+
return undefined;
|
|
169
|
+
if (!known && unreadable.length === 0)
|
|
170
|
+
return undefined;
|
|
171
|
+
return [...accounts, ...unreadable];
|
|
172
|
+
}
|
|
145
173
|
/**
|
|
146
174
|
* The file to write: what this runner knows, over everything a newer runner put
|
|
147
175
|
* there – at the top level, inside each account record and its identity, and
|
|
@@ -151,20 +179,16 @@ function mergeObject(raw, known, keys, identityRaw) {
|
|
|
151
179
|
function withForeignFields(original, known) {
|
|
152
180
|
const body = unknownFields(original, KNOWN_KEYS);
|
|
153
181
|
for (const [key, value] of Object.entries(known)) {
|
|
154
|
-
if (value === undefined || key
|
|
182
|
+
if (value === undefined || RECORD_LISTS.has(key))
|
|
183
|
+
continue;
|
|
184
|
+
if (key === 'claudeMachine')
|
|
155
185
|
continue;
|
|
156
186
|
body[key] = value;
|
|
157
187
|
}
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const rawEntry = rawById.get(record.id);
|
|
163
|
-
return mergeObject(rawEntry, record, RECORD_KEYS, isObject(rawEntry) ? rawEntry['lastSeenIdentity'] : undefined);
|
|
164
|
-
});
|
|
165
|
-
const unreadable = rawAccounts.filter((entry) => !isObject(entry) || !readable.has(String(entry['id'])));
|
|
166
|
-
if (accounts.length + unreadable.length > 0 && (known.claudeAccounts || unreadable.length > 0)) {
|
|
167
|
-
body['claudeAccounts'] = [...accounts, ...unreadable];
|
|
188
|
+
for (const list of RECORD_LISTS) {
|
|
189
|
+
const records = mergeRecordList(original, list, known[list]);
|
|
190
|
+
if (records)
|
|
191
|
+
body[list] = records;
|
|
168
192
|
}
|
|
169
193
|
if (known.claudeMachine) {
|
|
170
194
|
const rawMachine = original['claudeMachine'];
|
package/dist/auth-relay.d.ts
CHANGED
|
@@ -105,7 +105,9 @@ export declare class AuthRelay {
|
|
|
105
105
|
* (it prints an inference-only token, D1), so a «saved account» made from it
|
|
106
106
|
* would be a row with no login. `machine` keeps the fallback it always had.
|
|
107
107
|
*/
|
|
108
|
-
startAccountLogin(target: AccountLoginTarget
|
|
108
|
+
startAccountLogin(target: AccountLoginTarget, agent?: RelayAgent, options?: {
|
|
109
|
+
activate?: boolean;
|
|
110
|
+
}): Promise<LoginStartResult>;
|
|
109
111
|
/** The CLI and the pty helper are both there – or a sentence saying which is not. */
|
|
110
112
|
private assertCanRun;
|
|
111
113
|
private startWith;
|
|
@@ -198,6 +200,10 @@ export declare function agentAuthFailureActive(agent: RelayAgent, accountId?: st
|
|
|
198
200
|
* Codex reports its own login state via an exit code (0 signed in / 1 not).
|
|
199
201
|
* Probed against the RUNNER's home: the host user can be signed in while our
|
|
200
202
|
* isolated home is not, and it is ours that sessions use.
|
|
203
|
+
*
|
|
204
|
+
* About the login new sessions start under (#422 S4): the machine login through
|
|
205
|
+
* the link, or the saved login the link points at – named in `activeAccount`,
|
|
206
|
+
* read locally from the file, no CLI for it (R14).
|
|
201
207
|
*/
|
|
202
208
|
export declare function codexAuthStatus(): Promise<AgentAuthStatus>;
|
|
203
209
|
export declare function agentAuthStatuses(): Promise<{
|