@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
@@ -21,7 +21,12 @@
21
21
  import { spawnSync } from "node:child_process";
22
22
  import { existsSync, lstatSync, readFileSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
- import { join } from "node:path";
24
+ import { join, win32 as win32Path } from "node:path";
25
+ import {
26
+ resolveTrustedWindowsSchtasksExe,
27
+ resolveTrustedWindowsSystemDirectory,
28
+ } from "./lib/windows-elevation";
29
+ import { WINSW_SERVICE_ID } from "./lib/winsw";
25
30
 
26
31
  /** Short: this runs inside admission, and a slow answer is the same as none. */
27
32
  export const SERVICE_PROBE_TIMEOUT_MS = 2_000;
@@ -60,6 +65,18 @@ export interface ProbeRunner {
60
65
  };
61
66
  }
62
67
 
68
+ /**
69
+ * Windows probe runner: preserves schtasks stdout/stderr as raw bytes so the
70
+ * UTF-16LE task XML is not corrupted by a UTF-8 decode.
71
+ */
72
+ export type RawProbeRunner = (file: string, args: readonly string[]) => {
73
+ status: number | null;
74
+ stdout: Buffer;
75
+ stderr: Buffer;
76
+ timedOut: boolean;
77
+ spawnFailed: boolean;
78
+ };
79
+
63
80
  export const defaultProbeRunner: ProbeRunner = (file, args) => {
64
81
  const result = spawnSync(file, [...args], {
65
82
  encoding: "utf8",
@@ -76,11 +93,32 @@ export const defaultProbeRunner: ProbeRunner = (file, args) => {
76
93
  };
77
94
  };
78
95
 
96
+ export const defaultRawProbeRunner: RawProbeRunner = (file, args) => {
97
+ const result = spawnSync(file, [...args], {
98
+ encoding: "buffer",
99
+ windowsHide: true,
100
+ timeout: SERVICE_PROBE_TIMEOUT_MS,
101
+ });
102
+ return {
103
+ status: result.status,
104
+ stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0),
105
+ stderr: Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.alloc(0),
106
+ timedOut: result.signal !== null && result.error === undefined,
107
+ spawnFailed: result.error !== undefined,
108
+ };
109
+ };
110
+
79
111
  export interface ProbeDeps {
80
112
  readonly run?: ProbeRunner;
113
+ /** Raw-buffer runner for bounded Windows service-manager queries. */
114
+ readonly runRaw?: RawProbeRunner;
81
115
  readonly platform?: NodeJS.Platform;
82
116
  readonly uid?: number;
83
117
  readonly home?: string;
118
+ /** Effective OpenCodex config dir (OPENCODEX_HOME). Overrides `<home>/.opencodex`. */
119
+ readonly configDir?: string;
120
+ /** Test seam for WinSW SCM status. Production uses bounded trusted `sc.exe query`. */
121
+ readonly winswStatus?: () => "started" | "stopped" | "nonexistent" | "unknown";
84
122
  }
85
123
 
86
124
  const LABEL = "com.opencodex.proxy";
@@ -274,24 +312,496 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic
274
312
  }
275
313
 
276
314
  /**
277
- * Windows is deferred to its own phase and reports `unknown` until then.
315
+ * Windows: walk the scheduled-task definition chain and report the homes it names.
316
+ *
317
+ * The chain is not one file: the task XML names only the launcher, the launcher
318
+ * (VBS) names only the batch wrapper, and the homes live in the wrapper's
319
+ * `set "CODEX_HOME=..."` / `set "OPENCODEX_HOME=..."` lines. Parsing the XML
320
+ * and stopping would find no homes and read that as agreement, so the walk goes
321
+ * all the way to the wrapper.
322
+ *
323
+ * A `set` line is OMITTED by `buildWindowsServiceScript` when the value was
324
+ * unset at install time (windowsBatchSet returns null for empty values), so a
325
+ * missing home stays `null` — the same contract the launchd/systemd probes use —
326
+ * and a definition that names no homes cannot be mistaken for agreement.
278
327
  *
279
- * Not an oversight: the definition there is a chain, not a file. The task XML
280
- * names only the launcher, and the homes live in the batch wrapper it eventually
281
- * runs a probe that parsed the XML and stopped would find no homes and read
282
- * that as agreement. Reporting `unknown` refuses unattended convergence on
283
- * Windows, which is the safe direction while the chain walk is unwritten.
328
+ * Registration is answered by bounded `schtasks` queries so a definition staged
329
+ * on disk but never registered is still visible (the interrupted-install case).
330
+ * Every failure to ask is `unknown`, never absence.
284
331
  */
285
- function inspectWindows(): ServiceManagerInstallation {
286
- return unknown("the Windows definition chain is not inspected yet");
332
+ function windowsTaskName(): string {
333
+ return "opencodex-proxy";
334
+ }
335
+
336
+ function windowsConfigDirPath(deps: { home: string; configDir?: string }): string {
337
+ if (deps.configDir) return deps.configDir;
338
+ return join(deps.home, ".opencodex");
339
+ }
340
+
341
+ /** Decode an on-disk Windows text asset (task XML, VBS), which is UTF-16LE (often BOM-prefixed). */
342
+ function decodeWindowsText(buffer: Buffer): string {
343
+ if (buffer.length === 0) return "";
344
+ const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
345
+ const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff;
346
+ const looksUtf16Le = buffer.length >= 4
347
+ && buffer[1] === 0x00
348
+ && buffer[3] === 0x00
349
+ && buffer[0] !== 0x00;
350
+ if (bomUtf16Le || looksUtf16Le) {
351
+ return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim();
352
+ }
353
+ if (bomUtf16Be) {
354
+ const swapped = Buffer.alloc(buffer.length - 2);
355
+ for (let i = 2; i + 1 < buffer.length; i += 2) {
356
+ swapped[i - 2] = buffer[i + 1]!;
357
+ swapped[i - 1] = buffer[i]!;
358
+ }
359
+ return swapped.toString("utf16le").trim();
360
+ }
361
+ return buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
362
+ }
363
+
364
+ /** Decode the XML entities emitted by the service-definition writers. */
365
+ function decodeXmlEntities(value: string): string {
366
+ return value
367
+ .replace(/&quot;/g, '"')
368
+ .replace(/&lt;/g, "<")
369
+ .replace(/&gt;/g, ">")
370
+ .replace(/&apos;/g, "'")
371
+ .replace(/&amp;/g, "&");
372
+ }
373
+
374
+ /** Pull the launcher path out of the task XML `<Arguments>` element. */
375
+ function windowsTaskArguments(xml: string): string | null {
376
+ const match = /<Arguments[^>]*>\s*([^<]*?)\s*<\/Arguments>/i.exec(xml);
377
+ return match ? decodeXmlEntities(match[1]!.trim()) : null;
378
+ }
379
+
380
+ /**
381
+ * Pull the wrapper path out of a VBS `shell.Run` line.
382
+ *
383
+ * `buildWindowsLauncherVbs` escapes a `"` inside a VBS string literal by
384
+ * doubling it, so a wrapper `C:\...\opencodex-service.cmd` is emitted as
385
+ * `shell.Run """C:\...\opencodex-service.cmd""", 0, True`.
386
+ */
387
+ function vbsWrappedCommand(body: string): string | null {
388
+ const match = /\.Run\s+"""([^"]*)"""/.exec(body);
389
+ if (match) {
390
+ const unwrapped = match[1]!.trim();
391
+ if (unwrapped.length > 0) return unwrapped;
392
+ }
393
+ const plain = /\.Run\s+"([^"]+)"/.exec(body);
394
+ return plain ? plain[1]!.trim() : null;
395
+ }
396
+
397
+ /** Pull one `set "NAME=value"` out of a batch wrapper. */
398
+ function batchSetValue(body: string, name: string): string | null {
399
+ const match = new RegExp(`^\\s*set\\s+"${name}=([^"]*)"\\s*$`, "im").exec(body);
400
+ return match ? match[1]!.trim() : null;
401
+ }
402
+
403
+ /** Resolve generated batch env indirection before comparing homes. */
404
+ function decodeBatchPathValue(
405
+ value: string,
406
+ env: Record<string, string | undefined> = process.env,
407
+ ): string {
408
+ const escapedPercent = "\u0000";
409
+ const tokens: Record<string, string | undefined> = {
410
+ USERPROFILE: env.USERPROFILE,
411
+ APPDATA: env.APPDATA,
412
+ LOCALAPPDATA: env.LOCALAPPDATA,
413
+ SYSTEMROOT: env.SystemRoot,
414
+ };
415
+ return value
416
+ .replace(/%%/g, escapedPercent)
417
+ .replace(/%([A-Za-z][A-Za-z0-9_]*)%/g, (whole, name: string) => {
418
+ const resolved = tokens[name.toUpperCase()];
419
+ return resolved === undefined ? whole : resolved;
420
+ })
421
+ .replaceAll(escapedPercent, "%");
422
+ }
423
+
424
+ /** Validate the generated wrapper before interpreting omitted optional homes. */
425
+ function wrapperLooksGenerated(body: string): boolean {
426
+ return /:loop\s*[\s\S]*^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/im.test(body);
427
+ }
428
+
429
+ function normalizeWindowsPath(value: string): string {
430
+ return win32Path.normalize(value.replace(/\//g, "\\")).replace(/[\\]+$/, "").toLowerCase();
431
+ }
432
+
433
+ /** True only when a definition-provided path remains inside the effective OPENCODEX_HOME. */
434
+ function windowsPathInsideConfigDir(candidate: string, configDir: string): boolean {
435
+ const root = normalizeWindowsPath(configDir);
436
+ const path = normalizeWindowsPath(candidate);
437
+ const relative = win32Path.relative(root, path);
438
+ return relative === "" || (relative !== ".." && !relative.startsWith("..\\") && !win32Path.isAbsolute(relative));
439
+ }
440
+
441
+ /** Parse the first CSV field emitted by schtasks `/fo CSV`. */
442
+ function csvFirstField(line: string): string {
443
+ const trimmed = line.trim();
444
+ if (!trimmed.startsWith('"')) return (trimmed.split(",", 1)[0] ?? "").trim();
445
+ let value = "";
446
+ for (let i = 1; i < trimmed.length; i += 1) {
447
+ const ch = trimmed[i]!;
448
+ if (ch !== '"') {
449
+ value += ch;
450
+ continue;
451
+ }
452
+ if (trimmed[i + 1] === '"') {
453
+ value += '"';
454
+ i += 1;
455
+ continue;
456
+ }
457
+ break;
458
+ }
459
+ return value;
460
+ }
461
+
462
+ function windowsTaskListContains(body: string, taskName: string): boolean {
463
+ const target = taskName.toLowerCase();
464
+ return body.split(/\r?\n/).some(line => {
465
+ const field = csvFirstField(line).replace(/\//g, "\\").replace(/^\\+/, "");
466
+ return field.toLowerCase() === target;
467
+ });
468
+ }
469
+
470
+ /** English hosts provide a decisive fast path; other locales fall back to a full listing. */
471
+ const SCHTASKS_TASK_NOT_FOUND_EN = /cannot find the file specified/i;
472
+
473
+ /**
474
+ * Registration state of the scheduled task.
475
+ *
476
+ * The `/xml` query gives the authoritative registered definition. A nonzero
477
+ * result is locale-dependent. English's task-not-found message is decisive; all
478
+ * other nonzero responses use a bounded full listing as the locale-neutral
479
+ * fallback, and only a successful list without our task proves absence.
480
+ */
481
+ function probeWindowsTaskRegistration(deps: Required<Pick<ProbeDeps, "runRaw">>): {
482
+ registered: "present" | "absent" | "unknown";
483
+ registeredXml: string;
484
+ } {
485
+ let schtasks: string;
486
+ try {
487
+ schtasks = resolveTrustedWindowsSchtasksExe();
488
+ } catch {
489
+ return { registered: "unknown", registeredXml: "" };
490
+ }
491
+
492
+ const queried = deps.runRaw(schtasks, ["/query", "/tn", windowsTaskName(), "/xml"]);
493
+ if (queried.spawnFailed || queried.timedOut) return { registered: "unknown", registeredXml: "" };
494
+ if (queried.status === 0) {
495
+ const registeredXml = decodeWindowsText(queried.stdout) || decodeWindowsText(queried.stderr);
496
+ return registeredXml
497
+ ? { registered: "present", registeredXml }
498
+ : { registered: "unknown", registeredXml: "" };
499
+ }
500
+
501
+ const queryText = `${decodeWindowsText(queried.stdout)}\n${decodeWindowsText(queried.stderr)}`;
502
+ if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND_EN.test(queryText)) {
503
+ return { registered: "absent", registeredXml: "" };
504
+ }
505
+
506
+ const listed = deps.runRaw(schtasks, ["/query", "/fo", "CSV", "/nh"]);
507
+ if (listed.spawnFailed || listed.timedOut || listed.status !== 0) {
508
+ return { registered: "unknown", registeredXml: "" };
509
+ }
510
+ const listing = decodeWindowsText(listed.stdout) || decodeWindowsText(listed.stderr);
511
+ return windowsTaskListContains(listing, windowsTaskName())
512
+ ? { registered: "unknown", registeredXml: "" }
513
+ : { registered: "absent", registeredXml: "" };
514
+ }
515
+
516
+ type WinswRegistration = "present" | "absent" | "unknown";
517
+
518
+ /** Query WinSW registration through trusted System32 sc.exe; never execute the user-writable WinSW binary. */
519
+ function probeWinswRegistration(
520
+ deps: Required<Pick<ProbeDeps, "runRaw">> & Pick<ProbeDeps, "winswStatus">,
521
+ ): WinswRegistration {
522
+ if (deps.winswStatus) {
523
+ const injected = deps.winswStatus();
524
+ if (injected === "started" || injected === "stopped") return "present";
525
+ if (injected === "nonexistent") return "absent";
526
+ return "unknown";
527
+ }
528
+
529
+ let sc: string;
530
+ try {
531
+ sc = join(resolveTrustedWindowsSystemDirectory(), "sc.exe");
532
+ if (artifactPresence(sc) !== "present") return "unknown";
533
+ } catch {
534
+ return "unknown";
535
+ }
536
+
537
+ const queried = deps.runRaw(sc, ["query", WINSW_SERVICE_ID]);
538
+ if (queried.spawnFailed || queried.timedOut) return "unknown";
539
+ if (queried.status === 0) return "present";
540
+
541
+ // ERROR_SERVICE_DOES_NOT_EXIST (1060) is locale-invariant. Search raw byte
542
+ // text so localized OEM output cannot affect the numeric classification.
543
+ const text = `${queried.stdout.toString("latin1")}\n${queried.stderr.toString("latin1")}`;
544
+ return /\b1060\b/.test(text) ? "absent" : "unknown";
545
+ }
546
+
547
+ function inspectWindows(
548
+ deps: Required<Pick<ProbeDeps, "runRaw" | "home">> & Pick<ProbeDeps, "configDir" | "winswStatus">,
549
+ ): ServiceManagerInstallation {
550
+ const configDir = windowsConfigDirPath(deps);
551
+ const taskXmlPath = join(configDir, "opencodex-service-task.xml");
552
+ const task = artifactPresence(taskXmlPath);
553
+ const winsw = walkWinswChain(deps);
554
+ const winswInstalled = winsw.kind === "present" && winsw.claims[0].registration === "present";
555
+ const winswStaged = winsw.kind === "present" && winsw.claims[0].registration === "absent";
556
+
557
+ let xml = "";
558
+ if (task !== "absent") {
559
+ try {
560
+ xml = decodeWindowsText(readFileSync(taskXmlPath));
561
+ } catch (error) {
562
+ return unknown(`the scheduled-task XML exists but could not be read: ${String(error)}`);
563
+ }
564
+ }
565
+
566
+ const registration = probeWindowsTaskRegistration(deps);
567
+ if (registration.registered === "unknown") {
568
+ return unknown("Task Scheduler could not be asked whether opencodex-proxy is registered");
569
+ }
570
+ const schedulerRegistered = registration.registered === "present";
571
+
572
+ // Two live registrations are a conflict even if the staging copy of the task
573
+ // XML disappeared. The authoritative `/query /xml` definition is sufficient
574
+ // to walk the scheduler chain without inventing homes.
575
+ if (winswInstalled && schedulerRegistered) {
576
+ if (!registration.registeredXml.trim()) {
577
+ return unknown("Task Scheduler is registered but its definition XML could not be read");
578
+ }
579
+ const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath);
580
+ if (registeredWalk.kind !== "present") return registeredWalk;
581
+ return {
582
+ kind: "conflict",
583
+ claims: [
584
+ winsw.claims[0],
585
+ { ...registeredWalk.claims[0], registration: "present" },
586
+ ],
587
+ };
588
+ }
589
+
590
+ if (winswInstalled) {
591
+ // A staged scheduler definition beside a registered WinSW service is an
592
+ // interrupted backend switch, not proof that WinSW alone owns the machine.
593
+ if (task !== "absent") {
594
+ return unknown("a scheduled-task definition is staged while the native WinSW service is registered");
595
+ }
596
+ return winsw;
597
+ }
598
+
599
+ if (winsw.kind === "unknown") return winsw;
600
+
601
+ // Staged-but-unregistered WinSW remains evidence. If Scheduler is also live
602
+ // or staged, neither half-finished backend switch can be chosen unattended.
603
+ if (winswStaged && (schedulerRegistered || task !== "absent")) {
604
+ return unknown("native WinSW and Task Scheduler definitions overlap during an incomplete backend switch");
605
+ }
606
+ if (winswStaged && task === "absent" && registration.registered === "absent") {
607
+ return winsw;
608
+ }
609
+
610
+ if (task === "absent") {
611
+ return schedulerRegistered
612
+ ? unknown("Task Scheduler holds opencodex-proxy but its task XML is missing")
613
+ : { kind: "absent" };
614
+ }
615
+
616
+ const staged = walkWindowsChain(deps, xml, taskXmlPath);
617
+ if (staged.kind !== "present") return staged;
618
+ const stagedClaim = staged.claims[0];
619
+
620
+ if (schedulerRegistered) {
621
+ if (!registration.registeredXml.trim()) {
622
+ return unknown("Task Scheduler is registered but its definition XML could not be read");
623
+ }
624
+ const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath);
625
+ if (registeredWalk.kind !== "present") return registeredWalk;
626
+ const registeredClaim = registeredWalk.claims[0];
627
+ if (!homesEqual(registeredClaim.homes, stagedClaim.homes)) {
628
+ return unknown("the registered scheduled task names different homes than the staged task definition");
629
+ }
630
+ }
631
+
632
+ return {
633
+ kind: "present",
634
+ claims: [{
635
+ ...stagedClaim,
636
+ registration: schedulerRegistered ? "present" : "absent",
637
+ }],
638
+ };
639
+ }
640
+
641
+ /** Compare two home pairs with Windows path normalization (case, slashes, trailing separators). */
642
+ function homesEqual(
643
+ a: { codexHome: string | null; opencodexHome: string | null },
644
+ b: { codexHome: string | null; opencodexHome: string | null },
645
+ ): boolean {
646
+ const norm = (v: string | null): string | null => {
647
+ if (v === null) return null;
648
+ return v.replace(/[\\/]+$/, "").replace(/\//g, "\\").toLowerCase();
649
+ };
650
+ return norm(a.codexHome) === norm(b.codexHome) && norm(a.opencodexHome) === norm(b.opencodexHome);
651
+ }
652
+
653
+ /**
654
+ * Walk one scheduled-task definition (staged or registered XML) down to the
655
+ * generated batch wrapper and extract the homes it names. Definition-provided
656
+ * paths are followed only inside the effective OPENCODEX_HOME, preventing a
657
+ * foreign task from turning this ownership probe into an arbitrary local/UNC
658
+ * file read while preserving interrupted-reinstall diagnostics within the
659
+ * generated service-asset directory.
660
+ */
661
+ function walkWindowsChain(
662
+ deps: Required<Pick<ProbeDeps, "home">> & Pick<ProbeDeps, "configDir">,
663
+ xml: string,
664
+ definitionPath: string,
665
+ ): ServiceManagerInstallation {
666
+ const configDir = windowsConfigDirPath(deps);
667
+
668
+ const launcherArg = windowsTaskArguments(xml);
669
+ if (!launcherArg) {
670
+ return unknown("the scheduled-task XML names no launcher to run");
671
+ }
672
+ const launcherPath = /"([^"]+)"/.exec(launcherArg)?.[1];
673
+ if (!launcherPath) {
674
+ return unknown("the scheduled-task XML launcher argument is not a quoted path");
675
+ }
676
+ if (!windowsPathInsideConfigDir(launcherPath, configDir)) {
677
+ return unknown(`the scheduled-task XML names ${launcherPath}, outside the expected launcher directory ${configDir}`);
678
+ }
679
+
680
+ const launcher = artifactPresence(launcherPath);
681
+ if (launcher === "absent") {
682
+ return unknown(`the scheduled-task launcher is missing: ${launcherPath}`);
683
+ }
684
+ let launcherBody: string;
685
+ try {
686
+ launcherBody = decodeWindowsText(readFileSync(launcherPath));
687
+ } catch (error) {
688
+ return unknown(`the scheduled-task launcher could not be read: ${String(error)}`);
689
+ }
690
+
691
+ const wrapperPath = vbsWrappedCommand(launcherBody);
692
+ if (!wrapperPath) {
693
+ return unknown(`the launcher ${launcherPath} names no wrapper to run`);
694
+ }
695
+ if (!windowsPathInsideConfigDir(wrapperPath, configDir)) {
696
+ return unknown(`the scheduled-task launcher names ${wrapperPath}, outside the expected wrapper directory ${configDir}`);
697
+ }
698
+
699
+ const wrapper = artifactPresence(wrapperPath);
700
+ if (wrapper === "absent") {
701
+ return unknown(`the launcher wrapper is missing: ${wrapperPath}`);
702
+ }
703
+ let wrapperBody: string;
704
+ try {
705
+ wrapperBody = decodeWindowsText(readFileSync(wrapperPath));
706
+ } catch (error) {
707
+ return unknown(`the launcher wrapper could not be read: ${String(error)}`);
708
+ }
709
+
710
+ if (!wrapperLooksGenerated(wrapperBody)) {
711
+ return unknown(`the launcher wrapper does not look like a generated opencodex service wrapper: ${wrapperPath}`);
712
+ }
713
+
714
+ const rawCodexHome = batchSetValue(wrapperBody, "CODEX_HOME");
715
+ const rawOpencodexHome = batchSetValue(wrapperBody, "OPENCODEX_HOME");
716
+
717
+ return {
718
+ kind: "present",
719
+ claims: [{
720
+ backend: "scheduler",
721
+ definitionPath,
722
+ homes: {
723
+ codexHome: rawCodexHome === null ? null : decodeBatchPathValue(rawCodexHome),
724
+ opencodexHome: rawOpencodexHome === null ? null : decodeBatchPathValue(rawOpencodexHome),
725
+ },
726
+ registration: "absent",
727
+ }],
728
+ };
729
+ }
730
+
731
+ /**
732
+ * Walk the WinSW native-backend definition. SCM registration is queried via a
733
+ * trusted, bounded `sc.exe query`; the WinSW executable itself is never run by
734
+ * this read-only ownership probe.
735
+ */
736
+ function walkWinswChain(
737
+ deps: Required<Pick<ProbeDeps, "runRaw" | "home">> & Pick<ProbeDeps, "configDir" | "winswStatus">,
738
+ ): ServiceManagerInstallation {
739
+ const configDir = windowsConfigDirPath(deps);
740
+ const exePath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.exe`);
741
+ const xmlPath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.xml`);
742
+ const xml = artifactPresence(xmlPath);
743
+ const exe = artifactPresence(exePath);
744
+ const registration = probeWinswRegistration(deps);
745
+
746
+ if (xml === "absent" && exe === "absent" && registration === "absent") return { kind: "absent" };
747
+ if (registration === "unknown") {
748
+ return unknown("the native WinSW service registration could not be verified");
749
+ }
750
+ if (xml === "absent" || exe === "absent") {
751
+ return unknown("the native WinSW service registration could not be verified");
752
+ }
753
+
754
+ let body: string;
755
+ try {
756
+ body = decodeWindowsText(readFileSync(xmlPath));
757
+ } catch (error) {
758
+ return unknown(`the WinSW XML could not be read: ${String(error)}`);
759
+ }
760
+ if (!winswXmlLooksGenerated(body)) {
761
+ return unknown(`the WinSW XML does not look like a generated opencodex service definition: ${xmlPath}`);
762
+ }
763
+
764
+ const envValue = (name: string): string | null => {
765
+ const tag = new RegExp(`<env\\b[^>]*\\bname=["']${name}["'][^>]*>`, "i").exec(body);
766
+ if (!tag) return null;
767
+ const value = /value=(["'])(.*?)\1/i.exec(tag[0]);
768
+ return value ? decodeXmlEntities(value[2]!) : null;
769
+ };
770
+
771
+ return {
772
+ kind: "present",
773
+ claims: [{
774
+ backend: "winsw",
775
+ definitionPath: exePath,
776
+ homes: {
777
+ codexHome: envValue("CODEX_HOME"),
778
+ opencodexHome: envValue("OPENCODEX_HOME"),
779
+ },
780
+ registration,
781
+ }],
782
+ };
783
+ }
784
+
785
+ /** The generated WinSW XML embeds the SCM id and a `start --port` invocation. */
786
+ function winswXmlLooksGenerated(body: string): boolean {
787
+ return /<id>\s*opencodex-proxy-native\s*<\/id>/i.test(body)
788
+ && /<arguments>.*?start\s+--port\b/i.test(body);
287
789
  }
288
790
 
289
791
  export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): ServiceManagerInstallation {
290
792
  const platform = deps.platform ?? process.platform;
291
793
  const run = deps.run ?? defaultProbeRunner;
794
+ const runRaw = deps.runRaw ?? defaultRawProbeRunner;
292
795
  const home = deps.home ?? homedir();
293
796
  if (platform === "darwin") return inspectLaunchd({ run, uid: deps.uid ?? process.getuid?.() ?? 0, home });
294
797
  if (platform === "linux") return inspectSystemd({ run, home });
295
- if (platform === "win32") return inspectWindows();
798
+ if (platform === "win32") {
799
+ return inspectWindows({
800
+ runRaw,
801
+ home,
802
+ configDir: deps.configDir,
803
+ winswStatus: deps.winswStatus,
804
+ });
805
+ }
296
806
  return unknown(`no service manager probe for platform ${platform}`);
297
807
  }