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.
Files changed (76) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +3 -1
  3. package/dist/lib/accounts.d.ts +15 -0
  4. package/dist/lib/accounts.d.ts.map +1 -1
  5. package/dist/lib/accounts.js +126 -21
  6. package/dist/lib/codex-manager/account-pool-write.d.ts.map +1 -1
  7. package/dist/lib/codex-manager/account-pool-write.js +4 -0
  8. package/dist/lib/codex-manager/backend-settings-schema.js +1 -1
  9. package/dist/lib/codex-manager/commands/status.d.ts.map +1 -1
  10. package/dist/lib/codex-manager/commands/status.js +5 -1
  11. package/dist/lib/codex-manager/commands/uninstall.d.ts +1 -0
  12. package/dist/lib/codex-manager/commands/uninstall.d.ts.map +1 -1
  13. package/dist/lib/codex-manager/commands/uninstall.js +12 -1
  14. package/dist/lib/codex-manager/commands/why-selected.d.ts +1 -1
  15. package/dist/lib/codex-manager/commands/why-selected.d.ts.map +1 -1
  16. package/dist/lib/codex-manager/commands/why-selected.js +1 -1
  17. package/dist/lib/codex-manager/formatters/quota-formatters.d.ts.map +1 -1
  18. package/dist/lib/codex-manager/formatters/quota-formatters.js +32 -2
  19. package/dist/lib/codex-manager/health-check.d.ts.map +1 -1
  20. package/dist/lib/codex-manager/health-check.js +19 -3
  21. package/dist/lib/codex-manager/login-oauth.d.ts.map +1 -1
  22. package/dist/lib/codex-manager/login-oauth.js +13 -7
  23. package/dist/lib/codex-manager.d.ts +4 -0
  24. package/dist/lib/codex-manager.d.ts.map +1 -1
  25. package/dist/lib/codex-manager.js +50 -16
  26. package/dist/lib/config.d.ts +3 -1
  27. package/dist/lib/config.d.ts.map +1 -1
  28. package/dist/lib/config.js +3 -1
  29. package/dist/lib/forecast.d.ts.map +1 -1
  30. package/dist/lib/forecast.js +7 -1
  31. package/dist/lib/index.d.ts +1 -0
  32. package/dist/lib/index.d.ts.map +1 -1
  33. package/dist/lib/index.js +1 -0
  34. package/dist/lib/parallel-probe.d.ts.map +1 -1
  35. package/dist/lib/parallel-probe.js +3 -1
  36. package/dist/lib/policy/runtime-policy.d.ts +1 -0
  37. package/dist/lib/policy/runtime-policy.d.ts.map +1 -1
  38. package/dist/lib/policy/runtime-policy.js +7 -0
  39. package/dist/lib/preemptive-quota-scheduler.d.ts.map +1 -1
  40. package/dist/lib/preemptive-quota-scheduler.js +35 -15
  41. package/dist/lib/rotation.d.ts +4 -0
  42. package/dist/lib/rotation.d.ts.map +1 -1
  43. package/dist/lib/rotation.js +13 -6
  44. package/dist/lib/runtime/app-bind.d.ts +40 -0
  45. package/dist/lib/runtime/app-bind.d.ts.map +1 -1
  46. package/dist/lib/runtime/app-bind.js +550 -20
  47. package/dist/lib/runtime/rotation-account-selection.d.ts.map +1 -1
  48. package/dist/lib/runtime/rotation-account-selection.js +2 -0
  49. package/dist/lib/runtime/rotation-proxy-state.d.ts +2 -0
  50. package/dist/lib/runtime/rotation-proxy-state.d.ts.map +1 -1
  51. package/dist/lib/runtime/rotation-token-refresh.d.ts.map +1 -1
  52. package/dist/lib/runtime/rotation-token-refresh.js +3 -0
  53. package/dist/lib/runtime-constants.d.ts +2 -0
  54. package/dist/lib/runtime-constants.d.ts.map +1 -1
  55. package/dist/lib/runtime-constants.js +2 -0
  56. package/dist/lib/runtime-rotation-proxy.d.ts +8 -0
  57. package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
  58. package/dist/lib/runtime-rotation-proxy.js +59 -5
  59. package/dist/lib/schemas.d.ts +15 -0
  60. package/dist/lib/schemas.d.ts.map +1 -1
  61. package/dist/lib/schemas.js +3 -0
  62. package/dist/lib/storage/flagged-storage.d.ts.map +1 -1
  63. package/dist/lib/storage/flagged-storage.js +15 -0
  64. package/dist/lib/storage/path-state.d.ts +1 -0
  65. package/dist/lib/storage/path-state.d.ts.map +1 -1
  66. package/dist/lib/storage/path-state.js +30 -3
  67. package/dist/lib/storage/public-types.d.ts +6 -0
  68. package/dist/lib/storage/public-types.d.ts.map +1 -1
  69. package/dist/lib/storage.d.ts.map +1 -1
  70. package/dist/lib/storage.js +17 -4
  71. package/package.json +198 -198
  72. package/scripts/codex-app-router.js +47 -3
  73. package/scripts/codex.js +165 -21
  74. package/scripts/install-codex-auth-utils.js +9 -2
  75. package/scripts/postinstall.js +2 -0
  76. 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 stopRouter(router) {
441
- if (!router?.pid || !isProcessAlive(router.pid))
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
- process.kill(router.pid, "SIGTERM");
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
- for (let attempt = 0; attempt < 20; attempt += 1) {
450
- if (!isProcessAlive(router.pid))
451
- return;
452
- await new Promise((resolve) => setTimeout(resolve, 100));
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).catch(() => undefined);
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
- if (state) {
593
- await stopRouter(router);
594
- if (router?.pid && isProcessAlive(router.pid)) {
595
- options.log?.(`Warning: runtime router (pid ${router.pid}) did not stop; continuing cleanup`);
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
- for (const candidate of [
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) {