@bridge4dev/runner 0.66.0 → 0.67.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 +6 -7
- package/dist/account-commands.js +53 -10
- package/dist/adapters/codex-home.d.ts +143 -21
- package/dist/adapters/codex-home.js +623 -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 +5 -1
- package/dist/auth-relay.js +141 -62
- package/dist/claude-homes.d.ts +3 -6
- package/dist/claude-homes.js +3 -20
- 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/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { readAgentAuth, updateAgentAuth } from '../agent-auth.js';
|
|
5
|
+
import { AccountError, MACHINE_ACCOUNT_ID, clearRefusal, isAccountId, newAccountId, } from '../login-marks.js';
|
|
4
6
|
import { stateDir } from '../paths.js';
|
|
5
7
|
import { log } from '../log.js';
|
|
6
8
|
// A dedicated CODEX_HOME for the runner. This is a security control, not tidiness.
|
|
@@ -15,15 +17,40 @@ import { log } from '../log.js';
|
|
|
15
17
|
// Verified live: with CODEX_HOME pointed here, `config/read` reports exactly two
|
|
16
18
|
// layers (ours + the always-present system file) and the user's config is not
|
|
17
19
|
// among them.
|
|
20
|
+
//
|
|
21
|
+
// Several Codex logins (#422 S4). The home stays ONE – its `sessions/` (the
|
|
22
|
+
// history every resume reads), its logs and caches are shared by every account –
|
|
23
|
+
// and only the login differs: each saved login is a file of its own,
|
|
24
|
+
// `accounts/<id>/auth.json` (0600), and the account in use is the one the home's
|
|
25
|
+
// `auth.json` LINK points at. Switching is repointing that link. Nothing is ever
|
|
26
|
+
// copied: a login moves into the store by `rename`, and a rename that would have
|
|
27
|
+
// to cross filesystems is refused instead of falling back to a copy – two copies
|
|
28
|
+
// of one refresh token invalidate each other (the Claude side lost 47 agents to
|
|
29
|
+
// exactly that on 08.09.2026, gotcha 460).
|
|
30
|
+
//
|
|
31
|
+
// Measured on codex-cli 0.154.0 (17.09.2026) before a line of this was written:
|
|
32
|
+
// - the CLI writes `auth.json` THROUGH a symlink (open + truncate), so a token
|
|
33
|
+
// refresh lands in the store file the link points at and the link survives;
|
|
34
|
+
// - before it refreshes it re-reads the file, and a file of a different
|
|
35
|
+
// `account_id` is not written over («Skipping token refresh because auth
|
|
36
|
+
// changed after guarded reload»). A session still running under A after the
|
|
37
|
+
// machine was switched to B therefore never spends A's tokens into B's file;
|
|
38
|
+
// at its next refresh it stops with «signed in to another account» instead.
|
|
18
39
|
const CONFIG_FILENAME = 'config.toml';
|
|
19
40
|
const AUTH_FILENAME = 'auth.json';
|
|
20
41
|
/**
|
|
21
42
|
* Records how this home is meant to be authenticated. Without it a plain
|
|
22
43
|
* auth.json is ambiguous — it could be our own device login, or codex having
|
|
23
|
-
* replaced our symlink.
|
|
24
|
-
*
|
|
44
|
+
* replaced our symlink. Three states since #422 S4:
|
|
45
|
+
* - `link` – the machine login: the host user's `~/.codex/auth.json`;
|
|
46
|
+
* - `own` – no link on purpose (`[codex] auth = "own"` waiting for a login);
|
|
47
|
+
* - `account:<id>` – a saved login, `accounts/<id>/auth.json`.
|
|
48
|
+
* A runner from before S4 reads `account:<id>` as nothing, i.e. as `link`, and
|
|
49
|
+
* re-links the host login (К8): the machine login is intact, the switch is lost.
|
|
25
50
|
*/
|
|
26
51
|
const MODE_FILENAME = '.devbridge-auth-mode';
|
|
52
|
+
const ACCOUNT_MARK_PREFIX = 'account:';
|
|
53
|
+
const ACCOUNTS_DIRNAME = 'accounts';
|
|
27
54
|
/**
|
|
28
55
|
* Minimal config. Deliberately has NO `[mcp_servers]` section: a per-thread
|
|
29
56
|
* config overlay MERGES with the home config at the map-key level (verified
|
|
@@ -41,39 +68,194 @@ const CONFIG_BODY = [
|
|
|
41
68
|
export function codexHomePath() {
|
|
42
69
|
return path.join(stateDir(), 'codex-home');
|
|
43
70
|
}
|
|
71
|
+
/** Where the saved Codex logins live – inside the one home, beside what they share. */
|
|
72
|
+
export function codexAccountsDir(home = codexHomePath()) {
|
|
73
|
+
return path.join(home, ACCOUNTS_DIRNAME);
|
|
74
|
+
}
|
|
75
|
+
/** The directory of one saved login. Throws on anything that is not an account id. */
|
|
76
|
+
export function codexAccountDir(id, home = codexHomePath()) {
|
|
77
|
+
if (!isAccountId(id))
|
|
78
|
+
throw new AccountError('no such account on this server');
|
|
79
|
+
return path.join(codexAccountsDir(home), id);
|
|
80
|
+
}
|
|
81
|
+
/** The login file of one saved account. */
|
|
82
|
+
export function codexAccountAuthFile(id, home = codexHomePath()) {
|
|
83
|
+
return path.join(codexAccountDir(id, home), AUTH_FILENAME);
|
|
84
|
+
}
|
|
85
|
+
/** The machine login of Codex – the host user's own file, never written by DevBridge (R13). */
|
|
86
|
+
export function hostCodexAuthFile(homedir = os.homedir()) {
|
|
87
|
+
return path.join(homedir, '.codex', AUTH_FILENAME);
|
|
88
|
+
}
|
|
44
89
|
/**
|
|
45
|
-
* Throwaway
|
|
46
|
-
* into the
|
|
47
|
-
* cannot destroy a credential that was working.
|
|
90
|
+
* Throwaway homes for device-code logins, one per attempt. The flow runs there
|
|
91
|
+
* and its login is moved into the store only on success, so an abandoned or
|
|
92
|
+
* timed-out sign-in cannot destroy a credential that was working.
|
|
48
93
|
*/
|
|
49
94
|
export function stagingCodexHomePath() {
|
|
50
95
|
return path.join(stateDir(), 'codex-login');
|
|
51
96
|
}
|
|
52
|
-
|
|
97
|
+
// ─── The configured mode ─────────────────────────────────────────────
|
|
98
|
+
let configuredAuth;
|
|
99
|
+
/**
|
|
100
|
+
* `[codex] auth` as the machine's owner WROTE it – `undefined` when the key is
|
|
101
|
+
* not in `config.toml`. Set once at daemon start.
|
|
102
|
+
*
|
|
103
|
+
* Only a mode that is really written may be forced, and even then not over a
|
|
104
|
+
* saved login (S4 item 2): the runner used to pass `link` by default from three
|
|
105
|
+
* places – the daemon start, every session start and the minute probe – and each
|
|
106
|
+
* of them would have undone a switch of account within a minute (К4).
|
|
107
|
+
*/
|
|
108
|
+
export function configureCodexAuth(mode) {
|
|
109
|
+
configuredAuth = mode;
|
|
110
|
+
}
|
|
111
|
+
export function configuredCodexAuth() {
|
|
112
|
+
return configuredAuth;
|
|
113
|
+
}
|
|
114
|
+
// ─── The mark ────────────────────────────────────────────────────────
|
|
115
|
+
function readMark(dir) {
|
|
116
|
+
let value;
|
|
53
117
|
try {
|
|
54
|
-
|
|
55
|
-
return value === 'link' || value === 'own' ? value : null;
|
|
118
|
+
value = fs.readFileSync(path.join(dir, MODE_FILENAME), 'utf8').trim();
|
|
56
119
|
}
|
|
57
120
|
catch {
|
|
58
121
|
return null;
|
|
59
122
|
}
|
|
123
|
+
if (value === 'link' || value === 'own')
|
|
124
|
+
return { kind: value };
|
|
125
|
+
if (value.startsWith(ACCOUNT_MARK_PREFIX)) {
|
|
126
|
+
const id = value.slice(ACCOUNT_MARK_PREFIX.length);
|
|
127
|
+
if (isAccountId(id))
|
|
128
|
+
return { kind: 'account', id };
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
function markText(mark) {
|
|
133
|
+
return mark.kind === 'account' ? `${ACCOUNT_MARK_PREFIX}${mark.id}` : mark.kind;
|
|
60
134
|
}
|
|
61
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Write-then-rename: a torn mark reads as «no mark», which reads as `link` – a
|
|
137
|
+
* half-written `account:` would quietly put the machine login back.
|
|
138
|
+
*/
|
|
139
|
+
function writeMark(dir, mark) {
|
|
140
|
+
const target = path.join(dir, MODE_FILENAME);
|
|
62
141
|
try {
|
|
63
|
-
|
|
142
|
+
const current = readMark(dir);
|
|
143
|
+
if (current && markText(current) === markText(mark))
|
|
144
|
+
return;
|
|
145
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
146
|
+
fs.writeFileSync(tmp, markText(mark), { mode: 0o600 });
|
|
147
|
+
fs.renameSync(tmp, target);
|
|
64
148
|
}
|
|
65
149
|
catch (error) {
|
|
66
150
|
log.warn('codex: could not record the auth mode', { error: String(error) });
|
|
67
151
|
}
|
|
68
152
|
}
|
|
153
|
+
/** Which saved login the home is set to, if any – the mark, read only. */
|
|
154
|
+
export function markedCodexAccount(home = codexHomePath()) {
|
|
155
|
+
const mark = readMark(home);
|
|
156
|
+
return mark?.kind === 'account' ? mark.id : null;
|
|
157
|
+
}
|
|
158
|
+
const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
159
|
+
const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.trim().length <= max
|
|
160
|
+
? value.trim()
|
|
161
|
+
: undefined;
|
|
162
|
+
function jwtClaims(token) {
|
|
163
|
+
if (typeof token !== 'string')
|
|
164
|
+
return null;
|
|
165
|
+
const payload = token.split('.')[1];
|
|
166
|
+
if (!payload)
|
|
167
|
+
return null;
|
|
168
|
+
try {
|
|
169
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
170
|
+
return isObject(claims) ? claims : null;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Read a Codex login file without letting a token out of this function.
|
|
178
|
+
*
|
|
179
|
+
* The identity comes from the claims of `id_token`, decoded locally (S4 item 8):
|
|
180
|
+
* `email`, and under `https://api.openai.com/auth` the plan and the ChatGPT
|
|
181
|
+
* account; `tokens.account_id` first for the key, the claim when it is absent. An
|
|
182
|
+
* internal file of the CLI (§11): whatever cannot be read is simply not there –
|
|
183
|
+
* no identity is invented, and a row without a key merges with nothing.
|
|
184
|
+
*
|
|
185
|
+
* The verdict is the pre-S4 one (`readCodexCredential`): a refresh token means
|
|
186
|
+
* the CLI renews the access token on its own, so only a lone access token past
|
|
187
|
+
* its `exp` is `expired`.
|
|
188
|
+
*/
|
|
189
|
+
export function readCodexCredentialFile(file) {
|
|
190
|
+
let raw;
|
|
191
|
+
let writtenMs;
|
|
192
|
+
try {
|
|
193
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
194
|
+
writtenMs = Math.floor(fs.statSync(file).mtimeMs);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
const code = error.code;
|
|
198
|
+
return {
|
|
199
|
+
status: code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unknown',
|
|
200
|
+
identity: {},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
let parsed;
|
|
204
|
+
try {
|
|
205
|
+
parsed = JSON.parse(raw);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return { status: 'unreadable', identity: {}, writtenMs };
|
|
209
|
+
}
|
|
210
|
+
if (!isObject(parsed))
|
|
211
|
+
return { status: 'unreadable', identity: {}, writtenMs };
|
|
212
|
+
const tokens = isObject(parsed['tokens']) ? parsed['tokens'] : null;
|
|
213
|
+
const access = text(tokens?.['access_token'], 16_000);
|
|
214
|
+
const apiKey = text(parsed['OPENAI_API_KEY'], 1_000);
|
|
215
|
+
if (!access) {
|
|
216
|
+
return apiKey
|
|
217
|
+
? { status: 'ok', kind: 'apikey', identity: {}, writtenMs }
|
|
218
|
+
: { status: 'unreadable', identity: {}, writtenMs };
|
|
219
|
+
}
|
|
220
|
+
const idClaims = jwtClaims(tokens?.['id_token']);
|
|
221
|
+
const auth = isObject(idClaims?.['https://api.openai.com/auth'])
|
|
222
|
+
? idClaims['https://api.openai.com/auth']
|
|
223
|
+
: null;
|
|
224
|
+
const orgId = text(tokens?.['account_id'], 100) ?? text(auth?.['chatgpt_account_id'], 100);
|
|
225
|
+
const email = text(idClaims?.['email'], 320);
|
|
226
|
+
const plan = text(auth?.['chatgpt_plan_type'], 60);
|
|
227
|
+
const identity = {
|
|
228
|
+
...(email ? { email } : {}),
|
|
229
|
+
...(orgId ? { orgId } : {}),
|
|
230
|
+
...(plan ? { plan } : {}),
|
|
231
|
+
};
|
|
232
|
+
if (text(tokens?.['refresh_token'], 16_000)) {
|
|
233
|
+
return { status: 'ok', kind: 'chatgpt', identity, writtenMs };
|
|
234
|
+
}
|
|
235
|
+
const exp = jwtClaims(access)?.['exp'];
|
|
236
|
+
if (typeof exp !== 'number' || !Number.isFinite(exp) || Math.abs(exp) > 8.64e12) {
|
|
237
|
+
return { status: 'ok', kind: 'chatgpt', identity, writtenMs };
|
|
238
|
+
}
|
|
239
|
+
const expiresAt = new Date(exp * 1000).toISOString();
|
|
240
|
+
return {
|
|
241
|
+
status: exp * 1000 < Date.now() ? 'expired' : 'ok',
|
|
242
|
+
kind: 'chatgpt',
|
|
243
|
+
identity,
|
|
244
|
+
expiresAt,
|
|
245
|
+
writtenMs,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// ─── The home ────────────────────────────────────────────────────────
|
|
69
249
|
/**
|
|
70
250
|
* Create (or refresh) the runner's CODEX_HOME and return it.
|
|
71
251
|
*
|
|
72
|
-
* `
|
|
73
|
-
* runner uses their ChatGPT subscription and —
|
|
74
|
-
* credential store with their own CLI, so a token
|
|
75
|
-
* both working. `
|
|
76
|
-
* device-code login
|
|
252
|
+
* `link` (the default when nothing else is decided) symlinks the host user's
|
|
253
|
+
* `~/.codex/auth.json` so the runner uses their ChatGPT subscription and —
|
|
254
|
+
* importantly — shares one credential store with their own CLI, so a token
|
|
255
|
+
* refresh on either side keeps both working. `own` leaves the home
|
|
256
|
+
* unauthenticated until a device-code login is stored for it, which is the
|
|
257
|
+
* choice for full isolation. A saved login (`account:<id>`) is kept whatever
|
|
258
|
+
* the configured mode says (S4 item 2).
|
|
77
259
|
*
|
|
78
260
|
* A home under the OS temp dir still works, but codex then refuses to install
|
|
79
261
|
* its helper binaries ("Refusing to create helper binaries under temporary
|
|
@@ -99,64 +281,136 @@ export function ensureCodexHome(options = {}) {
|
|
|
99
281
|
// old, possibly wider permissions (QA-100 MINOR-8).
|
|
100
282
|
fs.chmodSync(configFile, 0o600);
|
|
101
283
|
}
|
|
102
|
-
return
|
|
284
|
+
return ensureAuth(dir, options);
|
|
285
|
+
}
|
|
286
|
+
function ensureAccountsRoot(dir) {
|
|
287
|
+
const root = codexAccountsDir(dir);
|
|
288
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
289
|
+
fs.chmodSync(root, 0o700);
|
|
290
|
+
return root;
|
|
291
|
+
}
|
|
292
|
+
function lstatOrNull(file) {
|
|
293
|
+
try {
|
|
294
|
+
return fs.lstatSync(file);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function accountDirExists(id, dir) {
|
|
301
|
+
return lstatOrNull(codexAccountDir(id, dir))?.isDirectory() === true;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Point `auth.json` at `target`, atomically: a new link is made beside it and
|
|
305
|
+
* renamed over the old one, so a CLI opening the file never finds it missing.
|
|
306
|
+
* Never over a real file – the caller has moved that into the store first.
|
|
307
|
+
*/
|
|
308
|
+
function pointLink(dir, target) {
|
|
309
|
+
const link = path.join(dir, AUTH_FILENAME);
|
|
310
|
+
const existing = lstatOrNull(link);
|
|
311
|
+
if (existing?.isSymbolicLink() && fs.readlinkSync(link) === target)
|
|
312
|
+
return;
|
|
313
|
+
if (existing && !existing.isSymbolicLink()) {
|
|
314
|
+
throw new AccountError('a login file stands where the link belongs – try again');
|
|
315
|
+
}
|
|
316
|
+
const tmp = `${link}.${process.pid}.link-tmp`;
|
|
317
|
+
fs.rmSync(tmp, { force: true });
|
|
318
|
+
fs.symlinkSync(target, tmp);
|
|
319
|
+
fs.renameSync(tmp, link);
|
|
103
320
|
}
|
|
104
321
|
function ensureAuth(dir, options) {
|
|
105
322
|
const target = path.join(dir, AUTH_FILENAME);
|
|
106
|
-
const userAuth =
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
323
|
+
const userAuth = hostCodexAuthFile(options.homedir);
|
|
324
|
+
const explicit = options.auth ?? configuredAuth;
|
|
325
|
+
let mark = readMark(dir);
|
|
326
|
+
// A real file where our link belongs is a usable credential, full stop. It is
|
|
327
|
+
// never pushed into the host user's ~/.codex: that file is theirs, and
|
|
328
|
+
// guessing which copy is newer costs them their login.
|
|
329
|
+
if (lstatOrNull(target)?.isFile()) {
|
|
330
|
+
if (mark?.kind === 'account' && accountDirExists(mark.id, dir)) {
|
|
331
|
+
// S4 item 3: the CLI left its fresh token here instead of in the store –
|
|
332
|
+
// back into the store it goes, or the store would keep a rotated-out one.
|
|
333
|
+
returnRealFile(dir, mark.id);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
// D18, R9: a login this home owned as a file (a dashboard sign-in of an
|
|
337
|
+
// older runner, or codex replacing our link) becomes the first saved
|
|
338
|
+
// account – and stays the one in use.
|
|
339
|
+
const adopted = moveHomeFileIntoStore(dir, mark);
|
|
340
|
+
if (!adopted) {
|
|
341
|
+
// The file is still here and is being used where it is; or it moved
|
|
342
|
+
// under us and the mark on disk – ours or another process's – decides.
|
|
343
|
+
if (lstatOrNull(target)?.isFile())
|
|
344
|
+
return { path: dir, auth: 'own' };
|
|
345
|
+
mark = readMark(dir);
|
|
346
|
+
if (mark?.kind !== 'account')
|
|
347
|
+
return { path: dir, auth: 'missing' };
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
mark = { kind: 'account', id: adopted };
|
|
351
|
+
}
|
|
125
352
|
}
|
|
126
|
-
writeMode(dir, 'own');
|
|
127
|
-
return 'own';
|
|
128
353
|
}
|
|
354
|
+
if (mark?.kind === 'account') {
|
|
355
|
+
if (accountDirExists(mark.id, dir)) {
|
|
356
|
+
const store = codexAccountAuthFile(mark.id, dir);
|
|
357
|
+
try {
|
|
358
|
+
pointLink(dir, store);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
log.warn('codex: could not point auth.json at the saved login', {
|
|
362
|
+
account: mark.id,
|
|
363
|
+
error: String(error),
|
|
364
|
+
});
|
|
365
|
+
return { path: dir, auth: 'missing', accountId: mark.id };
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
path: dir,
|
|
369
|
+
auth: fs.existsSync(store) ? 'account' : 'missing',
|
|
370
|
+
accountId: mark.id,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
// The login the home was set to is gone from the disk altogether (removed by
|
|
374
|
+
// hand): sessions and the verdict must agree on SOME login, and the machine's
|
|
375
|
+
// is the one that is always there. Said once, in the journal.
|
|
376
|
+
log.warn('codex: the saved login in use is gone from this machine – back to the machine login', {
|
|
377
|
+
account: mark.id,
|
|
378
|
+
});
|
|
379
|
+
mark = null;
|
|
380
|
+
}
|
|
381
|
+
const mode = explicit ?? (mark?.kind === 'own' ? 'own' : 'link');
|
|
382
|
+
const existing = lstatOrNull(target);
|
|
129
383
|
if (mode === 'own') {
|
|
130
384
|
if (existing?.isSymbolicLink())
|
|
131
385
|
fs.rmSync(target, { force: true });
|
|
132
|
-
|
|
133
|
-
return 'missing';
|
|
386
|
+
writeMark(dir, { kind: 'own' });
|
|
387
|
+
return { path: dir, auth: 'missing' };
|
|
134
388
|
}
|
|
135
389
|
if (existing?.isSymbolicLink()) {
|
|
136
390
|
// Repoint if the user's file moved or the link went stale.
|
|
137
391
|
const resolved = fs.readlinkSync(target);
|
|
138
392
|
if (resolved === userAuth && fs.existsSync(userAuth)) {
|
|
139
|
-
|
|
140
|
-
return 'linked';
|
|
393
|
+
writeMark(dir, { kind: 'link' });
|
|
394
|
+
return { path: dir, auth: 'linked' };
|
|
141
395
|
}
|
|
142
396
|
fs.rmSync(target, { force: true });
|
|
143
397
|
}
|
|
398
|
+
writeMark(dir, { kind: 'link' });
|
|
144
399
|
if (!fs.existsSync(userAuth))
|
|
145
|
-
return 'missing';
|
|
400
|
+
return { path: dir, auth: 'missing' };
|
|
146
401
|
try {
|
|
147
402
|
fs.symlinkSync(userAuth, target);
|
|
148
|
-
writeMode(dir, 'link');
|
|
149
403
|
// Loud on purpose: the link going missing under a live daemon is exactly
|
|
150
404
|
// the failure that made the panel report "login expired" about a perfectly
|
|
151
405
|
// valid credential, and nothing recorded it. Now it does.
|
|
152
406
|
if (!existing) {
|
|
153
407
|
log.info('codex: linked the host auth.json into the isolated home', { path: target });
|
|
154
408
|
}
|
|
155
|
-
return 'linked';
|
|
409
|
+
return { path: dir, auth: 'linked' };
|
|
156
410
|
}
|
|
157
411
|
catch (error) {
|
|
158
412
|
log.warn('codex: could not link the host auth.json', { error: String(error) });
|
|
159
|
-
return 'missing';
|
|
413
|
+
return { path: dir, auth: 'missing' };
|
|
160
414
|
}
|
|
161
415
|
}
|
|
162
416
|
/**
|
|
@@ -165,12 +419,15 @@ function ensureAuth(dir, options) {
|
|
|
165
419
|
* it only answers "is the credential still where we left it, and if not, can we
|
|
166
420
|
* put it back". This is what makes a credential disappearing under a running
|
|
167
421
|
* daemon self-healing instead of permanent.
|
|
422
|
+
*
|
|
423
|
+
* Forces nothing it was not told to by the machine's owner (S4 item 2): with no
|
|
424
|
+
* `[codex] auth` in `config.toml` the mark decides, and a saved login stays.
|
|
168
425
|
*/
|
|
169
426
|
export function repairCodexAuth(options = {}) {
|
|
170
427
|
const dir = codexHomePath();
|
|
171
428
|
if (!fs.existsSync(dir))
|
|
172
429
|
return ensureCodexHome(options);
|
|
173
|
-
return
|
|
430
|
+
return ensureAuth(dir, options);
|
|
174
431
|
}
|
|
175
432
|
function isUnderTempDir(dir) {
|
|
176
433
|
try {
|
|
@@ -181,54 +438,343 @@ function isUnderTempDir(dir) {
|
|
|
181
438
|
return false;
|
|
182
439
|
}
|
|
183
440
|
}
|
|
184
|
-
|
|
441
|
+
/**
|
|
442
|
+
* Drop a linked auth.json so a device-code login writes our own file instead.
|
|
443
|
+
* Never a real credential: only a symlink is removed.
|
|
444
|
+
*/
|
|
445
|
+
export function detachLinkedAuth(dir = codexHomePath()) {
|
|
446
|
+
const target = path.join(dir, AUTH_FILENAME);
|
|
447
|
+
if (lstatOrNull(target)?.isSymbolicLink())
|
|
448
|
+
fs.rmSync(target, { force: true });
|
|
449
|
+
}
|
|
450
|
+
// ─── The store ───────────────────────────────────────────────────────
|
|
451
|
+
function stampedIdentity(identity) {
|
|
452
|
+
return { ...identity, at: new Date().toISOString() };
|
|
453
|
+
}
|
|
454
|
+
/** The key a saved row is known by – its file first, what was last read from it second. */
|
|
455
|
+
function storedKeyOf(id, dir) {
|
|
456
|
+
const fromFile = readCodexCredentialFile(codexAccountAuthFile(id, dir)).identity.orgId;
|
|
457
|
+
if (fromFile)
|
|
458
|
+
return fromFile;
|
|
459
|
+
return readAgentAuth().codexAccounts?.find((record) => record.id === id)?.lastSeenIdentity?.orgId;
|
|
460
|
+
}
|
|
461
|
+
/** A rename that refuses to become a copy (EXDEV), and leaves the file 0600. */
|
|
462
|
+
function moveLogin(source, destination) {
|
|
185
463
|
try {
|
|
186
|
-
|
|
464
|
+
fs.renameSync(source, destination);
|
|
187
465
|
}
|
|
188
|
-
catch {
|
|
466
|
+
catch (error) {
|
|
467
|
+
if (error.code === 'EXDEV') {
|
|
468
|
+
throw new AccountError('the login is on another filesystem – it is moved, never copied');
|
|
469
|
+
}
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
fs.chmodSync(destination, 0o600);
|
|
473
|
+
}
|
|
474
|
+
/** A file that is not a login is set aside under a dated name – never over a login that works. */
|
|
475
|
+
function setAside(file) {
|
|
476
|
+
const aside = `${file}.displaced-${Date.now()}`;
|
|
477
|
+
fs.renameSync(file, aside);
|
|
478
|
+
log.warn('codex: a file that is not a login stood in place of auth.json – moved aside', {
|
|
479
|
+
aside,
|
|
480
|
+
});
|
|
481
|
+
return aside;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Move a login file into the store (§8 `agent_account_login_code` for Codex, S4
|
|
485
|
+
* items 4 and 5) – by rename, and one subscription, one row.
|
|
486
|
+
*
|
|
487
|
+
* A known `account_id` that a saved row already has REPLACES that row's login:
|
|
488
|
+
* the row keeps its id (refusal marks and a live session are keyed by it, R15)
|
|
489
|
+
* and its `addedAt` becomes now – the date is that of the login the row holds,
|
|
490
|
+
* and a second sign-in of the same subscription must change something the
|
|
491
|
+
* window can see (S3 hands this over: `deviceSignInResult`). An unknown key
|
|
492
|
+
* merges with nothing. The machine row takes no part: it is the host's own file.
|
|
493
|
+
*
|
|
494
|
+
* The record is written BEFORE the file moves, so a failure never leaves a login
|
|
495
|
+
* nobody can list or forget; a move that fails takes its new record back.
|
|
496
|
+
*/
|
|
497
|
+
export function storeCodexLogin(source, options) {
|
|
498
|
+
const dir = options.home ?? codexHomePath();
|
|
499
|
+
// A real file standing where the link belongs is settled BEFORE anything is
|
|
500
|
+
// moved in: otherwise this sign-in lands in the row's store and the file is
|
|
501
|
+
// then written over it as «the refreshed login of that row» – the new login
|
|
502
|
+
// would be gone (found by the independent check of S4).
|
|
503
|
+
if (path.resolve(source) !== path.resolve(dir, AUTH_FILENAME))
|
|
504
|
+
settleRealFile(dir);
|
|
505
|
+
const credential = readCodexCredentialFile(source);
|
|
506
|
+
if (credential.status === 'missing' || credential.status === 'unreadable') {
|
|
507
|
+
throw new AccountError('the sign-in finished but no usable login was stored – start again');
|
|
508
|
+
}
|
|
509
|
+
if (credential.status === 'unknown') {
|
|
510
|
+
throw new AccountError('the new login could not be read on this server – start again');
|
|
511
|
+
}
|
|
512
|
+
ensureAccountsRoot(dir);
|
|
513
|
+
const key = credential.identity.orgId;
|
|
514
|
+
const identity = stampedIdentity(credential.identity);
|
|
515
|
+
const records = readAgentAuth().codexAccounts ?? [];
|
|
516
|
+
const sameSubscription = key
|
|
517
|
+
? records.find((record) => isAccountId(record.id) &&
|
|
518
|
+
record.lastSeenIdentity?.orgId === key &&
|
|
519
|
+
accountDirExists(record.id, dir))
|
|
520
|
+
: undefined;
|
|
521
|
+
// `replaceActive` is the one-login window's door (D27: with the organization's
|
|
522
|
+
// option off, a sign-in REPLACES the login in use rather than adding a row).
|
|
523
|
+
// The subscription still wins when it is already saved elsewhere, or two rows
|
|
524
|
+
// would share one key and the list could no longer tell them apart.
|
|
525
|
+
const inUse = options.replaceActive ? markedCodexAccount(dir) : null;
|
|
526
|
+
const same = sameSubscription ??
|
|
527
|
+
(inUse && isAccountId(inUse) && accountDirExists(inUse, dir)
|
|
528
|
+
? records.find((record) => record.id === inUse)
|
|
529
|
+
: undefined);
|
|
530
|
+
let stored;
|
|
531
|
+
if (same) {
|
|
532
|
+
moveLogin(source, codexAccountAuthFile(same.id, dir));
|
|
533
|
+
if (options.activate)
|
|
534
|
+
markActiveCodexLogin(same.id, dir);
|
|
535
|
+
updateAgentAuth((file) => {
|
|
536
|
+
const record = file.codexAccounts?.find((entry) => entry.id === same.id);
|
|
537
|
+
if (!record)
|
|
538
|
+
return;
|
|
539
|
+
record.lastSeenIdentity = identity;
|
|
540
|
+
record.addedAt = new Date().toISOString();
|
|
541
|
+
delete record.loginExpiredAt;
|
|
542
|
+
});
|
|
543
|
+
// A fresh login outranks anything remembered about the one it replaced.
|
|
544
|
+
clearRefusal('codex', same.id);
|
|
545
|
+
stored = { id: same.id, replaced: same.id };
|
|
546
|
+
log.info('codex: a sign-in replaced the saved login of the same subscription', {
|
|
547
|
+
account: same.id,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
const id = newAccountId();
|
|
552
|
+
updateAgentAuth((file) => {
|
|
553
|
+
file.codexAccounts = [
|
|
554
|
+
...(file.codexAccounts ?? []),
|
|
555
|
+
{ id, addedAt: new Date().toISOString(), lastSeenIdentity: identity },
|
|
556
|
+
];
|
|
557
|
+
});
|
|
558
|
+
try {
|
|
559
|
+
fs.mkdirSync(codexAccountDir(id, dir), { mode: 0o700 });
|
|
560
|
+
moveLogin(source, codexAccountAuthFile(id, dir));
|
|
561
|
+
// The mark right after the move, before anything else can throw: a login
|
|
562
|
+
// already in the store under a mark that still says `own` reads as «not
|
|
563
|
+
// signed in» on the next repair (found by the independent check of S4).
|
|
564
|
+
if (options.activate)
|
|
565
|
+
markActiveCodexLogin(id, dir);
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
fs.rmSync(codexAccountDir(id, dir), { recursive: true, force: true });
|
|
569
|
+
updateAgentAuth((file) => {
|
|
570
|
+
file.codexAccounts = (file.codexAccounts ?? []).filter((entry) => entry.id !== id);
|
|
571
|
+
if (file.codexAccounts.length === 0)
|
|
572
|
+
delete file.codexAccounts;
|
|
573
|
+
});
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
stored = { id };
|
|
577
|
+
log.info('codex: a login was added to the saved accounts', { account: id });
|
|
578
|
+
}
|
|
579
|
+
if (options.activate)
|
|
580
|
+
setCodexActiveLogin(stored.id, dir);
|
|
581
|
+
return stored;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* The CLI left a real `auth.json` where the link to `activeId` was (S4 item 3).
|
|
585
|
+
*
|
|
586
|
+
* Normally it is the active login, refreshed: it replaces the store file (the
|
|
587
|
+
* store's copy is the older of the two). But the home is shared by every
|
|
588
|
+
* session, and the file of a DIFFERENT subscription must never be written over
|
|
589
|
+
* the active login – that would erase it. Such a file goes where its own key
|
|
590
|
+
* says (its row, or a new one), and a file that is not a login at all is set
|
|
591
|
+
* aside. The link is not touched here; the caller points it.
|
|
592
|
+
*/
|
|
593
|
+
function returnRealFile(dir, activeId) {
|
|
594
|
+
const source = path.join(dir, AUTH_FILENAME);
|
|
595
|
+
const credential = readCodexCredentialFile(source);
|
|
596
|
+
if (credential.status === 'unreadable') {
|
|
597
|
+
setAside(source);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (credential.status === 'missing' || credential.status === 'unknown')
|
|
601
|
+
return;
|
|
602
|
+
const key = credential.identity.orgId;
|
|
603
|
+
const activeKey = storedKeyOf(activeId, dir);
|
|
604
|
+
if (key && activeKey && key !== activeKey) {
|
|
605
|
+
log.warn('codex: a login of another subscription stood in place of the link – stored as its own', {
|
|
606
|
+
account: activeId,
|
|
607
|
+
});
|
|
608
|
+
storeCodexLogin(source, { activate: false, home: dir });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
moveLogin(source, codexAccountAuthFile(activeId, dir));
|
|
612
|
+
updateAgentAuth((file) => {
|
|
613
|
+
const record = file.codexAccounts?.find((entry) => entry.id === activeId);
|
|
614
|
+
if (record && Object.keys(credential.identity).length > 0) {
|
|
615
|
+
record.lastSeenIdentity = stampedIdentity(credential.identity);
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
log.info('codex: the refreshed login was returned to its saved account', { account: activeId });
|
|
619
|
+
}
|
|
620
|
+
/** D18, R9: the home's own login file becomes a saved account, in use. Null – it stays a file. */
|
|
621
|
+
function moveHomeFileIntoStore(dir, mark) {
|
|
622
|
+
const source = path.join(dir, AUTH_FILENAME);
|
|
623
|
+
if (readCodexCredentialFile(source).status === 'unreadable') {
|
|
624
|
+
try {
|
|
625
|
+
setAside(source);
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
log.warn('codex: could not set aside an unreadable auth.json', { error: String(error) });
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
const stored = storeCodexLogin(source, { activate: true, home: dir });
|
|
635
|
+
log.info("codex: this runner's own login became the first saved account", {
|
|
636
|
+
account: stored.id,
|
|
637
|
+
was: mark ? markText(mark) : 'unmarked',
|
|
638
|
+
});
|
|
639
|
+
return stored.id;
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
// Only when the login is still here. It may be gone because the move went
|
|
643
|
+
// through and something after it failed, or because another process – a
|
|
644
|
+
// `doctor` beside the daemon – moved it first: writing `own` then would say
|
|
645
|
+
// «no login here» about a login that is in the store and in use (found by
|
|
646
|
+
// the independent check of S4). The caller re-reads the mark instead.
|
|
647
|
+
if (lstatOrNull(source)?.isFile()) {
|
|
648
|
+
log.warn('codex: could not move the home login into the saved accounts – using it in place', {
|
|
649
|
+
error: String(error),
|
|
650
|
+
});
|
|
651
|
+
writeMark(dir, { kind: 'own' });
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
log.warn('codex: the home login was moved by somebody else while we were storing it', {
|
|
655
|
+
error: String(error),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
189
658
|
return null;
|
|
190
659
|
}
|
|
191
660
|
}
|
|
192
661
|
/**
|
|
193
|
-
*
|
|
662
|
+
* Make a row the one the home uses: the mark first, then the link.
|
|
663
|
+
*
|
|
664
|
+
* In that order on purpose – a daemon that dies between the two leaves a mark
|
|
665
|
+
* the next repair finishes (it points the link where the mark says), never a
|
|
666
|
+
* link that the next repair would undo.
|
|
194
667
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* the daemon.
|
|
668
|
+
* A real file in place of the link is dealt with FIRST (§8 `activate`, S4 item
|
|
669
|
+
* 7): the CLI may have refreshed the login in use a moment ago, and repointing
|
|
670
|
+
* over it would throw that token away.
|
|
199
671
|
*/
|
|
200
|
-
export function
|
|
672
|
+
export function markActiveCodexLogin(id, dir = codexHomePath()) {
|
|
673
|
+
writeMark(dir, id === MACHINE_ACCOUNT_ID ? { kind: 'link' } : { kind: 'account', id });
|
|
674
|
+
}
|
|
675
|
+
export function setCodexActiveLogin(id, dir = codexHomePath()) {
|
|
201
676
|
const target = path.join(dir, AUTH_FILENAME);
|
|
202
|
-
|
|
203
|
-
|
|
677
|
+
settleRealFile(dir);
|
|
678
|
+
if (id === MACHINE_ACCOUNT_ID) {
|
|
679
|
+
writeMark(dir, { kind: 'link' });
|
|
680
|
+
const existing = lstatOrNull(target);
|
|
681
|
+
// The link to a saved login goes; `ensureAuth` puts the host's in its place.
|
|
682
|
+
if (existing?.isSymbolicLink() && fs.readlinkSync(target) !== hostCodexAuthFile()) {
|
|
683
|
+
fs.rmSync(target, { force: true });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
if (!accountDirExists(id, dir)) {
|
|
688
|
+
throw new AccountError('the login of this account is gone from this server – forget it');
|
|
689
|
+
}
|
|
690
|
+
writeMark(dir, { kind: 'account', id });
|
|
691
|
+
}
|
|
692
|
+
return ensureAuth(dir, {});
|
|
204
693
|
}
|
|
205
694
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
695
|
+
* A real `auth.json` standing where the link belongs goes back where it belongs –
|
|
696
|
+
* the store of the row it is the login of – and nothing else moves until it has.
|
|
697
|
+
* Called before a sign-in is stored and before the link is repointed (S4 items 3
|
|
698
|
+
* and 7).
|
|
208
699
|
*/
|
|
209
|
-
|
|
210
|
-
const source = path.join(stagingDir, AUTH_FILENAME);
|
|
211
|
-
if (!lstatOrNull(source)?.isFile())
|
|
212
|
-
return false;
|
|
213
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
214
|
-
detachLinkedAuth(dir);
|
|
700
|
+
function settleRealFile(dir) {
|
|
215
701
|
const target = path.join(dir, AUTH_FILENAME);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
702
|
+
if (!lstatOrNull(target)?.isFile())
|
|
703
|
+
return;
|
|
704
|
+
const current = readMark(dir);
|
|
705
|
+
if (current?.kind === 'account' && accountDirExists(current.id, dir)) {
|
|
706
|
+
returnRealFile(dir, current.id);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
storeCodexLogin(target, { activate: false, home: dir });
|
|
221
710
|
}
|
|
222
|
-
|
|
711
|
+
// ─── Signing in ──────────────────────────────────────────────────────
|
|
712
|
+
/**
|
|
713
|
+
* A fresh throwaway home for one device-code sign-in (config only, no credential).
|
|
714
|
+
*
|
|
715
|
+
* One directory per attempt, and every other one is removed first: only one
|
|
716
|
+
* sign-in runs on a machine at a time (the relay cancels the previous one before
|
|
717
|
+
* it gets here), and the pre-S4 runner kept a single fixed staging home that was
|
|
718
|
+
* never cleaned up – on this machine it had lain there since 01.08.2026.
|
|
719
|
+
*/
|
|
223
720
|
export function prepareStagingHome() {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
fs.mkdirSync(
|
|
721
|
+
const root = stagingCodexHomePath();
|
|
722
|
+
discardAbandonedCodexStagingHomes();
|
|
723
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
724
|
+
fs.chmodSync(root, 0o700);
|
|
725
|
+
const dir = path.join(root, newAccountId());
|
|
726
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
227
727
|
fs.writeFileSync(path.join(dir, CONFIG_FILENAME), CONFIG_BODY, { mode: 0o600 });
|
|
228
728
|
return dir;
|
|
229
729
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
730
|
+
function isStagingDir(dir) {
|
|
731
|
+
return (path.dirname(path.resolve(dir)) === path.resolve(stagingCodexHomePath()) &&
|
|
732
|
+
isAccountId(path.basename(dir)));
|
|
733
|
+
}
|
|
734
|
+
/** Remove one sign-in's home whatever the outcome — it may hold a credential. */
|
|
735
|
+
export function discardStagingHome(dir) {
|
|
736
|
+
if (!isStagingDir(dir))
|
|
737
|
+
throw new AccountError('not a staging home');
|
|
738
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Remove every sign-in home – at daemon start (nothing can be signing in yet)
|
|
742
|
+
* and before a new sign-in. Includes the fixed-path staging home of runners
|
|
743
|
+
* before S4.
|
|
744
|
+
*/
|
|
745
|
+
export function discardAbandonedCodexStagingHomes() {
|
|
746
|
+
const root = stagingCodexHomePath();
|
|
747
|
+
let names;
|
|
748
|
+
try {
|
|
749
|
+
names = fs.readdirSync(root);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return 0;
|
|
753
|
+
}
|
|
754
|
+
for (const name of names)
|
|
755
|
+
fs.rmSync(path.join(root, name), { recursive: true, force: true });
|
|
756
|
+
return names.length;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* A device-code sign-in in `stagingDir` finished: its login becomes a saved
|
|
760
|
+
* account – by rename, deduplicated by subscription – and the one in use (R15).
|
|
761
|
+
* The staging home is removed either way.
|
|
762
|
+
*
|
|
763
|
+
* `replaceActive` – the sign-in came through the one-login window, where it has
|
|
764
|
+
* always meant «this login from now on»: it takes the place of the login in use
|
|
765
|
+
* rather than adding a row (D27, for an organization without several logins).
|
|
766
|
+
*/
|
|
767
|
+
export function adoptLoginResult(stagingDir, options = {}) {
|
|
768
|
+
if (!isStagingDir(stagingDir))
|
|
769
|
+
throw new AccountError('no sign-in in progress – start again');
|
|
770
|
+
try {
|
|
771
|
+
return storeCodexLogin(path.join(stagingDir, AUTH_FILENAME), {
|
|
772
|
+
activate: true,
|
|
773
|
+
...(options.replaceActive ? { replaceActive: true } : {}),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
finally {
|
|
777
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
778
|
+
}
|
|
233
779
|
}
|
|
234
780
|
//# sourceMappingURL=codex-home.js.map
|