@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901

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 (131) hide show
  1. package/bin/ocx.mjs +127 -16
  2. package/gui/dist/assets/index-6HZDkIpX.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/baseten.svg +13 -0
  7. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  8. package/gui/dist/provider-icons/cerebras.svg +26 -0
  9. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  10. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  11. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  12. package/gui/dist/provider-icons/featherless.svg +4 -0
  13. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  14. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  15. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  16. package/gui/dist/provider-icons/kilo.svg +13 -0
  17. package/gui/dist/provider-icons/litellm.svg +1 -0
  18. package/gui/dist/provider-icons/minimax.svg +1 -0
  19. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  20. package/gui/dist/provider-icons/nebius.svg +1 -0
  21. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  22. package/gui/dist/provider-icons/nous.svg +149 -0
  23. package/gui/dist/provider-icons/novita.svg +32 -0
  24. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  25. package/gui/dist/provider-icons/openclaw.svg +54 -0
  26. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  27. package/gui/dist/provider-icons/parallel.svg +13 -0
  28. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  29. package/gui/dist/provider-icons/sambanova.svg +276 -0
  30. package/gui/dist/provider-icons/scaleway.svg +11 -0
  31. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  32. package/gui/dist/provider-icons/synthetic.svg +12 -0
  33. package/gui/dist/provider-icons/together.svg +18 -0
  34. package/gui/dist/provider-icons/umans.svg +30 -0
  35. package/gui/dist/provider-icons/venice.svg +165 -0
  36. package/gui/dist/provider-icons/vultr.svg +15 -0
  37. package/gui/dist/provider-icons/zai.svg +218 -0
  38. package/gui/dist/provider-icons/zcode.svg +219 -0
  39. package/gui/dist/provider-icons/zenmux.svg +1 -0
  40. package/package.json +1 -1
  41. package/src/adapters/cursor/live-models.ts +1 -0
  42. package/src/adapters/cursor/protobuf-request.ts +4 -1
  43. package/src/adapters/cursor/tool-definitions.ts +36 -4
  44. package/src/adapters/openai-responses.ts +72 -10
  45. package/src/bridge.ts +15 -15
  46. package/src/cli/capabilities.ts +14 -0
  47. package/src/cli/codex-cli-update.ts +96 -0
  48. package/src/cli/codex-shim-autorestore.ts +3 -0
  49. package/src/cli/dispatch.ts +54 -18
  50. package/src/cli/export-command.ts +18 -17
  51. package/src/cli/help.ts +2 -2
  52. package/src/cli/index.ts +423 -32
  53. package/src/cli/integrations.ts +24 -2
  54. package/src/cli/launcher-context.ts +53 -2
  55. package/src/cli/opencode.ts +126 -33
  56. package/src/cli/registry.ts +16 -10
  57. package/src/cli/system-command.ts +6 -1
  58. package/src/cli/uninstall-plan.ts +86 -0
  59. package/src/clients/config-export.ts +293 -28
  60. package/src/codex/account-store.ts +131 -12
  61. package/src/codex/auth-api.ts +202 -33
  62. package/src/codex/autostart-health.ts +3 -3
  63. package/src/codex/catalog/provider-fetch.ts +96 -10
  64. package/src/codex/catalog/sync.ts +4 -3
  65. package/src/codex/cli-install-provenance.ts +795 -0
  66. package/src/codex/convergence.ts +4 -3
  67. package/src/codex/credential-mutation-epoch.ts +11 -0
  68. package/src/codex/history-job.ts +10 -0
  69. package/src/codex/history-manifest.ts +35 -2
  70. package/src/codex/history-provider.ts +196 -19
  71. package/src/codex/history-worker.ts +3 -0
  72. package/src/codex/main-account.ts +2 -0
  73. package/src/codex/model-entitlements.ts +430 -27
  74. package/src/codex/native-profile-manager.ts +4 -0
  75. package/src/codex/quota-401-recovery.ts +190 -0
  76. package/src/codex/quota-recovery-timing.ts +28 -0
  77. package/src/codex/quota.ts +6 -0
  78. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  79. package/src/codex/reset-credit-recovery.ts +20 -2
  80. package/src/codex/routing.ts +68 -16
  81. package/src/codex/shim.ts +204 -18
  82. package/src/codex/subagent-model-fallback.ts +4 -1
  83. package/src/codex/user-identity.ts +2 -1
  84. package/src/config/paths.ts +18 -3
  85. package/src/config/pending-teardown-names.d.mts +8 -0
  86. package/src/config/pending-teardown-names.mjs +69 -0
  87. package/src/config/pending-teardown.ts +286 -0
  88. package/src/config.ts +23 -0
  89. package/src/generated/compatibility-version.json +155 -67
  90. package/src/integrations/journal.ts +12 -1
  91. package/src/integrations/registry.ts +112 -0
  92. package/src/integrations/state.ts +67 -5
  93. package/src/integrations/writer.ts +107 -16
  94. package/src/lib/bounded-subprocess.ts +36 -0
  95. package/src/lib/process-control.ts +30 -5
  96. package/src/lib/state-store-registrations.ts +8 -0
  97. package/src/lib/strict-semver.ts +47 -0
  98. package/src/lib/windows-elevation.ts +32 -1
  99. package/src/lib/windows-secret-acl.ts +47 -25
  100. package/src/lib/windows-service-mutation-lock.ts +133 -0
  101. package/src/lib/windows-user-principal.ts +15 -17
  102. package/src/oauth/index.ts +220 -13
  103. package/src/oauth/store.ts +220 -18
  104. package/src/responses/spill-store.ts +354 -29
  105. package/src/responses/state.ts +775 -8
  106. package/src/server/index.ts +4 -3
  107. package/src/server/lifecycle.ts +5 -1
  108. package/src/server/management/integration-routes.ts +36 -5
  109. package/src/server/management/model-rows.ts +11 -2
  110. package/src/server/management/provider-routes.ts +4 -0
  111. package/src/server/management/system-restart.ts +5 -5
  112. package/src/server/management-api.ts +72 -13
  113. package/src/server/responses/encrypted-payload.ts +18 -1
  114. package/src/server/startup-action-control.ts +3 -2
  115. package/src/server/stop-teardown.ts +84 -0
  116. package/src/service.ts +749 -53
  117. package/src/sidecar/candidates.ts +1 -1
  118. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  119. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  120. package/src/update/index.ts +39 -6
  121. package/src/update/job.ts +10 -11
  122. package/src/update/proxy-liveness-probe.d.mts +6 -0
  123. package/src/update/proxy-liveness-probe.mjs +84 -0
  124. package/src/update/stop-contract.d.mts +2 -0
  125. package/src/update/stop-contract.mjs +15 -0
  126. package/src/update/stop-decision.d.mts +10 -0
  127. package/src/update/stop-decision.mjs +34 -0
  128. package/src/vision/eligibility.ts +19 -1
  129. package/src/vision/index.ts +4 -18
  130. package/gui/dist/assets/index-CowztZdo.js +0 -112
  131. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
package/src/service.ts CHANGED
@@ -46,10 +46,17 @@ import {
46
46
  hardenSecretPath,
47
47
  } from "./lib/windows-secret-acl";
48
48
  import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
49
+ import {
50
+ cachedCurrentWindowsIdentity,
51
+ resolveCurrentWindowsPrincipal,
52
+ WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS,
53
+ } from "./lib/windows-user-principal";
49
54
  import { recordOwnedConfigPath } from "./lib/config-ownership";
50
55
  import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers";
56
+ import { withWindowsServiceMutationLock } from "./lib/windows-service-mutation-lock";
51
57
  import { maybeShowStarPrompt } from "./cli/star-prompt";
52
58
  import { systemdProperty } from "./service-manager-probe";
59
+ import { isTestHomeGuardArmed } from "./lib/test-home-guard";
53
60
 
54
61
  const LABEL = "com.opencodex.proxy";
55
62
  const TASK = "opencodex-proxy";
@@ -322,11 +329,10 @@ export function readServiceBackend(): ServiceBackend {
322
329
  /**
323
330
  * The `ocx` argv that refreshes an already-installed service after an update.
324
331
  *
325
- * `repair` discovers the installed backend itself and, on Windows scheduler installs,
326
- * rewrites the wrapper assets and restarts the existing task WITHOUT `schtasks /create`
327
- * (see repairService below). `install` always reaches `/create`, which requires
328
- * elevation so an ordinary non-elevated `ocx update` used to stop a working proxy and
329
- * then fail to bring its service back.
332
+ * `repair` discovers the installed backend itself. A healthy Windows scheduler task only
333
+ * gets refreshed assets plus a restart; a stale live definition is re-registered and may
334
+ * require elevation. `install` always reaches `/create`, so using repair here avoids an
335
+ * unnecessary admin prompt for the common healthy update path.
330
336
  *
331
337
  * The historical export name is kept for callers outside this module.
332
338
  */
@@ -721,11 +727,11 @@ async function reportServiceServing(
721
727
  }
722
728
 
723
729
  /**
724
- * The command that repairs the CURRENTLY INSTALLED backend without re-registering it.
730
+ * The command that repairs the CURRENTLY INSTALLED backend without switching it.
725
731
  *
726
732
  * `ocx service repair` reads the recorded backend itself, so it cannot silently switch a
727
- * WinSW install to Task Scheduler the way a plain `ocx service install` would, and on
728
- * Windows it needs no elevation because it never calls `schtasks /create`.
733
+ * WinSW install to Task Scheduler the way a plain `ocx service install` would. A healthy
734
+ * scheduler definition needs no elevation; a stale definition can be re-registered and prompt.
729
735
  */
730
736
  function serviceRepairCommand(): string {
731
737
  return "ocx service repair";
@@ -900,6 +906,20 @@ function windowsWscript(): string {
900
906
  let querySchtasksForTests: ((args: string[]) => string) | null = null;
901
907
 
902
908
  function querySchtasks(args: string[]): string {
909
+ // The repository preload isolates HOME and OPENCODEX_HOME, but Task Scheduler is
910
+ // machine-global. A partially-faked service test once fell through here and replaced the
911
+ // user's real `opencodex-proxy` task with a launcher inside its temporary test home; the
912
+ // test passed and cleanup deleted that launcher. Queries are observation-only, but every
913
+ // other operation must be injected while the explicit test-home guard is armed.
914
+ if (
915
+ isTestHomeGuardArmed()
916
+ && args[0]?.trim().toLowerCase() !== "/query"
917
+ ) {
918
+ throw new Error(
919
+ "refusing to mutate the machine-global Windows Task Scheduler from an armed test process; "
920
+ + "inject the scheduler operation instead of calling the live manager.",
921
+ );
922
+ }
903
923
  if (querySchtasksForTests) return querySchtasksForTests(args);
904
924
  return runFile(windowsSchtasks(), args);
905
925
  }
@@ -1730,8 +1750,8 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath
1730
1750
  }
1731
1751
 
1732
1752
  /** Build the fixed scheduler-create command from an explicit staged XML document. */
1733
- export function buildWindowsSchtasksCreateArgsForXml(xml: string): string[] {
1734
- return ["/create", "/tn", TASK, "/xml", xml, "/f"];
1753
+ export function buildWindowsSchtasksCreateArgsForXml(xml: string, replace = true): string[] {
1754
+ return ["/create", "/tn", TASK, "/xml", xml, ...(replace ? ["/f"] : [])];
1735
1755
  }
1736
1756
 
1737
1757
  /**
@@ -1760,15 +1780,45 @@ function windowsTaskDescription(attemptNonce?: string): string {
1760
1780
  : "OpenCodex proxy service wrapper";
1761
1781
  }
1762
1782
 
1783
+ /**
1784
+ * Session transitions that must be able to bring the proxy back.
1785
+ *
1786
+ * The task runs under `InteractiveToken`, so the proxy lives inside the interactive session
1787
+ * and Windows tears it down with that session — the wrapper records the kill as exit code
1788
+ * 1073807364 (`STATUS_CONTROL_C_EXIT`). With `LogonTrigger` as the only trigger there was no
1789
+ * recovery path short of a fresh logon, so signing out of a Remote Desktop session left the
1790
+ * proxy down until the next interactive logon. On one machine's logs 19 such kills produced
1791
+ * gaps of up to ~60 hours.
1792
+ *
1793
+ * These triggers do not stop the kill; they make it recoverable at the next connect. Console
1794
+ * transitions are included because a local session can be disconnected the same way, and
1795
+ * `MultipleInstancesPolicy=IgnoreNew` keeps a still-running proxy from being started twice.
1796
+ */
1797
+ const WINDOWS_SESSION_RECOVERY_STATE_CHANGES = [
1798
+ "RemoteConnect",
1799
+ "SessionUnlock",
1800
+ "ConsoleConnect",
1801
+ ] as const;
1802
+
1763
1803
  export function buildWindowsTaskXml(
1764
1804
  script = windowsServiceScriptPath(),
1765
1805
  launcher = windowsLauncherVbsPath(),
1766
1806
  attemptNonce?: string,
1807
+ sessionTriggerUserId = cachedCurrentWindowsIdentity()?.name,
1767
1808
  ): string {
1768
1809
  const escapedWscript = taskXmlString(windowsWscript());
1769
1810
  // Escape the launcher path independently for the <Arguments> element; quoting it
1770
1811
  // keeps spaces intact, and /b (batch mode) suppresses script error popups.
1771
1812
  const escapedLauncherArgs = taskXmlString(`/b /nologo "${launcher}"`);
1813
+ // `UserId` is optional in the schema, and omitting it makes a SessionStateChangeTrigger
1814
+ // fire for ANY account's session change. Scope it to the installing account when that
1815
+ // account is already known. The lookup is never forced here: this builder is synchronous
1816
+ // and its output is validated before registration, so a failed or unavailable lookup must
1817
+ // degrade to the unscoped trigger rather than leave the task with no recovery at all.
1818
+ // `LogonTrigger` above is unscoped for the same reason and predates this change.
1819
+ const sessionUserIdElement = sessionTriggerUserId
1820
+ ? `\n <UserId>${taskXmlString(sessionTriggerUserId)}</UserId>`
1821
+ : "";
1772
1822
  return `<?xml version="1.0" encoding="UTF-16"?>
1773
1823
  <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1774
1824
  <RegistrationInfo>
@@ -1778,6 +1828,10 @@ export function buildWindowsTaskXml(
1778
1828
  <LogonTrigger>
1779
1829
  <Enabled>true</Enabled>
1780
1830
  </LogonTrigger>
1831
+ ${WINDOWS_SESSION_RECOVERY_STATE_CHANGES.map(stateChange => `<SessionStateChangeTrigger>
1832
+ <Enabled>true</Enabled>${sessionUserIdElement}
1833
+ <StateChange>${stateChange}</StateChange>
1834
+ </SessionStateChangeTrigger>`).join("\n ")}
1781
1835
  </Triggers>
1782
1836
  <Principals>
1783
1837
  <Principal id="Author">
@@ -1907,8 +1961,47 @@ export function windowsTaskRegistrationOwnedByAttempt(xml: string, attemptNonce:
1907
1961
  );
1908
1962
  }
1909
1963
 
1910
- /** Validate the security/lifecycle-critical fields of the registered scheduler task. */
1911
- export function windowsTaskRegistrationHealthy(
1964
+ /**
1965
+ * Every session-recovery trigger present and enabled, scoped to <Triggers>.
1966
+ *
1967
+ * Each StateChange is matched inside its OWN <SessionStateChangeTrigger> element: a document
1968
+ * carrying one disabled trigger plus a different enabled one must not pass because the two
1969
+ * halves were found in unrelated elements.
1970
+ */
1971
+ function windowsTaskHasSessionRecoveryTriggers(triggers: string, expectedUserId: string | undefined): boolean {
1972
+ const scoped = triggers.match(/<SessionStateChangeTrigger(?:\s[^>]*)?>[\s\S]*?<\/SessionStateChangeTrigger>/gi) ?? [];
1973
+ return WINDOWS_SESSION_RECOVERY_STATE_CHANGES.every(stateChange =>
1974
+ scoped.some(element =>
1975
+ taskXmlDecodedValueEquals(element, "StateChange", stateChange)
1976
+ && taskXmlOptionalValueEquals(element, "Enabled", "true")
1977
+ && windowsTaskTriggerScopeAcceptable(element, expectedUserId)));
1978
+ }
1979
+
1980
+ /**
1981
+ * A trigger's scope is acceptable when it is unscoped, or names the expected account.
1982
+ *
1983
+ * An unscoped trigger is accepted rather than rejected: the schema makes `UserId` optional,
1984
+ * the pre-existing `LogonTrigger` is unscoped for the same reason, and rejecting it would
1985
+ * mean an installation whose account lookup is unavailable loses session recovery entirely.
1986
+ * An explicitly scoped trigger is accepted only when the current account is known and matches.
1987
+ * Treating an unknown expected identity as a wildcard would let a fresh status process accept a
1988
+ * task bound to another user's session and suppress the repair that should replace it.
1989
+ */
1990
+ function windowsTaskTriggerScopeAcceptable(element: string, expectedUserId: string | undefined): boolean {
1991
+ // A prefixed `<t:UserId>` is a real scope this validator cannot read: taskXmlElementCount()
1992
+ // counts only unprefixed tags, so without this the element below would look ABSENT and the
1993
+ // trigger would be accepted as unscoped even though it is bound to some other account.
1994
+ // Reject it outright rather than guess, and do so before the optional-field check.
1995
+ if (taskXmlHasPrefixedTag(element, "UserId")) return false;
1996
+ const userIdCount = taskXmlElementCount(element, "UserId");
1997
+ if (userIdCount === 0) return true;
1998
+ if (userIdCount !== 1) return false;
1999
+ if (expectedUserId === undefined) return false;
2000
+ return taskXmlDecodedValueEquals(element, "UserId", expectedUserId);
2001
+ }
2002
+
2003
+ /** Validate the stable OpenCodex action, principal, settings, and logon trigger. */
2004
+ function windowsTaskRegistrationBaseHealthy(
1912
2005
  xml: string,
1913
2006
  wscript = windowsWscript(),
1914
2007
  launcher = windowsLauncherVbsPath(),
@@ -1941,6 +2034,35 @@ export function windowsTaskRegistrationHealthy(
1941
2034
  && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`);
1942
2035
  }
1943
2036
 
2037
+ /** Validate the security/lifecycle-critical fields of the registered scheduler task. */
2038
+ export function windowsTaskRegistrationHealthy(
2039
+ xml: string,
2040
+ wscript = windowsWscript(),
2041
+ launcher = windowsLauncherVbsPath(),
2042
+ expectedUserId: string | null = cachedCurrentWindowsIdentity()?.name ?? null,
2043
+ ): boolean {
2044
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
2045
+ const triggers = taskXmlSection(scrubbed, "Triggers");
2046
+ return windowsTaskRegistrationBaseHealthy(xml, wscript, launcher)
2047
+ // Without these the task can only recover at the next logon, so a disconnected session
2048
+ // leaves the proxy down indefinitely. Treating their absence as unhealthy is what lets
2049
+ // an already-registered task from an older install get repaired instead of staying broken.
2050
+ && windowsTaskHasSessionRecoveryTriggers(triggers, expectedUserId ?? undefined);
2051
+ }
2052
+
2053
+ /**
2054
+ * The only stale definition repair may replace automatically: the previous OpenCodex task
2055
+ * shape whose action/principal/settings are still exact and which has no session triggers yet.
2056
+ * Arbitrary unhealthy or partially modified fixed-name tasks are preserved for manual review.
2057
+ */
2058
+ function windowsTaskRegistrationRefreshableLegacy(xml: string): boolean {
2059
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
2060
+ const triggers = taskXmlSection(scrubbed, "Triggers");
2061
+ return windowsTaskRegistrationBaseHealthy(xml)
2062
+ && taskXmlElementCount(triggers, "SessionStateChangeTrigger") === 0
2063
+ && !taskXmlHasPrefixedTag(triggers, "SessionStateChangeTrigger");
2064
+ }
2065
+
1944
2066
  export interface WindowsSchedulerXmlState {
1945
2067
  installed: boolean;
1946
2068
  enabled: boolean;
@@ -1956,6 +2078,7 @@ export function readWindowsSchedulerXmlState(
1956
2078
  xml: string,
1957
2079
  wscript?: string,
1958
2080
  launcher?: string,
2081
+ expectedUserId: string | null = cachedCurrentWindowsIdentity()?.name ?? null,
1959
2082
  ): WindowsSchedulerXmlState {
1960
2083
  const installed = xml.length > 0;
1961
2084
  if (!installed) return { installed: false, enabled: false, registrationHealthy: false };
@@ -1965,7 +2088,7 @@ export function readWindowsSchedulerXmlState(
1965
2088
  return {
1966
2089
  installed: true,
1967
2090
  enabled: !hasData && taskXmlOptionalValueEquals(settings, "Enabled", "true"),
1968
- registrationHealthy: windowsTaskRegistrationHealthy(xml, wscript, launcher),
2091
+ registrationHealthy: windowsTaskRegistrationHealthy(xml, wscript, launcher, expectedUserId),
1969
2092
  };
1970
2093
  }
1971
2094
 
@@ -2108,8 +2231,8 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut
2108
2231
  }
2109
2232
 
2110
2233
  /**
2111
- * Rewrite on-disk scheduler assets (script/VBS/XML) without re-registering the task.
2112
- * Used by fresh install (before schtasks /create) and by repair (no elevation).
2234
+ * Rewrite on-disk scheduler assets (script/VBS/XML) without itself registering the task.
2235
+ * Fresh install creates it afterwards; repair does so only when the live definition is stale.
2113
2236
  */
2114
2237
  function writeWindowsSchedulerAssets(): void {
2115
2238
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
@@ -2230,9 +2353,15 @@ function removeWindowsSchedulerRegistrationStage(xmlPath: string): void {
2230
2353
 
2231
2354
  export interface FreshWindowsSchedulerRegistrationDeps {
2232
2355
  create?: (args: string[]) => void;
2233
- elevate?: (taskName: string, xml: string) => Promise<void>;
2356
+ elevate?: (
2357
+ taskName: string,
2358
+ xml: string,
2359
+ replace: boolean,
2360
+ expectedExistingXml?: string,
2361
+ ) => Promise<void>;
2234
2362
  probe?: () => WindowsSchedulerTaskProbe;
2235
2363
  queryXml?: () => string;
2364
+ readExistingXml?: () => string;
2236
2365
  rollback?: () => Promise<string | null>;
2237
2366
  }
2238
2367
 
@@ -2240,8 +2369,27 @@ export async function registerFreshWindowsSchedulerTask(
2240
2369
  xmlPath: string,
2241
2370
  attemptNonce: string,
2242
2371
  deps: FreshWindowsSchedulerRegistrationDeps = {},
2372
+ expectedExistingXml?: string,
2243
2373
  ): Promise<void> {
2244
- const args = buildWindowsSchtasksCreateArgsForXml(xmlPath);
2374
+ const replace = expectedExistingXml !== undefined;
2375
+ const readExistingXml = deps.readExistingXml ?? statusWindowsXml;
2376
+ const assertReplacementPrecondition = (): void => {
2377
+ if (!replace) return;
2378
+ if (!expectedExistingXml?.trim()) {
2379
+ throw new Error("Task Scheduler replacement requires a non-empty captured registration.");
2380
+ }
2381
+ let currentXml = "";
2382
+ try {
2383
+ currentXml = readExistingXml();
2384
+ } catch {
2385
+ throw new Error("Task Scheduler replacement was refused because the current registration could not be read.");
2386
+ }
2387
+ if (!windowsSchedulerRegistrationMatchesSnapshot(currentXml, expectedExistingXml)) {
2388
+ throw new Error("Task Scheduler replacement was refused because the current registration changed.");
2389
+ }
2390
+ };
2391
+ assertReplacementPrecondition();
2392
+ const args = buildWindowsSchtasksCreateArgsForXml(xmlPath, replace);
2245
2393
  // Capture and validate the exact definition before an access-denied attempt can
2246
2394
  // cross the UAC boundary. The elevated fallback receives these immutable bytes,
2247
2395
  // never the caller-writable staging pathname.
@@ -2264,11 +2412,24 @@ export async function registerFreshWindowsSchedulerTask(
2264
2412
  }
2265
2413
  // Register from the captured XML string inside the elevated process. Another
2266
2414
  // same-user process can mutate its own temp files, but cannot change this command.
2267
- const elevate = deps.elevate ?? (async (taskName: string, xml: string) => {
2268
- const exitCode = await runWindowsElevatedScheduledTaskRegistration(taskName, xml);
2415
+ // UAC can remain open for an arbitrary amount of time. Recheck the captured predecessor
2416
+ // before launch; the elevated helper repeats the same check after consent and before Force.
2417
+ assertReplacementPrecondition();
2418
+ const elevate = deps.elevate ?? (async (
2419
+ taskName: string,
2420
+ xml: string,
2421
+ replaceCurrent: boolean,
2422
+ previousXml?: string,
2423
+ ) => {
2424
+ const exitCode = await runWindowsElevatedScheduledTaskRegistration(
2425
+ taskName,
2426
+ xml,
2427
+ replaceCurrent,
2428
+ previousXml,
2429
+ );
2269
2430
  if (exitCode !== 0) throw new Error(`Background service install failed with exit code ${exitCode}.`);
2270
2431
  });
2271
- await elevate(TASK, expectedXml);
2432
+ await elevate(TASK, expectedXml, replace, expectedExistingXml);
2272
2433
  }
2273
2434
 
2274
2435
  const rollbackTask = deps.rollback ?? (() => rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK));
@@ -2328,8 +2489,17 @@ export interface RemoveNativeWindowsServiceDeps {
2328
2489
  export function removeNativeWindowsServiceForScheduler(
2329
2490
  deps: RemoveNativeWindowsServiceDeps = {},
2330
2491
  ): void {
2331
- const status = deps.status ?? statusWinswRaw;
2332
2492
  const uninstall = deps.uninstall ?? uninstallWinswService;
2493
+ // The test home cannot contain SCM. A partially mocked scheduler install must inject
2494
+ // the native-service mutation too; otherwise it can stop/delete the user's live WinSW
2495
+ // registration even though every filesystem path points at the isolated test home.
2496
+ if (isTestHomeGuardArmed() && uninstall === uninstallWinswService) {
2497
+ throw new Error(
2498
+ "refusing to mutate the machine-global Windows native service from an armed test process; "
2499
+ + "inject the native-service removal instead of calling the live manager.",
2500
+ );
2501
+ }
2502
+ const status = deps.status ?? statusWinswRaw;
2333
2503
  const sleep = deps.sleep ?? Bun.sleepSync;
2334
2504
  const settleChecks = Math.max(1, deps.settleChecks ?? 20);
2335
2505
  // Transactional backend switch: installing the scheduler backend removes a native
@@ -2361,6 +2531,107 @@ function installWindows(): void {
2361
2531
  writeServiceInstallState("scheduler");
2362
2532
  }
2363
2533
 
2534
+ /**
2535
+ * Re-register an already-installed scheduler task from a freshly staged definition.
2536
+ *
2537
+ * Reuses the fresh-install staging and registration path, so the same ownership and shape
2538
+ * validation applies and an access-denied `schtasks /create` still escalates through the
2539
+ * existing elevated fallback. The staged XML is removed on every exit.
2540
+ */
2541
+ async function reregisterWindowsSchedulerTask(
2542
+ attemptNonce: string,
2543
+ expectedExistingXml: string,
2544
+ ): Promise<void> {
2545
+ const stagedXml = stageWindowsSchedulerRegistrationXml(attemptNonce);
2546
+ try {
2547
+ await registerFreshWindowsSchedulerTask(stagedXml, attemptNonce, {}, expectedExistingXml);
2548
+ } finally {
2549
+ removeWindowsSchedulerRegistrationStage(stagedXml);
2550
+ }
2551
+ }
2552
+
2553
+ function stageWindowsSchedulerRestoreXml(registeredXml: string): string {
2554
+ if (!registeredXml.trim()) {
2555
+ throw new Error("Cannot restore an empty Task Scheduler registration.");
2556
+ }
2557
+ const stageDir = mkdtempSync(join(tmpdir(), WINDOWS_SCHEDULER_STAGE_PREFIX));
2558
+ const xmlPath = join(stageDir, "task.xml");
2559
+ try {
2560
+ try { chmodSync(stageDir, 0o700); } catch { /* required Windows ACL is authoritative */ }
2561
+ hardenSecretDir(stageDir, { required: true });
2562
+ writeFileSync(
2563
+ xmlPath,
2564
+ `\uFEFF${registeredXml.replace(/^\uFEFF/, "")}`,
2565
+ { encoding: "utf16le", flag: "wx", mode: 0o600 },
2566
+ );
2567
+ hardenSecretPath(xmlPath, { required: true });
2568
+ ownedWindowsSchedulerStages.add(xmlPath);
2569
+ return xmlPath;
2570
+ } catch (error) {
2571
+ try {
2572
+ cleanupWindowsSchedulerStage(stageDir, xmlPath, path => { rmdirSync(path); });
2573
+ } catch (cleanupError) {
2574
+ throw new AggregateError(
2575
+ [error, cleanupError],
2576
+ "Task Scheduler rollback staging failed and could not be cleaned up.",
2577
+ );
2578
+ }
2579
+ throw error;
2580
+ }
2581
+ }
2582
+
2583
+ /** Compare two live scheduler snapshots conservatively without treating formatting as mutation. */
2584
+ function windowsSchedulerRegistrationMatchesSnapshot(currentXml: string, previousXml: string): boolean {
2585
+ const normalize = (xml: string) => xml
2586
+ .replace(/^\uFEFF/, "")
2587
+ .replace(/\r\n?/g, "\n")
2588
+ .trim();
2589
+ const current = normalize(currentXml);
2590
+ const previous = normalize(previousXml);
2591
+ return current.length > 0 && previous.length > 0 && current === previous;
2592
+ }
2593
+
2594
+ /**
2595
+ * Restore the captured registration only while the fixed task name is still absent.
2596
+ *
2597
+ * Both publication paths deliberately omit force: another writer appearing after the
2598
+ * absence probe must make this operation fail instead of being overwritten. Exact live
2599
+ * XML readback is required before the caller may restart the recovered task.
2600
+ */
2601
+ async function restoreWindowsSchedulerTaskIfAbsent(registeredXml: string): Promise<void> {
2602
+ const before = probeWindowsSchedulerTask(TASK);
2603
+ if (before.status !== "absent") {
2604
+ throw new Error(before.status === "present"
2605
+ ? "A Task Scheduler registration appeared before recovery and was preserved."
2606
+ : `Task Scheduler absence could not be re-verified before recovery (${before.detail}).`);
2607
+ }
2608
+ const stagedXml = stageWindowsSchedulerRestoreXml(registeredXml);
2609
+ try {
2610
+ const args = buildWindowsSchtasksCreateArgsForXml(stagedXml, false);
2611
+ try {
2612
+ schtasks(args);
2613
+ } catch (error) {
2614
+ if (
2615
+ !(error instanceof WindowsSchtasksError)
2616
+ || error.operation !== "create"
2617
+ || error.reason !== "access-denied"
2618
+ ) {
2619
+ throw error;
2620
+ }
2621
+ const exitCode = await runWindowsElevatedScheduledTaskRegistration(TASK, registeredXml, false);
2622
+ if (exitCode !== 0) {
2623
+ throw new Error(`Task Scheduler rollback failed with exit code ${exitCode}.`);
2624
+ }
2625
+ }
2626
+ const recoveredXml = statusWindowsXml();
2627
+ if (!windowsSchedulerRegistrationMatchesSnapshot(recoveredXml, registeredXml)) {
2628
+ throw new Error("The recovered Task Scheduler registration did not match the captured definition.");
2629
+ }
2630
+ } finally {
2631
+ removeWindowsSchedulerRegistrationStage(stagedXml);
2632
+ }
2633
+ }
2634
+
2364
2635
  export interface RepairServiceDeps {
2365
2636
  diagnose?: () => ServiceDiagnostic;
2366
2637
  assertEnv?: () => void;
@@ -2373,14 +2644,69 @@ export interface RepairServiceDeps {
2373
2644
  repairNative?: () => void | Promise<void>;
2374
2645
  repairLaunchd?: () => void;
2375
2646
  repairSystemd?: () => void;
2647
+ /** Reads live registered task XML; may be called again after failure, empty when unreadable. */
2648
+ readSchedulerXml?: () => string;
2649
+ /** Bounded wait before retrying an unreadable live registration snapshot. */
2650
+ settleSchedulerRead?: (delayMs: number) => void | Promise<void>;
2651
+ /** Proves fixed-name task presence when its live XML is empty or unreadable. */
2652
+ probeScheduler?: () => WindowsSchedulerTaskProbe;
2653
+ /** Re-registers the task from freshly staged XML. Used only when the definition is stale. */
2654
+ reregisterScheduler?: (attemptNonce: string, expectedExistingXml: string) => Promise<void>;
2655
+ /** Publishes the captured registration only when the fixed task name remains absent. */
2656
+ restoreSchedulerIfAbsent?: (registeredXml: string) => Promise<void>;
2657
+ /** Resolves the account the registered triggers must match; null when it cannot be resolved. */
2658
+ resolveExpectedUserId?: (registeredXml: string) => string | null;
2376
2659
  /** Test seam — defaults to process.platform so Linux CI cannot hit real installSystemd. */
2377
2660
  platform?: NodeJS.Platform;
2378
2661
  }
2379
2662
 
2663
+ async function assertSchedulerSnapshotBeforeStart(
2664
+ readSchedulerXml: () => string,
2665
+ expectedXml: string,
2666
+ settle: (delayMs: number) => void | Promise<void>,
2667
+ changedMessage: string,
2668
+ unreadableMessage: string,
2669
+ ): Promise<void> {
2670
+ await assertSchedulerRegistrationBeforeStart(
2671
+ readSchedulerXml,
2672
+ settle,
2673
+ currentXml => windowsSchedulerRegistrationMatchesSnapshot(currentXml, expectedXml),
2674
+ changedMessage,
2675
+ unreadableMessage,
2676
+ );
2677
+ }
2678
+
2679
+ async function assertSchedulerRegistrationBeforeStart(
2680
+ readSchedulerXml: () => string,
2681
+ settle: (delayMs: number) => void | Promise<void>,
2682
+ matchesExpected: (currentXml: string) => boolean,
2683
+ changedMessage: string,
2684
+ unreadableMessage: string,
2685
+ ): Promise<void> {
2686
+ for (let attempt = 0; attempt <= SCHEDULER_SETTLE_DELAYS_MS.length; attempt += 1) {
2687
+ let beforeStartXml = "";
2688
+ try {
2689
+ beforeStartXml = readSchedulerXml();
2690
+ } catch {
2691
+ // Treat query errors like the default reader's empty result and retry below.
2692
+ }
2693
+ if (beforeStartXml.trim()) {
2694
+ if (!matchesExpected(beforeStartXml)) {
2695
+ throw new Error(changedMessage);
2696
+ }
2697
+ return;
2698
+ }
2699
+ const delayMs = SCHEDULER_SETTLE_DELAYS_MS[attempt];
2700
+ if (delayMs === undefined) break;
2701
+ await settle(delayMs);
2702
+ }
2703
+ throw new Error(unreadableMessage);
2704
+ }
2705
+
2380
2706
  /**
2381
- * Repair an already-installed background service without Task Scheduler re-registration.
2707
+ * Repair the already-installed background-service backend without switching managers.
2382
2708
  *
2383
- * Windows scheduler: rewrite assets + stop/start no `schtasks /create`, no UAC.
2709
+ * Windows scheduler: rewrite assets + stop/start; stale definitions are refreshed and may elevate.
2384
2710
  * Windows native: WinSW asset rewrite + restart (skips `install /p` when present).
2385
2711
  * macOS/Linux: re-run the user-level install/reload path.
2386
2712
  */
@@ -2410,8 +2736,159 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
2410
2736
  (deps.writeNativeState ?? (() => writeServiceInstallState("native")))();
2411
2737
  return;
2412
2738
  }
2739
+ const readSchedulerXml = deps.readSchedulerXml ?? statusWindowsXml;
2740
+ let registeredXml = "";
2741
+ try {
2742
+ registeredXml = readSchedulerXml();
2743
+ } catch {
2744
+ throw new Error(
2745
+ "Task Scheduler registration could not be read; repair stopped before changing or starting the service.",
2746
+ );
2747
+ }
2748
+ if (!registeredXml.trim()) {
2749
+ throw new Error(
2750
+ "Task Scheduler registration is empty or unreadable; repair stopped before changing or starting the service.",
2751
+ );
2752
+ }
2753
+ // Judge the definition against the same effective account the diagnostic uses. Relying on
2754
+ // the cached identity alone would make a scoped task this very version wrote look foreign
2755
+ // in a fresh process, and the message below would then name the wrong cause.
2756
+ const expectedUserId = (deps.resolveExpectedUserId ?? resolveWindowsTaskDiagnosticUserId)(registeredXml);
2757
+ const registrationHealthy = windowsTaskRegistrationHealthy(
2758
+ registeredXml,
2759
+ undefined,
2760
+ undefined,
2761
+ expectedUserId,
2762
+ );
2763
+ if (!registrationHealthy && !windowsTaskRegistrationRefreshableLegacy(registeredXml)) {
2764
+ const scopedButUnresolved = expectedUserId === null
2765
+ && taskXmlElementCount(
2766
+ taskXmlSection(taskXmlWithoutCommentsAndCdata(registeredXml), "Triggers"),
2767
+ "UserId",
2768
+ ) > 0;
2769
+ throw new Error(
2770
+ scopedButUnresolved
2771
+ ? "The registered Task Scheduler triggers name an account, but the current Windows identity could not be resolved, so the registration could not be verified. "
2772
+ + "It was preserved and not replaced; re-run repair once the account can be resolved."
2773
+ : "Task Scheduler registration is not a recognized legacy OpenCodex definition; it was preserved for manual review.",
2774
+ );
2775
+ }
2413
2776
  try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ }
2414
2777
  (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)();
2778
+ // Rewriting the on-disk assets does not touch the definition Task Scheduler holds, so a
2779
+ // task registered by an older version keeps its old triggers forever: status reports it
2780
+ // stale, tells the user to run repair, and repair changes nothing it complains about.
2781
+ // Re-register only when the registered XML is actually stale, so the ordinary repair
2782
+ // stays free of `schtasks /create` and its UAC prompt.
2783
+ let startExpectedXml = registeredXml;
2784
+ if (!registrationHealthy) {
2785
+ // The task was stopped above, so a failed replacement must not exit here: `/create /f`
2786
+ // can be rejected, elevation can be cancelled, and staging or verification can fail.
2787
+ // Any of those would leave a previously runnable proxy stopped and the user worse off
2788
+ // than before the repair. Restart the definition still registered and surface the
2789
+ // original failure instead.
2790
+ const attemptNonce = randomUUID();
2791
+ try {
2792
+ await (deps.reregisterScheduler ?? reregisterWindowsSchedulerTask)(attemptNonce, registeredXml);
2793
+ let replacementXml = "";
2794
+ try {
2795
+ replacementXml = readSchedulerXml();
2796
+ } catch {
2797
+ throw new Error("The refreshed Task Scheduler registration could not be read back.");
2798
+ }
2799
+ if (
2800
+ !windowsTaskRegistrationHealthy(replacementXml)
2801
+ || !windowsTaskRegistrationOwnedByAttempt(replacementXml, attemptNonce)
2802
+ ) {
2803
+ throw new Error(
2804
+ "The refreshed Task Scheduler registration failed live shape or attempt-ownership verification.",
2805
+ );
2806
+ }
2807
+ startExpectedXml = replacementXml;
2808
+ } catch (err) {
2809
+ const recoveryErrors: unknown[] = [];
2810
+ let restartExpectedXml: string | null = null;
2811
+ let currentXml: string | null = null;
2812
+ try {
2813
+ currentXml = readSchedulerXml();
2814
+ } catch {
2815
+ recoveryErrors.push(new Error(
2816
+ "Task Scheduler state became unreadable after the failed replacement; it was preserved and not started.",
2817
+ ));
2818
+ }
2819
+
2820
+ if (currentXml !== null) {
2821
+ if (windowsSchedulerRegistrationMatchesSnapshot(currentXml, registeredXml)) {
2822
+ restartExpectedXml = registeredXml;
2823
+ } else if (currentXml.trim()) {
2824
+ const attemptOwned = windowsTaskRegistrationOwnedByAttempt(currentXml, attemptNonce);
2825
+ if (attemptOwned && windowsTaskRegistrationHealthy(currentXml)) {
2826
+ restartExpectedXml = currentXml;
2827
+ } else {
2828
+ recoveryErrors.push(new Error(
2829
+ attemptOwned
2830
+ ? "The failed repair left an unhealthy attempt-owned registration; it was preserved and not started."
2831
+ : windowsTaskRegistrationHealthy(currentXml)
2832
+ ? "A different healthy OpenCodex Task Scheduler registration appeared during repair; it was preserved and not started."
2833
+ : "A different or unhealthy Task Scheduler registration appeared during repair; it was preserved and not started.",
2834
+ ));
2835
+ }
2836
+ } else {
2837
+ let probe: WindowsSchedulerTaskProbe;
2838
+ try {
2839
+ probe = (deps.probeScheduler ?? (() => probeWindowsSchedulerTask(TASK)))();
2840
+ } catch {
2841
+ probe = { status: "unknown", detail: "presence probe failed" };
2842
+ }
2843
+ if (probe.status === "absent") {
2844
+ try {
2845
+ await (deps.restoreSchedulerIfAbsent ?? restoreWindowsSchedulerTaskIfAbsent)(registeredXml);
2846
+ restartExpectedXml = registeredXml;
2847
+ } catch (error) {
2848
+ recoveryErrors.push(error);
2849
+ }
2850
+ } else {
2851
+ recoveryErrors.push(new Error(probe.status === "present"
2852
+ ? "A Task Scheduler registration is present but its XML is unreadable; it was preserved and not started."
2853
+ : `Task Scheduler state is unknown after the failed replacement (${probe.detail}); no registration was overwritten or started.`));
2854
+ }
2855
+ }
2856
+ }
2857
+
2858
+ if (restartExpectedXml !== null) {
2859
+ try {
2860
+ await assertSchedulerSnapshotBeforeStart(
2861
+ readSchedulerXml,
2862
+ restartExpectedXml,
2863
+ deps.settleSchedulerRead ?? settleDelay,
2864
+ "The Task Scheduler registration changed again before restart; the newer definition was preserved and not started.",
2865
+ "Task Scheduler state remained unreadable before restart; the registration was preserved and not started.",
2866
+ );
2867
+ (deps.startScheduler ?? startWindows)();
2868
+ } catch (error) {
2869
+ recoveryErrors.push(error);
2870
+ }
2871
+ }
2872
+ if (recoveryErrors.length > 0) {
2873
+ throw new AggregateError(
2874
+ [err, ...recoveryErrors],
2875
+ "Task Scheduler repair failed; concurrent or unverified scheduler state was preserved.",
2876
+ );
2877
+ }
2878
+ throw err;
2879
+ }
2880
+ }
2881
+ // The final live read is the proof that `/run` still targets the definition this repair
2882
+ // verified. A failed `schtasks /query` becomes an empty string, so allow only a bounded
2883
+ // retry for that unreadable state. A readable mismatch is authoritative and fails
2884
+ // immediately; presence alone cannot prove that the fixed-name task still has our XML.
2885
+ await assertSchedulerSnapshotBeforeStart(
2886
+ readSchedulerXml,
2887
+ startExpectedXml,
2888
+ deps.settleSchedulerRead ?? settleDelay,
2889
+ "Task Scheduler registration changed before restart; the current definition was preserved and not started.",
2890
+ "Task Scheduler registration became unreadable before restart; it was preserved and not started.",
2891
+ );
2415
2892
  (deps.startScheduler ?? startWindows)();
2416
2893
  (deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))();
2417
2894
  return;
@@ -2521,6 +2998,22 @@ export function stopWindows(): void {
2521
2998
  if (isWindowsSchedulerEndBenign(error)) return;
2522
2999
  }
2523
3000
  }
3001
+
3002
+ /**
3003
+ * `stopWindows` for callers that need to know whether it worked.
3004
+ *
3005
+ * The void form swallows a non-benign `/end` failure, which is right for best-effort
3006
+ * teardown and wrong for deciding whether an update may replace files: a scheduler that
3007
+ * refused to stop can respawn the proxy on top of a half-written install (#3008).
3008
+ */
3009
+ export function stopWindowsChecked(): boolean {
3010
+ try {
3011
+ schtasks(["/end", "/tn", TASK]);
3012
+ return true;
3013
+ } catch (error) {
3014
+ return isWindowsSchedulerEndBenign(error);
3015
+ }
3016
+ }
2524
3017
  function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
2525
3018
  function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
2526
3019
 
@@ -3017,6 +3510,11 @@ export interface FreshWindowsSchedulerInstallDeps {
3017
3510
  prepare?: () => Promise<void>;
3018
3511
  removeNativeService?: () => void;
3019
3512
  publishAssets?: () => void;
3513
+ verifyBeforeRun?: (attemptNonce: string) => void | Promise<void>;
3514
+ /** Reads the newly registered task; empty or throwing reads are retried before rollback. */
3515
+ readSchedulerXml?: () => string;
3516
+ /** Bounded wait before retrying an unreadable fresh-install registration. */
3517
+ settleSchedulerRead?: (delayMs: number) => void | Promise<void>;
3020
3518
  runTask?: () => void;
3021
3519
  writeState?: () => void;
3022
3520
  rollbackTask?: (attemptNonce: string) => Promise<string | null>;
@@ -3040,6 +3538,18 @@ export async function installFreshWindowsSchedulerSafely(
3040
3538
  const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler"));
3041
3539
  const removeNativeService = deps.removeNativeService ?? removeNativeWindowsServiceForScheduler;
3042
3540
  const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets;
3541
+ const verifyBeforeRun = deps.verifyBeforeRun ?? ((nonce: string) => (
3542
+ assertSchedulerRegistrationBeforeStart(
3543
+ deps.readSchedulerXml ?? statusWindowsXml,
3544
+ deps.settleSchedulerRead ?? settleDelay,
3545
+ liveXml => (
3546
+ windowsTaskRegistrationHealthy(liveXml)
3547
+ && windowsTaskRegistrationOwnedByAttempt(liveXml, nonce)
3548
+ ),
3549
+ "The fresh Task Scheduler registration changed before start; it was preserved and not run.",
3550
+ "The fresh Task Scheduler registration remained unreadable before start; it was preserved and not run.",
3551
+ )
3552
+ ));
3043
3553
  const runTask = deps.runTask ?? startWindows;
3044
3554
  const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler"));
3045
3555
  const rollbackTask = deps.rollbackTask ?? ((attemptNonce: string) => (
@@ -3074,6 +3584,7 @@ export async function installFreshWindowsSchedulerSafely(
3074
3584
  await prepare();
3075
3585
  removeNativeService();
3076
3586
  publishAssets();
3587
+ await verifyBeforeRun(attemptNonce);
3077
3588
  runTask();
3078
3589
  started = true;
3079
3590
  writeState();
@@ -3109,36 +3620,129 @@ export async function installFreshWindowsSchedulerSafely(
3109
3620
  }
3110
3621
  }
3111
3622
 
3623
+ // `stopServiceIfInstalled` (boolean) is deliberately gone. It collapsed "not installed",
3624
+ // "refused to stop" and "state could not be read" into the same `false`, and every caller
3625
+ // that trusted it eventually read a live manager as absence — the route, then uninstall
3626
+ // (#3008). Callers take `stopServiceIfInstalledDetailed` and handle the outcomes.
3627
+ /**
3628
+ * Would stopping the installed manager leave something that can respawn the proxy?
3629
+ *
3630
+ * Answered WITHOUT stopping anything, because a caller that must refuse the stop has to
3631
+ * refuse before it acts: `POST /api/stop` briefly ended the Task Scheduler task and then
3632
+ * returned 409, which left the proxy running with its manager stopped — worse than either
3633
+ * outcome it was choosing between.
3634
+ *
3635
+ * Task Scheduler only. `schtasks /end` ends the task instance while the `cmd :loop`
3636
+ * wrapper survives and respawns its child (#764); launchd, systemd and WinSW are down when
3637
+ * they report stopped.
3638
+ */
3639
+ export function installedServiceRespawnRisk(
3640
+ probe: () => WindowsSchedulerTaskProbe = probeWindowsSchedulerTask,
3641
+ platform: NodeJS.Platform = process.platform,
3642
+ ): "none" | "respawnable" | "unknown" {
3643
+ // launchd, systemd and WinSW are down when they report stopped; only the Task Scheduler
3644
+ // wrapper survives its task ending (#764).
3645
+ if (platform !== "win32") return "none";
3646
+ try {
3647
+ // `probeWindowsSchedulerTask` returns "unknown" as an ordinary value when its queries
3648
+ // fail — it does not throw — so testing for "present" let an unanswerable probe
3649
+ // through, and the route then killed scheduler wrappers before refusing.
3650
+ //
3651
+ // "unknown" is kept SEPARATE from "respawnable" because the remedies differ. Telling
3652
+ // an operator whose schtasks query is broken to run `ocx stop` is circular: that
3653
+ // command maps the same unknown to a stop failure, so it cannot finish either.
3654
+ const status = probe().status;
3655
+ if (status === "absent") return "none";
3656
+ return status === "present" ? "respawnable" : "unknown";
3657
+ } catch {
3658
+ // A probe that cannot answer is not evidence of absence either.
3659
+ return "unknown";
3660
+ }
3661
+ }
3662
+
3663
+ /**
3664
+ * Outcome of stopping an installed process manager.
3665
+ *
3666
+ * `stopServiceIfInstalled` collapses "no service was installed" and "a service was
3667
+ * installed and would not stop" into the same `false`, which is fine for a caller that
3668
+ * only wants to log. It is not fine for one deciding whether an update may replace package
3669
+ * files: a manager that refused to stop can respawn the proxy on top of a half-written
3670
+ * install (#3008).
3671
+ */
3672
+ /**
3673
+ * `stopped-respawnable` is Task Scheduler specifically: `schtasks /end` ends the task
3674
+ * instance while the `cmd :loop` wrapper survives and respawns its child seconds later
3675
+ * (#764). Only that backend needs the restart-window wait — launchd, systemd and WinSW
3676
+ * are down when they report stopped, and making them pay a seven-second poll would be a
3677
+ * regression in every ordinary `ocx stop`.
3678
+ */
3112
3679
  /**
3113
- * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
3114
- * Returns true if a service was found and stopped.
3680
+ * `state-unknown` is kept apart from `failed` because the remedies differ. A manager that
3681
+ * refused to stop is a stop failure the operator can retry; a scheduler whose state cannot
3682
+ * be READ is a broken query, and telling that operator "the manager did not stop" sends
3683
+ * them looking for the wrong thing (#3008).
3115
3684
  */
3116
- export function stopServiceIfInstalled(): boolean {
3685
+ export type ServiceStopOutcome = "absent" | "stopped" | "stopped-respawnable" | "failed" | "state-unknown";
3686
+
3687
+ /**
3688
+ * Collapse the Windows backend observations into one outcome.
3689
+ *
3690
+ * Extracted so the precedence is testable by calling it. The rule that matters: a readable
3691
+ * failure outranks an unreadable state, and an unreadable state outranks success — a
3692
+ * scheduler we cannot see may still respawn the proxy.
3693
+ */
3694
+ export function classifyWindowsServiceStop(o: {
3695
+ stopped: boolean;
3696
+ failed: boolean;
3697
+ schedulerStopped: boolean;
3698
+ stateUnknown: boolean;
3699
+ }): ServiceStopOutcome {
3700
+ if (o.failed) return "failed";
3701
+ if (o.stateUnknown) return "state-unknown";
3702
+ if (o.stopped) return o.schedulerStopped ? "stopped-respawnable" : "stopped";
3703
+ return "absent";
3704
+ }
3705
+
3706
+ export function stopServiceIfInstalledDetailed(): ServiceStopOutcome {
3117
3707
  assertServiceEnvironmentMatchesInstall();
3118
3708
  if (process.platform === "darwin") {
3119
3709
  if (existsSync(plistPath())) {
3120
- try { stopLaunchd(); return true; } catch { return false; }
3710
+ try { stopLaunchd(); return "stopped"; } catch { return "failed"; }
3121
3711
  }
3122
3712
  } else if (process.platform === "win32") {
3123
3713
  // Query BOTH backends regardless of state: a failed switch or stale state can leave
3124
3714
  // two managers installed, and either one would respawn the proxy after `ocx stop`.
3125
3715
  let stopped = false;
3126
- try {
3127
- const q = schtasks(["/query", "/tn", TASK]);
3128
- if (q.includes(TASK)) { stopWindows(); stopped = true; }
3129
- } catch { /* task not found */ }
3716
+ let failed = false;
3717
+ let schedulerStopped = false;
3718
+ let stateUnknown = false;
3719
+ // `probeWindowsSchedulerTask` is tri-state on purpose: a query that THROWS is not the
3720
+ // same as a task that is absent, and treating it as absent lets a live scheduler
3721
+ // survive a "successful" stop.
3722
+ const probe = probeWindowsSchedulerTask();
3723
+ if (probe.status === "present") {
3724
+ if (stopWindowsChecked()) { stopped = true; schedulerStopped = true; }
3725
+ else failed = true;
3726
+ } else if (probe.status === "unknown") {
3727
+ // Not "failed": nothing refused to stop. The query itself could not answer, which is
3728
+ // a different problem with a different fix.
3729
+ stateUnknown = true;
3730
+ }
3130
3731
  if (statusWinswRaw() !== "nonexistent") {
3131
- try { stopWinswService(); stopped = true; } catch { /* best-effort */ }
3732
+ try { stopWinswService(); stopped = true; } catch { failed = true; }
3132
3733
  }
3133
3734
  // `schtasks /end` ends the task instance but the cmd `:loop` wrapper survives and
3134
3735
  // respawns its child seconds later (issue #764), resurrecting the proxy during a
3135
3736
  // stop or a tray restart. Kill the launcher/wrapper processes outright.
3136
3737
  killWindowsServiceWrapperProcesses();
3137
- if (stopped) return true;
3738
+ // A failure on either backend wins: the other one stopping does not make the live one
3739
+ // safe to update over.
3740
+ const outcome = classifyWindowsServiceStop({ stopped, failed, schedulerStopped, stateUnknown });
3741
+ if (outcome !== "absent") return outcome;
3138
3742
  } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
3139
- try { stopSystemd(); return true; } catch { return false; }
3743
+ try { stopSystemd(); return "stopped"; } catch { return "failed"; }
3140
3744
  }
3141
- return false;
3745
+ return "absent";
3142
3746
  }
3143
3747
 
3144
3748
  /** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */
@@ -3171,13 +3775,22 @@ export function setUninstallServiceHooksForTests(hooks: UninstallServiceHooksFor
3171
3775
  * service or scheduler task that cannot be removed throws so the caller cannot erase state and
3172
3776
  * report success.
3173
3777
  */
3174
- export function uninstallServiceIfInstalled(): boolean {
3778
+ /**
3779
+ * Outcome of removing an installed manager.
3780
+ *
3781
+ * `false` used to mean both "nothing was installed" and "removal failed" on darwin and
3782
+ * linux, so a failed removal was reported as absence and authorized the shared teardown
3783
+ * while the service assets were still there (#3008).
3784
+ */
3785
+ export type ServiceUninstallOutcome = "absent" | "removed" | "failed";
3786
+
3787
+ export function uninstallServiceDetailed(): ServiceUninstallOutcome {
3175
3788
  const hooks = uninstallServiceHooksForTests;
3176
3789
  (hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)();
3177
3790
  const platform = hooks?.platform ?? process.platform;
3178
3791
  if (platform === "darwin") {
3179
3792
  if (existsSync(plistPath())) {
3180
- try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
3793
+ try { uninstallLaunchd(); removeServiceInstallState(); return "removed"; } catch { return "failed"; }
3181
3794
  }
3182
3795
  } else if (platform === "win32") {
3183
3796
  let removed = false;
@@ -3193,13 +3806,20 @@ export function uninstallServiceIfInstalled(): boolean {
3193
3806
  (hooks?.uninstallNative ?? uninstallWinswService)();
3194
3807
  removed = true;
3195
3808
  }
3196
- if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return true; }
3809
+ if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return "removed"; }
3197
3810
  } else if (platform === "linux" && existsSync(unitPath())) {
3198
- try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
3199
- try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
3811
+ try { uninstallSystemd(); removeServiceInstallState(); return "removed"; } catch {
3812
+ try { unlinkSync(unitPath()); removeServiceInstallState(); return "removed"; } catch { return "failed"; }
3200
3813
  }
3201
3814
  }
3202
- return false;
3815
+ return "absent";
3816
+ }
3817
+
3818
+ /** Boolean form for callers that only distinguish "something was removed". */
3819
+ export function uninstallServiceIfInstalled(): boolean {
3820
+ const outcome = uninstallServiceDetailed();
3821
+ if (outcome === "failed") throw new Error("the installed service could not be removed");
3822
+ return outcome === "removed";
3203
3823
  }
3204
3824
 
3205
3825
  /** True if a background service (launchd/systemd/Task Scheduler) is installed. */
@@ -3234,6 +3854,36 @@ export function serviceStartableFromTray(service: ServiceDiagnostic): boolean {
3234
3854
  return service.startable && !service.stale && !service.conflict;
3235
3855
  }
3236
3856
 
3857
+ export interface WindowsTaskDiagnosticIdentityDeps {
3858
+ currentIdentity?: () => Readonly<{ name: string }> | null;
3859
+ resolvePrincipal?: (timeoutMs: number) => string;
3860
+ }
3861
+
3862
+ /**
3863
+ * Resolve the effective account only when the registered task carries an explicit unprefixed
3864
+ * trigger scope. Empty/unscoped tasks do not need identity and must not pay a repeated sync
3865
+ * lookup timeout; prefixed scopes remain unreadable and fail closed in the XML validator.
3866
+ */
3867
+ export function resolveWindowsTaskDiagnosticUserId(
3868
+ schedulerXml: string,
3869
+ deps: WindowsTaskDiagnosticIdentityDeps = {},
3870
+ ): string | null {
3871
+ const currentIdentity = deps.currentIdentity ?? cachedCurrentWindowsIdentity;
3872
+ const cached = currentIdentity();
3873
+ if (cached) return cached.name;
3874
+
3875
+ const scrubbed = taskXmlWithoutCommentsAndCdata(schedulerXml);
3876
+ const triggers = taskXmlSection(scrubbed, "Triggers");
3877
+ if (taskXmlElementCount(triggers, "UserId") === 0) return null;
3878
+
3879
+ try {
3880
+ (deps.resolvePrincipal ?? resolveCurrentWindowsPrincipal)(WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS);
3881
+ } catch {
3882
+ return null;
3883
+ }
3884
+ return currentIdentity()?.name ?? null;
3885
+ }
3886
+
3237
3887
  export interface WindowsServiceDiagnosticInputs {
3238
3888
  /**
3239
3889
  * Raw `schtasks /query /xml` output; empty when no task is registered. Passed as
@@ -3242,6 +3892,8 @@ export interface WindowsServiceDiagnosticInputs {
3242
3892
  * silently reintroduce the stale-status false positive (#432).
3243
3893
  */
3244
3894
  schedulerXml: string;
3895
+ /** Resolved effective account for explicit scheduler trigger scopes; null means unknown. */
3896
+ schedulerExpectedUserId?: string | null;
3245
3897
  /** Whether the on-disk service assets exist. A filesystem concern, not an XML one. */
3246
3898
  schedulerAssetsPresent: boolean;
3247
3899
  nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
@@ -3252,7 +3904,15 @@ export interface WindowsServiceDiagnosticInputs {
3252
3904
  }
3253
3905
 
3254
3906
  export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticInputs): ServiceDiagnostic {
3255
- const schedulerState = readWindowsSchedulerXmlState(inputs.schedulerXml);
3907
+ const expectedUserId = inputs.schedulerExpectedUserId === undefined
3908
+ ? cachedCurrentWindowsIdentity()?.name ?? null
3909
+ : inputs.schedulerExpectedUserId;
3910
+ const schedulerState = readWindowsSchedulerXmlState(
3911
+ inputs.schedulerXml,
3912
+ undefined,
3913
+ undefined,
3914
+ expectedUserId,
3915
+ );
3256
3916
  const schedulerInstalled = schedulerState.installed;
3257
3917
  const schedulerEnabled = schedulerState.enabled;
3258
3918
  const schedulerAssetsHealthy = inputs.schedulerAssetsPresent && schedulerState.registrationHealthy;
@@ -3298,6 +3958,17 @@ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticI
3298
3958
  };
3299
3959
  }
3300
3960
 
3961
+ /** Bind the live Windows identity to a scheduler snapshot before deriving service health. */
3962
+ export function deriveWindowsServiceDiagnosticForCurrentUser(
3963
+ inputs: Omit<WindowsServiceDiagnosticInputs, "schedulerExpectedUserId">,
3964
+ identityDeps: WindowsTaskDiagnosticIdentityDeps = {},
3965
+ ): ServiceDiagnostic {
3966
+ return deriveWindowsServiceDiagnostic({
3967
+ ...inputs,
3968
+ schedulerExpectedUserId: resolveWindowsTaskDiagnosticUserId(inputs.schedulerXml, identityDeps),
3969
+ });
3970
+ }
3971
+
3301
3972
  /**
3302
3973
  * Fail-closed restart diagnostic. Presence alone is never enough: conflicting
3303
3974
  * managers, stale baked paths, disabled registrations, and unknown/stopped
@@ -3325,7 +3996,7 @@ export function diagnoseService(): ServiceDiagnostic {
3325
3996
  const recordedBackend: ServiceBackend | null = !installState
3326
3997
  ? null
3327
3998
  : installState.backend === "native" ? "native" : "scheduler";
3328
- return deriveWindowsServiceDiagnostic({
3999
+ return deriveWindowsServiceDiagnosticForCurrentUser({
3329
4000
  schedulerXml,
3330
4001
  schedulerAssetsPresent,
3331
4002
  nativeStatus,
@@ -3483,7 +4154,8 @@ export function probeServiceInstallation(
3483
4154
  /**
3484
4155
  * A bare invocation is an idempotent "make the installed service current"
3485
4156
  * operation. First-time setup still installs, but an existing registration must
3486
- * use the repair path so Windows does not re-run the elevated `schtasks /create`.
4157
+ * use the repair path so Windows avoids unconditional elevated registration; repair may
4158
+ * still refresh a stale scheduler definition.
3487
4159
  * Backend flags remain an explicit install request because they select which
3488
4160
  * registration mechanism to create.
3489
4161
  */
@@ -3560,12 +4232,15 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
3560
4232
 
3561
4233
  export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
3562
4234
  const filteredArgs = args.filter((a): a is string => Boolean(a));
3563
- const plan = planServiceCommand(filteredArgs);
3564
- if (!plan.ok) {
3565
- console.error(plan.message);
3566
- process.exit(1);
3567
- }
3568
- const { parsed, command } = plan;
4235
+ const execute = async (): Promise<void> => {
4236
+ // Planning reads manager state. Repeat it only after the writer lock is held, otherwise a
4237
+ // bare command can choose install from a snapshot another service command already changed.
4238
+ const plan = planServiceCommand(filteredArgs);
4239
+ if (!plan.ok) {
4240
+ console.error(plan.message);
4241
+ process.exit(1);
4242
+ }
4243
+ const { parsed, command } = plan;
3569
4244
  if (command === "repair") {
3570
4245
  assertServiceEnvironmentMatchesInstall();
3571
4246
  assertServiceAuthEnvironment();
@@ -3654,11 +4329,17 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
3654
4329
  const restore = await restoreNativeCodexAsync();
3655
4330
  if (restore.success) console.log("✅ service stopped + native Codex restored.");
3656
4331
  else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
4332
+ if (!restore.success) process.exitCode = 1;
3657
4333
  // The Grok fence is the other managed config this command owns. Leaving it behind
3658
4334
  // pointed grok at a dead endpoint while native Codex was already restored.
3659
4335
  const grok = stripGrokConfig();
3660
4336
  if (grok.changed) console.log(`↩️ ${grok.message}`);
3661
- else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
4337
+ else if (!grok.ok) {
4338
+ // A failed strip leaves Grok aimed at a proxy this command just stopped. Exiting
4339
+ // 0 tells a script the teardown finished when half of it did not.
4340
+ console.error(`⚠️ ${grok.message}`);
4341
+ process.exitCode = 1;
4342
+ }
3662
4343
  }
3663
4344
  break;
3664
4345
  }
@@ -3692,10 +4373,14 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
3692
4373
  const restore = await restoreNativeCodexAsync();
3693
4374
  if (!restore.success) {
3694
4375
  console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
4376
+ process.exitCode = 1;
3695
4377
  }
3696
4378
  const grok = stripGrokConfig();
3697
4379
  if (grok.changed) console.log(`↩️ ${grok.message}`);
3698
- else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
4380
+ else if (!grok.ok) {
4381
+ console.error(`⚠️ ${grok.message}`);
4382
+ process.exitCode = 1;
4383
+ }
3699
4384
  }
3700
4385
  removeServiceInstallState();
3701
4386
  try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
@@ -3704,9 +4389,20 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
3704
4389
  default:
3705
4390
  console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove] [--native|--scheduler]");
3706
4391
  console.error(" With no subcommand, installs when absent or repairs/restarts an existing service.");
3707
- console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
4392
+ console.error(" repair: refresh and restart the installed backend; stale Windows tasks may request admin approval.");
3708
4393
  console.error(" restart: alias of repair.");
3709
4394
  console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
3710
4395
  process.exit(1);
3711
4396
  }
4397
+ };
4398
+
4399
+ const preliminary = parseServiceArgs(filteredArgs);
4400
+ const windowsMutation = process.platform === "win32"
4401
+ && preliminary.invalid.length === 0
4402
+ && preliminary.sub !== "status";
4403
+ if (windowsMutation) {
4404
+ await withWindowsServiceMutationLock(execute);
4405
+ return;
4406
+ }
4407
+ await execute();
3712
4408
  }