codex-multi-auth 2.8.1 → 2.8.3
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/.codex-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/dist/lib/accounts.d.ts +15 -0
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/accounts.js +126 -21
- package/dist/lib/codex-manager/account-pool-write.d.ts.map +1 -1
- package/dist/lib/codex-manager/account-pool-write.js +4 -0
- package/dist/lib/codex-manager/backend-settings-schema.js +1 -1
- package/dist/lib/codex-manager/commands/status.d.ts.map +1 -1
- package/dist/lib/codex-manager/commands/status.js +5 -1
- package/dist/lib/codex-manager/commands/uninstall.d.ts +1 -0
- package/dist/lib/codex-manager/commands/uninstall.d.ts.map +1 -1
- package/dist/lib/codex-manager/commands/uninstall.js +12 -1
- package/dist/lib/codex-manager/commands/why-selected.d.ts +1 -1
- package/dist/lib/codex-manager/commands/why-selected.d.ts.map +1 -1
- package/dist/lib/codex-manager/commands/why-selected.js +1 -1
- package/dist/lib/codex-manager/formatters/quota-formatters.d.ts.map +1 -1
- package/dist/lib/codex-manager/formatters/quota-formatters.js +32 -2
- package/dist/lib/codex-manager/health-check.d.ts.map +1 -1
- package/dist/lib/codex-manager/health-check.js +19 -3
- package/dist/lib/codex-manager/login-oauth.d.ts.map +1 -1
- package/dist/lib/codex-manager/login-oauth.js +13 -7
- package/dist/lib/codex-manager.d.ts +4 -0
- package/dist/lib/codex-manager.d.ts.map +1 -1
- package/dist/lib/codex-manager.js +50 -16
- package/dist/lib/config.d.ts +3 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +3 -1
- package/dist/lib/forecast.d.ts.map +1 -1
- package/dist/lib/forecast.js +7 -1
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +1 -0
- package/dist/lib/parallel-probe.d.ts.map +1 -1
- package/dist/lib/parallel-probe.js +3 -1
- package/dist/lib/policy/runtime-policy.d.ts +1 -0
- package/dist/lib/policy/runtime-policy.d.ts.map +1 -1
- package/dist/lib/policy/runtime-policy.js +7 -0
- package/dist/lib/preemptive-quota-scheduler.d.ts.map +1 -1
- package/dist/lib/preemptive-quota-scheduler.js +35 -15
- package/dist/lib/rotation.d.ts +4 -0
- package/dist/lib/rotation.d.ts.map +1 -1
- package/dist/lib/rotation.js +13 -6
- package/dist/lib/runtime/app-bind.d.ts +40 -0
- package/dist/lib/runtime/app-bind.d.ts.map +1 -1
- package/dist/lib/runtime/app-bind.js +550 -20
- package/dist/lib/runtime/rotation-account-selection.d.ts.map +1 -1
- package/dist/lib/runtime/rotation-account-selection.js +2 -0
- package/dist/lib/runtime/rotation-proxy-state.d.ts +2 -0
- package/dist/lib/runtime/rotation-proxy-state.d.ts.map +1 -1
- package/dist/lib/runtime/rotation-token-refresh.d.ts.map +1 -1
- package/dist/lib/runtime/rotation-token-refresh.js +3 -0
- package/dist/lib/runtime-constants.d.ts +2 -0
- package/dist/lib/runtime-constants.d.ts.map +1 -1
- package/dist/lib/runtime-constants.js +2 -0
- package/dist/lib/runtime-rotation-proxy.d.ts +8 -0
- package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
- package/dist/lib/runtime-rotation-proxy.js +59 -5
- package/dist/lib/schemas.d.ts +15 -0
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +3 -0
- package/dist/lib/storage/flagged-storage.d.ts.map +1 -1
- package/dist/lib/storage/flagged-storage.js +15 -0
- package/dist/lib/storage/path-state.d.ts +1 -0
- package/dist/lib/storage/path-state.d.ts.map +1 -1
- package/dist/lib/storage/path-state.js +30 -3
- package/dist/lib/storage/public-types.d.ts +6 -0
- package/dist/lib/storage/public-types.d.ts.map +1 -1
- package/dist/lib/storage.d.ts.map +1 -1
- package/dist/lib/storage.js +17 -4
- package/package.json +198 -198
- package/scripts/codex-app-router.js +47 -3
- package/scripts/codex.js +165 -21
- package/scripts/install-codex-auth-utils.js +9 -2
- package/scripts/postinstall.js +2 -0
- package/scripts/preuninstall.js +11 -0
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { closeSync, existsSync, mkdirSync, openSync } from "node:fs";
|
|
4
|
-
import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import { mkdir, open, readFile, rename, rm, unlink } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { basename, dirname, join } from "node:path";
|
|
7
7
|
import process from "node:process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { withFileOperationRetry } from "../fs-retry.js";
|
|
10
10
|
import { getCodexMultiAuthDir } from "../runtime-paths.js";
|
|
11
|
+
import { APP_RUNTIME_HELPER_OWNER_FILE, APP_RUNTIME_HELPER_STATUS_FILE, } from "../runtime-constants.js";
|
|
11
12
|
import { configHasRuntimeRotationProvider, restoreConfigTomlFromRuntimeRotationProvider, restoreConfigTomlFromRuntimeRotationProviderWithoutBackup, rewriteConfigTomlForRuntimeRotationProvider, } from "./config-toml.js";
|
|
12
13
|
const APP_BIND_DIR_NAME = "app-bind";
|
|
13
14
|
const APP_BIND_STATE_FILE = "runtime-rotation-app-bind.json";
|
|
14
15
|
const APP_BIND_BACKUP_FILE = "codex-config-backup.json";
|
|
16
|
+
const RUNTIME_ROTATION_APP_HELPER_ARG = "--codex-multi-auth-runtime-app-helper";
|
|
17
|
+
const PROCESS_IDENTITY_PROBE_TIMEOUT_MS = 2_000;
|
|
18
|
+
const WINDOWS_PROCESS_IDENTITY_PROBE_TIMEOUT_MS = 5_000;
|
|
19
|
+
const PROCESS_START_TIME_TOLERANCE_MS = 5_000;
|
|
20
|
+
const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000;
|
|
15
21
|
const APP_BIND_STATUS_FILE = "runtime-rotation-app-bind-status.json";
|
|
16
22
|
const WINDOWS_STARTUP_FILE = "Codex Multi Auth Runtime Router.cmd";
|
|
17
23
|
const MACOS_LAUNCH_AGENT_ID = "com.ndycode.codex-multi-auth.runtime-router";
|
|
@@ -144,6 +150,7 @@ function readAppBindStateRecord(record) {
|
|
|
144
150
|
const nodePath = readString(record, "nodePath");
|
|
145
151
|
const routerScriptPath = readString(record, "routerScriptPath");
|
|
146
152
|
const clientApiKey = readString(record, "clientApiKey");
|
|
153
|
+
const identityToken = readString(record, "identityToken");
|
|
147
154
|
const boundConfigHash = readString(record, "boundConfigHash");
|
|
148
155
|
const updatedAt = readNumber(record, "updatedAt");
|
|
149
156
|
const platformValue = readString(record, "platform");
|
|
@@ -176,6 +183,7 @@ function readAppBindStateRecord(record) {
|
|
|
176
183
|
nodePath,
|
|
177
184
|
routerScriptPath,
|
|
178
185
|
clientApiKey,
|
|
186
|
+
identityToken: identityToken ?? undefined,
|
|
179
187
|
startupPath: readString(record, "startupPath"),
|
|
180
188
|
launchAgentPath: readString(record, "launchAgentPath"),
|
|
181
189
|
boundConfigHash,
|
|
@@ -219,6 +227,10 @@ async function readRouterStatus(path) {
|
|
|
219
227
|
return {
|
|
220
228
|
state: readString(record, "state"),
|
|
221
229
|
pid: readNumber(record, "pid"),
|
|
230
|
+
startedAt: readNumber(record, "startedAt"),
|
|
231
|
+
statusPath: path,
|
|
232
|
+
routerScriptPath: readString(record, "routerScriptPath"),
|
|
233
|
+
identityToken: readString(record, "identityToken"),
|
|
222
234
|
baseUrl: readString(record, "baseUrl"),
|
|
223
235
|
totalRequests: readNumber(record, "totalRequests"),
|
|
224
236
|
lastAccountIndex: readNumber(record, "lastAccountIndex"),
|
|
@@ -230,7 +242,7 @@ async function readRouterStatus(path) {
|
|
|
230
242
|
};
|
|
231
243
|
}
|
|
232
244
|
function isProcessAlive(pid) {
|
|
233
|
-
if (!pid)
|
|
245
|
+
if (!pid || !Number.isInteger(pid) || pid < 1)
|
|
234
246
|
return false;
|
|
235
247
|
try {
|
|
236
248
|
process.kill(pid, 0);
|
|
@@ -307,7 +319,7 @@ function createWindowsStartupCommand(state) {
|
|
|
307
319
|
const logPath = escapeWindowsBatchPath(state.logPath);
|
|
308
320
|
return [
|
|
309
321
|
"@echo off",
|
|
310
|
-
`"${nodePath}" "${routerScriptPath}" --port ${state.port} --status "${statusPath}" --state "${statePath}" --log "${logPath}" --max-log-bytes ${APP_ROUTER_MAX_LOG_BYTES} >> "${logPath}" 2>&1`,
|
|
322
|
+
`"${nodePath}" "${routerScriptPath}" --port ${state.port} --status "${statusPath}" --identity-token "${state.identityToken ?? ""}" --state "${statePath}" --log "${logPath}" --max-log-bytes ${APP_ROUTER_MAX_LOG_BYTES} >> "${logPath}" 2>&1`,
|
|
311
323
|
"",
|
|
312
324
|
].join("\r\n");
|
|
313
325
|
}
|
|
@@ -325,6 +337,8 @@ function createMacLaunchAgentPlist(state) {
|
|
|
325
337
|
String(state.port),
|
|
326
338
|
"--status",
|
|
327
339
|
state.statusPath,
|
|
340
|
+
"--identity-token",
|
|
341
|
+
state.identityToken ?? "",
|
|
328
342
|
"--state",
|
|
329
343
|
state.statePath,
|
|
330
344
|
"--log",
|
|
@@ -388,6 +402,8 @@ function spawnRouter(state) {
|
|
|
388
402
|
String(state.port),
|
|
389
403
|
"--status",
|
|
390
404
|
state.statusPath,
|
|
405
|
+
"--identity-token",
|
|
406
|
+
state.identityToken ?? "",
|
|
391
407
|
"--state",
|
|
392
408
|
state.statePath,
|
|
393
409
|
"--log",
|
|
@@ -437,20 +453,455 @@ async function waitForRouterStatus(statusPath, timeoutMs) {
|
|
|
437
453
|
const suffix = latest?.lastError ? `: ${latest.lastError}` : "";
|
|
438
454
|
throw new Error(`Codex app runtime router did not report ready${suffix}`);
|
|
439
455
|
}
|
|
440
|
-
async function
|
|
441
|
-
if (
|
|
442
|
-
return;
|
|
456
|
+
export async function stopRuntimeRotationRouterProcess(router, platform, routerScriptPath, options = {}) {
|
|
457
|
+
if (router?.state !== "running" || router.pid === null) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
if (router.routerScriptPath &&
|
|
461
|
+
normalizeProcessIdentityPath(router.routerScriptPath, platform) !==
|
|
462
|
+
normalizeProcessIdentityPath(routerScriptPath, platform)) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
const startedAt = typeof router.startedAt === "number" &&
|
|
466
|
+
Number.isFinite(router.startedAt) &&
|
|
467
|
+
router.startedAt > 0
|
|
468
|
+
? router.startedAt
|
|
469
|
+
: null;
|
|
470
|
+
const updatedAt = typeof router.updatedAt === "number" &&
|
|
471
|
+
Number.isFinite(router.updatedAt) &&
|
|
472
|
+
router.updatedAt > 0
|
|
473
|
+
? router.updatedAt
|
|
474
|
+
: null;
|
|
475
|
+
const identityTimestamp = startedAt ?? updatedAt;
|
|
476
|
+
if (identityTimestamp === null)
|
|
477
|
+
return false;
|
|
478
|
+
const expectedIdentityToken = options.identityToken;
|
|
479
|
+
if (router.identityToken && !expectedIdentityToken) {
|
|
480
|
+
// A tokenized status record must be paired with the trusted token from
|
|
481
|
+
// bind state. The status file alone is mutable by any replacement process.
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
const verifyProcessIdentity = options.verifyProcessIdentity;
|
|
485
|
+
let verified;
|
|
486
|
+
if (verifyProcessIdentity) {
|
|
487
|
+
verified = expectedIdentityToken
|
|
488
|
+
? await verifyProcessIdentity(router.pid, identityTimestamp, platform, expectedIdentityToken)
|
|
489
|
+
: await verifyProcessIdentity(router.pid, identityTimestamp, platform);
|
|
490
|
+
}
|
|
491
|
+
else if (startedAt !== null) {
|
|
492
|
+
verified = await verifyRuntimeProcessIdentity(router.pid, startedAt, platform, routerScriptPath, router.statusPath, options.log, expectedIdentityToken);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
verified = await verifyLegacyRuntimeProcessIdentity(router.pid, updatedAt, platform, routerScriptPath, router.statusPath, options.log, expectedIdentityToken);
|
|
496
|
+
}
|
|
497
|
+
if (!verified) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
return stopDetachedProcess(router.pid, platform, options);
|
|
501
|
+
}
|
|
502
|
+
async function stopRouter(router, platform, routerScriptPath, options = {}) {
|
|
503
|
+
return stopRuntimeRotationRouterProcess(router, platform, routerScriptPath, options);
|
|
504
|
+
}
|
|
505
|
+
async function runWindowsTaskkill(pid) {
|
|
506
|
+
return new Promise((resolve) => {
|
|
507
|
+
let settled = false;
|
|
508
|
+
let child = null;
|
|
509
|
+
let timeout = null;
|
|
510
|
+
const finish = (succeeded) => {
|
|
511
|
+
if (settled)
|
|
512
|
+
return;
|
|
513
|
+
settled = true;
|
|
514
|
+
if (timeout)
|
|
515
|
+
clearTimeout(timeout);
|
|
516
|
+
resolve(succeeded);
|
|
517
|
+
};
|
|
518
|
+
try {
|
|
519
|
+
child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
520
|
+
stdio: "ignore",
|
|
521
|
+
windowsHide: true,
|
|
522
|
+
});
|
|
523
|
+
timeout = setTimeout(() => {
|
|
524
|
+
try {
|
|
525
|
+
child?.kill();
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// The taskkill child may already have exited at the timeout boundary.
|
|
529
|
+
}
|
|
530
|
+
finish(false);
|
|
531
|
+
}, WINDOWS_TASKKILL_TIMEOUT_MS);
|
|
532
|
+
child.once("error", () => finish(false));
|
|
533
|
+
child.once("close", (code) => finish(code === 0));
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
finish(false);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
async function runProcessIdentityProbe(command, args, options = {}) {
|
|
541
|
+
return new Promise((resolve) => {
|
|
542
|
+
let output = "";
|
|
543
|
+
let settled = false;
|
|
544
|
+
let child = null;
|
|
545
|
+
const timeoutMs = typeof options.timeoutMs === "number" &&
|
|
546
|
+
Number.isFinite(options.timeoutMs) &&
|
|
547
|
+
options.timeoutMs > 0
|
|
548
|
+
? options.timeoutMs
|
|
549
|
+
: PROCESS_IDENTITY_PROBE_TIMEOUT_MS;
|
|
550
|
+
const finish = (value) => {
|
|
551
|
+
if (settled)
|
|
552
|
+
return;
|
|
553
|
+
settled = true;
|
|
554
|
+
clearTimeout(timeout);
|
|
555
|
+
resolve(value);
|
|
556
|
+
};
|
|
557
|
+
const timeout = setTimeout(() => {
|
|
558
|
+
options.log?.(`Process identity probe timed out while running ${command}`);
|
|
559
|
+
try {
|
|
560
|
+
child?.kill();
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// The probe may have exited at the timeout boundary.
|
|
564
|
+
}
|
|
565
|
+
finish(null);
|
|
566
|
+
}, timeoutMs);
|
|
567
|
+
try {
|
|
568
|
+
child = spawn(command, args, {
|
|
569
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
570
|
+
windowsHide: true,
|
|
571
|
+
});
|
|
572
|
+
child.stdout?.setEncoding("utf8");
|
|
573
|
+
child.stdout?.on("data", (chunk) => {
|
|
574
|
+
output += chunk;
|
|
575
|
+
});
|
|
576
|
+
child.once("error", (error) => {
|
|
577
|
+
options.log?.(`Process identity probe ${command} was unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
578
|
+
finish(null);
|
|
579
|
+
});
|
|
580
|
+
child.once("close", (code) => {
|
|
581
|
+
if (code !== 0) {
|
|
582
|
+
options.log?.(`Process identity probe ${command} exited with status ${code ?? "unknown"}`);
|
|
583
|
+
}
|
|
584
|
+
finish(code === 0 ? output : null);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
options.log?.(`Process identity probe ${command} was unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
589
|
+
finish(null);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
async function readProcessIdentity(pid, platform, log) {
|
|
594
|
+
if (platform === "win32") {
|
|
595
|
+
const script = [
|
|
596
|
+
`$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'`,
|
|
597
|
+
"if ($null -ne $process) {",
|
|
598
|
+
"[Console]::WriteLine($process.CreationDate.ToUniversalTime().ToString('o'))",
|
|
599
|
+
"[Console]::WriteLine($process.CommandLine)",
|
|
600
|
+
"}",
|
|
601
|
+
].join("; ");
|
|
602
|
+
const output = await runProcessIdentityProbe("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeoutMs: WINDOWS_PROCESS_IDENTITY_PROBE_TIMEOUT_MS, log });
|
|
603
|
+
if (!output) {
|
|
604
|
+
log?.(`Process identity probe returned no Windows record for PID ${pid}`);
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
const [startedAtRaw, ...commandLines] = output.trim().split(/\r?\n/);
|
|
608
|
+
const startedAt = startedAtRaw ? Date.parse(startedAtRaw.trim()) : NaN;
|
|
609
|
+
const commandLine = commandLines.join(" ").trim();
|
|
610
|
+
if (!Number.isFinite(startedAt) || commandLine.length === 0) {
|
|
611
|
+
log?.(`Process identity probe returned an invalid Windows record for PID ${pid}`);
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
startedAt: Number.isFinite(startedAt) ? startedAt : null,
|
|
615
|
+
commandLine,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
const [startedAtRaw, commandLineRaw] = await Promise.all([
|
|
619
|
+
runProcessIdentityProbe("ps", ["-p", String(pid), "-o", "lstart="], { log }),
|
|
620
|
+
runProcessIdentityProbe("ps", ["-p", String(pid), "-o", "command="], { log }),
|
|
621
|
+
]);
|
|
622
|
+
if (!startedAtRaw || !commandLineRaw) {
|
|
623
|
+
log?.(`Process identity probe returned no POSIX record for PID ${pid}`);
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
const startedAt = parsePosixProcessStartTime(startedAtRaw);
|
|
627
|
+
if (startedAt === null) {
|
|
628
|
+
log?.(`Process identity probe returned an invalid POSIX start time for PID ${pid}`);
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
startedAt,
|
|
632
|
+
commandLine: commandLineRaw.trim(),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const POSIX_LSTART_MONTHS = new Map([
|
|
636
|
+
["Jan", 0],
|
|
637
|
+
["Feb", 1],
|
|
638
|
+
["Mar", 2],
|
|
639
|
+
["Apr", 3],
|
|
640
|
+
["May", 4],
|
|
641
|
+
["Jun", 5],
|
|
642
|
+
["Jul", 6],
|
|
643
|
+
["Aug", 7],
|
|
644
|
+
["Sep", 8],
|
|
645
|
+
["Oct", 9],
|
|
646
|
+
["Nov", 10],
|
|
647
|
+
["Dec", 11],
|
|
648
|
+
]);
|
|
649
|
+
/** Parse the documented local-time format emitted by `ps -o lstart=`. */
|
|
650
|
+
export function parsePosixProcessStartTime(value) {
|
|
651
|
+
const match = /^\s*[A-Za-z]{3}\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})\s*$/.exec(value);
|
|
652
|
+
if (!match)
|
|
653
|
+
return null;
|
|
654
|
+
const month = POSIX_LSTART_MONTHS.get(match[1] ?? "");
|
|
655
|
+
const day = Number(match[2]);
|
|
656
|
+
const hour = Number(match[3]);
|
|
657
|
+
const minute = Number(match[4]);
|
|
658
|
+
const second = Number(match[5]);
|
|
659
|
+
const year = Number(match[6]);
|
|
660
|
+
if (month === undefined ||
|
|
661
|
+
!Number.isInteger(day) ||
|
|
662
|
+
!Number.isInteger(hour) ||
|
|
663
|
+
!Number.isInteger(minute) ||
|
|
664
|
+
!Number.isInteger(second) ||
|
|
665
|
+
!Number.isInteger(year) ||
|
|
666
|
+
day < 1 ||
|
|
667
|
+
day > 31 ||
|
|
668
|
+
hour > 23 ||
|
|
669
|
+
minute > 59 ||
|
|
670
|
+
second > 59) {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
const date = new Date(year, month, day, hour, minute, second, 0);
|
|
674
|
+
if (!Number.isFinite(date.getTime()) ||
|
|
675
|
+
date.getFullYear() !== year ||
|
|
676
|
+
date.getMonth() !== month ||
|
|
677
|
+
date.getDate() !== day ||
|
|
678
|
+
date.getHours() !== hour ||
|
|
679
|
+
date.getMinutes() !== minute ||
|
|
680
|
+
date.getSeconds() !== second) {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
return date.getTime();
|
|
684
|
+
}
|
|
685
|
+
function normalizeProcessIdentityPath(value, platform) {
|
|
686
|
+
const normalized = value.trim().replace(/["']/g, "").replace(/\\/g, "/");
|
|
687
|
+
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
688
|
+
}
|
|
689
|
+
function commandLineContainsProcessPath(commandLine, path, platform) {
|
|
690
|
+
const normalizedPath = normalizeProcessIdentityPath(path, platform);
|
|
691
|
+
if (normalizedPath.length === 0)
|
|
692
|
+
return false;
|
|
693
|
+
const normalizedCommandLine = normalizeProcessIdentityPath(commandLine, platform);
|
|
694
|
+
let offset = normalizedCommandLine.indexOf(normalizedPath);
|
|
695
|
+
while (offset >= 0) {
|
|
696
|
+
const before = normalizedCommandLine[offset - 1] ?? "";
|
|
697
|
+
const after = normalizedCommandLine[offset + normalizedPath.length] ?? "";
|
|
698
|
+
if ((before === "" || /\s/.test(before)) && (after === "" || /\s/.test(after))) {
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
offset = normalizedCommandLine.indexOf(normalizedPath, offset + 1);
|
|
702
|
+
}
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
async function verifyRuntimeProcessIdentity(pid, startedAt, platform, expectedScriptPath, expectedStatusPath, log, expectedIdentityToken) {
|
|
706
|
+
const identity = await readProcessIdentity(pid, platform, log);
|
|
707
|
+
if (!identity || identity.startedAt === null)
|
|
708
|
+
return false;
|
|
709
|
+
if (!commandLineContainsProcessPath(identity.commandLine, expectedScriptPath, platform) ||
|
|
710
|
+
(expectedStatusPath !== undefined &&
|
|
711
|
+
expectedStatusPath !== null &&
|
|
712
|
+
!commandLineContainsProcessPath(identity.commandLine, expectedStatusPath, platform))) {
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
if (expectedIdentityToken &&
|
|
716
|
+
!commandLineContainsProcessPath(identity.commandLine, expectedIdentityToken, platform)) {
|
|
717
|
+
log?.("Process identity probe rejected a mismatched ownership token");
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
return Math.abs(identity.startedAt - startedAt) <= PROCESS_START_TIME_TOLERANCE_MS;
|
|
721
|
+
}
|
|
722
|
+
async function verifyLegacyRuntimeProcessIdentity(pid, lastObservedAt, platform, expectedScriptPath, expectedStatusPath, log, expectedIdentityToken) {
|
|
723
|
+
if (lastObservedAt > Date.now() + PROCESS_START_TIME_TOLERANCE_MS) {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
const identity = await readProcessIdentity(pid, platform, log);
|
|
727
|
+
if (!identity || identity.startedAt === null)
|
|
728
|
+
return false;
|
|
729
|
+
if (!commandLineContainsProcessPath(identity.commandLine, expectedScriptPath, platform) ||
|
|
730
|
+
(expectedStatusPath !== undefined &&
|
|
731
|
+
expectedStatusPath !== null &&
|
|
732
|
+
!commandLineContainsProcessPath(identity.commandLine, expectedStatusPath, platform))) {
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
if (expectedIdentityToken &&
|
|
736
|
+
!commandLineContainsProcessPath(identity.commandLine, expectedIdentityToken, platform)) {
|
|
737
|
+
log?.("Process identity probe rejected a mismatched ownership token");
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
// Legacy router status did not persist its own start time. A status update
|
|
741
|
+
// is still written after the router process starts, so a process with a
|
|
742
|
+
// later creation time indicates a stale/reused PID and must not be signalled.
|
|
743
|
+
return identity.startedAt <= lastObservedAt + PROCESS_START_TIME_TOLERANCE_MS;
|
|
744
|
+
}
|
|
745
|
+
function isIgnorableProcessSignalError(error) {
|
|
746
|
+
const code = error && typeof error === "object" && "code" in error
|
|
747
|
+
? error.code
|
|
748
|
+
: undefined;
|
|
749
|
+
return code === "ESRCH" || code === "EPERM";
|
|
750
|
+
}
|
|
751
|
+
function reportProcessStopError(options, operation, error) {
|
|
752
|
+
options.log?.(`Failed to ${operation}: ${error instanceof Error ? error.message : String(error)}`);
|
|
753
|
+
}
|
|
754
|
+
function resolveStopTimeout(value, fallback) {
|
|
755
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
756
|
+
? Math.floor(value)
|
|
757
|
+
: fallback;
|
|
758
|
+
}
|
|
759
|
+
async function waitForDetachedProcessExit(pid, options) {
|
|
760
|
+
const isAlive = options.isAlive ?? isProcessAlive;
|
|
761
|
+
const timeoutMs = resolveStopTimeout(options.gracefulTimeoutMs, 2_000);
|
|
762
|
+
const pollIntervalMs = Math.max(1, resolveStopTimeout(options.pollIntervalMs, 100));
|
|
763
|
+
const deadline = Date.now() + timeoutMs;
|
|
764
|
+
while (isAlive(pid)) {
|
|
765
|
+
if (Date.now() >= deadline)
|
|
766
|
+
return false;
|
|
767
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, Math.max(1, deadline - Date.now()))));
|
|
768
|
+
}
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
export async function stopDetachedProcess(pid, platform, options = {}) {
|
|
772
|
+
if (!pid || !Number.isInteger(pid) || pid < 1)
|
|
773
|
+
return false;
|
|
774
|
+
const isAlive = options.isAlive ?? isProcessAlive;
|
|
775
|
+
const kill = options.kill ??
|
|
776
|
+
((target, signal) => {
|
|
777
|
+
process.kill(target, signal);
|
|
778
|
+
});
|
|
779
|
+
const taskkill = options.runWindowsTaskkill ?? runWindowsTaskkill;
|
|
780
|
+
if (!isAlive(pid))
|
|
781
|
+
return true;
|
|
782
|
+
if (platform === "win32") {
|
|
783
|
+
// Node's SIGTERM emulation can let a target exit before the tree-kill
|
|
784
|
+
// fallback runs. Kill the exact PID tree directly so detached descendants
|
|
785
|
+
// cannot survive a graceful wait and so no unrelated process is touched.
|
|
786
|
+
try {
|
|
787
|
+
const result = await taskkill(pid);
|
|
788
|
+
if (result !== false || !isAlive(pid))
|
|
789
|
+
return true;
|
|
790
|
+
reportProcessStopError(options, "terminate the Windows process tree", new Error("taskkill reported failure while the process is still alive"));
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
catch (error) {
|
|
794
|
+
if (!isAlive(pid))
|
|
795
|
+
return true;
|
|
796
|
+
reportProcessStopError(options, "terminate the Windows process tree", error);
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
kill(pid, "SIGTERM");
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
// ESRCH means the process exited between the liveness probe and signal;
|
|
805
|
+
// EPERM means it is not ours or cannot be signalled. Do not escalate an
|
|
806
|
+
// unexpected signal error or silently claim that cleanup succeeded.
|
|
807
|
+
if (isIgnorableProcessSignalError(error))
|
|
808
|
+
return !isAlive(pid);
|
|
809
|
+
reportProcessStopError(options, "send SIGTERM", error);
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
if (await waitForDetachedProcessExit(pid, options))
|
|
813
|
+
return true;
|
|
814
|
+
// A detached POSIX helper/router may ignore SIGTERM. Escalate once after the
|
|
815
|
+
// bounded graceful window; a second bounded wait prevents unbind from racing
|
|
816
|
+
// the process while it is still unwinding.
|
|
443
817
|
try {
|
|
444
|
-
|
|
818
|
+
kill(pid, "SIGKILL");
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
if (isIgnorableProcessSignalError(error))
|
|
822
|
+
return !isAlive(pid);
|
|
823
|
+
reportProcessStopError(options, "send SIGKILL", error);
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
return waitForDetachedProcessExit(pid, {
|
|
827
|
+
...options,
|
|
828
|
+
gracefulTimeoutMs: Math.min(resolveStopTimeout(options.gracefulTimeoutMs, 2_000), 500),
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async function readRuntimeHelperStatus(path) {
|
|
832
|
+
let raw;
|
|
833
|
+
try {
|
|
834
|
+
raw = await readFile(path, "utf8");
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
const code = error && typeof error === "object" && "code" in error
|
|
838
|
+
? error.code
|
|
839
|
+
: undefined;
|
|
840
|
+
return {
|
|
841
|
+
kind: code === "ENOENT" ? "missing" : "unreadable",
|
|
842
|
+
status: null,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
const record = parseJsonRecord(raw);
|
|
846
|
+
if (!record)
|
|
847
|
+
return { kind: "malformed", status: null };
|
|
848
|
+
const pid = record ? readNumber(record, "pid") : null;
|
|
849
|
+
const startedAt = record ? readNumber(record, "startedAt") : null;
|
|
850
|
+
return {
|
|
851
|
+
kind: "valid",
|
|
852
|
+
status: {
|
|
853
|
+
state: readString(record, "state"),
|
|
854
|
+
kind: readString(record, "kind"),
|
|
855
|
+
pid: pid !== null && Number.isInteger(pid) && pid > 0 ? pid : null,
|
|
856
|
+
startedAt: startedAt !== null && Number.isFinite(startedAt) && startedAt > 0
|
|
857
|
+
? startedAt
|
|
858
|
+
: null,
|
|
859
|
+
scriptPath: readString(record, "scriptPath"),
|
|
860
|
+
identityToken: readString(record, "identityToken"),
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
async function readRuntimeHelperOwner(path) {
|
|
865
|
+
try {
|
|
866
|
+
const record = parseJsonRecord(await readFile(path, "utf8"));
|
|
867
|
+
const kind = record ? readString(record, "kind") : null;
|
|
868
|
+
const identityToken = record ? readString(record, "identityToken") : null;
|
|
869
|
+
return kind === "codex-app-runtime-rotation-helper-owner" && identityToken
|
|
870
|
+
? { kind, identityToken }
|
|
871
|
+
: null;
|
|
445
872
|
}
|
|
446
873
|
catch {
|
|
447
|
-
return;
|
|
874
|
+
return null;
|
|
448
875
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
876
|
+
}
|
|
877
|
+
function resolveRuntimeHelperOwnerPath(baseDir, pid) {
|
|
878
|
+
if (pid === null || !Number.isInteger(pid) || pid < 1)
|
|
879
|
+
return null;
|
|
880
|
+
return join(baseDir, APP_RUNTIME_HELPER_OWNER_FILE.replace(/\.json$/i, `.${pid}.json`));
|
|
881
|
+
}
|
|
882
|
+
export async function stopRuntimeRotationAppHelperProcess(helper, options = {}) {
|
|
883
|
+
if (helper.kind !== "codex-app-runtime-rotation-helper" ||
|
|
884
|
+
helper.state !== "running" ||
|
|
885
|
+
helper.pid === null ||
|
|
886
|
+
helper.startedAt === null) {
|
|
887
|
+
return false;
|
|
453
888
|
}
|
|
889
|
+
const platform = options.platform ?? process.platform;
|
|
890
|
+
const expectedIdentityToken = options.identityToken;
|
|
891
|
+
if (helper.identityToken && !expectedIdentityToken) {
|
|
892
|
+
// Do not trust a token that was read from the same mutable status file
|
|
893
|
+
// whose PID is about to be signalled.
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
const verifyProcessIdentity = options.verifyProcessIdentity ??
|
|
897
|
+
((pid, startedAt, processPlatform) => verifyRuntimeProcessIdentity(pid, startedAt, processPlatform, RUNTIME_ROTATION_APP_HELPER_ARG, helper.scriptPath, options.log, expectedIdentityToken));
|
|
898
|
+
const verified = expectedIdentityToken
|
|
899
|
+
? await verifyProcessIdentity(helper.pid, helper.startedAt, platform, expectedIdentityToken)
|
|
900
|
+
: await verifyProcessIdentity(helper.pid, helper.startedAt, platform);
|
|
901
|
+
if (!verified) {
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
return stopDetachedProcess(helper.pid, platform, options);
|
|
454
905
|
}
|
|
455
906
|
async function readConfigIfExists(configPath) {
|
|
456
907
|
try {
|
|
@@ -519,6 +970,7 @@ async function bindCodexAppRuntimeRotationLocked(options, paths) {
|
|
|
519
970
|
nodePath: options.nodePath ?? process.execPath,
|
|
520
971
|
routerScriptPath: paths.routerScriptPath,
|
|
521
972
|
clientApiKey,
|
|
973
|
+
identityToken: existingState?.identityToken ?? randomBytes(24).toString("hex"),
|
|
522
974
|
startupPath: paths.startupPath,
|
|
523
975
|
launchAgentPath: paths.launchAgentPath,
|
|
524
976
|
boundConfigHash: sha256(boundConfig),
|
|
@@ -558,7 +1010,11 @@ async function bindCodexAppRuntimeRotationLocked(options, paths) {
|
|
|
558
1010
|
if (startedRouter) {
|
|
559
1011
|
// Best-effort stop of the router we just spawned
|
|
560
1012
|
const orphan = await readRouterStatus(state.statusPath).catch(() => null);
|
|
561
|
-
await stopRouter(orphan
|
|
1013
|
+
await stopRouter(orphan, platform, state.routerScriptPath, {
|
|
1014
|
+
log: options.log,
|
|
1015
|
+
identityToken: state.identityToken,
|
|
1016
|
+
verifyProcessIdentity: options.verifyProcessIdentity,
|
|
1017
|
+
}).catch(() => undefined);
|
|
562
1018
|
}
|
|
563
1019
|
throw new Error("Codex app bind could not resolve a runtime router port; refusing to write config.toml with port=0.");
|
|
564
1020
|
}
|
|
@@ -589,13 +1045,77 @@ export async function unbindCodexAppRuntimeRotation(options = {}) {
|
|
|
589
1045
|
async function unbindCodexAppRuntimeRotationLocked(options, paths) {
|
|
590
1046
|
const state = await readAppBindState(paths.statePath);
|
|
591
1047
|
const router = await readRouterStatus(paths.statusPath);
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1048
|
+
const platform = options.platform ?? process.platform;
|
|
1049
|
+
const routerStopped = await stopRouter(router, platform, state?.routerScriptPath ?? paths.routerScriptPath, {
|
|
1050
|
+
log: options.log,
|
|
1051
|
+
identityToken: state?.identityToken,
|
|
1052
|
+
verifyProcessIdentity: options.verifyProcessIdentity,
|
|
1053
|
+
});
|
|
1054
|
+
if (router?.pid && (!routerStopped || isProcessAlive(router.pid))) {
|
|
1055
|
+
options.log?.(`Warning: runtime router (pid ${router.pid}) did not stop; continuing cleanup`);
|
|
1056
|
+
}
|
|
1057
|
+
const helperStatusPath = join(dirname(paths.bindDir), APP_RUNTIME_HELPER_STATUS_FILE);
|
|
1058
|
+
const helperRead = await readRuntimeHelperStatus(helperStatusPath);
|
|
1059
|
+
let removeHelperStatus = false;
|
|
1060
|
+
let removeHelperOwner = false;
|
|
1061
|
+
let helperOwnerPath = null;
|
|
1062
|
+
if (helperRead.kind === "valid") {
|
|
1063
|
+
const helper = helperRead.status;
|
|
1064
|
+
if (helper.kind === "codex-app-runtime-rotation-helper") {
|
|
1065
|
+
helperOwnerPath = resolveRuntimeHelperOwnerPath(dirname(paths.bindDir), helper.pid);
|
|
1066
|
+
const helperOwner = helperOwnerPath
|
|
1067
|
+
? await readRuntimeHelperOwner(helperOwnerPath)
|
|
1068
|
+
: null;
|
|
1069
|
+
const helperOwnershipMatches = !helper.identityToken ||
|
|
1070
|
+
(helperOwner !== null &&
|
|
1071
|
+
helper.identityToken === helperOwner.identityToken);
|
|
1072
|
+
if (helper.state === "running") {
|
|
1073
|
+
if (helper.pid === null) {
|
|
1074
|
+
options.log?.("Warning: runtime app helper status has no valid PID; preserving status");
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
const wasAlive = isProcessAlive(helper.pid);
|
|
1078
|
+
if (!wasAlive) {
|
|
1079
|
+
removeHelperStatus = true;
|
|
1080
|
+
removeHelperOwner =
|
|
1081
|
+
helperOwnershipMatches && helperOwnerPath !== null;
|
|
1082
|
+
}
|
|
1083
|
+
else if (!helperOwnershipMatches) {
|
|
1084
|
+
options.log?.("Warning: runtime app helper ownership metadata does not match; preserving status");
|
|
1085
|
+
}
|
|
1086
|
+
else {
|
|
1087
|
+
const stopped = await stopRuntimeRotationAppHelperProcess(helper, {
|
|
1088
|
+
platform,
|
|
1089
|
+
log: options.log,
|
|
1090
|
+
identityToken: helper.identityToken
|
|
1091
|
+
? helperOwner?.identityToken
|
|
1092
|
+
: undefined,
|
|
1093
|
+
verifyProcessIdentity: options.verifyProcessIdentity,
|
|
1094
|
+
});
|
|
1095
|
+
const stillAlive = isProcessAlive(helper.pid);
|
|
1096
|
+
removeHelperStatus = stopped && !stillAlive;
|
|
1097
|
+
removeHelperOwner =
|
|
1098
|
+
removeHelperStatus && helperOwnerPath !== null;
|
|
1099
|
+
if (!removeHelperStatus) {
|
|
1100
|
+
options.log?.(`Warning: runtime app helper (pid ${helper.pid}) did not stop; preserving status`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
// A non-running, owned record is removable only when its PID is
|
|
1107
|
+
// absent or no longer alive. This avoids deleting a status file
|
|
1108
|
+
// while a helper is still serving despite a stale state value.
|
|
1109
|
+
removeHelperStatus =
|
|
1110
|
+
helper.pid === null || !isProcessAlive(helper.pid);
|
|
1111
|
+
removeHelperOwner =
|
|
1112
|
+
removeHelperStatus &&
|
|
1113
|
+
helperOwnershipMatches &&
|
|
1114
|
+
helperOwnerPath !== null;
|
|
1115
|
+
}
|
|
596
1116
|
}
|
|
597
|
-
await removeAppBindStartup(state);
|
|
598
1117
|
}
|
|
1118
|
+
await removeAppBindStartup(state ?? paths);
|
|
599
1119
|
const backup = await readAppBindBackup(paths.backupPath);
|
|
600
1120
|
let selfHealed = false;
|
|
601
1121
|
if (backup) {
|
|
@@ -629,11 +1149,15 @@ async function unbindCodexAppRuntimeRotationLocked(options, paths) {
|
|
|
629
1149
|
selfHealed = true;
|
|
630
1150
|
}
|
|
631
1151
|
}
|
|
632
|
-
|
|
1152
|
+
const cleanupCandidates = [
|
|
633
1153
|
paths.statePath,
|
|
634
1154
|
paths.backupPath,
|
|
635
1155
|
paths.statusPath,
|
|
636
|
-
|
|
1156
|
+
state?.logPath ?? paths.logPath,
|
|
1157
|
+
...(removeHelperStatus ? [helperStatusPath] : []),
|
|
1158
|
+
...(removeHelperOwner && helperOwnerPath ? [helperOwnerPath] : []),
|
|
1159
|
+
];
|
|
1160
|
+
for (const candidate of cleanupCandidates) {
|
|
637
1161
|
try {
|
|
638
1162
|
await unlinkIfExists(candidate);
|
|
639
1163
|
}
|
|
@@ -641,6 +1165,12 @@ async function unbindCodexAppRuntimeRotationLocked(options, paths) {
|
|
|
641
1165
|
// Best-effort cleanup.
|
|
642
1166
|
}
|
|
643
1167
|
}
|
|
1168
|
+
try {
|
|
1169
|
+
await withFileOperationRetry(() => rm(paths.bindDir, { force: true, recursive: true }));
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
// The bind directory may still contain unrelated or locked files.
|
|
1173
|
+
}
|
|
644
1174
|
const status = await getAppBindStatus(options);
|
|
645
1175
|
let message;
|
|
646
1176
|
if (backup) {
|