@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.
- package/bin/ocx.mjs +69 -10
- package/gui/dist/assets/{index-CowztZdo.js → index-C14iCj_Q.js} +13 -13
- package/gui/dist/assets/index-D7PIz7_g.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/aside.svg +3 -0
- package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
- package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
- package/gui/dist/provider-icons/openclaw.svg +54 -0
- package/gui/dist/provider-icons/prime-agent.svg +21 -0
- package/gui/dist/provider-icons/zcode.svg +219 -0
- package/package.json +1 -1
- package/src/adapters/cursor/protobuf-request.ts +4 -1
- package/src/adapters/cursor/tool-definitions.ts +36 -4
- package/src/cli/capabilities.ts +14 -0
- package/src/cli/codex-cli-update.ts +96 -0
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/export-command.ts +18 -17
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +3 -2
- package/src/cli/launcher-context.ts +53 -2
- package/src/cli/opencode.ts +126 -33
- package/src/cli/registry.ts +16 -10
- package/src/cli/system-command.ts +6 -1
- package/src/clients/config-export.ts +293 -28
- package/src/codex/account-store.ts +10 -4
- package/src/codex/autostart-health.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +20 -1
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/cli-install-provenance.ts +795 -0
- package/src/codex/convergence.ts +4 -3
- package/src/codex/credential-mutation-epoch.ts +11 -0
- package/src/codex/main-account.ts +2 -0
- package/src/codex/model-entitlements.ts +430 -27
- package/src/codex/native-profile-manager.ts +4 -0
- package/src/codex/reset-credit-operation-ledger.ts +1411 -0
- package/src/codex/reset-credit-recovery.ts +20 -2
- package/src/codex/shim.ts +204 -18
- package/src/codex/user-identity.ts +2 -1
- package/src/config/paths.ts +18 -3
- package/src/config.ts +23 -0
- package/src/generated/compatibility-version.json +81 -45
- package/src/integrations/registry.ts +112 -0
- package/src/integrations/state.ts +67 -5
- package/src/integrations/writer.ts +25 -9
- package/src/lib/bounded-subprocess.ts +36 -0
- package/src/lib/strict-semver.ts +47 -0
- package/src/lib/windows-elevation.ts +32 -1
- package/src/lib/windows-secret-acl.ts +47 -25
- package/src/lib/windows-service-mutation-lock.ts +133 -0
- package/src/lib/windows-user-principal.ts +15 -17
- package/src/responses/spill-store.ts +334 -29
- package/src/responses/state.ts +488 -7
- package/src/server/index.ts +4 -3
- package/src/server/lifecycle.ts +5 -1
- package/src/server/management/model-rows.ts +11 -2
- package/src/server/management/provider-routes.ts +4 -0
- package/src/server/management/system-restart.ts +5 -5
- package/src/server/management-api.ts +7 -2
- package/src/server/startup-action-control.ts +3 -2
- package/src/service.ts +594 -33
- package/src/sidecar/candidates.ts +1 -1
- package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/update/index.ts +3 -2
- package/src/update/job.ts +10 -11
- 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
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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
|
|
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
|
|
728
|
-
*
|
|
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
|
-
/**
|
|
1911
|
-
|
|
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
|
|
2112
|
-
*
|
|
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?: (
|
|
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
|
|
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
|
-
|
|
2268
|
-
|
|
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
|
|
2707
|
+
* Repair the already-installed background-service backend without switching managers.
|
|
2382
2708
|
*
|
|
2383
|
-
* Windows scheduler: rewrite assets + stop/start
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
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
|
|
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
|
}
|