@bitkyc08/opencodex 2.37.0 → 2.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/bin/ocx.mjs +69 -10
  2. package/gui/dist/assets/{index-CowztZdo.js → index-C14iCj_Q.js} +13 -13
  3. package/gui/dist/assets/index-D7PIz7_g.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/deepseek-harness.svg +3 -0
  7. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  8. package/gui/dist/provider-icons/openclaw.svg +54 -0
  9. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  10. package/gui/dist/provider-icons/zcode.svg +219 -0
  11. package/package.json +1 -1
  12. package/src/adapters/cursor/protobuf-request.ts +4 -1
  13. package/src/adapters/cursor/tool-definitions.ts +36 -4
  14. package/src/cli/capabilities.ts +14 -0
  15. package/src/cli/codex-cli-update.ts +96 -0
  16. package/src/cli/codex-shim-autorestore.ts +3 -0
  17. package/src/cli/export-command.ts +18 -17
  18. package/src/cli/help.ts +2 -2
  19. package/src/cli/index.ts +3 -2
  20. package/src/cli/launcher-context.ts +53 -2
  21. package/src/cli/opencode.ts +126 -33
  22. package/src/cli/registry.ts +16 -10
  23. package/src/cli/system-command.ts +6 -1
  24. package/src/clients/config-export.ts +293 -28
  25. package/src/codex/account-store.ts +10 -4
  26. package/src/codex/autostart-health.ts +3 -3
  27. package/src/codex/catalog/provider-fetch.ts +20 -1
  28. package/src/codex/catalog/sync.ts +4 -3
  29. package/src/codex/cli-install-provenance.ts +795 -0
  30. package/src/codex/convergence.ts +4 -3
  31. package/src/codex/credential-mutation-epoch.ts +11 -0
  32. package/src/codex/main-account.ts +2 -0
  33. package/src/codex/model-entitlements.ts +430 -27
  34. package/src/codex/native-profile-manager.ts +4 -0
  35. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  36. package/src/codex/reset-credit-recovery.ts +20 -2
  37. package/src/codex/shim.ts +204 -18
  38. package/src/codex/user-identity.ts +2 -1
  39. package/src/config/paths.ts +18 -3
  40. package/src/config.ts +23 -0
  41. package/src/generated/compatibility-version.json +81 -45
  42. package/src/integrations/registry.ts +112 -0
  43. package/src/integrations/state.ts +67 -5
  44. package/src/integrations/writer.ts +25 -9
  45. package/src/lib/bounded-subprocess.ts +36 -0
  46. package/src/lib/strict-semver.ts +47 -0
  47. package/src/lib/windows-elevation.ts +32 -1
  48. package/src/lib/windows-secret-acl.ts +47 -25
  49. package/src/lib/windows-service-mutation-lock.ts +133 -0
  50. package/src/lib/windows-user-principal.ts +15 -17
  51. package/src/responses/spill-store.ts +334 -29
  52. package/src/responses/state.ts +488 -7
  53. package/src/server/index.ts +4 -3
  54. package/src/server/lifecycle.ts +5 -1
  55. package/src/server/management/model-rows.ts +11 -2
  56. package/src/server/management/provider-routes.ts +4 -0
  57. package/src/server/management/system-restart.ts +5 -5
  58. package/src/server/management-api.ts +7 -2
  59. package/src/server/startup-action-control.ts +3 -2
  60. package/src/service.ts +594 -33
  61. package/src/sidecar/candidates.ts +1 -1
  62. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  63. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  64. package/src/update/index.ts +3 -2
  65. package/src/update/job.ts +10 -11
  66. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -28,7 +28,7 @@ import {
28
28
  semanticProtectedContributionFingerprint,
29
29
  } from "./ownership-policy";
30
30
  import { createdContainerPaths, mergeContribution, removeFragments } from "./merge";
31
- import { INTEGRATION_CLIENTS, isLoopbackOnly, type IntegrationClientId } from "./registry";
31
+ import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry";
32
32
  import { classifyIntegration, exportContextOf } from "./state";
33
33
  import type { IntegrationState } from "./state";
34
34
  import { serializeDocument, UnserializableValueError } from "./serialize";
@@ -210,8 +210,20 @@ function preflight(input: IntegrationWriteInput) {
210
210
  * whole Integrations page because one client is misconfigured.
211
211
  */
212
212
  let configPath: string;
213
+ let detectDir: string;
213
214
  try {
214
- configPath = input.resolvedPaths?.configPath ?? spec.configPath(input.env, input.home);
215
+ /*
216
+ * Resolve the PAIR, never one half.
217
+ *
218
+ * The coordinated path hands us a frozen pair, but applyIntegration,
219
+ * refreshIntegration and disableIntegration are public and may be called
220
+ * without one. Resolving configPath here and detectDir separately later let
221
+ * an Aside account switch land between the two, so a direct apply could
222
+ * verify account 1 was installed and then write account 0's catalog.
223
+ */
224
+ const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
225
+ configPath = resolved.configPath;
226
+ detectDir = resolved.detectDir;
215
227
  } catch (error) {
216
228
  if (!(error instanceof ClientPathError)) throw error;
217
229
  return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const;
@@ -248,15 +260,17 @@ function preflight(input: IntegrationWriteInput) {
248
260
  const classified = classifyIntegration({
249
261
  fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
250
262
  });
251
- return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } as const;
263
+ return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const;
252
264
  }
253
265
 
254
266
  function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: boolean): WriteOutcome {
255
267
  const pre = preflight(input);
256
268
  if (pre.failed) return pre.failed;
257
- const { store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } = pre;
269
+ const { store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
258
270
 
259
- if (io.statKind(input.resolvedPaths?.detectDir ?? spec.detectDir(input.env, input.home)) !== "dir") {
271
+ // The detect directory preflight already resolved, so it cannot name a
272
+ // different account than the config path this operation is about to write.
273
+ if (io.statKind(detectDir) !== "dir") {
260
274
  return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`);
261
275
  }
262
276
  if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) {
@@ -624,10 +638,12 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
624
638
  const store = input.store ?? createIntegrationStateStore();
625
639
  const io = input.io ?? defaultIntegrationIO(store);
626
640
  const spec = INTEGRATION_CLIENTS[input.clientId];
627
- const resolvedPaths = {
628
- configPath: spec.configPath(env, home),
629
- detectDir: spec.detectDir(env, home),
630
- };
641
+ /*
642
+ * One resolution for both paths. Aside derives them from the account id in
643
+ * its manifest, so two independent calls could verify one account's install
644
+ * and then write another account's catalog if a switch landed between them.
645
+ */
646
+ const resolvedPaths = resolveIntegrationPaths(input.clientId, env, home);
631
647
  return { ...input, env, home, store, io, resolvedPaths };
632
648
  }
633
649
 
@@ -0,0 +1,36 @@
1
+ export interface KillableSubprocess {
2
+ exited: Promise<number>;
3
+ kill(): unknown;
4
+ unref?(): unknown;
5
+ }
6
+
7
+ export interface BoundedSubprocessExit {
8
+ exitCode: number | null;
9
+ timedOut: boolean;
10
+ }
11
+
12
+ /** Kill at the deadline and abandon immediately; late exit/rejection remains observed. */
13
+ export function waitForSubprocessExit(
14
+ proc: KillableSubprocess,
15
+ timeoutMs: number,
16
+ ): Promise<BoundedSubprocessExit> {
17
+ return new Promise(resolve => {
18
+ let settled = false;
19
+ let timer: ReturnType<typeof setTimeout> | undefined;
20
+ const finish = (result: BoundedSubprocessExit): void => {
21
+ if (settled) return;
22
+ settled = true;
23
+ if (timer !== undefined) clearTimeout(timer);
24
+ resolve(result);
25
+ };
26
+ timer = setTimeout(() => {
27
+ try { proc.kill(); } catch { /* already exited */ }
28
+ try { proc.unref?.(); } catch { /* abandonment is still authoritative */ }
29
+ finish({ exitCode: null, timedOut: true });
30
+ }, Math.max(1, timeoutMs));
31
+ void proc.exited.then(
32
+ exitCode => finish({ exitCode, timedOut: false }),
33
+ () => finish({ exitCode: null, timedOut: false }),
34
+ );
35
+ });
36
+ }
@@ -0,0 +1,47 @@
1
+ // Core and build metadata are unambiguous and stay inline. The prerelease section does not:
2
+ // the semver.org pattern for one identifier is
3
+ // 0 | [1-9]\d* | [0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*
4
+ // whose three alternatives overlap, and wrapping that in `(?:\.…)*` gives a regex engine an
5
+ // exponential number of ways to split the same string. CodeQL flagged it (`js/redos`) and the
6
+ // cost is real, not theoretical: `0.0.0-0.` followed by repetitions of `--.` took **522ms for a
7
+ // single 125-character input** — inside the 128-char ceiling this module already enforced, and
8
+ // inside the 96-char one its only caller uses. A length cap does not fix superlinear blowup; it
9
+ // only decides where the curve is sampled.
10
+ //
11
+ // So the prerelease section is matched with one non-backtracking pass and its identifiers are
12
+ // validated individually. Each identifier is checked by an anchored regex with no repetition of
13
+ // an alternation, which is linear in the identifier's length.
14
+ const STRICT_SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
15
+
16
+ const NUMERIC_IDENTIFIER_RE = /^(?:0|[1-9]\d*)$/;
17
+ const ALPHANUMERIC_IDENTIFIER_RE = /^[0-9A-Za-z-]+$/;
18
+
19
+ /**
20
+ * A prerelease identifier is either a numeric identifier with no leading zero, or an
21
+ * alphanumeric one that contains at least one non-digit. Empty identifiers are invalid,
22
+ * which is what rejects a trailing or doubled dot.
23
+ */
24
+ function isPrereleaseIdentifier(part: string): boolean {
25
+ if (part.length === 0) return false;
26
+ if (NUMERIC_IDENTIFIER_RE.test(part)) return true;
27
+ return ALPHANUMERIC_IDENTIFIER_RE.test(part) && !/^\d+$/.test(part);
28
+ }
29
+
30
+ export interface StrictSemver {
31
+ readonly raw: string;
32
+ readonly core: readonly [bigint, bigint, bigint];
33
+ readonly prerelease: readonly (bigint | string)[];
34
+ }
35
+
36
+ export function parseStrictSemver(value: unknown, maxLength = 128): StrictSemver | null {
37
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return null;
38
+ const match = STRICT_SEMVER_RE.exec(value);
39
+ if (!match) return null;
40
+ const prereleaseParts = match[4] === undefined ? [] : match[4].split(".");
41
+ if (!prereleaseParts.every(isPrereleaseIdentifier)) return null;
42
+ return Object.freeze({
43
+ raw: value,
44
+ core: Object.freeze([BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!)]) as readonly [bigint, bigint, bigint],
45
+ prerelease: Object.freeze(prereleaseParts.map(part => /^\d+$/.test(part) ? BigInt(part) : part)),
46
+ });
47
+ }
@@ -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. */