@bitkyc08/opencodex 2.36.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 (70) hide show
  1. package/bin/ocx.mjs +69 -10
  2. package/gui/dist/assets/index-C14iCj_Q.js +112 -0
  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/adapters/kiro-constants.ts +36 -2
  15. package/src/adapters/kiro.ts +13 -2
  16. package/src/cli/capabilities.ts +14 -0
  17. package/src/cli/claude.ts +12 -0
  18. package/src/cli/codex-cli-update.ts +96 -0
  19. package/src/cli/codex-shim-autorestore.ts +3 -0
  20. package/src/cli/export-command.ts +18 -17
  21. package/src/cli/help.ts +2 -2
  22. package/src/cli/index.ts +3 -2
  23. package/src/cli/launcher-context.ts +53 -2
  24. package/src/cli/opencode.ts +126 -33
  25. package/src/cli/registry.ts +16 -10
  26. package/src/cli/system-command.ts +6 -1
  27. package/src/clients/config-export.ts +293 -28
  28. package/src/codex/account-store.ts +10 -4
  29. package/src/codex/autostart-health.ts +3 -3
  30. package/src/codex/catalog/provider-fetch.ts +20 -1
  31. package/src/codex/catalog/sync.ts +4 -3
  32. package/src/codex/cli-install-provenance.ts +795 -0
  33. package/src/codex/convergence.ts +4 -3
  34. package/src/codex/credential-mutation-epoch.ts +11 -0
  35. package/src/codex/main-account.ts +2 -0
  36. package/src/codex/model-entitlements.ts +489 -30
  37. package/src/codex/native-profile-manager.ts +4 -0
  38. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  39. package/src/codex/reset-credit-recovery.ts +20 -2
  40. package/src/codex/shim.ts +204 -18
  41. package/src/codex/user-identity.ts +2 -1
  42. package/src/config/paths.ts +18 -3
  43. package/src/config.ts +23 -0
  44. package/src/generated/compatibility-version.json +84 -48
  45. package/src/integrations/registry.ts +112 -0
  46. package/src/integrations/state.ts +67 -5
  47. package/src/integrations/writer.ts +25 -9
  48. package/src/lib/bounded-subprocess.ts +36 -0
  49. package/src/lib/strict-semver.ts +47 -0
  50. package/src/lib/windows-elevation.ts +32 -1
  51. package/src/lib/windows-secret-acl.ts +47 -25
  52. package/src/lib/windows-service-mutation-lock.ts +133 -0
  53. package/src/lib/windows-user-principal.ts +15 -17
  54. package/src/responses/spill-store.ts +334 -29
  55. package/src/responses/state.ts +488 -7
  56. package/src/server/index.ts +4 -3
  57. package/src/server/lifecycle.ts +5 -1
  58. package/src/server/management/model-rows.ts +11 -2
  59. package/src/server/management/provider-routes.ts +4 -0
  60. package/src/server/management/system-restart.ts +5 -5
  61. package/src/server/management-api.ts +7 -2
  62. package/src/server/startup-action-control.ts +3 -2
  63. package/src/service.ts +594 -33
  64. package/src/sidecar/candidates.ts +1 -1
  65. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  66. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  67. package/src/update/index.ts +3 -2
  68. package/src/update/job.ts +10 -11
  69. package/gui/dist/assets/index-Cy7Z_pl0.css +0 -1
  70. package/gui/dist/assets/index-DO8liQVL.js +0 -112
@@ -19,7 +19,12 @@ import {
19
19
  semanticProtectedContributionFingerprint,
20
20
  validRefreshablePaths,
21
21
  } from "./ownership-policy";
22
- import { INTEGRATION_CLIENTS, type IntegrationClientId } from "./registry";
22
+ import {
23
+ INTEGRATION_CLIENTS,
24
+ resolveIntegrationPaths,
25
+ unresolvedPathHintFor,
26
+ type IntegrationClientId,
27
+ } from "./registry";
23
28
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
24
29
 
25
30
  export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe";
@@ -238,6 +243,42 @@ export function classifyIntegration(input: {
238
243
  return { state: "unsafe", reason: "blocked-container" };
239
244
  }
240
245
  if (!hasOurFragments(input.parsed, input.contribution)) return { state: "absent" };
246
+
247
+ /*
248
+ * Fragments the desired contribution carries beyond the paths this record names. Both
249
+ * states appear whenever a client gains a second owned block:
250
+ *
251
+ * - occupied by a value we did not write -> refuse. A refresh merges the WHOLE
252
+ * contribution, so without this check applying would replace a block the user wrote
253
+ * themselves and report success.
254
+ * - empty -> our own block is missing, because the record predates it. Report drift so
255
+ * a refresh adds it. Without this the file reads `current` forever and the second
256
+ * block never arrives, which is exactly what an older installation hits on upgrade.
257
+ *
258
+ * A byte-identical value is ours in substance: adopt it instead of dead-ending a
259
+ * hand-merged config on a conflict the user can only resolve by deleting our own block.
260
+ */
261
+ const recordedPaths = new Set((input.record?.fragmentPaths ?? []).map(path => path.join("\u0000")));
262
+ let addedPathMissing = false;
263
+ for (const fragment of input.contribution.fragments) {
264
+ if (recordedPaths.has(fragment.path.join("\u0000"))) continue;
265
+ const observed = readPath(input.parsed, fragment.path);
266
+ if (observed === undefined) {
267
+ addedPathMissing = true;
268
+ continue;
269
+ }
270
+ const one = (value: unknown): string => fingerprint(canonicalContribution({
271
+ clientId: (input.clientId ?? input.record?.clientId) as IntegrationClientId,
272
+ fragments: [{ path: fragment.path, value }],
273
+ }));
274
+ if (one(observed) !== one(fragment.value)) return { state: "conflict", reason: "unowned-key" };
275
+ }
276
+ /*
277
+ * No record: whatever occupies our paths is not ours to touch. A byte-identical value
278
+ * would be ours in substance, but `stale` without a record is not actionable — the writer
279
+ * reads `createdContainers` off the record to decide what it may prune, so adopting a
280
+ * hand-merged block needs an apply path that creates one first. Refuse, exactly as before.
281
+ */
241
282
  if (!input.record) return { state: "conflict", reason: "unowned-key" };
242
283
  /*
243
284
  * A record proves ownership of ONE file. Change HOME, XDG_CONFIG_HOME,
@@ -286,6 +327,12 @@ export function classifyIntegration(input: {
286
327
  }
287
328
  return { state: "stale" };
288
329
  }
330
+ /*
331
+ * Checked after everything else that could refuse: an owned fragment that no longer
332
+ * matches, or a sibling edit in a format that cannot be rewritten safely, still wins.
333
+ * What is left is a block we own on paper and are merely missing on disk.
334
+ */
335
+ if (addedPathMissing) return { state: "stale" };
289
336
  const desiredFingerprint = typeof input.record.semanticBlockFingerprint === "string"
290
337
  ? fingerprint(semanticContribution(input.contribution))
291
338
  : fingerprint(canonicalContribution(input.contribution));
@@ -382,15 +429,30 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
382
429
  let configPath: string;
383
430
  let installed: boolean;
384
431
  try {
385
- configPath = spec.configPath(input.env, input.home);
386
- installed = io.statKind(spec.detectDir(input.env, input.home)) === "dir";
432
+ // One resolution for both, so a client whose paths come from mutable state
433
+ // cannot report one account's install beside another account's config path.
434
+ const paths = resolveIntegrationPaths(input.clientId, input.env, input.home);
435
+ configPath = paths.configPath;
436
+ installed = io.statKind(paths.detectDir) === "dir";
387
437
  } catch (error) {
388
438
  if (!(error instanceof ClientPathError)) throw error;
439
+ /*
440
+ * Two different situations reach here and they are not the same answer.
441
+ *
442
+ * A relative `OPENCLAW_CONFIG_PATH` is a misconfiguration: there is nothing
443
+ * to name, and "cannot verify" is correct. Aside's absent account manifest
444
+ * is the ORDINARY state of an Aside that has been installed and never
445
+ * signed into, and answering that with a red danger badge and an empty path
446
+ * told the user their config was suspect when in fact there is no account
447
+ * yet. A client that can name where its config would go gets `installed:
448
+ * false` and that location, which reads as "not installed" in the UI.
449
+ */
450
+ const hint = unresolvedPathHintFor(input.clientId, input.env, input.home);
389
451
  return {
390
452
  clientId: input.clientId,
391
- state: "unsafe",
453
+ state: hint ? "absent" : "unsafe",
392
454
  installed: false,
393
- configPath: "",
455
+ configPath: hint,
394
456
  reason: "unresolvable-path",
395
457
  ...retention,
396
458
  };
@@ -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
+ }