@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901

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 (131) hide show
  1. package/bin/ocx.mjs +127 -16
  2. package/gui/dist/assets/index-6HZDkIpX.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/baseten.svg +13 -0
  7. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  8. package/gui/dist/provider-icons/cerebras.svg +26 -0
  9. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  10. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  11. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  12. package/gui/dist/provider-icons/featherless.svg +4 -0
  13. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  14. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  15. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  16. package/gui/dist/provider-icons/kilo.svg +13 -0
  17. package/gui/dist/provider-icons/litellm.svg +1 -0
  18. package/gui/dist/provider-icons/minimax.svg +1 -0
  19. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  20. package/gui/dist/provider-icons/nebius.svg +1 -0
  21. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  22. package/gui/dist/provider-icons/nous.svg +149 -0
  23. package/gui/dist/provider-icons/novita.svg +32 -0
  24. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  25. package/gui/dist/provider-icons/openclaw.svg +54 -0
  26. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  27. package/gui/dist/provider-icons/parallel.svg +13 -0
  28. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  29. package/gui/dist/provider-icons/sambanova.svg +276 -0
  30. package/gui/dist/provider-icons/scaleway.svg +11 -0
  31. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  32. package/gui/dist/provider-icons/synthetic.svg +12 -0
  33. package/gui/dist/provider-icons/together.svg +18 -0
  34. package/gui/dist/provider-icons/umans.svg +30 -0
  35. package/gui/dist/provider-icons/venice.svg +165 -0
  36. package/gui/dist/provider-icons/vultr.svg +15 -0
  37. package/gui/dist/provider-icons/zai.svg +218 -0
  38. package/gui/dist/provider-icons/zcode.svg +219 -0
  39. package/gui/dist/provider-icons/zenmux.svg +1 -0
  40. package/package.json +1 -1
  41. package/src/adapters/cursor/live-models.ts +1 -0
  42. package/src/adapters/cursor/protobuf-request.ts +4 -1
  43. package/src/adapters/cursor/tool-definitions.ts +36 -4
  44. package/src/adapters/openai-responses.ts +72 -10
  45. package/src/bridge.ts +15 -15
  46. package/src/cli/capabilities.ts +14 -0
  47. package/src/cli/codex-cli-update.ts +96 -0
  48. package/src/cli/codex-shim-autorestore.ts +3 -0
  49. package/src/cli/dispatch.ts +54 -18
  50. package/src/cli/export-command.ts +18 -17
  51. package/src/cli/help.ts +2 -2
  52. package/src/cli/index.ts +423 -32
  53. package/src/cli/integrations.ts +24 -2
  54. package/src/cli/launcher-context.ts +53 -2
  55. package/src/cli/opencode.ts +126 -33
  56. package/src/cli/registry.ts +16 -10
  57. package/src/cli/system-command.ts +6 -1
  58. package/src/cli/uninstall-plan.ts +86 -0
  59. package/src/clients/config-export.ts +293 -28
  60. package/src/codex/account-store.ts +131 -12
  61. package/src/codex/auth-api.ts +202 -33
  62. package/src/codex/autostart-health.ts +3 -3
  63. package/src/codex/catalog/provider-fetch.ts +96 -10
  64. package/src/codex/catalog/sync.ts +4 -3
  65. package/src/codex/cli-install-provenance.ts +795 -0
  66. package/src/codex/convergence.ts +4 -3
  67. package/src/codex/credential-mutation-epoch.ts +11 -0
  68. package/src/codex/history-job.ts +10 -0
  69. package/src/codex/history-manifest.ts +35 -2
  70. package/src/codex/history-provider.ts +196 -19
  71. package/src/codex/history-worker.ts +3 -0
  72. package/src/codex/main-account.ts +2 -0
  73. package/src/codex/model-entitlements.ts +430 -27
  74. package/src/codex/native-profile-manager.ts +4 -0
  75. package/src/codex/quota-401-recovery.ts +190 -0
  76. package/src/codex/quota-recovery-timing.ts +28 -0
  77. package/src/codex/quota.ts +6 -0
  78. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  79. package/src/codex/reset-credit-recovery.ts +20 -2
  80. package/src/codex/routing.ts +68 -16
  81. package/src/codex/shim.ts +204 -18
  82. package/src/codex/subagent-model-fallback.ts +4 -1
  83. package/src/codex/user-identity.ts +2 -1
  84. package/src/config/paths.ts +18 -3
  85. package/src/config/pending-teardown-names.d.mts +8 -0
  86. package/src/config/pending-teardown-names.mjs +69 -0
  87. package/src/config/pending-teardown.ts +286 -0
  88. package/src/config.ts +23 -0
  89. package/src/generated/compatibility-version.json +155 -67
  90. package/src/integrations/journal.ts +12 -1
  91. package/src/integrations/registry.ts +112 -0
  92. package/src/integrations/state.ts +67 -5
  93. package/src/integrations/writer.ts +107 -16
  94. package/src/lib/bounded-subprocess.ts +36 -0
  95. package/src/lib/process-control.ts +30 -5
  96. package/src/lib/state-store-registrations.ts +8 -0
  97. package/src/lib/strict-semver.ts +47 -0
  98. package/src/lib/windows-elevation.ts +32 -1
  99. package/src/lib/windows-secret-acl.ts +47 -25
  100. package/src/lib/windows-service-mutation-lock.ts +133 -0
  101. package/src/lib/windows-user-principal.ts +15 -17
  102. package/src/oauth/index.ts +220 -13
  103. package/src/oauth/store.ts +220 -18
  104. package/src/responses/spill-store.ts +354 -29
  105. package/src/responses/state.ts +775 -8
  106. package/src/server/index.ts +4 -3
  107. package/src/server/lifecycle.ts +5 -1
  108. package/src/server/management/integration-routes.ts +36 -5
  109. package/src/server/management/model-rows.ts +11 -2
  110. package/src/server/management/provider-routes.ts +4 -0
  111. package/src/server/management/system-restart.ts +5 -5
  112. package/src/server/management-api.ts +72 -13
  113. package/src/server/responses/encrypted-payload.ts +18 -1
  114. package/src/server/startup-action-control.ts +3 -2
  115. package/src/server/stop-teardown.ts +84 -0
  116. package/src/service.ts +749 -53
  117. package/src/sidecar/candidates.ts +1 -1
  118. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  119. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  120. package/src/update/index.ts +39 -6
  121. package/src/update/job.ts +10 -11
  122. package/src/update/proxy-liveness-probe.d.mts +6 -0
  123. package/src/update/proxy-liveness-probe.mjs +84 -0
  124. package/src/update/stop-contract.d.mts +2 -0
  125. package/src/update/stop-contract.mjs +15 -0
  126. package/src/update/stop-decision.d.mts +10 -0
  127. package/src/update/stop-decision.mjs +34 -0
  128. package/src/vision/eligibility.ts +19 -1
  129. package/src/vision/index.ts +4 -18
  130. package/gui/dist/assets/index-CowztZdo.js +0 -112
  131. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
2
2
  import { existsSync } from "node:fs";
3
3
  import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
4
4
  import { dlopen, ptr, type Pointer } from "bun:ffi";
5
+ import { isTestHomeGuardArmed } from "./test-home-guard";
5
6
 
6
7
  type ElevationSpawn = (
7
8
  command: string,
@@ -530,6 +531,19 @@ export function startPowerShellCommand(commandScript: string): WindowsElevationE
530
531
  };
531
532
  }
532
533
 
534
+ // HOME isolation cannot contain UAC children or other machine-global effects. Keep the
535
+ // final process boundary closed while the real launcher is installed; explicitly injected
536
+ // launchers remain available to tests that exercise the elevation protocol in memory.
537
+ if (isTestHomeGuardArmed() && elevationSpawn === spawn) {
538
+ return {
539
+ launcherPid: null,
540
+ completion: Promise.reject(new WindowsElevationError(
541
+ "launch-failed",
542
+ "Refusing to launch a live Windows elevation process from an armed test process; inject the elevation launcher instead.",
543
+ )),
544
+ };
545
+ }
546
+
533
547
  let child: ChildProcess;
534
548
  try {
535
549
  child = elevationSpawn(
@@ -638,8 +652,16 @@ export function runWindowsElevated(file: string, args: string[]): Promise<number
638
652
  export function runWindowsElevatedScheduledTaskRegistration(
639
653
  taskName: string,
640
654
  xml: string,
655
+ replace = false,
656
+ expectedExistingXml?: string,
641
657
  ): Promise<number> {
658
+ if (replace && !expectedExistingXml?.trim()) {
659
+ throw new Error("Elevated Task Scheduler replacement requires a captured existing definition.");
660
+ }
642
661
  const xmlBase64 = Buffer.from(xml, "utf16le").toString("base64");
662
+ const expectedExistingBase64 = expectedExistingXml === undefined
663
+ ? null
664
+ : Buffer.from(expectedExistingXml, "utf16le").toString("base64");
643
665
  const powerShellPath = windowsPowerShell();
644
666
  const powerShellDirectory = powerShellPath.replace(/[\\/][^\\/]+$/, "");
645
667
  const scheduledTasksModule = `${powerShellDirectory}\\Modules\\ScheduledTasks\\ScheduledTasks.psd1`;
@@ -650,7 +672,16 @@ export function runWindowsElevatedScheduledTaskRegistration(
650
672
  `$module = Microsoft.PowerShell.Core\\Import-Module -Name ${psSingleQuote(scheduledTasksModule)} -PassThru -Force -ErrorAction Stop`,
651
673
  "$registerTask = $module.ExportedCommands['Register-ScheduledTask']",
652
674
  "if ($null -eq $registerTask) { throw 'Trusted ScheduledTasks module does not export Register-ScheduledTask.' }",
653
- "& $registerTask -TaskName $taskName -Xml $xml -Force -ErrorAction Stop | Out-Null",
675
+ ...(replace ? [
676
+ `$expectedBase64 = ${psSingleQuote(expectedExistingBase64!)}`,
677
+ "$expectedXml = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($expectedBase64))",
678
+ `$schtasks = ${psSingleQuote(resolveTrustedWindowsSchtasksExe())}`,
679
+ "$currentXml = & $schtasks /query /tn $taskName /xml 2>$null | Out-String",
680
+ "if ($LASTEXITCODE -ne 0) { throw 'Task Scheduler replacement precondition could not be read.' }",
681
+ "function Normalize-OcxTaskXml([string]$value) { return (($value.TrimStart([char]0xFEFF) -replace \"`r`n?\", \"`n\").Trim()) }",
682
+ "if ((Normalize-OcxTaskXml $currentXml) -cne (Normalize-OcxTaskXml $expectedXml)) { throw 'Task Scheduler replacement precondition changed.' }",
683
+ ] : []),
684
+ `& $registerTask -TaskName $taskName -Xml $xml${replace ? " -Force" : ""} -ErrorAction Stop | Out-Null`,
654
685
  ].join("; ");
655
686
  const encodedCommand = Buffer.from(inner, "utf16le").toString("base64");
656
687
  const script = [
@@ -31,6 +31,7 @@
31
31
 
32
32
  import { existsSync, statSync } from "node:fs";
33
33
  import { env, platform } from "node:process";
34
+ import { waitForSubprocessExit } from "./bounded-subprocess";
34
35
  import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation";
35
36
  import {
36
37
  cachedCurrentWindowsIdentity,
@@ -216,6 +217,11 @@ export interface HardenResult {
216
217
 
217
218
  export interface HardenOptions {
218
219
  required: boolean;
220
+ /**
221
+ * Explicit total budget for this harden call. Shutdown recovery uses a reduced
222
+ * caller-owned slice instead of opening the normal 30-second window.
223
+ */
224
+ deadlineMs?: number;
219
225
  /**
220
226
  * Optional timeout-memo key distinct from `targetPath` (issue #612).
221
227
  * Atomic writers mint a fresh `.tmp` path per write; keying the timeout cache by the
@@ -257,7 +263,11 @@ const HARDEN_DEADLINE_MIN_MS = 1_000;
257
263
  const HARDEN_DEADLINE_MAX_MS = 60_000;
258
264
 
259
265
  /** Resolve the total harden budget once per call (env mutation cannot change it midway). */
260
- function resolveHardenDeadlineMs(): number {
266
+ function resolveHardenDeadlineMs(overrideMs?: number): number {
267
+ if (overrideMs !== undefined) {
268
+ if (!Number.isSafeInteger(overrideMs) || overrideMs <= 0) return 1;
269
+ return Math.min(HARDEN_DEADLINE_MAX_MS, overrideMs);
270
+ }
261
271
  const raw = env["OPENCODEX_ACL_TIMEOUT_MS"]?.trim();
262
272
  if (!raw) return HARDEN_DEADLINE_DEFAULT_MS;
263
273
  const parsed = Number(raw);
@@ -328,27 +338,16 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
328
338
 
329
339
  /**
330
340
  * Async icacls runner (#612): yields the event loop while waiting for the child.
331
- * Timeout provenance is recorded by our timer (async Subprocess has no exitedDueToTimeout);
332
- * we still await process exit before classifying so settlement is confirmed.
341
+ * Async Subprocess has no exitedDueToTimeout, so the shared settlement helper
342
+ * classifies the deadline and abandons a child that does not settle after kill.
333
343
  */
334
344
  async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
335
345
  const proc = trySpawnIcacls(args);
336
346
  if (!proc) return spawnFailedResult();
337
- let timedOutByUs = false;
338
- const timer = setTimeout(() => {
339
- timedOutByUs = true;
340
- try { proc.kill(); } catch { /* already exited */ }
341
- }, Math.max(1, timeoutMs));
342
- let exitCode: number | null = null;
343
- try {
344
- exitCode = await proc.exited;
345
- } finally {
346
- clearTimeout(timer);
347
- }
348
- const stdout = proc.stdout
347
+ const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs);
348
+ const stdout = !timedOut && proc.stdout
349
349
  ? await new Response(proc.stdout).text().catch(() => "")
350
350
  : "";
351
- const timedOut = timedOutByUs;
352
351
  return {
353
352
  success: !timedOut && exitCode === 0,
354
353
  exitCode: timedOut ? null : exitCode,
@@ -357,6 +356,24 @@ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Prom
357
356
  };
358
357
  }
359
358
 
359
+ function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
360
+ return new Promise(resolve => {
361
+ let settled = false;
362
+ let timer: ReturnType<typeof setTimeout> | undefined;
363
+ const finish = (result: IcaclsResult): void => {
364
+ if (settled) return;
365
+ settled = true;
366
+ if (timer !== undefined) clearTimeout(timer);
367
+ resolve(result);
368
+ };
369
+ timer = setTimeout(
370
+ () => finish({ success: false, exitCode: null, timedOut: true, stdout: "" }),
371
+ Math.max(1, timeoutMs),
372
+ );
373
+ void asyncIcaclsRunner(args, timeoutMs).then(finish, () => finish(spawnFailedResult()));
374
+ });
375
+ }
376
+
360
377
  let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
361
378
  let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner;
362
379
  let platformOverride: string | null = null;
@@ -537,12 +554,14 @@ function shouldVerifyExistingAcl(): boolean {
537
554
  return env["OPENCODEX_ACL_VERIFY_EXISTING"] === "1";
538
555
  }
539
556
 
540
- function existingAclAlreadyCompliant(targetPath: string, directory: boolean): boolean {
557
+ function existingAclAlreadyCompliant(targetPath: string, directory: boolean, deadline: number): boolean {
541
558
  if (!shouldVerifyExistingAcl()) return false;
542
559
  const identity = cachedCurrentWindowsIdentity();
543
560
  if (!identity) return false;
544
561
  try {
545
- const result = icaclsRunner([targetPath], resolveHardenDeadlineMs());
562
+ const remaining = deadline - nowFn();
563
+ if (remaining <= 0) return false;
564
+ const result = icaclsRunner([targetPath], remaining);
546
565
  return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name);
547
566
  } catch {
548
567
  return false;
@@ -552,12 +571,15 @@ function existingAclAlreadyCompliant(targetPath: string, directory: boolean): bo
552
571
  async function existingAclAlreadyCompliantAsync(
553
572
  targetPath: string,
554
573
  directory: boolean,
574
+ deadline: number,
555
575
  ): Promise<boolean> {
556
576
  if (!shouldVerifyExistingAcl()) return false;
557
577
  const identity = cachedCurrentWindowsIdentity();
558
578
  if (!identity) return false;
559
579
  try {
560
- const result = await asyncIcaclsRunner([targetPath], resolveHardenDeadlineMs());
580
+ const remaining = deadline - nowFn();
581
+ if (remaining <= 0) return false;
582
+ const result = await awaitAsyncIcaclsRunner([targetPath], remaining);
561
583
  return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name);
562
584
  } catch {
563
585
  return false;
@@ -618,7 +640,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline:
618
640
  if (remaining <= 0) {
619
641
  throw icaclsError(step, { success: false, exitCode: null, timedOut: true, stdout: "" });
620
642
  }
621
- return asyncIcaclsRunner(args, remaining);
643
+ return awaitAsyncIcaclsRunner(args, remaining);
622
644
  };
623
645
  const runOrThrow = async (step: string, args: string[]): Promise<void> => {
624
646
  const result = await run(step, args);
@@ -751,7 +773,7 @@ async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: n
751
773
  for (const sid of BROAD_SIDS) {
752
774
  const remaining = deadline - nowFn();
753
775
  if (remaining <= 0) return "ACL state unverified (budget exhausted)";
754
- const found = await asyncIcaclsRunner([targetPath, "/findsid", sid], remaining);
776
+ const found = await awaitAsyncIcaclsRunner([targetPath, "/findsid", sid], remaining);
755
777
  if (!found.success) return "ACL state unverified (probe failed)";
756
778
  if (found.stdout.includes(targetPath)) return "broad ACL grants still present";
757
779
  }
@@ -788,7 +810,8 @@ function hardenEntry(
788
810
  if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
789
811
  if (effectivePlatform() !== "win32") return { ok: true };
790
812
  if (memoSatisfied(cache, targetPath)) return { ok: true };
791
- if (existingAclAlreadyCompliant(targetPath, directory)) return { ok: true };
813
+ const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs);
814
+ if (existingAclAlreadyCompliant(targetPath, directory, deadline)) return { ok: true };
792
815
  const memoKey = timeoutMemoKey(targetPath, opts);
793
816
  const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
794
817
  if (timeoutMemoError) {
@@ -796,7 +819,6 @@ function hardenEntry(
796
819
  return { ok: false, diagnostics: timeoutMemoError.message };
797
820
  }
798
821
 
799
- const deadline = nowFn() + resolveHardenDeadlineMs();
800
822
  let lastErr: unknown;
801
823
  for (let attempt = 0; attempt < 2; attempt++) {
802
824
  if (attempt > 0 && deadline - nowFn() <= 0) break; // retry only while budget remains
@@ -841,7 +863,8 @@ async function hardenEntryAsync(
841
863
  if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
842
864
  if (effectivePlatform() !== "win32") return { ok: true };
843
865
  if (memoSatisfied(cache, targetPath)) return { ok: true };
844
- if (await existingAclAlreadyCompliantAsync(targetPath, directory)) return { ok: true };
866
+ const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs);
867
+ if (await existingAclAlreadyCompliantAsync(targetPath, directory, deadline)) return { ok: true };
845
868
  const memoKey = timeoutMemoKey(targetPath, opts);
846
869
  const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
847
870
  if (timeoutMemoError) {
@@ -849,7 +872,6 @@ async function hardenEntryAsync(
849
872
  return { ok: false, diagnostics: timeoutMemoError.message };
850
873
  }
851
874
 
852
- const deadline = nowFn() + resolveHardenDeadlineMs();
853
875
  let lastErr: unknown;
854
876
  for (let attempt = 0; attempt < 2; attempt++) {
855
877
  if (attempt > 0 && deadline - nowFn() <= 0) break;
@@ -0,0 +1,133 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { chmodSync, lstatSync, mkdirSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { resolveEffectiveUserIdentity, resolveEffectiveUserRuntimeRoot } from "../codex/user-identity";
6
+ import { hardenSecretDir, hardenSecretPath } from "./windows-secret-acl";
7
+
8
+ type LockDatabase = Pick<Database, "exec" | "close">;
9
+
10
+ export interface WindowsServiceMutationLockDeps {
11
+ lockPath?: string;
12
+ openDatabase?: (path: string) => LockDatabase;
13
+ hardenDirectory?: (path: string) => void;
14
+ hardenFile?: (path: string) => void;
15
+ }
16
+
17
+ export class WindowsServiceMutationBusyError extends Error {
18
+ readonly code = "WINDOWS_SERVICE_MUTATION_BUSY";
19
+
20
+ constructor() {
21
+ super("Another OpenCodex Windows service operation is already in progress. Wait for it to finish, then retry.");
22
+ this.name = "WindowsServiceMutationBusyError";
23
+ }
24
+ }
25
+
26
+ export class WindowsServiceMutationLockError extends Error {
27
+ readonly code = "WINDOWS_SERVICE_MUTATION_LOCK_FAILED";
28
+
29
+ constructor(operation: "acquire" | "release", cause: unknown) {
30
+ super(`The Windows service mutation lock could not be ${operation === "acquire" ? "acquired" : "released"}.`, { cause });
31
+ this.name = "WindowsServiceMutationLockError";
32
+ }
33
+ }
34
+
35
+ function errorCode(error: unknown): string | undefined {
36
+ return error && typeof error === "object" && "code" in error
37
+ ? String((error as { code?: unknown }).code)
38
+ : undefined;
39
+ }
40
+
41
+ function isBusy(error: unknown): boolean {
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ return errorCode(error) === "SQLITE_BUSY"
44
+ || errorCode(error) === "SQLITE_LOCKED"
45
+ || /database (?:is|table is) locked/i.test(message);
46
+ }
47
+
48
+ /**
49
+ * Stable per-user lock namespace for the fixed `opencodex-proxy` task name.
50
+ *
51
+ * Deliberately outside OPENCODEX_HOME: creating the lock must not make a genuinely fresh
52
+ * config root look pre-existing before the installer records its uninstall ownership. The
53
+ * effective-user runtime root ignores LOCALAPPDATA/USERPROFILE overrides, so two processes
54
+ * running as the same SID cannot split the lock by changing their environment or config home.
55
+ */
56
+ export function windowsServiceMutationLockPath(): string {
57
+ const identity = resolveEffectiveUserIdentity();
58
+ if (identity.platform !== "win32") {
59
+ throw new Error("The Windows service mutation lock is only available on Windows.");
60
+ }
61
+ return join(resolveEffectiveUserRuntimeRoot(identity), "windows-service-mutation.sqlite");
62
+ }
63
+
64
+ function assertRegularPath(path: string, kind: "directory" | "file"): void {
65
+ const entry = lstatSync(path);
66
+ const valid = kind === "directory" ? entry.isDirectory() : entry.isFile();
67
+ if (!valid || entry.isSymbolicLink()) {
68
+ throw new Error(`The Windows service mutation lock ${kind} is not a regular ${kind}.`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Serialize one complete Windows service mutation across OpenCodex processes.
74
+ *
75
+ * The SQLite write transaction is the lock. It stays held across UAC and async verification,
76
+ * and the OS releases it if the process exits, so no stale lock file needs unsafe reclamation.
77
+ */
78
+ export async function withWindowsServiceMutationLock<T>(
79
+ operation: () => Promise<T>,
80
+ deps: WindowsServiceMutationLockDeps = {},
81
+ ): Promise<T> {
82
+ const lockPath = deps.lockPath ?? windowsServiceMutationLockPath();
83
+ const lockDir = dirname(lockPath);
84
+ let database: LockDatabase | undefined;
85
+ let acquired = false;
86
+
87
+ try {
88
+ mkdirSync(lockDir, { recursive: true, mode: 0o700 });
89
+ assertRegularPath(lockDir, "directory");
90
+ try { chmodSync(lockDir, 0o700); } catch { /* Windows ACL below is authoritative. */ }
91
+ (deps.hardenDirectory ?? (path => { hardenSecretDir(path, { required: true }); }))(lockDir);
92
+
93
+ try {
94
+ database = (deps.openDatabase ?? (path => new Database(path, { create: true })))(lockPath);
95
+ assertRegularPath(lockPath, "file");
96
+ try { chmodSync(lockPath, 0o600); } catch { /* Windows ACL below is authoritative. */ }
97
+ (deps.hardenFile ?? (path => { hardenSecretPath(path, { required: true }); }))(lockPath);
98
+ database.exec("PRAGMA locking_mode = NORMAL; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
99
+ acquired = true;
100
+ } catch (error) {
101
+ try { database?.close(); } catch { /* acquisition already failed */ }
102
+ database = undefined;
103
+ if (isBusy(error)) throw new WindowsServiceMutationBusyError();
104
+ throw new WindowsServiceMutationLockError("acquire", error);
105
+ }
106
+
107
+ let result: T;
108
+ let operationError: unknown;
109
+ try {
110
+ result = await operation();
111
+ } catch (error) {
112
+ operationError = error;
113
+ }
114
+
115
+ let releaseError: unknown;
116
+ if (acquired) {
117
+ try { database.exec("ROLLBACK"); } catch (error) { releaseError = error; }
118
+ }
119
+ try { database.close(); } catch (error) { releaseError ??= error; }
120
+ acquired = false;
121
+ database = undefined;
122
+
123
+ if (operationError !== undefined) throw operationError;
124
+ if (releaseError !== undefined) throw new WindowsServiceMutationLockError("release", releaseError);
125
+ return result!;
126
+ } catch (error) {
127
+ if (acquired) {
128
+ try { database?.exec("ROLLBACK"); } catch { /* close still releases the OS lock */ }
129
+ }
130
+ try { database?.close(); } catch { /* preserve the primary error */ }
131
+ throw error;
132
+ }
133
+ }
@@ -22,12 +22,20 @@
22
22
 
23
23
  import { existsSync } from "node:fs";
24
24
  import { win32 as windowsPath } from "node:path";
25
+ import { waitForSubprocessExit } from "./bounded-subprocess";
25
26
 
26
27
  import {
27
28
  resolveTrustedWindowsPowerShellExe,
28
29
  WindowsSystemDirectoryFfiUnavailableError,
29
30
  } from "./windows-elevation";
30
31
 
32
+ /**
33
+ * Shared ceiling for a full effective-token identity lookup. PowerShell startup can
34
+ * legitimately take several seconds on loaded desktops as well as CI, so every caller
35
+ * that is not spending a smaller pre-existing deadline uses the same #2914-tested budget.
36
+ */
37
+ export const WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS = 30_000;
38
+
31
39
  const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
32
40
  const IDENTITY_EXPRESSION =
33
41
  "$identity=[System.Security.Principal.WindowsIdentity]::GetCurrent();$identity.User.Value;$identity.Name";
@@ -143,18 +151,8 @@ async function defaultAsyncWindowsPrincipalRunner(
143
151
  stderr: "ignore",
144
152
  windowsHide: true,
145
153
  });
146
- let timedOut = false;
147
- const timer = setTimeout(() => {
148
- timedOut = true;
149
- try { proc.kill(); } catch { /* already exited */ }
150
- }, Math.max(1, timeoutMs));
151
- let exitCode: number | null = null;
152
- try {
153
- exitCode = await proc.exited;
154
- } finally {
155
- clearTimeout(timer);
156
- }
157
- const stdout = proc.stdout
154
+ const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs);
155
+ const stdout = !timedOut && proc.stdout
158
156
  ? await new Response(proc.stdout).text().catch(() => "")
159
157
  : "";
160
158
  return {
@@ -314,11 +312,11 @@ export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Pr
314
312
  return `*${cachedIdentity.sid}`;
315
313
  })();
316
314
  asyncLookupInFlight = lookup;
317
- try {
318
- return await lookup;
319
- } finally {
320
- if (asyncLookupInFlight === lookup) asyncLookupInFlight = null;
321
- }
315
+ void lookup.then(
316
+ () => { if (asyncLookupInFlight === lookup) asyncLookupInFlight = null; },
317
+ () => { if (asyncLookupInFlight === lookup) asyncLookupInFlight = null; },
318
+ );
319
+ return waitForExistingLookup(lookup, timeoutMs);
322
320
  }
323
321
 
324
322
  /** Test seam: replace the sync resolver process and clear its successful cache. */