@bitkyc08/opencodex 2.7.43 → 2.8.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 (73) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  3. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/discovery.ts +4 -1
  7. package/src/adapters/cursor/effort-map.ts +3 -0
  8. package/src/adapters/kiro.ts +15 -1
  9. package/src/claude/alias.ts +94 -14
  10. package/src/claude/outbound.ts +6 -3
  11. package/src/cli/catalog-prewarm.ts +24 -0
  12. package/src/cli/claude.ts +32 -7
  13. package/src/cli/doctor.ts +48 -1
  14. package/src/cli/index.ts +5 -0
  15. package/src/cli/interactive-confirm.ts +5 -1
  16. package/src/cli/star-prompt.ts +26 -4
  17. package/src/cli/v2.ts +10 -1
  18. package/src/codex/account-store.ts +2 -0
  19. package/src/codex/catalog/bundled.ts +9 -2
  20. package/src/codex/catalog/parsing.ts +26 -1
  21. package/src/codex/catalog/provider-fetch.ts +240 -82
  22. package/src/codex/catalog/sync.ts +27 -5
  23. package/src/codex/catalog.ts +1 -1
  24. package/src/codex/features.ts +524 -5
  25. package/src/codex/quota.ts +77 -2
  26. package/src/codex/runtime.ts +10 -1
  27. package/src/config.ts +8 -0
  28. package/src/generated/jawcode-model-metadata.ts +12 -12
  29. package/src/github/star-state.ts +191 -0
  30. package/src/lib/bun-binary-validator.d.mts +3 -0
  31. package/src/lib/bun-binary-validator.mjs +18 -0
  32. package/src/lib/bun-runtime.ts +6 -20
  33. package/src/lib/destination-policy.ts +10 -3
  34. package/src/lib/provider-outbound.ts +5 -2
  35. package/src/lib/shadow-call.ts +30 -0
  36. package/src/lib/test-home-guard.ts +90 -0
  37. package/src/lib/win-exec.ts +12 -2
  38. package/src/oauth/index.ts +29 -5
  39. package/src/oauth/key-providers.ts +21 -2
  40. package/src/oauth/kiro-credentials.ts +57 -8
  41. package/src/oauth/kiro.ts +2 -1
  42. package/src/oauth/login-cli.ts +1 -1
  43. package/src/oauth/store.ts +2 -0
  44. package/src/providers/derive.ts +2 -2
  45. package/src/providers/model-discovery.ts +356 -0
  46. package/src/providers/registry.ts +114 -0
  47. package/src/router.ts +5 -3
  48. package/src/server/auth-cors.ts +4 -2
  49. package/src/server/live.ts +75 -25
  50. package/src/server/management/agent-settings-routes.ts +78 -4
  51. package/src/server/management/config-routes.ts +19 -7
  52. package/src/server/management/context.ts +11 -1
  53. package/src/server/management/model-routes.ts +46 -13
  54. package/src/server/management/provider-routes.ts +44 -9
  55. package/src/server/management/shared.ts +2 -2
  56. package/src/server/management/sidebar-routes.ts +39 -0
  57. package/src/server/management-api.ts +3 -1
  58. package/src/server/responses/core.ts +31 -20
  59. package/src/server/responses/upstream-error.ts +48 -0
  60. package/src/server/startup-action-control.ts +30 -14
  61. package/src/service.ts +237 -19
  62. package/src/storage/policy-job.ts +26 -5
  63. package/src/storage/restore-job.ts +16 -5
  64. package/src/storage/worker-lifecycle.ts +81 -0
  65. package/src/tray/windows.ts +32 -4
  66. package/src/types.ts +11 -0
  67. package/src/update/badge.ts +72 -0
  68. package/src/update/job.ts +8 -4
  69. package/src/usage/expected-prices.ts +6 -5
  70. package/src/usage/log.ts +8 -0
  71. package/src/web-search/loop.ts +57 -16
  72. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  73. package/gui/dist/assets/index-cmds12BG.js +0 -67
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Upstream connection failures share one message shape across the three catch sites in
3
+ * core.ts. A TLS certificate/hostname mismatch deserves its own wording: the generic
4
+ * "Provider unreachable" reads as if opencodex built a wrong endpoint, which sent issue
5
+ * #553 looking for an adapter URL bug that does not exist. Name the likely cause and the
6
+ * command that settles it.
7
+ */
8
+ export function describeUpstreamConnectFailure(err: unknown, connectMs: number): string {
9
+ if (err instanceof Error && err.name === "TimeoutError") {
10
+ return `Provider connect timeout after ${connectMs}ms`;
11
+ }
12
+ const detail = err instanceof Error ? err.message : String(err);
13
+ const code = err instanceof Error ? (err as { code?: unknown }).code : undefined;
14
+ // `code` is the reliable signal. The message fallback is anchored to the head because Bun
15
+ // renders this rejection as `ERR_TLS_CERT_ALTNAME_INVALID fetching "<url>"`; matching the
16
+ // bare substring anywhere would also fire on text that merely quotes the code back at us.
17
+ // Only transport failures reach these call sites, so that is defensive rather than load-bearing.
18
+ if (code === "ERR_TLS_CERT_ALTNAME_INVALID" || detail.startsWith("ERR_TLS_CERT_ALTNAME_INVALID")) {
19
+ const host = extractHostname(detail);
20
+ const target = host ?? "the provider host";
21
+ const probe = host ?? "<host>";
22
+ return `Provider TLS certificate does not match ${target}: ${redactUrlUserinfo(detail)}. `
23
+ + "opencodex did not rewrite this hostname — a certificate that does not cover it normally "
24
+ + "means TLS interception (corporate proxy, VPN, or local MITM tooling) or a poisoned DNS "
25
+ + `answer. Check with: openssl s_client -connect ${probe}:443 -servername ${probe} `
26
+ + "</dev/null | openssl x509 -noout -subject -ext subjectAltName";
27
+ }
28
+ return `Provider unreachable: ${redactUrlUserinfo(detail)}`;
29
+ }
30
+
31
+ /**
32
+ * A provider base URL may carry credentials as userinfo (`https://user:token@host/`), and the
33
+ * runtime error echoes the URL it was fetching. Strip it before the message reaches a client
34
+ * or a log line.
35
+ */
36
+ function redactUrlUserinfo(detail: string): string {
37
+ return detail.replace(/(https?:\/\/)[^/\s"'@]*@/g, "$1<redacted>@");
38
+ }
39
+
40
+ function extractHostname(detail: string): string | null {
41
+ const match = detail.match(/https?:\/\/([^/\s"']+)/);
42
+ if (!match?.[1]) return null;
43
+ try {
44
+ return new URL(`https://${match[1]}`).hostname || null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
@@ -106,10 +106,14 @@ export function resetStartupInstallStateForTests(): void {
106
106
  installState = { status: "idle" };
107
107
  }
108
108
 
109
- export function startupInstallArgv(action: StartupInstallAction): string[] {
110
- return action === "install-service"
111
- ? ["service", "install"]
112
- : ["codex-shim", "install"];
109
+ export function startupInstallArgv(
110
+ action: StartupInstallAction,
111
+ options?: { repair?: boolean },
112
+ ): string[] {
113
+ if (action === "install-service") {
114
+ return options?.repair ? ["service", "repair"] : ["service", "install"];
115
+ }
116
+ return ["codex-shim", "install"];
113
117
  }
114
118
 
115
119
  export interface CliInstallFailure {
@@ -152,10 +156,13 @@ export function installFailureDetail(stdout: string, stderr: string, error: Erro
152
156
  return classifyCliInstallFailure(stdout, stderr, error).detail;
153
157
  }
154
158
 
155
- function runCliInstall(action: StartupInstallAction): Promise<{ stdout: string; stderr: string }> {
159
+ function runCliInstall(
160
+ action: StartupInstallAction,
161
+ options?: { repair?: boolean },
162
+ ): Promise<{ stdout: string; stderr: string }> {
156
163
  const bun = durableBunPath();
157
164
  const cli = join(import.meta.dir, "..", "cli", "index.ts");
158
- const argv = [cli, ...startupInstallArgv(action)];
165
+ const argv = [cli, ...startupInstallArgv(action, options)];
159
166
  return new Promise((resolve, reject) => {
160
167
  execFile(bun, argv, {
161
168
  encoding: "utf8",
@@ -219,29 +226,37 @@ function applyReconciliationOutcome(
219
226
  /**
220
227
  * Execute the existing fixed CLI installer outside the proxy event loop.
221
228
  *
229
+ * Repair mode (`options.repair`) runs `ocx service repair` — asset rewrite + restart
230
+ * without Task Scheduler re-registration, so it must not enter the UAC elevation path.
231
+ *
222
232
  * After an elevation request timeout the lock becomes `indeterminate` until the
223
233
  * original elevated transaction completes and is reconciled. A process restart
224
234
  * clears this in-memory lock — callers must then inspect Task Scheduler reality
225
235
  * (see evaluateSchedulerInstallRestartReconciliation) before installing again.
226
236
  */
227
- export function runStartupInstallAction(action: StartupInstallAction): Promise<{ message: string }> {
237
+ export function runStartupInstallAction(
238
+ action: StartupInstallAction,
239
+ options?: { repair?: boolean },
240
+ ): Promise<{ message: string }> {
228
241
  const busy = rejectIfBusy(action);
229
242
  if (busy) return Promise.reject(busy);
230
243
 
244
+ const repair = options?.repair === true;
231
245
  const attemptId = randomUUID();
232
246
  const startedAt = Date.now();
233
247
  installState = { status: "running", action, attemptId, startedAt };
234
248
 
235
249
  const operation = (async () => {
236
250
  try {
237
- await runCliInstall(action);
251
+ await runCliInstall(action, { repair });
238
252
  } catch (error) {
239
253
  const code = installFailureCode(error);
240
254
  const detail = error instanceof Error ? error.message : String(error);
241
- // Elevate only for a structured Task Scheduler /create access denial — never for
242
- // WinSW removal, asset writes, or generic permission errors.
255
+ // Elevate only for fresh install + structured Task Scheduler /create access denial —
256
+ // never for repair, WinSW removal, asset writes, or generic permission errors.
243
257
  if (
244
- action === "install-service"
258
+ !repair
259
+ && action === "install-service"
245
260
  && process.platform === "win32"
246
261
  && (code === WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER
247
262
  || isWindowsSchtasksCreateAccessDenied(detail))
@@ -276,10 +291,11 @@ export function runStartupInstallAction(action: StartupInstallAction): Promise<{
276
291
  throw error;
277
292
  }
278
293
  }
294
+ if (action === "install-service") {
295
+ return { message: repair ? "Background service repaired." : "Background service installed." };
296
+ }
279
297
  return {
280
- message: action === "install-service"
281
- ? "Background service installed."
282
- : "Codex launcher shim installed.",
298
+ message: repair ? "Codex launcher shim repaired." : "Codex launcher shim installed.",
283
299
  };
284
300
  })();
285
301
 
package/src/service.ts CHANGED
@@ -17,6 +17,7 @@ import { isWslRuntime } from "./codex/home";
17
17
  import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
18
18
  import { isProcessAlive, stopProxy } from "./lib/process-control";
19
19
  import { serviceApiTokenFilePath } from "./lib/service-secrets";
20
+ import { findLiveProxy } from "./server/proxy-liveness";
20
21
  import { randomUUID } from "node:crypto";
21
22
  import {
22
23
  ELEVATION_REQUEST_TIMEOUT_MS,
@@ -35,6 +36,7 @@ import { defaultWinswEntry, installWinswService, startWinswService, stopWinswSer
35
36
  import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
36
37
  import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
37
38
  import { recordOwnedConfigPath } from "./lib/config-ownership";
39
+ import { maybeShowStarPrompt } from "./cli/star-prompt";
38
40
 
39
41
  const LABEL = "com.opencodex.proxy";
40
42
  const TASK = "opencodex-proxy";
@@ -354,8 +356,41 @@ function sh(cmd: string): string {
354
356
  return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
355
357
  }
356
358
 
359
+ /**
360
+ * Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
361
+ * registered task document is UTF-16; reading that as UTF-8 makes every health check
362
+ * fail ("registration present but unhealthy") and rolls back a successful elevated create.
363
+ */
364
+ export function decodeSchtasksOutput(buffer: Buffer): string {
365
+ if (buffer.length === 0) return "";
366
+ const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
367
+ const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff;
368
+ const looksUtf16Le = buffer.length >= 4
369
+ && buffer[1] === 0x00
370
+ && buffer[3] === 0x00
371
+ && buffer[0] !== 0x00;
372
+ if (bomUtf16Le || looksUtf16Le) {
373
+ return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim();
374
+ }
375
+ if (bomUtf16Be) {
376
+ // Swap pairs then decode as utf16le.
377
+ const swapped = Buffer.alloc(buffer.length - 2);
378
+ for (let i = 2; i + 1 < buffer.length; i += 2) {
379
+ swapped[i - 2] = buffer[i + 1]!;
380
+ swapped[i - 1] = buffer[i]!;
381
+ }
382
+ return swapped.toString("utf16le").trim();
383
+ }
384
+ return buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
385
+ }
386
+
357
387
  function runFile(file: string, args: string[]): string {
358
- return execFileSync(file, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
388
+ const buffer = execFileSync(file, args, {
389
+ encoding: "buffer",
390
+ stdio: ["ignore", "pipe", "pipe"],
391
+ windowsHide: true,
392
+ }) as Buffer;
393
+ return decodeSchtasksOutput(buffer);
359
394
  }
360
395
 
361
396
  function windowsSchtasks(): string {
@@ -393,6 +428,70 @@ export type WindowsSchedulerTaskProbe =
393
428
  | { status: "absent" }
394
429
  | { status: "unknown"; detail: string };
395
430
 
431
+ export type WindowsSchedulerProxyProbe =
432
+ | { status: "running"; port: number }
433
+ | { status: "not-running" }
434
+ | { status: "unknown" };
435
+
436
+ /**
437
+ * Render Task Scheduler status without exposing localized `schtasks` table output.
438
+ * The task probe answers installation state; the identity-checked health probe answers
439
+ * runtime state. Keep probe details out of this user-facing line because they can contain
440
+ * incorrectly decoded, locale-specific command output.
441
+ */
442
+ export function formatWindowsSchedulerServiceStatus(
443
+ task: WindowsSchedulerTaskProbe,
444
+ proxy: WindowsSchedulerProxyProbe,
445
+ ): string {
446
+ if (task.status === "present") {
447
+ if (proxy.status === "running") {
448
+ return `✅ service installed (Task Scheduler); OpenCodex proxy running on port ${proxy.port}.`;
449
+ }
450
+ if (proxy.status === "not-running") {
451
+ return "⚠️ service installed (Task Scheduler); OpenCodex proxy not running.";
452
+ }
453
+ return "⚠️ service installed (Task Scheduler); OpenCodex proxy status unknown.";
454
+ }
455
+ if (task.status === "absent") {
456
+ if (proxy.status === "running") {
457
+ return `❌ service not installed (Task Scheduler); OpenCodex proxy is running independently on port ${proxy.port}.`;
458
+ }
459
+ if (proxy.status === "unknown") {
460
+ return "❌ service not installed (Task Scheduler); OpenCodex proxy status unknown.";
461
+ }
462
+ return "❌ service not installed (Task Scheduler).";
463
+ }
464
+ if (proxy.status === "running") {
465
+ return `⚠️ Task Scheduler registration unknown; OpenCodex proxy running on port ${proxy.port}.`;
466
+ }
467
+ if (proxy.status === "not-running") {
468
+ return "⚠️ service status unknown (Task Scheduler query failed); OpenCodex proxy not running.";
469
+ }
470
+ return "⚠️ service status unknown (Task Scheduler and proxy checks failed).";
471
+ }
472
+
473
+ export async function inspectWindowsSchedulerServiceStatus(io: {
474
+ probeTask?: () => WindowsSchedulerTaskProbe;
475
+ findProxy?: () => Promise<{ port: number } | null>;
476
+ } = {}): Promise<string> {
477
+ let task: WindowsSchedulerTaskProbe;
478
+ try {
479
+ task = (io.probeTask ?? probeWindowsSchedulerTask)();
480
+ } catch (error) {
481
+ task = { status: "unknown", detail: schtasksErrorDetail(error) };
482
+ }
483
+
484
+ let proxy: WindowsSchedulerProxyProbe;
485
+ try {
486
+ const live = await (io.findProxy ?? findLiveProxy)();
487
+ proxy = live ? { status: "running", port: live.port } : { status: "not-running" };
488
+ } catch {
489
+ proxy = { status: "unknown" };
490
+ }
491
+
492
+ return formatWindowsSchedulerServiceStatus(task, proxy);
493
+ }
494
+
396
495
  function schtasksErrorDetail(error: unknown): string {
397
496
  return error instanceof Error ? error.message : String(error);
398
497
  }
@@ -486,7 +585,9 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
486
585
  : !assetsHealthy
487
586
  ? "Required scheduler service assets are missing."
488
587
  : !registrationHealthy
489
- ? "Task Scheduler registration is present but unhealthy."
588
+ ? (inputs.xml.trim()
589
+ ? "Task Scheduler registration is present but unhealthy."
590
+ : "Task Scheduler task is present but its XML could not be read.")
490
591
  : nativeStatusUnknown
491
592
  ? "The Task Scheduler task was created, but OpenCodex could not verify that the native WinSW service is absent."
492
593
  : "ok";
@@ -505,9 +606,18 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
505
606
  /** Conflict-free postcondition check for an elevated scheduler install. */
506
607
  export function verifyWindowsSchedulerInstall(taskName = TASK): WindowsSchedulerInstallVerification {
507
608
  const taskInstalled = windowsSchedulerTaskInstalled(taskName);
508
- const xml = taskInstalled ? (() => {
509
- try { return querySchtasks(["/query", "/tn", taskName, "/xml"]); } catch { return ""; }
510
- })() : "";
609
+ let xml = "";
610
+ if (taskInstalled) {
611
+ try { xml = querySchtasks(["/query", "/tn", taskName, "/xml"]); } catch { xml = ""; }
612
+ }
613
+ // After elevated create, non-elevated `/query /xml` can fail or return empty while the
614
+ // task is still listed. Fall back to the on-disk document we registered.
615
+ if (taskInstalled && !xml.trim()) {
616
+ const diskPath = windowsTaskXmlPath();
617
+ if (existsSync(diskPath)) {
618
+ try { xml = decodeSchtasksOutput(readFileSync(diskPath)); } catch { /* keep empty */ }
619
+ }
620
+ }
511
621
  return evaluateWindowsSchedulerInstallVerification({
512
622
  taskInstalled,
513
623
  xml,
@@ -895,6 +1005,22 @@ function taskXmlString(value: string): string {
895
1005
  .replace(/'/g, "&apos;");
896
1006
  }
897
1007
 
1008
+ /**
1009
+ * RunLevel check. Schema default is LeastPrivilege (omitted on export). Elevated
1010
+ * `schtasks /create` often rewrites the registered task to HighestAvailable even when
1011
+ * the source XML asked for LeastPrivilege — still InteractiveToken / same user.
1012
+ * Keep accepting HighestAvailable here: rejecting it would false-fail healthy elevated
1013
+ * installs, and windowsTaskRegistrationHealthy tests encode that contract.
1014
+ */
1015
+ function taskXmlRunLevelAcceptable(principal: string): boolean {
1016
+ if (taskXmlHasPrefixedTag(principal, "RunLevel")) return false;
1017
+ const count = taskXmlElementCount(principal, "RunLevel");
1018
+ if (count === 0) return true;
1019
+ if (count > 1) return false;
1020
+ const value = new RegExp(`<RunLevel(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/RunLevel>`, "i").exec(principal)?.[1]?.trim().toLowerCase();
1021
+ return value === "leastprivilege" || value === "highestavailable";
1022
+ }
1023
+
898
1024
  export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string {
899
1025
  const { bun, cli } = entry;
900
1026
  const bunRuntime = durableBunRuntime();
@@ -1076,7 +1202,7 @@ function taskXmlDecodedValueEquals(xml: string, tag: string, expected: string):
1076
1202
  // `[^<]*` refuses nested markup, so a decoy inside a child element cannot match.
1077
1203
  const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
1078
1204
  if (value === undefined) return false;
1079
- return taskXmlDecodeEntities(value).trim() === expected.trim();
1205
+ return taskXmlDecodeEntities(value).trim().toLowerCase() === expected.trim().toLowerCase();
1080
1206
  }
1081
1207
 
1082
1208
  function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
@@ -1112,13 +1238,14 @@ export function windowsTaskRegistrationHealthy(
1112
1238
  return taskXmlElementCount(triggers, "LogonTrigger") > 0
1113
1239
  && taskXmlOptionalValueEquals(trigger, "Enabled", "true")
1114
1240
  && /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(principal)
1115
- && taskXmlOptionalValueEquals(principal, "RunLevel", "LeastPrivilege")
1241
+ && taskXmlRunLevelAcceptable(principal)
1116
1242
  && taskXmlOptionalValueEquals(settings, "Enabled", "true")
1117
1243
  && /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
1118
1244
  && /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
1119
1245
  // Compare decoded VALUES, not encodings: Task Scheduler canonicalizes the
1120
1246
  // quotes we wrote as `&quot;` back to literal `"` on export, so an escaped
1121
1247
  // needle never matched and a healthy task read as permanently stale (#608).
1248
+ // Case-insensitive: elevated `schtasks /create` may rewrite System32 casing.
1122
1249
  && taskXmlDecodedValueEquals(action, "Command", wscript)
1123
1250
  && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`);
1124
1251
  }
@@ -1191,10 +1318,23 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut
1191
1318
  }
1192
1319
  }
1193
1320
 
1194
- function installWindows(): void {
1195
- recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1321
+ /**
1322
+ * Rewrite on-disk scheduler assets (script/VBS/XML) without re-registering the task.
1323
+ * Used by fresh install (before schtasks /create) and by repair (no elevation).
1324
+ */
1325
+ function writeWindowsSchedulerAssets(): void {
1196
1326
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1197
1327
  writeServiceApiTokenFile();
1328
+ const script = windowsServiceScriptPath();
1329
+ writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
1330
+ // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
1331
+ // paths on some WSH/codepage combinations — same contract as the task XML below.
1332
+ writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
1333
+ writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
1334
+ }
1335
+
1336
+ function installWindows(): void {
1337
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1198
1338
  // Transactional backend switch: installing the scheduler backend removes a native
1199
1339
  // service first — two live managers would both respawn the proxy (conflict).
1200
1340
  if (statusWinswRaw() !== "nonexistent") {
@@ -1211,17 +1351,78 @@ function installWindows(): void {
1211
1351
  // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
1212
1352
  // script mid-rewrite runs a torn batch file, and its open handle can fail the write.
1213
1353
  try { stopWindows(); } catch { /* not running */ }
1214
- const script = windowsServiceScriptPath();
1215
- writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
1216
- // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
1217
- // paths on some WSH/codepage combinations — same contract as the task XML below.
1218
- writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
1219
- writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
1220
- schtasks(buildWindowsSchtasksCreateArgs(script));
1354
+ writeWindowsSchedulerAssets();
1355
+ schtasks(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath()));
1221
1356
  schtasks(["/run", "/tn", TASK]);
1222
1357
  writeServiceInstallState("scheduler");
1223
1358
  }
1224
1359
 
1360
+ export interface RepairServiceDeps {
1361
+ diagnose?: () => ServiceDiagnostic;
1362
+ assertEnv?: () => void;
1363
+ assertAuth?: () => void;
1364
+ writeSchedulerAssets?: () => void;
1365
+ stopScheduler?: () => void;
1366
+ startScheduler?: () => void;
1367
+ writeSchedulerState?: () => void;
1368
+ writeNativeState?: () => void;
1369
+ repairNative?: () => void | Promise<void>;
1370
+ repairLaunchd?: () => void;
1371
+ repairSystemd?: () => void;
1372
+ /** Test seam — defaults to process.platform so Linux CI cannot hit real installSystemd. */
1373
+ platform?: NodeJS.Platform;
1374
+ }
1375
+
1376
+ /**
1377
+ * Repair an already-installed background service without Task Scheduler re-registration.
1378
+ *
1379
+ * Windows scheduler: rewrite assets + stop/start — no `schtasks /create`, no UAC.
1380
+ * Windows native: WinSW asset rewrite + restart (skips `install /p` when present).
1381
+ * macOS/Linux: re-run the user-level install/reload path.
1382
+ */
1383
+ export async function repairService(deps: RepairServiceDeps = {}): Promise<void> {
1384
+ const diagnose = deps.diagnose ?? diagnoseService;
1385
+ const platform = deps.platform ?? process.platform;
1386
+ const diag = diagnose();
1387
+ if (!diag.supported) {
1388
+ throw new Error(`Background service is unsupported (${diag.summary}).`);
1389
+ }
1390
+ if (diag.conflict) {
1391
+ throw new Error(
1392
+ "Cannot repair while Task Scheduler and native WinSW are both present. "
1393
+ + "Run 'ocx service uninstall' then reinstall one backend with 'ocx service install'.",
1394
+ );
1395
+ }
1396
+ if (!diag.installed) {
1397
+ throw new Error("Background service is not installed. Run 'ocx service install' first.");
1398
+ }
1399
+
1400
+ (deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)();
1401
+ (deps.assertAuth ?? assertServiceAuthEnvironment)();
1402
+
1403
+ if (platform === "win32") {
1404
+ if (diag.backend === "native") {
1405
+ await (deps.repairNative ?? (() => installWinswService(defaultWinswEntry(import.meta.dir))))();
1406
+ (deps.writeNativeState ?? (() => writeServiceInstallState("native")))();
1407
+ return;
1408
+ }
1409
+ try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ }
1410
+ (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)();
1411
+ (deps.startScheduler ?? startWindows)();
1412
+ (deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))();
1413
+ return;
1414
+ }
1415
+ if (platform === "darwin") {
1416
+ (deps.repairLaunchd ?? installLaunchd)();
1417
+ return;
1418
+ }
1419
+ if (platform === "linux") {
1420
+ (deps.repairSystemd ?? installSystemd)();
1421
+ return;
1422
+ }
1423
+ throw new Error(`Background service repair is unsupported on ${platform}.`);
1424
+ }
1425
+
1225
1426
  /**
1226
1427
  * Opt-in native backend (`ocx service install --native`). Transactional: removes the
1227
1428
  * scheduler backend first; on failure the machine is left with NO service (explicitly
@@ -1711,6 +1912,13 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1711
1912
  console.error("--native (WinSW) is Windows-only.");
1712
1913
  process.exit(1);
1713
1914
  }
1915
+ if (command === "repair") {
1916
+ assertServiceEnvironmentMatchesInstall();
1917
+ assertServiceAuthEnvironment();
1918
+ await repairService();
1919
+ console.log("✅ opencodex background service repaired (assets refreshed, no Task Scheduler re-registration).");
1920
+ return;
1921
+ }
1714
1922
  // Non-install subcommands follow the backend recorded at install time (state v2).
1715
1923
  const backend: ServiceBackend = parsed.backend ?? (process.platform === "win32" ? readServiceBackend() : "scheduler");
1716
1924
  const ops = platformOps(backend);
@@ -1727,6 +1935,11 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1727
1935
  ? "✅ opencodex native service installed + started (windowless, starts at boot, auto-restarts on crash)."
1728
1936
  : "✅ opencodex service installed + started (auto-starts on login, auto-restarts on crash).");
1729
1937
  if (process.platform === "linux") console.log(" For auto-start on boot: loginctl enable-linger $USER");
1938
+ // Service users never reach the `ocx start` prompt: the proxy they run is the
1939
+ // supervised child, which always carries OCX_SERVICE=1. This command, though, is
1940
+ // hand-typed in a real terminal, so it is the one interactive moment they get.
1941
+ // Same one-time marker and same guards (TTY, gh auth, agent deferral) apply.
1942
+ await maybeShowStarPrompt();
1730
1943
  break;
1731
1944
  case "start":
1732
1945
  ops.start();
@@ -1750,8 +1963,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1750
1963
  }
1751
1964
  break;
1752
1965
  case "status": {
1753
- const s = ops.status();
1754
- console.log(s ? `✅ running:\n${s}` : "❌ service not installed/running.");
1966
+ if (process.platform === "win32" && backend === "scheduler") {
1967
+ console.log(await inspectWindowsSchedulerServiceStatus());
1968
+ } else {
1969
+ const s = ops.status();
1970
+ console.log(s ? `✅ running:\n${s}` : "❌ service not installed/running.");
1971
+ }
1755
1972
  console.log(`Diagnostics: ${serviceDiagnosticsSummary()}`);
1756
1973
  break;
1757
1974
  }
@@ -1783,8 +2000,9 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1783
2000
  console.log("✅ service uninstalled.");
1784
2001
  break;
1785
2002
  default:
1786
- console.error("Usage: ocx service [install|start|stop|status|uninstall|remove] [--native|--scheduler]");
2003
+ console.error("Usage: ocx service [install|repair|start|stop|status|uninstall|remove] [--native|--scheduler]");
1787
2004
  console.error(" With no subcommand, installs/updates and starts the background service.");
2005
+ console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
1788
2006
  console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
1789
2007
  process.exit(1);
1790
2008
  }
@@ -18,6 +18,11 @@ import {
18
18
  type PolicyRunResult,
19
19
  type PolicySkipReason,
20
20
  } from "./policy";
21
+ import {
22
+ drainStorageWorkers,
23
+ registerStorageWorker,
24
+ terminateStorageWorker,
25
+ } from "./worker-lifecycle";
21
26
 
22
27
  export type PolicyJobStatus = "idle" | "running";
23
28
 
@@ -127,7 +132,7 @@ function disownActiveRun(): void {
127
132
  export function resetStorageCleanupPolicyJobForTests(): void {
128
133
  disownActiveRun();
129
134
  if (activeWorker) {
130
- try { activeWorker.terminate(); } catch { /* */ }
135
+ void terminateStorageWorker(activeWorker);
131
136
  activeWorker = null;
132
137
  }
133
138
  inflight = null;
@@ -136,11 +141,24 @@ export function resetStorageCleanupPolicyJobForTests(): void {
136
141
  state = { status: "idle" };
137
142
  }
138
143
 
144
+ /**
145
+ * Await-able sibling of the reset above, for test teardown.
146
+ *
147
+ * `bun test --isolate` reclaims a file's realm at the file boundary. A storage
148
+ * worker still exiting at that moment trips a Bun-internal assertion on Windows
149
+ * and takes the whole run down, so a suite that spawns workers must be able to
150
+ * wait for them rather than fire-and-forget.
151
+ */
152
+ export async function resetStorageCleanupPolicyJobForTestsAsync(): Promise<void> {
153
+ resetStorageCleanupPolicyJobForTests();
154
+ await drainStorageWorkers();
155
+ }
156
+
139
157
  /** Terminate an in-flight worker during process shutdown. */
140
158
  export function abortStorageCleanupPolicyJob(): void {
141
159
  disownActiveRun();
142
160
  if (activeWorker) {
143
- try { activeWorker.terminate(); } catch { /* */ }
161
+ void terminateStorageWorker(activeWorker);
144
162
  activeWorker = null;
145
163
  }
146
164
  releaseHeldMutationSlot();
@@ -218,13 +236,14 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
218
236
  const requestId = crypto.randomUUID();
219
237
  let settled = false;
220
238
  const worker = new Worker(new URL("./policy-worker.ts", import.meta.url).href);
239
+ registerStorageWorker(worker);
221
240
  activeWorker = worker;
222
241
 
223
242
  const timer = setTimeout(() => {
224
243
  if (settled) return;
225
244
  settled = true;
226
245
  cancelActiveRun = null;
227
- try { worker.terminate(); } catch { /* */ }
246
+ void terminateStorageWorker(worker);
228
247
  if (activeWorker === worker) activeWorker = null;
229
248
  reject(new Error("storage_cleanup_worker_timeout"));
230
249
  }, WORKER_TIMEOUT_MS);
@@ -235,8 +254,10 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
235
254
  cancelActiveRun = null;
236
255
  clearTimeout(timer);
237
256
  if (activeWorker === worker) activeWorker = null;
238
- try { worker.terminate(); } catch { /* */ }
239
- fn();
257
+ // Settle the caller only after the thread is actually gone, so a suite
258
+ // that awaits its request cannot reach the next test file with a worker
259
+ // still exiting behind it.
260
+ void terminateStorageWorker(worker).then(fn, fn);
240
261
  };
241
262
 
242
263
  cancelActiveRun = () => {
@@ -16,6 +16,11 @@ import {
16
16
  withStorageMutationSlot,
17
17
  type StorageMutationCoordinatorTestHooks,
18
18
  } from "./storage-mutation-coordinator";
19
+ import {
20
+ drainStorageWorkers,
21
+ registerStorageWorker,
22
+ terminateStorageWorker,
23
+ } from "./worker-lifecycle";
19
24
 
20
25
  export interface RestoreJobTestHooks extends StorageMutationCoordinatorTestHooks {
21
26
  /**
@@ -69,7 +74,7 @@ export function setRestoreTrashJobTestHooks(hooks: RestoreJobTestHooks | null):
69
74
 
70
75
  export function resetRestoreTrashJobForTests(): void {
71
76
  if (activeWorker) {
72
- try { activeWorker.terminate(); } catch { /* */ }
77
+ void terminateStorageWorker(activeWorker);
73
78
  activeWorker = null;
74
79
  }
75
80
  cancelActiveRun?.();
@@ -78,10 +83,16 @@ export function resetRestoreTrashJobForTests(): void {
78
83
  resetStorageMutationCoordinatorForTests();
79
84
  }
80
85
 
86
+ /** Await-able reset for test teardown; see policy-job's equivalent for why. */
87
+ export async function resetRestoreTrashJobForTestsAsync(): Promise<void> {
88
+ resetRestoreTrashJobForTests();
89
+ await drainStorageWorkers();
90
+ }
91
+
81
92
  /** Terminate an in-flight worker during process shutdown. */
82
93
  export function abortRestoreTrashJob(): void {
83
94
  if (activeWorker) {
84
- try { activeWorker.terminate(); } catch { /* */ }
95
+ void terminateStorageWorker(activeWorker);
85
96
  activeWorker = null;
86
97
  }
87
98
  cancelActiveRun?.();
@@ -117,13 +128,14 @@ function runInWorker(opts: {
117
128
  const requestId = crypto.randomUUID();
118
129
  let settled = false;
119
130
  const worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href);
131
+ registerStorageWorker(worker);
120
132
  activeWorker = worker;
121
133
 
122
134
  const timer = setTimeout(() => {
123
135
  if (settled) return;
124
136
  settled = true;
125
137
  cancelActiveRun = null;
126
- try { worker.terminate(); } catch { /* */ }
138
+ void terminateStorageWorker(worker);
127
139
  if (activeWorker === worker) activeWorker = null;
128
140
  reject(new Error("restore_worker_timeout"));
129
141
  }, WORKER_TIMEOUT_MS);
@@ -134,8 +146,7 @@ function runInWorker(opts: {
134
146
  cancelActiveRun = null;
135
147
  clearTimeout(timer);
136
148
  if (activeWorker === worker) activeWorker = null;
137
- try { worker.terminate(); } catch { /* */ }
138
- fn();
149
+ void terminateStorageWorker(worker).then(fn, fn);
139
150
  };
140
151
 
141
152
  cancelActiveRun = () => {