@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -31,11 +31,20 @@
31
31
 
32
32
  import { existsSync, statSync } from "node:fs";
33
33
  import { env, platform } from "node:process";
34
+ import {
35
+ resolveCurrentWindowsPrincipal,
36
+ resolveCurrentWindowsPrincipalAsync,
37
+ setSyntheticWindowsPrincipalForTests,
38
+ } from "./windows-user-principal";
34
39
 
35
40
  const hardenedDirectories = new Map<string, HardenedIdentity>();
36
41
  const hardenedPaths = new Map<string, HardenedIdentity>();
37
- /** Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them. */
38
- const timedOutPaths = new Set<string>();
42
+ /**
43
+ * Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them.
44
+ * `false` means one explicitly authorized recovery attempt remains; `true` means
45
+ * that attempt was consumed. Ordinary callers never consume it.
46
+ */
47
+ const timedOutPaths = new Map<string, boolean>();
39
48
 
40
49
  /**
41
50
  * The memo value: `object:freshness` for a file a harden was actually attributed
@@ -212,6 +221,12 @@ export interface HardenOptions {
212
221
  * Must NOT be a parent directory — directory ACLs are not authoritative for new files.
213
222
  */
214
223
  timeoutMemoKey?: string;
224
+ /**
225
+ * Consume the one recovery attempt for a previously timed-out memo key.
226
+ * Only a caller that owns its own single-flight and bounded retry policy should
227
+ * set this. It never clears or bypasses an already-consumed timeout memo.
228
+ */
229
+ retryTimedOutOnce?: boolean;
215
230
  }
216
231
 
217
232
  /**
@@ -219,9 +234,23 @@ export interface HardenOptions {
219
234
  * timeout retry and the diagnostic verification pass (no per-attempt fresh budget:
220
235
  * loadConfig hardens dir+config+auth sequentially, so per-attempt budgets stack
221
236
  * into multi-minute startup stalls). Override with OPENCODEX_ACL_TIMEOUT_MS
222
- * (integer ms, clamped to [1000, 60000]; invalid values fall back to 5000).
237
+ * (integer ms, clamped to [1000, 60000]; invalid values fall back to 30000).
238
+ *
239
+ * The default was 5s until #1156. One envelope has to cover the whole sequence —
240
+ * `/grant:r`, `/inheritance:r`, `/remove:g`, plus the conditional `/findsid`
241
+ * verification — and on machines where icacls is slow (Defender real-time scanning,
242
+ * roaming profiles, a domain-controller round trip) 5s ran out mid-sequence. The
243
+ * harden then failed closed, the native-main owner published a permanent
244
+ * `unavailable`, and every native request returned 503 until restart. A slow start
245
+ * is recoverable; that is not.
246
+ *
247
+ * The cost is honest and worth stating: because loadConfig hardens three paths
248
+ * sequentially, the timeout-path worst case at load is ~90s, and the owner path
249
+ * (initial call + one recovery) is ~60.25s. Both require icacls to be
250
+ * pathologically slow on every call; a healthy machine finishes in milliseconds
251
+ * and sees no change. Operators who prefer the old bound can set the env override.
223
252
  */
224
- const HARDEN_DEADLINE_DEFAULT_MS = 5_000;
253
+ const HARDEN_DEADLINE_DEFAULT_MS = 30_000;
225
254
  const HARDEN_DEADLINE_MIN_MS = 1_000;
226
255
  const HARDEN_DEADLINE_MAX_MS = 60_000;
227
256
 
@@ -312,9 +341,21 @@ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null):
312
341
  asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner;
313
342
  }
314
343
 
315
- /** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */
344
+ /**
345
+ * Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner.
346
+ *
347
+ * Faking win32 on a host without System32 also has to supply a principal, or
348
+ * every forced-branch test would fail on the identity lookup instead of
349
+ * exercising icacls. The synthetic value is registered with the resolver, not
350
+ * chosen here, so a test that injects its own runner still wins.
351
+ */
352
+ const SYNTHETIC_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001";
353
+
316
354
  export function setPlatformForTests(value: string | null): void {
317
355
  platformOverride = value;
356
+ setSyntheticWindowsPrincipalForTests(
357
+ value === "win32" && platform !== "win32" ? SYNTHETIC_TEST_PRINCIPAL : null,
358
+ );
318
359
  }
319
360
 
320
361
  /** Test seam: injectable clock for deadline tests (no real sleeps). */
@@ -384,17 +425,26 @@ function icaclsError(step: string, result: IcaclsResult): NodeJS.ErrnoException
384
425
  }
385
426
 
386
427
  /**
387
- * Return the current Windows username from the environment.
388
- * Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous.
389
- * The value is used directly in icacls arguments, so it must be present.
428
+ * The ACL principal is the effective token SID and nothing else.
429
+ *
430
+ * There is no name-shaped fallback here, and that absence is the fix for #1149
431
+ * rather than an omission. `USERDOMAIN\USERNAME` has the right shape but is not
432
+ * evidence of the current token's subject, and both variables are writable by
433
+ * the process that launched us. Granting Full Control to a wrong principal and
434
+ * then running `/inheritance:r` is destructive in both directions: another
435
+ * account can be left holding the secret, or the file can be left with no ACE
436
+ * the current user can use. When the SID cannot be resolved we decline.
437
+ *
438
+ * Non-Windows hosts that force this branch through `setPlatformForTests` get
439
+ * their principal from `setSyntheticWindowsPrincipalForTests`, which lives with
440
+ * the resolver so an injected runner can still take precedence over it.
390
441
  */
391
- function currentWindowsUser(): string | undefined {
392
- const username = env["USERNAME"];
393
- const domain = env["USERDOMAIN"];
394
- if (!username) return undefined;
395
- // USERDOMAIN is the machine/domain name; USERNAME is the account name.
396
- // icacls accepts "DOMAIN\User" or just "User" for local accounts.
397
- return domain ? `${domain}\\${username}` : username;
442
+ function currentWindowsPrincipal(deadline: number): string {
443
+ return resolveCurrentWindowsPrincipal(deadline - nowFn());
444
+ }
445
+
446
+ async function currentWindowsPrincipalAsync(deadline: number): Promise<string> {
447
+ return resolveCurrentWindowsPrincipalAsync(deadline - nowFn());
398
448
  }
399
449
 
400
450
  /**
@@ -414,10 +464,7 @@ function grantAce(user: string, directory: boolean): string {
414
464
  }
415
465
 
416
466
  function runIcacls(targetPath: string, directory: boolean, deadline: number): void {
417
- const user = currentWindowsUser();
418
- if (!user) {
419
- throw new Error("Cannot determine current Windows user for ACL hardening");
420
- }
467
+ const principal = currentWindowsPrincipal(deadline);
421
468
 
422
469
  // The deadline is owned by hardenEntry (total budget incl. retry + verification).
423
470
  const run = (step: string, args: string[]): IcaclsResult => {
@@ -434,7 +481,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
434
481
 
435
482
  // Step 1: grant current user full control BEFORE any destructive ACL change.
436
483
  // If this fails, inheritance is untouched and the writer keeps inherited access.
437
- runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]);
484
+ runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]);
438
485
 
439
486
  // Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE
440
487
  // from step 1 survives this transition, so a later failure still leaves cleanup access.
@@ -463,10 +510,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
463
510
 
464
511
  /** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */
465
512
  async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise<void> {
466
- const user = currentWindowsUser();
467
- if (!user) {
468
- throw new Error("Cannot determine current Windows user for ACL hardening");
469
- }
513
+ const principal = await currentWindowsPrincipalAsync(deadline);
470
514
 
471
515
  const run = async (step: string, args: string[]): Promise<IcaclsResult> => {
472
516
  const remaining = deadline - nowFn();
@@ -480,7 +524,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline:
480
524
  if (!result.success) throw icaclsError(step, result);
481
525
  };
482
526
 
483
- await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]);
527
+ await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]);
484
528
  await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]);
485
529
 
486
530
  const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]);
@@ -514,6 +558,8 @@ function sanitizeDiagnostics(error: unknown): string {
514
558
  return `ACL hardening failed (${code}) — permission denied running icacls`;
515
559
  case "EICACLS":
516
560
  return "ACL hardening failed (EICACLS) — icacls command error; filesystem may not support per-user NTFS ACLs";
561
+ case "EACLIDENTITY":
562
+ return "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved";
517
563
  default:
518
564
  return `ACL hardening failed${code ? ` (${code})` : ""} — filesystem may not support per-user NTFS ACLs`;
519
565
  }
@@ -524,6 +570,60 @@ function isTimeoutError(error: unknown): boolean {
524
570
  && String((error as NodeJS.ErrnoException).code) === "ETIMEDOUT";
525
571
  }
526
572
 
573
+ /** Preserve only the bounded machine-readable cause on a sanitized public error. */
574
+ function sanitizedAclError(diagnostics: string, cause: unknown): NodeJS.ErrnoException {
575
+ const error = new Error(diagnostics) as NodeJS.ErrnoException;
576
+ const code = cause && typeof cause === "object" && "code" in cause
577
+ ? String((cause as { code?: unknown }).code)
578
+ : "";
579
+ // EACLIDENTITY belongs here for the same reason as the rest: a caller that
580
+ // catches a required-mode failure has to tell "the SID could not be resolved"
581
+ // apart from "icacls stalled". Without it the code was dropped and only the
582
+ // message carried the cause, which no caller can branch on.
583
+ if (
584
+ code === "ETIMEDOUT" ||
585
+ code === "EICACLS" ||
586
+ code === "EACCES" ||
587
+ code === "EPERM" ||
588
+ code === "EACLIDENTITY"
589
+ ) {
590
+ error.code = code;
591
+ }
592
+ return error;
593
+ }
594
+
595
+ function previousTimeoutError(retryConsumed: boolean): NodeJS.ErrnoException {
596
+ if (retryConsumed) {
597
+ const error = new Error(
598
+ "ACL hardening skipped — the previous timeout recovery was already consumed",
599
+ ) as NodeJS.ErrnoException;
600
+ error.code = "EACLRETRYEXHAUSTED";
601
+ return error;
602
+ }
603
+ return sanitizedAclError(
604
+ "ACL hardening skipped — previous attempt timed out",
605
+ Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }),
606
+ );
607
+ }
608
+
609
+ /** Consume, but never reset, the single explicit recovery attempt for this key. */
610
+ function timeoutMemoErrorIfBlocked(
611
+ memoKey: string,
612
+ opts: HardenOptions,
613
+ ): NodeJS.ErrnoException | null {
614
+ const retryConsumed = timedOutPaths.get(memoKey);
615
+ if (retryConsumed === undefined) return null;
616
+ if (opts.retryTimedOutOnce && retryConsumed === false) {
617
+ timedOutPaths.set(memoKey, true);
618
+ return null;
619
+ }
620
+ return previousTimeoutError(retryConsumed);
621
+ }
622
+
623
+ function recordTimeout(memoKey: string): void {
624
+ if (!timedOutPaths.has(memoKey)) timedOutPaths.set(memoKey, false);
625
+ }
626
+
527
627
  /**
528
628
  * Diagnostic-only post-timeout probe (never promotes to ok:true — a clean /findsid
529
629
  * does not prove inheritance was disabled or the user grant ran; only a fully
@@ -588,10 +688,10 @@ function hardenEntry(
588
688
  if (effectivePlatform() !== "win32") return { ok: true };
589
689
  if (memoSatisfied(cache, targetPath)) return { ok: true };
590
690
  const memoKey = timeoutMemoKey(targetPath, opts);
591
- if (timedOutPaths.has(memoKey)) {
592
- const diagnostics = "ACL hardening skipped — previous attempt timed out";
593
- if (opts.required) throw new Error(diagnostics);
594
- return { ok: false, diagnostics };
691
+ const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
692
+ if (timeoutMemoError) {
693
+ if (opts.required) throw timeoutMemoError;
694
+ return { ok: false, diagnostics: timeoutMemoError.message };
595
695
  }
596
696
 
597
697
  const deadline = nowFn() + resolveHardenDeadlineMs();
@@ -606,6 +706,7 @@ function hardenEntry(
606
706
  if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
607
707
  return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
608
708
  }
709
+ timedOutPaths.delete(memoKey);
609
710
  return { ok: true };
610
711
  } catch (err) {
611
712
  // A substitution is not a transient icacls stall; do not spend the retry on it.
@@ -617,14 +718,14 @@ function hardenEntry(
617
718
 
618
719
  const diagnostics = sanitizeDiagnostics(lastErr);
619
720
  if (isTimeoutError(lastErr)) {
620
- timedOutPaths.add(memoKey);
721
+ recordTimeout(memoKey);
621
722
  const state = describeAclStateAfterTimeout(targetPath, deadline);
622
723
  const annotated = `${diagnostics}; ${state}`;
623
- if (opts.required) throw new Error(annotated);
724
+ if (opts.required) throw sanitizedAclError(annotated, lastErr);
624
725
  console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
625
726
  return { ok: false, diagnostics: annotated };
626
727
  }
627
- if (opts.required) throw new Error(diagnostics);
728
+ if (opts.required) throw sanitizedAclError(diagnostics, lastErr);
628
729
  return { ok: false, diagnostics };
629
730
  }
630
731
 
@@ -639,10 +740,10 @@ async function hardenEntryAsync(
639
740
  if (effectivePlatform() !== "win32") return { ok: true };
640
741
  if (memoSatisfied(cache, targetPath)) return { ok: true };
641
742
  const memoKey = timeoutMemoKey(targetPath, opts);
642
- if (timedOutPaths.has(memoKey)) {
643
- const diagnostics = "ACL hardening skipped — previous attempt timed out";
644
- if (opts.required) throw new Error(diagnostics);
645
- return { ok: false, diagnostics };
743
+ const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
744
+ if (timeoutMemoError) {
745
+ if (opts.required) throw timeoutMemoError;
746
+ return { ok: false, diagnostics: timeoutMemoError.message };
646
747
  }
647
748
 
648
749
  const deadline = nowFn() + resolveHardenDeadlineMs();
@@ -656,6 +757,7 @@ async function hardenEntryAsync(
656
757
  if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
657
758
  return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
658
759
  }
760
+ timedOutPaths.delete(memoKey);
659
761
  return { ok: true };
660
762
  } catch (err) {
661
763
  if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err;
@@ -666,14 +768,14 @@ async function hardenEntryAsync(
666
768
 
667
769
  const diagnostics = sanitizeDiagnostics(lastErr);
668
770
  if (isTimeoutError(lastErr)) {
669
- timedOutPaths.add(memoKey);
771
+ recordTimeout(memoKey);
670
772
  const state = await describeAclStateAfterTimeoutAsync(targetPath, deadline);
671
773
  const annotated = `${diagnostics}; ${state}`;
672
- if (opts.required) throw new Error(annotated);
774
+ if (opts.required) throw sanitizedAclError(annotated, lastErr);
673
775
  console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
674
776
  return { ok: false, diagnostics: annotated };
675
777
  }
676
- if (opts.required) throw new Error(diagnostics);
778
+ if (opts.required) throw sanitizedAclError(diagnostics, lastErr);
677
779
  return { ok: false, diagnostics };
678
780
  }
679
781
 
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Resolve the effective Windows token to the locale-independent SID form that
3
+ * icacls accepts ("*S-1-..."). Environment values such as USERDOMAIN are not
4
+ * an authority for the current token: on workgroup machines USERDOMAIN may be
5
+ * the literal WORKGROUP even though the account belongs to the local computer.
6
+ *
7
+ * There is deliberately NO name-shaped fallback. A `DOMAIN\User` string has a
8
+ * valid shape, but shape is not evidence that the account is the current
9
+ * token's subject, and both environment variables are writable by whatever
10
+ * launched us. A wrong principal here is not cosmetic: `runIcacls` grants it
11
+ * Full Control and then removes inheritance, so a wrong grant either leaves a
12
+ * different account holding the secret or strands the file with no usable ACE.
13
+ * When the SID cannot be resolved, the caller declines to touch the ACL at all.
14
+ *
15
+ * Budget caveat: callers pass their REMAINING harden budget, which becomes the
16
+ * child process timeout. Trusted-executable resolution (a `GetSystemDirectoryW`
17
+ * FFI call) and spawn setup happen before that timeout starts, and the async
18
+ * timer only arms once `Bun.spawn` returns. Both are small in practice, but the
19
+ * lookup is not bounded by the deadline to the microsecond. Tightening that
20
+ * would mean passing an absolute deadline through the runner interface.
21
+ */
22
+
23
+ import { resolveTrustedWindowsPowerShellExe } from "./windows-elevation";
24
+
25
+ const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
26
+ const SID_EXPRESSION =
27
+ "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value";
28
+
29
+ export interface WindowsPrincipalLookupResult {
30
+ success: boolean;
31
+ exitCode: number | null;
32
+ timedOut: boolean;
33
+ stdout: string;
34
+ }
35
+
36
+ export type WindowsPrincipalRunner = (
37
+ timeoutMs: number,
38
+ ) => WindowsPrincipalLookupResult;
39
+
40
+ export type AsyncWindowsPrincipalRunner = (
41
+ timeoutMs: number,
42
+ ) => Promise<WindowsPrincipalLookupResult>;
43
+
44
+ const POWERSHELL_ARGS = [
45
+ "-NoLogo",
46
+ "-NoProfile",
47
+ "-NonInteractive",
48
+ "-WindowStyle",
49
+ "Hidden",
50
+ "-Command",
51
+ SID_EXPRESSION,
52
+ ] as const;
53
+
54
+ function windowsPrincipalPowerShellCommand(): string[] {
55
+ return [resolveTrustedWindowsPowerShellExe(), ...POWERSHELL_ARGS];
56
+ }
57
+
58
+ /** Test-only readback of the exact trusted executable and static arguments. */
59
+ export function windowsPrincipalPowerShellCommandForTests(): string[] {
60
+ return windowsPrincipalPowerShellCommand();
61
+ }
62
+
63
+ function defaultWindowsPrincipalRunner(timeoutMs: number): WindowsPrincipalLookupResult {
64
+ const result = Bun.spawnSync(windowsPrincipalPowerShellCommand(), {
65
+ stdin: "ignore",
66
+ stdout: "pipe",
67
+ stderr: "ignore",
68
+ timeout: Math.max(1, timeoutMs),
69
+ windowsHide: true,
70
+ });
71
+ return {
72
+ success: result.success,
73
+ exitCode: result.exitCode,
74
+ timedOut: result.exitedDueToTimeout ?? false,
75
+ stdout: result.stdout ? result.stdout.toString() : "",
76
+ };
77
+ }
78
+
79
+ async function defaultAsyncWindowsPrincipalRunner(
80
+ timeoutMs: number,
81
+ ): Promise<WindowsPrincipalLookupResult> {
82
+ const proc = Bun.spawn(windowsPrincipalPowerShellCommand(), {
83
+ stdin: "ignore",
84
+ stdout: "pipe",
85
+ stderr: "ignore",
86
+ windowsHide: true,
87
+ });
88
+ let timedOut = false;
89
+ const timer = setTimeout(() => {
90
+ timedOut = true;
91
+ try { proc.kill(); } catch { /* already exited */ }
92
+ }, Math.max(1, timeoutMs));
93
+ let exitCode: number | null = null;
94
+ try {
95
+ exitCode = await proc.exited;
96
+ } finally {
97
+ clearTimeout(timer);
98
+ }
99
+ const stdout = proc.stdout
100
+ ? await new Response(proc.stdout).text().catch(() => "")
101
+ : "";
102
+ return {
103
+ success: !timedOut && exitCode === 0,
104
+ exitCode: timedOut ? null : exitCode,
105
+ timedOut,
106
+ stdout,
107
+ };
108
+ }
109
+
110
+ let principalRunner: WindowsPrincipalRunner = defaultWindowsPrincipalRunner;
111
+ let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrincipalRunner;
112
+ let cachedPrincipal: string | null = null;
113
+ let asyncLookupInFlight: Promise<string> | null = null;
114
+
115
+ /**
116
+ * POSIX CI drives the Windows ACL branch through `setPlatformForTests("win32")`,
117
+ * on hosts that have neither System32 nor PowerShell. Those runs need SOME
118
+ * principal, so this seam supplies a synthetic one.
119
+ *
120
+ * It lives here rather than in `windows-secret-acl.ts` for one reason that is
121
+ * not cosmetic: an explicitly injected runner must be able to beat it. When the
122
+ * synthetic value was chosen first, in the ACL module, a test could not inject a
123
+ * lookup FAILURE on POSIX at all — so the fail-closed and memo-isolation cases
124
+ * were guarded with `if (process.platform !== "win32") return;` and never ran
125
+ * outside Windows. Resolution order below is what makes those cases executable
126
+ * on every runner.
127
+ */
128
+ let syntheticPrincipalForTests: string | null = null;
129
+
130
+ /**
131
+ * Test seam: supply the principal used when no runner was injected and the host
132
+ * is not really Windows. Pass null to disable.
133
+ */
134
+ export function setSyntheticWindowsPrincipalForTests(principal: string | null): void {
135
+ syntheticPrincipalForTests = principal;
136
+ cachedPrincipal = null;
137
+ }
138
+
139
+ /** True when an explicit runner override is installed and must take precedence. */
140
+ function hasSyncRunnerOverride(): boolean {
141
+ return principalRunner !== defaultWindowsPrincipalRunner;
142
+ }
143
+
144
+ function hasAsyncRunnerOverride(): boolean {
145
+ return asyncPrincipalRunner !== defaultAsyncWindowsPrincipalRunner;
146
+ }
147
+
148
+ function identityError(reason: string): NodeJS.ErrnoException {
149
+ const error = new Error(`Windows effective-account SID lookup ${reason}`) as NodeJS.ErrnoException;
150
+ // Keep identity lookup failures distinct from icacls timeouts. In particular,
151
+ // they must not populate windows-secret-acl's destination timeout memo.
152
+ error.code = "EACLIDENTITY";
153
+ return error;
154
+ }
155
+
156
+ function principalFromResult(result: WindowsPrincipalLookupResult): string {
157
+ if (!result.success) {
158
+ throw identityError(result.timedOut
159
+ ? "timed out"
160
+ : `exited ${result.exitCode ?? "null"}`);
161
+ }
162
+ const sid = result.stdout.trim();
163
+ if (!SID_PATTERN.test(sid)) {
164
+ throw identityError(sid ? "returned an invalid SID" : "returned an empty SID");
165
+ }
166
+ return `*${sid.toUpperCase()}`;
167
+ }
168
+
169
+ /** Resolve and process-cache the effective token SID for synchronous ACL paths. */
170
+ export function resolveCurrentWindowsPrincipal(timeoutMs: number): string {
171
+ // Order matters: an explicitly injected runner outranks the synthetic value,
172
+ // so a test can inject a FAILURE on a POSIX host. See the seam comment above.
173
+ if (hasSyncRunnerOverride()) {
174
+ if (cachedPrincipal) return cachedPrincipal;
175
+ if (timeoutMs <= 0) throw identityError("had no remaining deadline");
176
+ let overridden: WindowsPrincipalLookupResult;
177
+ try {
178
+ overridden = principalRunner(timeoutMs);
179
+ } catch {
180
+ throw identityError("could not start");
181
+ }
182
+ const principal = principalFromResult(overridden);
183
+ cachedPrincipal = principal;
184
+ return principal;
185
+ }
186
+ if (cachedPrincipal) return cachedPrincipal;
187
+ if (syntheticPrincipalForTests) return syntheticPrincipalForTests;
188
+ if (timeoutMs <= 0) throw identityError("had no remaining deadline");
189
+ let result: WindowsPrincipalLookupResult;
190
+ try {
191
+ result = principalRunner(timeoutMs);
192
+ } catch {
193
+ throw identityError("could not start");
194
+ }
195
+ const principal = principalFromResult(result);
196
+ cachedPrincipal = principal;
197
+ return principal;
198
+ }
199
+
200
+ async function waitForExistingLookup(
201
+ lookup: Promise<string>,
202
+ timeoutMs: number,
203
+ ): Promise<string> {
204
+ if (timeoutMs <= 0) throw identityError("had no remaining deadline");
205
+ let timer: ReturnType<typeof setTimeout> | undefined;
206
+ try {
207
+ return await Promise.race([
208
+ lookup,
209
+ new Promise<never>((_, reject) => {
210
+ timer = setTimeout(
211
+ () => reject(identityError("timed out while awaiting the shared lookup")),
212
+ Math.max(1, timeoutMs),
213
+ );
214
+ }),
215
+ ]);
216
+ } finally {
217
+ if (timer) clearTimeout(timer);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Async counterpart. Concurrent callers share one owned child lookup; a later
223
+ * caller may exhaust its own budget without cancelling the lookup owned by the
224
+ * first caller. The first caller owns that child and its process timeout; a
225
+ * later, longer budget deliberately does not extend an already-running child.
226
+ */
227
+ export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Promise<string> {
228
+ const overridden = hasAsyncRunnerOverride();
229
+ if (cachedPrincipal) return cachedPrincipal;
230
+ if (asyncLookupInFlight) return waitForExistingLookup(asyncLookupInFlight, timeoutMs);
231
+ // Same precedence rule as the sync path: an injected runner beats the synthetic.
232
+ if (!overridden && syntheticPrincipalForTests) return syntheticPrincipalForTests;
233
+ if (timeoutMs <= 0) throw identityError("had no remaining deadline");
234
+
235
+ const lookup = (async (): Promise<string> => {
236
+ let result: WindowsPrincipalLookupResult;
237
+ try {
238
+ result = await asyncPrincipalRunner(timeoutMs);
239
+ } catch {
240
+ throw identityError("could not start");
241
+ }
242
+ const principal = principalFromResult(result);
243
+ cachedPrincipal = principal;
244
+ return principal;
245
+ })();
246
+ asyncLookupInFlight = lookup;
247
+ try {
248
+ return await lookup;
249
+ } finally {
250
+ if (asyncLookupInFlight === lookup) asyncLookupInFlight = null;
251
+ }
252
+ }
253
+
254
+ /** Test seam: replace the sync resolver process and clear its successful cache. */
255
+ export function setWindowsPrincipalRunnerForTests(
256
+ runner: WindowsPrincipalRunner | null,
257
+ ): void {
258
+ if (asyncLookupInFlight) {
259
+ throw new Error("Cannot replace the Windows principal runner while a lookup is in flight.");
260
+ }
261
+ principalRunner = runner ?? defaultWindowsPrincipalRunner;
262
+ cachedPrincipal = null;
263
+ }
264
+
265
+ /** Test seam: replace the async resolver process and clear its successful cache. */
266
+ export function setAsyncWindowsPrincipalRunnerForTests(
267
+ runner: AsyncWindowsPrincipalRunner | null,
268
+ ): void {
269
+ if (asyncLookupInFlight) {
270
+ throw new Error("Cannot replace the Windows principal runner while a lookup is in flight.");
271
+ }
272
+ asyncPrincipalRunner = runner ?? defaultAsyncWindowsPrincipalRunner;
273
+ cachedPrincipal = null;
274
+ }
275
+
276
+ /** Test seam: clear only process-local principal state. */
277
+ export function resetWindowsPrincipalForTests(): void {
278
+ if (asyncLookupInFlight) {
279
+ throw new Error("Cannot reset the Windows principal while a lookup is in flight.");
280
+ }
281
+ cachedPrincipal = null;
282
+ syntheticPrincipalForTests = null;
283
+ }
package/src/lib/winsw.ts CHANGED
@@ -328,7 +328,23 @@ export async function installWinswService(entry: WinswEntry, deps: WinswInstallD
328
328
  }
329
329
 
330
330
  export function startWinswService(): void { runWinsw(["start"]); }
331
- export function stopWinswService(): void { try { runWinsw(["stopwait"]); } catch { /* not running */ } }
331
+
332
+ /**
333
+ * Stop the native service and prove it is no longer running. `stopwait` can fail both
334
+ * for the benign already-stopped case and for real access/timeout failures, so a bare
335
+ * catch cannot decide whether it is safe for lifecycle callers to continue. Re-read
336
+ * SCM state and only accept the two states that cannot still own the proxy listener.
337
+ */
338
+ export function stopWinswService(): void {
339
+ try { runWinsw(["stopwait"]); } catch { /* classify by verified state below */ }
340
+ const status = statusWinswRaw();
341
+ if (status === "stopped" || status === "nonexistent") return;
342
+ if (status === "unknown") {
343
+ throw new Error("Native service stop could not be verified.");
344
+ }
345
+ throw new Error("Native service is still running after stop.");
346
+ }
347
+
332
348
  export function uninstallWinswService(): void {
333
349
  if (!existsSync(winswExePath())) {
334
350
  // The binary is gone but the SCM registration can outlive it (quarantine, partial
@@ -378,4 +394,4 @@ export function winswStatusSummary(): string {
378
394
  export function defaultWinswEntry(cliDir: string): WinswEntry {
379
395
  const runtime = durableBunRuntime();
380
396
  return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(cliDir, "cli", "index.ts") };
381
- }
397
+ }
@@ -18,9 +18,21 @@ export const KEY_LOGIN_PROVIDERS: Record<string, KeyLoginProvider> = deriveKeyLo
18
18
  * `noReasoningModels`, `defaultModel`) onto a provider config being created, for any field the
19
19
  * caller didn't already supply. Lets the vision/reasoning classification actually reach the saved
20
20
  * config (the GUI/API only send adapter/baseUrl/apiKey/defaultModel). No-op for unknown names.
21
+ *
22
+ * `modelSupportsReasoningSummaries` is deliberately excluded from what gets persisted. It is
23
+ * registry-only metadata resolved at runtime, and this function feeds a config that is about to
24
+ * be written to disk. Persisting today's registry defaults would freeze them as the user's own
25
+ * overrides: a later registry correction — say we learn a model's backend rejects summary
26
+ * delivery — would never reach anyone who created their provider before the correction, and they
27
+ * would keep getting upstream 400s with no way to know why. Catalog gathering enriches a
28
+ * detached runtime clone, so the defaults still apply where they matter.
21
29
  */
22
30
  export function enrichProviderFromCatalog(name: string, prov: OcxProviderConfig): void {
31
+ const hadOwnSummaries = Object.hasOwn(prov, "modelSupportsReasoningSummaries");
32
+ const submittedSummaries = prov.modelSupportsReasoningSummaries;
23
33
  enrichProviderFromRegistry(name, prov);
34
+ if (hadOwnSummaries) prov.modelSupportsReasoningSummaries = submittedSummaries;
35
+ else delete prov.modelSupportsReasoningSummaries;
24
36
  }
25
37
 
26
38
  export function isKeyLoginProvider(name: string): boolean {