@bitkyc08/opencodex 2.37.0 → 2.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/bin/ocx.mjs +69 -10
  2. package/gui/dist/assets/{index-CowztZdo.js → index-C14iCj_Q.js} +13 -13
  3. package/gui/dist/assets/index-D7PIz7_g.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  7. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  8. package/gui/dist/provider-icons/openclaw.svg +54 -0
  9. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  10. package/gui/dist/provider-icons/zcode.svg +219 -0
  11. package/package.json +1 -1
  12. package/src/adapters/cursor/protobuf-request.ts +4 -1
  13. package/src/adapters/cursor/tool-definitions.ts +36 -4
  14. package/src/cli/capabilities.ts +14 -0
  15. package/src/cli/codex-cli-update.ts +96 -0
  16. package/src/cli/codex-shim-autorestore.ts +3 -0
  17. package/src/cli/export-command.ts +18 -17
  18. package/src/cli/help.ts +2 -2
  19. package/src/cli/index.ts +3 -2
  20. package/src/cli/launcher-context.ts +53 -2
  21. package/src/cli/opencode.ts +126 -33
  22. package/src/cli/registry.ts +16 -10
  23. package/src/cli/system-command.ts +6 -1
  24. package/src/clients/config-export.ts +293 -28
  25. package/src/codex/account-store.ts +10 -4
  26. package/src/codex/autostart-health.ts +3 -3
  27. package/src/codex/catalog/provider-fetch.ts +20 -1
  28. package/src/codex/catalog/sync.ts +4 -3
  29. package/src/codex/cli-install-provenance.ts +795 -0
  30. package/src/codex/convergence.ts +4 -3
  31. package/src/codex/credential-mutation-epoch.ts +11 -0
  32. package/src/codex/main-account.ts +2 -0
  33. package/src/codex/model-entitlements.ts +430 -27
  34. package/src/codex/native-profile-manager.ts +4 -0
  35. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  36. package/src/codex/reset-credit-recovery.ts +20 -2
  37. package/src/codex/shim.ts +204 -18
  38. package/src/codex/user-identity.ts +2 -1
  39. package/src/config/paths.ts +18 -3
  40. package/src/config.ts +23 -0
  41. package/src/generated/compatibility-version.json +81 -45
  42. package/src/integrations/registry.ts +112 -0
  43. package/src/integrations/state.ts +67 -5
  44. package/src/integrations/writer.ts +25 -9
  45. package/src/lib/bounded-subprocess.ts +36 -0
  46. package/src/lib/strict-semver.ts +47 -0
  47. package/src/lib/windows-elevation.ts +32 -1
  48. package/src/lib/windows-secret-acl.ts +47 -25
  49. package/src/lib/windows-service-mutation-lock.ts +133 -0
  50. package/src/lib/windows-user-principal.ts +15 -17
  51. package/src/responses/spill-store.ts +334 -29
  52. package/src/responses/state.ts +488 -7
  53. package/src/server/index.ts +4 -3
  54. package/src/server/lifecycle.ts +5 -1
  55. package/src/server/management/model-rows.ts +11 -2
  56. package/src/server/management/provider-routes.ts +4 -0
  57. package/src/server/management/system-restart.ts +5 -5
  58. package/src/server/management-api.ts +7 -2
  59. package/src/server/startup-action-control.ts +3 -2
  60. package/src/service.ts +594 -33
  61. package/src/sidecar/candidates.ts +1 -1
  62. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  63. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  64. package/src/update/index.ts +3 -2
  65. package/src/update/job.ts +10 -11
  66. 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;
@@ -3017,6 +3494,11 @@ export interface FreshWindowsSchedulerInstallDeps {
3017
3494
  prepare?: () => Promise<void>;
3018
3495
  removeNativeService?: () => void;
3019
3496
  publishAssets?: () => void;
3497
+ verifyBeforeRun?: (attemptNonce: string) => void | Promise<void>;
3498
+ /** Reads the newly registered task; empty or throwing reads are retried before rollback. */
3499
+ readSchedulerXml?: () => string;
3500
+ /** Bounded wait before retrying an unreadable fresh-install registration. */
3501
+ settleSchedulerRead?: (delayMs: number) => void | Promise<void>;
3020
3502
  runTask?: () => void;
3021
3503
  writeState?: () => void;
3022
3504
  rollbackTask?: (attemptNonce: string) => Promise<string | null>;
@@ -3040,6 +3522,18 @@ export async function installFreshWindowsSchedulerSafely(
3040
3522
  const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler"));
3041
3523
  const removeNativeService = deps.removeNativeService ?? removeNativeWindowsServiceForScheduler;
3042
3524
  const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets;
3525
+ const verifyBeforeRun = deps.verifyBeforeRun ?? ((nonce: string) => (
3526
+ assertSchedulerRegistrationBeforeStart(
3527
+ deps.readSchedulerXml ?? statusWindowsXml,
3528
+ deps.settleSchedulerRead ?? settleDelay,
3529
+ liveXml => (
3530
+ windowsTaskRegistrationHealthy(liveXml)
3531
+ && windowsTaskRegistrationOwnedByAttempt(liveXml, nonce)
3532
+ ),
3533
+ "The fresh Task Scheduler registration changed before start; it was preserved and not run.",
3534
+ "The fresh Task Scheduler registration remained unreadable before start; it was preserved and not run.",
3535
+ )
3536
+ ));
3043
3537
  const runTask = deps.runTask ?? startWindows;
3044
3538
  const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler"));
3045
3539
  const rollbackTask = deps.rollbackTask ?? ((attemptNonce: string) => (
@@ -3074,6 +3568,7 @@ export async function installFreshWindowsSchedulerSafely(
3074
3568
  await prepare();
3075
3569
  removeNativeService();
3076
3570
  publishAssets();
3571
+ await verifyBeforeRun(attemptNonce);
3077
3572
  runTask();
3078
3573
  started = true;
3079
3574
  writeState();
@@ -3234,6 +3729,36 @@ export function serviceStartableFromTray(service: ServiceDiagnostic): boolean {
3234
3729
  return service.startable && !service.stale && !service.conflict;
3235
3730
  }
3236
3731
 
3732
+ export interface WindowsTaskDiagnosticIdentityDeps {
3733
+ currentIdentity?: () => Readonly<{ name: string }> | null;
3734
+ resolvePrincipal?: (timeoutMs: number) => string;
3735
+ }
3736
+
3737
+ /**
3738
+ * Resolve the effective account only when the registered task carries an explicit unprefixed
3739
+ * trigger scope. Empty/unscoped tasks do not need identity and must not pay a repeated sync
3740
+ * lookup timeout; prefixed scopes remain unreadable and fail closed in the XML validator.
3741
+ */
3742
+ export function resolveWindowsTaskDiagnosticUserId(
3743
+ schedulerXml: string,
3744
+ deps: WindowsTaskDiagnosticIdentityDeps = {},
3745
+ ): string | null {
3746
+ const currentIdentity = deps.currentIdentity ?? cachedCurrentWindowsIdentity;
3747
+ const cached = currentIdentity();
3748
+ if (cached) return cached.name;
3749
+
3750
+ const scrubbed = taskXmlWithoutCommentsAndCdata(schedulerXml);
3751
+ const triggers = taskXmlSection(scrubbed, "Triggers");
3752
+ if (taskXmlElementCount(triggers, "UserId") === 0) return null;
3753
+
3754
+ try {
3755
+ (deps.resolvePrincipal ?? resolveCurrentWindowsPrincipal)(WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS);
3756
+ } catch {
3757
+ return null;
3758
+ }
3759
+ return currentIdentity()?.name ?? null;
3760
+ }
3761
+
3237
3762
  export interface WindowsServiceDiagnosticInputs {
3238
3763
  /**
3239
3764
  * Raw `schtasks /query /xml` output; empty when no task is registered. Passed as
@@ -3242,6 +3767,8 @@ export interface WindowsServiceDiagnosticInputs {
3242
3767
  * silently reintroduce the stale-status false positive (#432).
3243
3768
  */
3244
3769
  schedulerXml: string;
3770
+ /** Resolved effective account for explicit scheduler trigger scopes; null means unknown. */
3771
+ schedulerExpectedUserId?: string | null;
3245
3772
  /** Whether the on-disk service assets exist. A filesystem concern, not an XML one. */
3246
3773
  schedulerAssetsPresent: boolean;
3247
3774
  nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
@@ -3252,7 +3779,15 @@ export interface WindowsServiceDiagnosticInputs {
3252
3779
  }
3253
3780
 
3254
3781
  export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticInputs): ServiceDiagnostic {
3255
- const schedulerState = readWindowsSchedulerXmlState(inputs.schedulerXml);
3782
+ const expectedUserId = inputs.schedulerExpectedUserId === undefined
3783
+ ? cachedCurrentWindowsIdentity()?.name ?? null
3784
+ : inputs.schedulerExpectedUserId;
3785
+ const schedulerState = readWindowsSchedulerXmlState(
3786
+ inputs.schedulerXml,
3787
+ undefined,
3788
+ undefined,
3789
+ expectedUserId,
3790
+ );
3256
3791
  const schedulerInstalled = schedulerState.installed;
3257
3792
  const schedulerEnabled = schedulerState.enabled;
3258
3793
  const schedulerAssetsHealthy = inputs.schedulerAssetsPresent && schedulerState.registrationHealthy;
@@ -3298,6 +3833,17 @@ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticI
3298
3833
  };
3299
3834
  }
3300
3835
 
3836
+ /** Bind the live Windows identity to a scheduler snapshot before deriving service health. */
3837
+ export function deriveWindowsServiceDiagnosticForCurrentUser(
3838
+ inputs: Omit<WindowsServiceDiagnosticInputs, "schedulerExpectedUserId">,
3839
+ identityDeps: WindowsTaskDiagnosticIdentityDeps = {},
3840
+ ): ServiceDiagnostic {
3841
+ return deriveWindowsServiceDiagnostic({
3842
+ ...inputs,
3843
+ schedulerExpectedUserId: resolveWindowsTaskDiagnosticUserId(inputs.schedulerXml, identityDeps),
3844
+ });
3845
+ }
3846
+
3301
3847
  /**
3302
3848
  * Fail-closed restart diagnostic. Presence alone is never enough: conflicting
3303
3849
  * managers, stale baked paths, disabled registrations, and unknown/stopped
@@ -3325,7 +3871,7 @@ export function diagnoseService(): ServiceDiagnostic {
3325
3871
  const recordedBackend: ServiceBackend | null = !installState
3326
3872
  ? null
3327
3873
  : installState.backend === "native" ? "native" : "scheduler";
3328
- return deriveWindowsServiceDiagnostic({
3874
+ return deriveWindowsServiceDiagnosticForCurrentUser({
3329
3875
  schedulerXml,
3330
3876
  schedulerAssetsPresent,
3331
3877
  nativeStatus,
@@ -3483,7 +4029,8 @@ export function probeServiceInstallation(
3483
4029
  /**
3484
4030
  * A bare invocation is an idempotent "make the installed service current"
3485
4031
  * 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`.
4032
+ * use the repair path so Windows avoids unconditional elevated registration; repair may
4033
+ * still refresh a stale scheduler definition.
3487
4034
  * Backend flags remain an explicit install request because they select which
3488
4035
  * registration mechanism to create.
3489
4036
  */
@@ -3560,12 +4107,15 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
3560
4107
 
3561
4108
  export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
3562
4109
  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;
4110
+ const execute = async (): Promise<void> => {
4111
+ // Planning reads manager state. Repeat it only after the writer lock is held, otherwise a
4112
+ // bare command can choose install from a snapshot another service command already changed.
4113
+ const plan = planServiceCommand(filteredArgs);
4114
+ if (!plan.ok) {
4115
+ console.error(plan.message);
4116
+ process.exit(1);
4117
+ }
4118
+ const { parsed, command } = plan;
3569
4119
  if (command === "repair") {
3570
4120
  assertServiceEnvironmentMatchesInstall();
3571
4121
  assertServiceAuthEnvironment();
@@ -3704,9 +4254,20 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
3704
4254
  default:
3705
4255
  console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove] [--native|--scheduler]");
3706
4256
  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).");
4257
+ console.error(" repair: refresh and restart the installed backend; stale Windows tasks may request admin approval.");
3708
4258
  console.error(" restart: alias of repair.");
3709
4259
  console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
3710
4260
  process.exit(1);
3711
4261
  }
4262
+ };
4263
+
4264
+ const preliminary = parseServiceArgs(filteredArgs);
4265
+ const windowsMutation = process.platform === "win32"
4266
+ && preliminary.invalid.length === 0
4267
+ && preliminary.sub !== "status";
4268
+ if (windowsMutation) {
4269
+ await withWindowsServiceMutationLock(execute);
4270
+ return;
4271
+ }
4272
+ await execute();
3712
4273
  }