@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.
@@ -0,0 +1,386 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { readAgentAuth, updateAgentAuth } from './agent-auth.js';
5
+ import { codexAccountAuthFile, codexAccountDir, codexAccountsDir, codexHomePath, configuredCodexAuth, hostCodexAuthFile, markedCodexAccount, readCodexCredentialFile, repairCodexAuth, setCodexActiveLogin, } from './adapters/codex-home.js';
6
+ import { AccountError, MACHINE_ACCOUNT_ID, isAccountId, refusalActive } from './login-marks.js';
7
+ import { log } from './log.js';
8
+ /**
9
+ * Several Codex logins on one machine, as rows (#422 S4, plan §8).
10
+ *
11
+ * `adapters/codex-home.ts` moves the files; this module is what the wire and a
12
+ * session read: the list of cards, switching, forgetting, which account a
13
+ * session starts under, and the verdict on a saved row. The rows have the SAME
14
+ * shape as Claude's (`AccountCard`), so the API, the mirror and the window need
15
+ * nothing new to serve Codex.
16
+ *
17
+ * Differences from Claude, each on purpose:
18
+ * - identity is read from the login file itself (claims of `id_token`) – there
19
+ * is no public `auth status` with an e-mail in it, and reading a local file
20
+ * costs no subprocess, so the list may read it every time;
21
+ * - the machine row is the host's `~/.codex/auth.json`, which DevBridge never
22
+ * wrote and does not start writing: no «Log in again» for it (R13), and a
23
+ * missing file is `expired`, not a reason to drop the row (S4 item 6);
24
+ * - limits: Codex measures nothing by itself – a session reports its account's
25
+ * windows as it works, and the card shows the last ones this daemon saw.
26
+ */
27
+ // ─── Rows ────────────────────────────────────────────────────────────
28
+ function recordsOf() {
29
+ return readAgentAuth().codexAccounts ?? [];
30
+ }
31
+ function recordOf(id) {
32
+ return recordsOf().find((record) => record.id === id);
33
+ }
34
+ function dirExists(id, home) {
35
+ try {
36
+ return fs.lstatSync(codexAccountDir(id, home)).isDirectory();
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /**
43
+ * A saved login whose record went missing (the record file was set aside as
44
+ * unreadable, say) is still a login on this disk: it gets its record back,
45
+ * dated by its directory, instead of becoming a file nobody can list or forget.
46
+ */
47
+ function adoptOrphanDirectories(home) {
48
+ let names;
49
+ try {
50
+ names = fs.readdirSync(codexAccountsDir(home));
51
+ }
52
+ catch {
53
+ return;
54
+ }
55
+ const known = new Set(recordsOf().map((record) => record.id));
56
+ const orphans = names.filter((name) => isAccountId(name) && !known.has(name));
57
+ if (orphans.length === 0)
58
+ return;
59
+ updateAgentAuth((file) => {
60
+ const records = file.codexAccounts ?? [];
61
+ for (const id of orphans) {
62
+ if (records.some((record) => record.id === id))
63
+ continue;
64
+ let addedAt = new Date().toISOString();
65
+ try {
66
+ addedAt = new Date(Math.floor(fs.statSync(codexAccountDir(id, home)).mtimeMs)).toISOString();
67
+ }
68
+ catch {
69
+ /* dated now */
70
+ }
71
+ records.push({ id, addedAt });
72
+ }
73
+ file.codexAccounts = records;
74
+ });
75
+ log.warn('codex-accounts: saved logins without a record got one back', { count: orphans.length });
76
+ }
77
+ /** Did a refusal persisted on the row outlive the login file? A file written after it wins. */
78
+ function markHolds(markedAt, writtenMs) {
79
+ if (!markedAt)
80
+ return false;
81
+ const marked = Date.parse(markedAt);
82
+ if (!Number.isFinite(marked))
83
+ return false;
84
+ return writtenMs === undefined || writtenMs <= marked;
85
+ }
86
+ function loginOf(credential, refused) {
87
+ const login = credential.status === 'unknown'
88
+ ? 'unknown'
89
+ : credential.status === 'ok' && !refused
90
+ ? 'ok'
91
+ : 'expired';
92
+ return { login, ...(credential.expiresAt ? { loginUntil: credential.expiresAt } : {}) };
93
+ }
94
+ function identityFields(identity) {
95
+ return {
96
+ ...(identity?.email ? { email: identity.email } : {}),
97
+ ...(identity?.orgId ? { orgId: identity.orgId } : {}),
98
+ ...(identity?.plan ? { plan: identity.plan } : {}),
99
+ };
100
+ }
101
+ const sameIdentity = (a, b) => (a?.email ?? '') === (b.email ?? '') &&
102
+ (a?.orgId ?? '') === (b.orgId ?? '') &&
103
+ (a?.plan ?? '') === (b.plan ?? '');
104
+ /** Which row the home uses: a saved one only while its directory is there. */
105
+ export function activeCodexAccountId(home = codexHomePath()) {
106
+ const marked = markedCodexAccount(home);
107
+ return marked && dirExists(marked, home) ? marked : MACHINE_ACCOUNT_ID;
108
+ }
109
+ /**
110
+ * The list of Codex logins on this machine (§8 `agent_accounts`).
111
+ *
112
+ * Repairs the home first, without forcing anything (S4 item 2), so a real file
113
+ * the CLI left in place of the link is back in its store before the rows are
114
+ * read. No subprocess anywhere in it.
115
+ */
116
+ export function listCodexAccounts(homedir = os.homedir()) {
117
+ const home = codexHomePath();
118
+ if (fs.existsSync(home))
119
+ repairCodexAuth({ homedir });
120
+ adoptOrphanDirectories(home);
121
+ const active = activeCodexAccountId(home);
122
+ const host = readCodexCredentialFile(hostCodexAuthFile(homedir));
123
+ const machineCard = {
124
+ id: MACHINE_ACCOUNT_ID,
125
+ kind: 'machine',
126
+ ...identityFields(host.identity),
127
+ active: active === MACHINE_ACCOUNT_ID,
128
+ ...loginOf(host, refusalActive('codex', MACHINE_ACCOUNT_ID)),
129
+ ...usageOf(host.identity.orgId),
130
+ };
131
+ const refreshed = new Map();
132
+ const saved = recordsOf()
133
+ .filter((record) => isAccountId(record.id) && dirExists(record.id, home))
134
+ .sort((a, b) => Date.parse(a.addedAt) - Date.parse(b.addedAt))
135
+ .map((record) => {
136
+ const credential = readCodexCredentialFile(codexAccountAuthFile(record.id, home));
137
+ const readable = Object.keys(credential.identity).length > 0;
138
+ if (readable && !sameIdentity(record.lastSeenIdentity, credential.identity)) {
139
+ refreshed.set(record.id, credential.identity);
140
+ }
141
+ // What the file says now; what it said last time when it cannot be read
142
+ // (a row whose login is gone keeps its name, D2).
143
+ const identity = readable ? credential.identity : record.lastSeenIdentity;
144
+ const refused = refusalActive('codex', record.id) || markHolds(record.loginExpiredAt, credential.writtenMs);
145
+ const same = identity?.orgId !== undefined &&
146
+ host.identity.orgId !== undefined &&
147
+ identity.orgId === host.identity.orgId;
148
+ return {
149
+ id: record.id,
150
+ kind: 'saved',
151
+ ...identityFields(identity),
152
+ addedAt: record.addedAt,
153
+ active: active === record.id,
154
+ ...loginOf(credential, refused),
155
+ ...usageOf(identity?.orgId),
156
+ ...(same ? { sameAsMachine: true } : {}),
157
+ };
158
+ });
159
+ if (refreshed.size > 0) {
160
+ try {
161
+ updateAgentAuth((file) => {
162
+ const at = new Date().toISOString();
163
+ for (const record of file.codexAccounts ?? []) {
164
+ const identity = refreshed.get(record.id);
165
+ if (identity)
166
+ record.lastSeenIdentity = { ...identity, at };
167
+ }
168
+ });
169
+ }
170
+ catch (error) {
171
+ log.warn('codex-accounts: could not remember who the saved logins are', {
172
+ error: String(error),
173
+ });
174
+ }
175
+ }
176
+ return { accounts: [machineCard, ...saved], active };
177
+ }
178
+ function cardOf(id) {
179
+ const card = listCodexAccounts().accounts.find((row) => row.id === id);
180
+ if (!card)
181
+ throw new AccountError('the account is not on this server any more');
182
+ return card;
183
+ }
184
+ /** The card of a row that just signed in – for the relay's log and the tests. */
185
+ export function codexAccountCard(id) {
186
+ return cardOf(id);
187
+ }
188
+ // ─── Switching and forgetting ────────────────────────────────────────
189
+ /**
190
+ * Make a row the login new Codex sessions start under (§8 `agent_account_activate`).
191
+ *
192
+ * Running sessions keep the tokens they started with; a refreshed login is
193
+ * returned to its own store before the link moves (S4 item 7). The machine row is
194
+ * refused only where the machine's owner wrote `[codex] auth = "own"`: there the
195
+ * host login is kept away from the runner on purpose, and the next repair would
196
+ * take the link out again.
197
+ */
198
+ export function activateCodexAccount(id) {
199
+ const home = codexHomePath();
200
+ if (id === MACHINE_ACCOUNT_ID) {
201
+ if (configuredCodexAuth() === 'own') {
202
+ throw new AccountError('this server keeps Codex away from the login of the machine ([codex] auth = "own" in the runner config) – switch to a saved login instead');
203
+ }
204
+ assertNoLiveSessionsForMachineSwitch();
205
+ }
206
+ else {
207
+ if (!isAccountId(id) || !recordOf(id))
208
+ throw new AccountError('no such account on this server');
209
+ if (!dirExists(id, home)) {
210
+ throw new AccountError('the login of this account is gone from this server – forget it');
211
+ }
212
+ }
213
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
214
+ setCodexActiveLogin(id, home);
215
+ return activeCodexAccountId(home);
216
+ }
217
+ /**
218
+ * Forget a saved Codex login: its file goes, its record goes (§8 `agent_account_forget`).
219
+ *
220
+ * The machine row cannot be forgotten. A row a live session runs under is
221
+ * refused – its process refreshes into that file until it ends. The row in use is
222
+ * switched to the machine login FIRST, so the link never points at nothing.
223
+ */
224
+ export function forgetCodexAccount(id) {
225
+ if (id === MACHINE_ACCOUNT_ID) {
226
+ throw new AccountError('the login of this machine cannot be forgotten');
227
+ }
228
+ const record = isAccountId(id) ? recordOf(id) : undefined;
229
+ if (!record)
230
+ throw new AccountError('no such account on this server');
231
+ if (liveCodexAccountIds().has(id)) {
232
+ throw new AccountError('a running session uses this account – stop it first, then forget it');
233
+ }
234
+ const home = codexHomePath();
235
+ if (markedCodexAccount(home) === id) {
236
+ assertNoLiveSessionsForMachineSwitch();
237
+ if (configuredCodexAuth() === 'own') {
238
+ throw new AccountError('this is the only login Codex may use on this server ([codex] auth = "own") – switch to another saved login first');
239
+ }
240
+ setCodexActiveLogin(MACHINE_ACCOUNT_ID, home);
241
+ }
242
+ fs.rmSync(codexAccountDir(id, home), { recursive: true, force: true });
243
+ updateAgentAuth((file) => {
244
+ file.codexAccounts = (file.codexAccounts ?? []).filter((entry) => entry.id !== id);
245
+ if (file.codexAccounts.length === 0)
246
+ delete file.codexAccounts;
247
+ });
248
+ if (record.lastSeenIdentity?.orgId)
249
+ usageByKey.delete(record.lastSeenIdentity.orgId);
250
+ log.info('codex-accounts: an account was forgotten', { account: id });
251
+ return { active: activeCodexAccountId(home) };
252
+ }
253
+ // ─── What a session starts under ─────────────────────────────────────
254
+ /**
255
+ * Moving the link to the MACHINE login while Codex sessions run is refused.
256
+ *
257
+ * Codex writes a refreshed token through whatever `auth.json` points at when the
258
+ * refresh comes back, and it only compares accounts when it RE-READS the file, not
259
+ * when it writes. A switch in that second would put a session's tokens into the
260
+ * file the link points at by then – and the host user's own `~/.codex/auth.json`
261
+ * is the one file DevBridge must never write (R13). Between saved logins the same
262
+ * second costs a re-login of a saved account and is left open (the plan: a switch
263
+ * acts on what starts after it); over the host's file it is not ours to risk.
264
+ */
265
+ function assertNoLiveSessionsForMachineSwitch() {
266
+ const live = liveCodexAccountIds().size;
267
+ if (live === 0)
268
+ return;
269
+ throw new AccountError(live === 1
270
+ ? 'a Codex session is running on this server – close or pause it before switching to the login of this machine'
271
+ : `${live} Codex sessions are running on this server – close or pause them before switching to the login of this machine`);
272
+ }
273
+ /**
274
+ * Who the home a session is starting with belongs to: the row it is set to, and
275
+ * the identity of the login the link resolves to right now – read locally.
276
+ */
277
+ export function codexSessionAccount(home) {
278
+ const id = home.auth === 'linked' || !home.accountId ? MACHINE_ACCOUNT_ID : home.accountId;
279
+ if (home.auth === 'missing')
280
+ return { id };
281
+ const identity = readCodexCredentialFile(path.join(home.path, 'auth.json')).identity;
282
+ return { id, ...identity };
283
+ }
284
+ /** sessionId → the Codex row that session's process runs under, and which process said so. */
285
+ const sessionAccounts = new Map();
286
+ /**
287
+ * `owner` is the session object itself: a session can end twice (the boot catch
288
+ * calls `stop()`, and the process exit calls it again), and a bare delete by
289
+ * sessionId then wiped the note of the RELAUNCHED session that had taken the
290
+ * same id – «forget» stopped refusing the account that session was using (found
291
+ * by the independent check of S4).
292
+ */
293
+ export function noteCodexSessionAccount(sessionId, accountId, owner) {
294
+ sessionAccounts.set(sessionId, { accountId, owner });
295
+ }
296
+ export function releaseCodexSessionAccount(sessionId, owner) {
297
+ if (sessionAccounts.get(sessionId)?.owner === owner)
298
+ sessionAccounts.delete(sessionId);
299
+ }
300
+ /** Codex rows some live session process runs under right now. */
301
+ export function liveCodexAccountIds() {
302
+ return new Set([...sessionAccounts.values()].map((entry) => entry.accountId));
303
+ }
304
+ // ─── The verdict on a saved row ──────────────────────────────────────
305
+ /** Who the active row is, for `auth_status.activeAccount` (§8) – read locally, no CLI. */
306
+ export function codexActiveAccountSummary(id, homedir = os.homedir()) {
307
+ const file = id === MACHINE_ACCOUNT_ID ? hostCodexAuthFile(homedir) : codexAccountAuthFile(id);
308
+ const identity = readCodexCredentialFile(file).identity;
309
+ const known = identity.orgId || identity.email ? identity : recordOf(id)?.lastSeenIdentity;
310
+ return {
311
+ id,
312
+ ...(known?.email ? { email: known.email } : {}),
313
+ ...(known?.orgId ? { orgId: known.orgId } : {}),
314
+ };
315
+ }
316
+ /** A refusal persisted on a saved row that still stands against its login file. */
317
+ export function codexRowRefused(id) {
318
+ if (id === MACHINE_ACCOUNT_ID || !isAccountId(id))
319
+ return false;
320
+ const record = recordOf(id);
321
+ if (!record?.loginExpiredAt)
322
+ return false;
323
+ return markHolds(record.loginExpiredAt, readCodexCredentialFile(codexAccountAuthFile(id)).writtenMs);
324
+ }
325
+ /** A session under this saved row was refused (D2): mark it, remove nothing. */
326
+ export function markCodexLoginExpired(id) {
327
+ if (!isAccountId(id))
328
+ return;
329
+ try {
330
+ updateAgentAuth((file) => {
331
+ const record = file.codexAccounts?.find((entry) => entry.id === id);
332
+ if (record)
333
+ record.loginExpiredAt = new Date().toISOString();
334
+ });
335
+ }
336
+ catch (error) {
337
+ log.warn('codex-accounts: could not record the refusal', { account: id, error: String(error) });
338
+ }
339
+ }
340
+ /** The row worked again. Writes only when there was a mark to clear. */
341
+ export function clearCodexLoginExpired(id) {
342
+ if (!isAccountId(id) || !recordOf(id)?.loginExpiredAt)
343
+ return;
344
+ try {
345
+ updateAgentAuth((file) => {
346
+ const record = file.codexAccounts?.find((entry) => entry.id === id);
347
+ if (record)
348
+ delete record.loginExpiredAt;
349
+ });
350
+ }
351
+ catch (error) {
352
+ log.warn('codex-accounts: could not clear the refusal', { account: id, error: String(error) });
353
+ }
354
+ }
355
+ // ─── Limits a session saw ────────────────────────────────────────────
356
+ /** subscription key → the windows a Codex session of it last reported, in this daemon. */
357
+ const usageByKey = new Map();
358
+ /**
359
+ * A Codex session reported its account's windows. Codex sends these after every
360
+ * model request, for the account the session runs under – so for a card they
361
+ * are that subscription's figures, as fresh as its last working session.
362
+ */
363
+ export function noteCodexUsage(orgId, windows) {
364
+ if (!orgId)
365
+ return;
366
+ // The two windows a card has words for; a window Codex names otherwise stays in
367
+ // the session's own panel.
368
+ const rows = windows.flatMap((window) => (window.key === 'five_hour' || window.key === 'seven_day') &&
369
+ typeof window.usedPercent === 'number'
370
+ ? [{ key: window.key, label: null, percent: window.usedPercent, resetsAt: window.resetsAt }]
371
+ : []);
372
+ if (rows.length === 0)
373
+ return;
374
+ usageByKey.set(orgId, { rows, measuredAtMs: Date.now() });
375
+ }
376
+ function usageOf(orgId) {
377
+ const reading = orgId ? usageByKey.get(orgId) : undefined;
378
+ if (!reading)
379
+ return {};
380
+ return { usage: reading.rows, usageMeasuredAt: new Date(reading.measuredAtMs).toISOString() };
381
+ }
382
+ /** Forget every remembered reading – tests. */
383
+ export function resetCodexUsage() {
384
+ usageByKey.clear();
385
+ }
386
+ //# sourceMappingURL=codex-accounts.js.map
package/dist/config.d.ts CHANGED
@@ -34,9 +34,9 @@ declare const ConfigSchema: z.ZodObject<{
34
34
  token: string;
35
35
  }>>;
36
36
  codex: z.ZodOptional<z.ZodObject<{
37
- auth: z.ZodDefault<z.ZodEnum<["link", "own"]>>;
37
+ auth: z.ZodOptional<z.ZodEnum<["link", "own"]>>;
38
38
  }, "strip", z.ZodTypeAny, {
39
- auth: "link" | "own";
39
+ auth?: "link" | "own" | undefined;
40
40
  }, {
41
41
  auth?: "link" | "own" | undefined;
42
42
  }>>;
@@ -184,7 +184,7 @@ declare const ConfigSchema: z.ZodObject<{
184
184
  token: string;
185
185
  };
186
186
  codex?: {
187
- auth: "link" | "own";
187
+ auth?: "link" | "own" | undefined;
188
188
  } | undefined;
189
189
  checkpoints?: {
190
190
  enabled: boolean;
package/dist/config.js CHANGED
@@ -30,9 +30,12 @@ const ConfigSchema = z.object({
30
30
  // the owner also uses codex interactively on this machine:
31
31
  // OpenAI rotates refresh tokens, and two processes sharing
32
32
  // one credential store can invalidate each other.
33
+ // No schema default (#422 S4 item 2): «absent» and «written as link» are two
34
+ // different facts. Only a written mode is forced, and neither is forced over a
35
+ // saved login the dashboard switched to – the home's own mark decides then.
33
36
  codex: z
34
37
  .object({
35
- auth: z.enum(['link', 'own']).default('link'),
38
+ auth: z.enum(['link', 'own']).optional(),
36
39
  })
37
40
  .optional(),
38
41
  // Layer-1 resource guard (plan §8.6): a ceiling the dashboard cannot raise.
package/dist/index.js CHANGED
@@ -7,11 +7,11 @@ import path from 'node:path';
7
7
  import { promisify } from 'node:util';
8
8
  import { ClaudeAdapter } from './adapters/claude.js';
9
9
  import { CodexAdapter } from './adapters/codex.js';
10
- import { ensureCodexHome } from './adapters/codex-home.js';
10
+ import { configureCodexAuth, discardAbandonedCodexStagingHomes, ensureCodexHome, } from './adapters/codex-home.js';
11
11
  import { sessionClaudePath } from './agent-binary.js';
12
12
  import { claimCageAuthority, runSystemctl } from './cage-authority.js';
13
13
  import { acquireDaemonLock, isHeldByAnother } from './daemon-lock.js';
14
- import { loadConfig, mergeIntoPairedConfig, requireConfig, saveConfig, } from './config.js';
14
+ import { loadConfig, mergeIntoPairedConfig, requireConfig, saveConfig } from './config.js';
15
15
  import { log } from './log.js';
16
16
  import { installIsWritable, installPrefixFor, isSupervisedProcess, restartCapability, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
17
17
  import { applyStoredClaudeToken } from './agent-auth.js';
@@ -647,15 +647,19 @@ async function cmdPair(args) {
647
647
  * per-session: a fresh home clones ~90 MB of plugin marketplace on first use.
648
648
  * A failure here disables Codex rather than the whole daemon.
649
649
  */
650
- function bootstrapCodex(config) {
650
+ function bootstrapCodex() {
651
651
  try {
652
- const home = ensureCodexHome({ auth: config.codex?.auth ?? 'link' });
653
- log.info('codex: isolated home ready', { path: home.path, auth: home.auth });
652
+ const home = ensureCodexHome();
653
+ log.info('codex: isolated home ready', {
654
+ path: home.path,
655
+ auth: home.auth,
656
+ ...(home.accountId ? { account: home.accountId } : {}),
657
+ });
654
658
  // Deliberately NOT passing `codexHome`: the adapter re-asserts the home on
655
659
  // every session. Freezing this snapshot is how a credential that went
656
660
  // missing mid-day kept being reported as present while sessions failed.
657
- // The mode does travel, so those repairs honour `[codex] auth = "own"`.
658
- return new CodexAdapter({ authMode: config.codex?.auth ?? 'link' });
661
+ // Nor the mode: those repairs read it from `configureCodexAuth`.
662
+ return new CodexAdapter();
659
663
  }
660
664
  catch (error) {
661
665
  log.error('codex: could not prepare an isolated home — Codex disabled', {
@@ -985,8 +989,10 @@ async function cmdDaemon() {
985
989
  }
986
990
  try {
987
991
  // A sign-in the previous daemon accepted but never adopted (S2): nothing can
988
- // be adopting before the supervisor exists.
992
+ // be adopting before the supervisor exists. Codex sign-ins too (S4) – and
993
+ // the fixed-path staging home runners before S4 never removed.
989
994
  discardAbandonedStagingHomes();
995
+ discardAbandonedCodexStagingHomes();
990
996
  }
991
997
  catch (error) {
992
998
  log.warn('daemon: could not remove sign-in homes a previous daemon left', {
@@ -994,9 +1000,16 @@ async function cmdDaemon() {
994
1000
  });
995
1001
  }
996
1002
  void refreshAccountIdentity(MACHINE_ACCOUNT_ID).catch(() => undefined);
1003
+ // Only a mode the owner WROTE is ever forced, and never over a saved login
1004
+ // (#422 S4 item 2): the defaults that used to be passed around re-linked the
1005
+ // host login at every start, session and probe. Recorded whatever is on PATH:
1006
+ // Codex may be installed from the dashboard later, and the account commands and
1007
+ // the minute probe read this from the first moment (found by the independent
1008
+ // check of S4).
1009
+ configureCodexAuth(config.codex?.auth);
997
1010
  const agents = installedAgents();
998
1011
  const ws = new RunnerWsClient(config.api.ws_url, config.server.token, runnerCapabilities());
999
- const codex = agents.includes('codex') ? bootstrapCodex(config) : null;
1012
+ const codex = agents.includes('codex') ? bootstrapCodex() : null;
1000
1013
  const supervisor = new Supervisor(ws, {
1001
1014
  adapters: {
1002
1015
  CLAUDE: new ClaudeAdapter(),
@@ -1015,7 +1028,7 @@ async function cmdDaemon() {
1015
1028
  * `hasExecutable` is asked HERE, at the moment of the question, which is
1016
1029
  * the whole point.
1017
1030
  */
1018
- makeAdapter: (agent) => agent === 'CODEX' && hasExecutable('codex') ? bootstrapCodex(config) : null,
1031
+ makeAdapter: (agent) => (agent === 'CODEX' && hasExecutable('codex') ? bootstrapCodex() : null),
1019
1032
  ...(config.mcp ? { mcp: { url: config.mcp.url, token: config.mcp.token } } : {}),
1020
1033
  ...(config.limits?.max_sessions ? { maxSessionsLimit: config.limits.max_sessions } : {}),
1021
1034
  /**
@@ -1673,6 +1686,9 @@ async function cmdDoctor(args) {
1673
1686
  // reloads systemd, which is the `install` grade and nothing above it (#403).
1674
1687
  claimCageAuthority(fix ? 'install' : 'probe');
1675
1688
  const config = loadConfig();
1689
+ // The same Codex mode the daemon honours, or doctor's verdict would repair the
1690
+ // home by a rule the daemon does not follow (#422 S4 item 2).
1691
+ configureCodexAuth(config?.codex?.auth);
1676
1692
  print(`devbridge-runner ${RUNNER_VERSION}`);
1677
1693
  print(config ? `Paired with: ${config.server.name} (${config.api.url})` : 'Not paired');
1678
1694
  // Paths given on the command line win; otherwise check what this runner has
@@ -22,6 +22,17 @@ import type { RelayAgentName } from './agent-registry.js';
22
22
  */
23
23
  /** The machine's own login – the one row every agent has. */
24
24
  export declare const MACHINE_ACCOUNT_ID = "machine";
25
+ /**
26
+ * A saved account's id – twelve lowercase letters and digits, never `machine`.
27
+ * One format for both agents (#422 S4): the wire, the refusal marks and the
28
+ * dashboard read the same alphabet whichever agent a row belongs to.
29
+ */
30
+ export declare function isAccountId(id: unknown): id is string;
31
+ /** A refusal meant for a person: the wire (S2) sends its message as it is, and it names no path. */
32
+ export declare class AccountError extends Error {
33
+ constructor(message: string);
34
+ }
35
+ export declare function newAccountId(): string;
25
36
  /** A session under this agent and account was just refused. */
26
37
  export declare function noteRefusal(agent: RelayAgentName, accountId?: string): void;
27
38
  /** The sign-in worked again. Answers whether a refusal was being held. */
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  /**
2
3
  * What the agent actually experienced with a sign-in — per agent AND per account.
3
4
  *
@@ -21,6 +22,28 @@
21
22
  */
22
23
  /** The machine's own login – the one row every agent has. */
23
24
  export const MACHINE_ACCOUNT_ID = 'machine';
25
+ const ACCOUNT_ID = /^[a-z0-9]{12}$/;
26
+ /**
27
+ * A saved account's id – twelve lowercase letters and digits, never `machine`.
28
+ * One format for both agents (#422 S4): the wire, the refusal marks and the
29
+ * dashboard read the same alphabet whichever agent a row belongs to.
30
+ */
31
+ export function isAccountId(id) {
32
+ return typeof id === 'string' && ACCOUNT_ID.test(id);
33
+ }
34
+ /** A refusal meant for a person: the wire (S2) sends its message as it is, and it names no path. */
35
+ export class AccountError extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = 'AccountError';
39
+ }
40
+ }
41
+ export function newAccountId() {
42
+ // base32-ish from random bytes: 12 characters of [a-z0-9].
43
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
44
+ const bytes = crypto.randomBytes(12);
45
+ return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join('');
46
+ }
24
47
  const AUTH_FAILURE_TTL_MS = 15 * 60_000;
25
48
  const refusals = new Map();
26
49
  const keyOf = (agent, accountId) => `${agent}:${accountId}`;
@@ -1144,6 +1144,36 @@ export declare class Supervisor {
1144
1144
  * rewound (ticket #126).
1145
1145
  */
1146
1146
  private park;
1147
+ /**
1148
+ * Move the sessions running right now onto the account just made active (#422 S4.1).
1149
+ *
1150
+ * A process reads its account ONCE, at its start: `CLAUDE_CONFIG_DIR` for
1151
+ * Claude, the link `codex-home/auth.json` resolves to for Codex. Neither can
1152
+ * be changed under a live process, so «apply to the open sessions» is parking
1153
+ * them – the conversation is kept (`--resume` / `thread/resume`) and the next
1154
+ * message starts a process on the account the machine is now set to.
1155
+ *
1156
+ * A session in the middle of a turn is not killed for this: it is marked and
1157
+ * parked at the end of its turn (`applyPendingAccountSwitch`). Which is why
1158
+ * the answer counts the two separately – the window says so in words.
1159
+ */
1160
+ private applyAccountToSessions;
1161
+ /**
1162
+ * Park a session because the account under it changed – its own words.
1163
+ *
1164
+ * `quiet`, then a note of its own: «the runner switched to another session»
1165
+ * is simply untrue here, and this is the one thing the person needs to read
1166
+ * to know why their session is waiting for a message (the same reason #126
1167
+ * gave the rewind its own wording).
1168
+ */
1169
+ private parkForAccount;
1170
+ /**
1171
+ * The turn is over – carry out a switch that arrived while it was running.
1172
+ *
1173
+ * Still not parkable (a card is open, a question is waiting): the mark stays
1174
+ * and the next end of a turn tries again. Nothing here ever kills a turn.
1175
+ */
1176
+ private applyPendingAccountSwitch;
1147
1177
  /**
1148
1178
  * The agent is producing output while this runner still says a human is
1149
1179
  * expected — put `lastReported` back to RUNNING (ticket #185).