@bitkyc08/opencodex 2.52.0-preview.20260912 → 2.53.0-preview.20260913
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/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-D7ynYo2K.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-D_t6sCWs.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath } from "./state";
|
|
3
|
+
import { windowsWscript } from "./windows-scheduler";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "../lib/bun-runtime";
|
|
6
|
+
import { serviceApiTokenFilePath } from "../lib/service-secrets";
|
|
7
|
+
import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "../lib/win-paths";
|
|
8
|
+
import { cachedCurrentWindowsIdentity, resolveCurrentWindowsPrincipal, WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../lib/windows-user-principal";
|
|
9
|
+
import { resolveServiceListenPort, resolvedProxyEnv } from "./health";
|
|
10
|
+
import { cliEntry, serviceLogPath, currentCodexSqliteHomeAbsolute } from "./state";
|
|
11
|
+
|
|
12
|
+
function windowsBatchValue(value: string): string {
|
|
13
|
+
return value
|
|
14
|
+
.replace(/%/g, "%%")
|
|
15
|
+
.replace(/\^/g, "^^")
|
|
16
|
+
.replace(/"/g, "")
|
|
17
|
+
.replace(/[\r\n]/g, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type WindowsBatchValueKind = "raw" | "path" | "pathList";
|
|
21
|
+
|
|
22
|
+
function windowsBatchSet(name: string, value: string | undefined, kind: WindowsBatchValueKind = "raw"): string | null {
|
|
23
|
+
if (!value) return null;
|
|
24
|
+
const rendered =
|
|
25
|
+
kind === "path" ? windowsEnvIndirectBatchValue(value, windowsBatchValue)
|
|
26
|
+
: kind === "pathList" ? windowsEnvIndirectBatchPathList(value, windowsBatchValue)
|
|
27
|
+
: windowsBatchValue(value);
|
|
28
|
+
return `set "${name}=${rendered}"`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function taskXmlString(value: string): string {
|
|
32
|
+
return value
|
|
33
|
+
.replace(/&/g, "&")
|
|
34
|
+
.replace(/</g, "<")
|
|
35
|
+
.replace(/>/g, ">")
|
|
36
|
+
.replace(/"/g, """)
|
|
37
|
+
.replace(/'/g, "'");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* RunLevel check. Schema default is LeastPrivilege (omitted on export). Elevated
|
|
42
|
+
* `schtasks /create` often rewrites the registered task to HighestAvailable even when
|
|
43
|
+
* the source XML asked for LeastPrivilege — still InteractiveToken / same user.
|
|
44
|
+
* Keep accepting HighestAvailable here: rejecting it would false-fail healthy elevated
|
|
45
|
+
* installs, and windowsTaskRegistrationHealthy tests encode that contract.
|
|
46
|
+
*/
|
|
47
|
+
function taskXmlRunLevelAcceptable(principal: string): boolean {
|
|
48
|
+
if (taskXmlHasPrefixedTag(principal, "RunLevel")) return false;
|
|
49
|
+
const count = taskXmlElementCount(principal, "RunLevel");
|
|
50
|
+
if (count === 0) return true;
|
|
51
|
+
if (count > 1) return false;
|
|
52
|
+
const value = new RegExp(`<RunLevel(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/RunLevel>`, "i").exec(principal)?.[1]?.trim().toLowerCase();
|
|
53
|
+
return value === "leastprivilege" || value === "highestavailable";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildWindowsServiceScript(
|
|
57
|
+
entry = cliEntry(),
|
|
58
|
+
port = resolveServiceListenPort(),
|
|
59
|
+
proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
|
|
60
|
+
): string {
|
|
61
|
+
// Provenance rides along with the entry: a second durableBunRuntime() call here could
|
|
62
|
+
// resolve differently from the binary the caller actually baked.
|
|
63
|
+
const { bun, bunRuntimeSource, cli } = entry;
|
|
64
|
+
const path = process.env.PATH ?? "";
|
|
65
|
+
const lines = [
|
|
66
|
+
"@echo off",
|
|
67
|
+
"setlocal",
|
|
68
|
+
// The wrapper console is hidden by the wscript launcher (window style 0), so switching
|
|
69
|
+
// it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
|
|
70
|
+
"chcp 65001 >nul",
|
|
71
|
+
windowsBatchSet("OCX_SERVICE", "1"),
|
|
72
|
+
windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
|
|
73
|
+
windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"),
|
|
74
|
+
windowsBatchSet("PATH", path, "pathList"),
|
|
75
|
+
windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
|
|
76
|
+
windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"),
|
|
77
|
+
windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
|
|
78
|
+
...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)),
|
|
79
|
+
windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
|
|
80
|
+
windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
|
|
81
|
+
windowsBatchSet("OCX_BUN", bun, "path"),
|
|
82
|
+
windowsBatchSet("OCX_CLI", cli, "path"),
|
|
83
|
+
// Package root for the transactional-update restore path (#1942): cli is
|
|
84
|
+
// <pkg>\src\cli\index.ts, so the package dir is three levels up.
|
|
85
|
+
'for %%I in ("%OCX_CLI%\\..\\..\\..") do set "OCX_PKG_DIR=%%~fI"',
|
|
86
|
+
'if exist "%OCX_API_TOKEN_FILE%" (',
|
|
87
|
+
' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
|
|
88
|
+
")",
|
|
89
|
+
":loop",
|
|
90
|
+
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start',
|
|
91
|
+
'>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"',
|
|
92
|
+
`>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntimeSource}"`,
|
|
93
|
+
'>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"',
|
|
94
|
+
'>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
|
|
95
|
+
'>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
|
|
96
|
+
'>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"',
|
|
97
|
+
'if not exist "%OCX_BUN%" (',
|
|
98
|
+
" call :restore_backup",
|
|
99
|
+
")",
|
|
100
|
+
'if not exist "%OCX_BUN%" (',
|
|
101
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: bundled Bun is missing; reinstall opencodex, then run ocx service repair',
|
|
102
|
+
" exit /b 3",
|
|
103
|
+
")",
|
|
104
|
+
'if not exist "%OCX_CLI%" (',
|
|
105
|
+
" call :restore_backup",
|
|
106
|
+
")",
|
|
107
|
+
'if not exist "%OCX_CLI%" (',
|
|
108
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair',
|
|
109
|
+
" exit /b 3",
|
|
110
|
+
")",
|
|
111
|
+
`"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
|
|
112
|
+
"if %ERRORLEVEL% NEQ 0 (",
|
|
113
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
|
|
114
|
+
// `timeout` needs console stdin and dies with "Input redirection is not supported"
|
|
115
|
+
// under Task Scheduler, turning the 5s cooldown into a hot restart loop; ping doesn't.
|
|
116
|
+
" ping -n 6 127.0.0.1 >nul",
|
|
117
|
+
" goto loop",
|
|
118
|
+
")",
|
|
119
|
+
"endlocal",
|
|
120
|
+
"goto :eof",
|
|
121
|
+
"",
|
|
122
|
+
// #1942/#1849: a power loss mid-swap leaves the live package dir missing/broken and
|
|
123
|
+
// a sibling .ocx-backup-* holding the previous version. This wrapper lives OUTSIDE
|
|
124
|
+
// the package tree, so it can restore when the launcher itself is gone — the exact
|
|
125
|
+
// window the in-launcher boot probe cannot reach.
|
|
126
|
+
":restore_backup",
|
|
127
|
+
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] install incomplete - looking for a transactional-update backup to restore',
|
|
128
|
+
'for /f "delims=" %%B in (\'dir /b /ad /o-n "%OCX_PKG_DIR%\\..\\.ocx-backup-*" 2^>nul\') do (',
|
|
129
|
+
' if exist "%OCX_PKG_DIR%\\..\\%%B\\opencodex\\package.json" (',
|
|
130
|
+
' if exist "%OCX_PKG_DIR%" rmdir /s /q "%OCX_PKG_DIR%" 2>nul',
|
|
131
|
+
' move "%OCX_PKG_DIR%\\..\\%%B\\opencodex" "%OCX_PKG_DIR%" >nul 2>&1',
|
|
132
|
+
' if exist "%OCX_PKG_DIR%\\package.json" (',
|
|
133
|
+
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %%B',
|
|
134
|
+
" goto :eof",
|
|
135
|
+
" )",
|
|
136
|
+
" )",
|
|
137
|
+
")",
|
|
138
|
+
'>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] no restorable backup found',
|
|
139
|
+
"goto :eof",
|
|
140
|
+
].filter((line): line is string => Boolean(line));
|
|
141
|
+
return `${lines.join("\r\n")}\r\n`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath()): string[] {
|
|
145
|
+
const xml = script === windowsServiceScriptPath() ? windowsTaskXmlPath() : `${script}.xml`;
|
|
146
|
+
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build the fixed scheduler-create command from an explicit staged XML document. */
|
|
150
|
+
export function buildWindowsSchtasksCreateArgsForXml(xml: string, replace = true): string[] {
|
|
151
|
+
return ["/create", "/tn", TASK, "/xml", xml, ...(replace ? ["/f"] : [])];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* VBS launcher that starts the batch wrapper with a hidden window (style 0).
|
|
156
|
+
* bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the
|
|
157
|
+
* scheduled task stays "running": MultipleInstancesPolicy=IgnoreNew keeps preventing
|
|
158
|
+
* duplicates and `schtasks /end` still has a live task instance to stop. Without the
|
|
159
|
+
* launcher, the console batch action shows a closable cmd window in the interactive
|
|
160
|
+
* session (issue #165). VBS string literals escape `"` as `""`.
|
|
161
|
+
*/
|
|
162
|
+
export function buildWindowsLauncherVbs(script = windowsServiceScriptPath()): string {
|
|
163
|
+
const escaped = script.replace(/"/g, '""');
|
|
164
|
+
const lines = [
|
|
165
|
+
"' OpenCodex service launcher — runs the batch wrapper with a hidden window.",
|
|
166
|
+
"' Generated by `ocx service install`; do not edit.",
|
|
167
|
+
'Set shell = CreateObject("WScript.Shell")',
|
|
168
|
+
// WshShell.Run(command, windowStyle 0 = hidden, bWaitOnReturn True = stay resident).
|
|
169
|
+
`shell.Run """${escaped}""", 0, True`,
|
|
170
|
+
];
|
|
171
|
+
return `${lines.join("\r\n")}\r\n`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function windowsTaskDescription(attemptNonce?: string): string {
|
|
175
|
+
return attemptNonce
|
|
176
|
+
? `OpenCodex proxy service wrapper; install-attempt=${attemptNonce}`
|
|
177
|
+
: "OpenCodex proxy service wrapper";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Session transitions that must be able to bring the proxy back.
|
|
182
|
+
*
|
|
183
|
+
* The task runs under `InteractiveToken`, so the proxy lives inside the interactive session
|
|
184
|
+
* and Windows tears it down with that session — the wrapper records the kill as exit code
|
|
185
|
+
* 1073807364 (`STATUS_CONTROL_C_EXIT`). With `LogonTrigger` as the only trigger there was no
|
|
186
|
+
* recovery path short of a fresh logon, so signing out of a Remote Desktop session left the
|
|
187
|
+
* proxy down until the next interactive logon. On one machine's logs 19 such kills produced
|
|
188
|
+
* gaps of up to ~60 hours.
|
|
189
|
+
*
|
|
190
|
+
* These triggers do not stop the kill; they make it recoverable at the next connect. Console
|
|
191
|
+
* transitions are included because a local session can be disconnected the same way, and
|
|
192
|
+
* `MultipleInstancesPolicy=IgnoreNew` keeps a still-running proxy from being started twice.
|
|
193
|
+
*/
|
|
194
|
+
const WINDOWS_SESSION_RECOVERY_STATE_CHANGES = [
|
|
195
|
+
"RemoteConnect",
|
|
196
|
+
"SessionUnlock",
|
|
197
|
+
"ConsoleConnect",
|
|
198
|
+
] as const;
|
|
199
|
+
|
|
200
|
+
export function buildWindowsTaskXml(
|
|
201
|
+
script = windowsServiceScriptPath(),
|
|
202
|
+
launcher = windowsLauncherVbsPath(),
|
|
203
|
+
attemptNonce?: string,
|
|
204
|
+
sessionTriggerUserId = cachedCurrentWindowsIdentity()?.sid,
|
|
205
|
+
): string {
|
|
206
|
+
const escapedWscript = taskXmlString(windowsWscript());
|
|
207
|
+
// Escape the launcher path independently for the <Arguments> element; quoting it
|
|
208
|
+
// keeps spaces intact, and /b (batch mode) suppresses script error popups.
|
|
209
|
+
const escapedLauncherArgs = taskXmlString(`/b /nologo "${launcher}"`);
|
|
210
|
+
// `UserId` is optional in the schema, and omitting it makes a SessionStateChangeTrigger
|
|
211
|
+
// fire for ANY account's session change. An unscoped LogonTrigger is read as "any user's
|
|
212
|
+
// logon", which a non-elevated user may not register: schtasks /create answers "Access is
|
|
213
|
+
// denied" for a task whose design (InteractiveToken + LeastPrivilege) needs no elevation
|
|
214
|
+
// (#4425). Production registration resolves and passes the installing account SID
|
|
215
|
+
// explicitly; the optional parameter remains only for deterministic builders/tests, and
|
|
216
|
+
// the live validator rejects an unscoped recovery trigger.
|
|
217
|
+
const sessionUserIdElement = sessionTriggerUserId
|
|
218
|
+
? `\n <UserId>${taskXmlString(sessionTriggerUserId)}</UserId>`
|
|
219
|
+
: "";
|
|
220
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
221
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
222
|
+
<RegistrationInfo>
|
|
223
|
+
<Description>${taskXmlString(windowsTaskDescription(attemptNonce))}</Description>
|
|
224
|
+
</RegistrationInfo>
|
|
225
|
+
<Triggers>
|
|
226
|
+
<LogonTrigger>
|
|
227
|
+
<Enabled>true</Enabled>${sessionUserIdElement}
|
|
228
|
+
</LogonTrigger>
|
|
229
|
+
${WINDOWS_SESSION_RECOVERY_STATE_CHANGES.map(stateChange => `<SessionStateChangeTrigger>
|
|
230
|
+
<Enabled>true</Enabled>${sessionUserIdElement}
|
|
231
|
+
<StateChange>${stateChange}</StateChange>
|
|
232
|
+
</SessionStateChangeTrigger>`).join("\n ")}
|
|
233
|
+
</Triggers>
|
|
234
|
+
<Principals>
|
|
235
|
+
<Principal id="Author">
|
|
236
|
+
<LogonType>InteractiveToken</LogonType>
|
|
237
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
238
|
+
</Principal>
|
|
239
|
+
</Principals>
|
|
240
|
+
<Settings>
|
|
241
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
242
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
243
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
244
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
245
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
246
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
247
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
248
|
+
<Enabled>true</Enabled>
|
|
249
|
+
<Hidden>false</Hidden>
|
|
250
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
251
|
+
<Priority>4</Priority>
|
|
252
|
+
<RestartOnFailure>
|
|
253
|
+
<Interval>PT1M</Interval>
|
|
254
|
+
<Count>3</Count>
|
|
255
|
+
</RestartOnFailure>
|
|
256
|
+
</Settings>
|
|
257
|
+
<Actions Context="Author">
|
|
258
|
+
<Exec>
|
|
259
|
+
<Command>${escapedWscript}</Command>
|
|
260
|
+
<Arguments>${escapedLauncherArgs}</Arguments>
|
|
261
|
+
</Exec>
|
|
262
|
+
</Actions>
|
|
263
|
+
</Task>
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export type ExpectedWindowsTaskUserId = string | readonly string[];
|
|
268
|
+
|
|
269
|
+
export function cachedWindowsTaskUserIds(): readonly string[] | null {
|
|
270
|
+
const identity = cachedCurrentWindowsIdentity();
|
|
271
|
+
return identity ? [identity.sid, identity.name] : null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function resolvedWindowsTaskSid(): string {
|
|
275
|
+
let identity = cachedCurrentWindowsIdentity();
|
|
276
|
+
if (!identity) {
|
|
277
|
+
const principal = resolveCurrentWindowsPrincipal(WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS);
|
|
278
|
+
identity = cachedCurrentWindowsIdentity();
|
|
279
|
+
if (!identity && /^\*S-1-(?:\d+-)+\d+$/i.test(principal)) return principal.slice(1).toUpperCase();
|
|
280
|
+
}
|
|
281
|
+
if (!identity) throw new Error("Windows Task Scheduler identity could not be resolved.");
|
|
282
|
+
return identity.sid;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Render the exact UTF-16 task document published by production registration paths. */
|
|
286
|
+
export function buildWindowsTaskXmlDocument(
|
|
287
|
+
script = windowsServiceScriptPath(),
|
|
288
|
+
launcher = windowsLauncherVbsPath(),
|
|
289
|
+
attemptNonce?: string,
|
|
290
|
+
sessionTriggerUserId = resolvedWindowsTaskSid(),
|
|
291
|
+
): string {
|
|
292
|
+
return `\uFEFF${buildWindowsTaskXml(script, launcher, attemptNonce, sessionTriggerUserId)}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function taskXmlSection(xml: string, tag: string): string {
|
|
296
|
+
return new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, "i").exec(xml)?.[1] ?? "";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Drop comments and CDATA so a commented-out decoy cannot satisfy any check. */
|
|
300
|
+
export function taskXmlWithoutCommentsAndCdata(xml: string): string {
|
|
301
|
+
return xml.replace(/<!--[\s\S]*?-->/g, "").replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Count occurrences of an unprefixed tag, including the self-closing form. The
|
|
306
|
+
* element boundary matters: `<EnabledExtra>` must not count as `Enabled`.
|
|
307
|
+
*/
|
|
308
|
+
export function taskXmlElementCount(xml: string, tag: string): number {
|
|
309
|
+
return xml.match(new RegExp(`<${tag}(?:\\s[^>]*?)?\\s*\\/?>`, "gi"))?.length ?? 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* True when a namespace-prefixed form of the tag appears. A prefixed element bound
|
|
314
|
+
* to the task namespace carries a real value, but this module parses by regex and
|
|
315
|
+
* cannot resolve prefixes — so it fails closed instead of reading the element as
|
|
316
|
+
* absent (which would silently apply the schema default).
|
|
317
|
+
*/
|
|
318
|
+
function taskXmlHasPrefixedTag(xml: string, tag: string): boolean {
|
|
319
|
+
return new RegExp(`<[A-Za-z_][\\w.-]*:${tag}(?:[\\s/>])`, "i").test(xml);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Compare an element that Task Scheduler may omit when exporting a registered task.
|
|
324
|
+
* Absence means the documented schema default (#432); a present element must still
|
|
325
|
+
* match exactly, so a malformed or explicitly unsafe value never reads as healthy.
|
|
326
|
+
*/
|
|
327
|
+
/**
|
|
328
|
+
* Decode XML's five predefined entities, exactly once.
|
|
329
|
+
*
|
|
330
|
+
* Task Scheduler re-encodes element text when it exports a task, so a needle we
|
|
331
|
+
* escaped ourselves can never match its output (#608). Compare decoded values
|
|
332
|
+
* instead of encoded ones.
|
|
333
|
+
*
|
|
334
|
+
* The single pass is the point: decoding twice would turn `&quot;` into `"`,
|
|
335
|
+
* letting a doubly-encoded value impersonate the expected launcher path.
|
|
336
|
+
*/
|
|
337
|
+
function taskXmlDecodeEntities(value: string): string {
|
|
338
|
+
return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => (
|
|
339
|
+
name === "amp" ? "&"
|
|
340
|
+
: name === "lt" ? "<"
|
|
341
|
+
: name === "gt" ? ">"
|
|
342
|
+
: name === "quot" ? "\""
|
|
343
|
+
: "'"
|
|
344
|
+
));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Exactly one unprefixed `<tag>` whose DECODED text equals `expected`.
|
|
349
|
+
*
|
|
350
|
+
* Unlike taskXmlOptionalValueEquals(), an absent element is NOT a pass: these
|
|
351
|
+
* elements name what actually gets executed, so a missing <Command>/<Arguments>
|
|
352
|
+
* must fail the health check rather than inherit a schema default.
|
|
353
|
+
*/
|
|
354
|
+
function taskXmlDecodedValueEquals(xml: string, tag: string, expected: string): boolean {
|
|
355
|
+
// Same reasoning as the optional helper: `<t:Arguments>` must not read as absent.
|
|
356
|
+
if (taskXmlHasPrefixedTag(xml, tag)) return false;
|
|
357
|
+
if (taskXmlElementCount(xml, tag) !== 1) return false;
|
|
358
|
+
// `[^<]*` refuses nested markup, so a decoy inside a child element cannot match.
|
|
359
|
+
const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
|
|
360
|
+
if (value === undefined) return false;
|
|
361
|
+
return taskXmlDecodeEntities(value).trim().toLowerCase() === expected.trim().toLowerCase();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Characters a console code page substitutes when it cannot carry the original.
|
|
366
|
+
* Windows writes `?` per unrepresentable character, some layers write U+FFFD, and a
|
|
367
|
+
* few drop them entirely.
|
|
368
|
+
*/
|
|
369
|
+
const CODE_PAGE_SUBSTITUTIONS = /^[?\uFFFD]*$/;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Compare a value that OpenCodex itself wrote against what `schtasks /query /xml` read
|
|
373
|
+
* back, tolerating ONLY the characters the console code page could not carry.
|
|
374
|
+
*
|
|
375
|
+
* `runFile` already reads the query as bytes, so this is not a spawn-decoding bug: the
|
|
376
|
+
* conversion happens inside `schtasks` before the bytes exist. A profile named outside
|
|
377
|
+
* the active code page — `C:\\Users\\김병준\\...` — comes back as `C:\\Users\\???\\...`, so an
|
|
378
|
+
* exact comparison rejected a registration this process had just created correctly and
|
|
379
|
+
* `ocx service install` rolled it back (#3064).
|
|
380
|
+
*
|
|
381
|
+
* The tolerance is deliberately narrow. Each unrepresentable RUN in the expected value
|
|
382
|
+
* may match only a run of substitution characters — never arbitrary text, and never a
|
|
383
|
+
* path separator. A wildcard as wide as `[^\\\\/]*` would leave a fully non-ASCII segment with
|
|
384
|
+
* no anchors at all, so `C:\\Users\\김병준\\x.vbs` would match `C:\\Users\\Admin\\x.vbs` and this
|
|
385
|
+
* process would adopt, repair, or delete another account's task. Accepting a foreign
|
|
386
|
+
* live task is a worse failure than the rollback this fixes.
|
|
387
|
+
*/
|
|
388
|
+
function taskXmlLossyValueEquals(reported: string, expected: string): boolean {
|
|
389
|
+
const a = reported.trim().toLowerCase();
|
|
390
|
+
const b = expected.trim().toLowerCase();
|
|
391
|
+
if (a === b) return true;
|
|
392
|
+
// Nothing unrepresentable in the expectation means there was nothing to mangle,
|
|
393
|
+
// so any difference is a real one.
|
|
394
|
+
if (!/[^\x00-\x7F]/.test(b)) return false;
|
|
395
|
+
const parts = b.split(/([^\x00-\x7F]+)/);
|
|
396
|
+
let rest = a;
|
|
397
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
398
|
+
const part = parts[i]!;
|
|
399
|
+
if (i % 2 === 0) {
|
|
400
|
+
// Literal ASCII run: it must be present verbatim, which is what keeps every
|
|
401
|
+
// directory boundary and file name in the path verified.
|
|
402
|
+
if (!rest.startsWith(part)) return false;
|
|
403
|
+
rest = rest.slice(part.length);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// Unrepresentable run: consume only substitution characters, and stop at the
|
|
407
|
+
// next literal so a trailing run cannot swallow the remainder of the string.
|
|
408
|
+
const next = parts[i + 1] ?? "";
|
|
409
|
+
const end = next === "" ? rest.length : rest.indexOf(next);
|
|
410
|
+
if (end < 0) return false;
|
|
411
|
+
if (!CODE_PAGE_SUBSTITUTIONS.test(rest.slice(0, end))) return false;
|
|
412
|
+
rest = rest.slice(end);
|
|
413
|
+
}
|
|
414
|
+
return rest === "";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function taskXmlDecodedLossyValueEquals(xml: string, tag: string, expected: string): boolean {
|
|
418
|
+
if (taskXmlHasPrefixedTag(xml, tag)) return false;
|
|
419
|
+
if (taskXmlElementCount(xml, tag) !== 1) return false;
|
|
420
|
+
const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
|
|
421
|
+
if (value === undefined) return false;
|
|
422
|
+
return taskXmlLossyValueEquals(taskXmlDecodeEntities(value), expected);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
|
|
426
|
+
// Check the prefixed form first: treating `<t:Enabled>false</t:Enabled>` as an
|
|
427
|
+
// omission would turn an explicitly disabled task into a healthy one.
|
|
428
|
+
if (taskXmlHasPrefixedTag(xml, tag)) return false;
|
|
429
|
+
const count = taskXmlElementCount(xml, tag);
|
|
430
|
+
if (count === 0) return true;
|
|
431
|
+
if (count > 1) return false;
|
|
432
|
+
const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/${tag}>`, "i").exec(xml)?.[1];
|
|
433
|
+
return value?.trim().toLowerCase() === expected.toLowerCase();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** True only when the exported live task carries this install attempt's nonce. */
|
|
437
|
+
export function windowsTaskRegistrationOwnedByAttempt(xml: string, attemptNonce: string): boolean {
|
|
438
|
+
if (!attemptNonce) return false;
|
|
439
|
+
const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
|
|
440
|
+
if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
|
|
441
|
+
if (taskXmlHasPrefixedTag(scrubbed, "RegistrationInfo")) return false;
|
|
442
|
+
if (taskXmlElementCount(scrubbed, "RegistrationInfo") !== 1) return false;
|
|
443
|
+
const registrationInfo = taskXmlSection(scrubbed, "RegistrationInfo");
|
|
444
|
+
return taskXmlDecodedValueEquals(
|
|
445
|
+
registrationInfo,
|
|
446
|
+
"Description",
|
|
447
|
+
windowsTaskDescription(attemptNonce),
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Every session-recovery trigger present and enabled, scoped to <Triggers>.
|
|
453
|
+
*
|
|
454
|
+
* Each StateChange is matched inside its OWN <SessionStateChangeTrigger> element: a document
|
|
455
|
+
* carrying one disabled trigger plus a different enabled one must not pass because the two
|
|
456
|
+
* halves were found in unrelated elements.
|
|
457
|
+
*/
|
|
458
|
+
export function windowsTaskHasSessionRecoveryTriggers(
|
|
459
|
+
triggers: string,
|
|
460
|
+
expectedUserId: ExpectedWindowsTaskUserId | undefined,
|
|
461
|
+
): boolean {
|
|
462
|
+
const scoped = triggers.match(/<SessionStateChangeTrigger(?:\s[^>]*)?>[\s\S]*?<\/SessionStateChangeTrigger>/gi) ?? [];
|
|
463
|
+
return WINDOWS_SESSION_RECOVERY_STATE_CHANGES.every(stateChange =>
|
|
464
|
+
scoped.some(element =>
|
|
465
|
+
taskXmlDecodedValueEquals(element, "StateChange", stateChange)
|
|
466
|
+
&& taskXmlOptionalValueEquals(element, "Enabled", "true")
|
|
467
|
+
&& windowsTaskTriggerScopeAcceptable(element, expectedUserId)));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* A trigger's scope is acceptable only when it names the expected account exactly.
|
|
472
|
+
*
|
|
473
|
+
* An unscoped recovery trigger is not identity proof. Production registration resolves a SID
|
|
474
|
+
* before writing XML; a missing scope therefore means the fixed-name task is legacy or foreign
|
|
475
|
+
* and must be refreshed from an exact legacy snapshot or preserved for manual review.
|
|
476
|
+
* Treating an unknown expected identity as a wildcard would let a fresh status process accept a
|
|
477
|
+
* task bound to another user's session and suppress the repair that should replace it.
|
|
478
|
+
*/
|
|
479
|
+
function windowsTaskTriggerScopeAcceptable(
|
|
480
|
+
element: string,
|
|
481
|
+
expectedUserId: ExpectedWindowsTaskUserId | undefined,
|
|
482
|
+
): boolean {
|
|
483
|
+
// A prefixed `<t:UserId>` is a real scope this validator cannot read: taskXmlElementCount()
|
|
484
|
+
// counts only unprefixed tags, so without this the element below would look ABSENT and the
|
|
485
|
+
// trigger would be accepted as unscoped even though it is bound to some other account.
|
|
486
|
+
// Reject it outright rather than guess, and do so before the optional-field check.
|
|
487
|
+
if (taskXmlHasPrefixedTag(element, "UserId")) return false;
|
|
488
|
+
const userIdCount = taskXmlElementCount(element, "UserId");
|
|
489
|
+
if (userIdCount === 0) return false;
|
|
490
|
+
if (userIdCount !== 1) return false;
|
|
491
|
+
if (expectedUserId === undefined) return false;
|
|
492
|
+
// Scope is an identity boundary, unlike the launcher path. Newly generated tasks
|
|
493
|
+
// use the locale-independent SID from cachedCurrentWindowsIdentity(), so there is
|
|
494
|
+
// no reason to forgive code-page substitutions here. A lossy account-name compare
|
|
495
|
+
// lets two non-ASCII users collapse to the same `???` value and can make repair
|
|
496
|
+
// start another account's fixed-name task.
|
|
497
|
+
const expectedValues = typeof expectedUserId === "string" ? [expectedUserId] : expectedUserId;
|
|
498
|
+
return expectedValues.some(value => taskXmlDecodedValueEquals(element, "UserId", value));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Validate the stable OpenCodex action, principal, settings, and logon trigger. */
|
|
502
|
+
function windowsTaskRegistrationBaseHealthy(
|
|
503
|
+
xml: string,
|
|
504
|
+
wscript = windowsWscript(),
|
|
505
|
+
launcher = windowsLauncherVbsPath(),
|
|
506
|
+
allowLossyPaths = true,
|
|
507
|
+
): boolean {
|
|
508
|
+
const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
|
|
509
|
+
// taskXmlSection() takes the FIRST match and the schema allows arbitrary XML under
|
|
510
|
+
// Task/Data, so a Data block placed before the real sections could shadow them.
|
|
511
|
+
// We never emit Data, so its presence alone disqualifies the registration. Both
|
|
512
|
+
// forms are rejected because taskXmlElementCount() ignores prefixed tags.
|
|
513
|
+
if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
|
|
514
|
+
const triggers = taskXmlSection(scrubbed, "Triggers");
|
|
515
|
+
const trigger = taskXmlSection(triggers, "LogonTrigger");
|
|
516
|
+
const principal = taskXmlSection(scrubbed, "Principal");
|
|
517
|
+
const settings = taskXmlSection(scrubbed, "Settings");
|
|
518
|
+
const action = taskXmlSection(scrubbed, "Exec");
|
|
519
|
+
// A self-closing <LogonTrigger /> leaves an empty section, so look for the element
|
|
520
|
+
// itself — scoped to <Triggers> so a decoy elsewhere cannot satisfy it.
|
|
521
|
+
return taskXmlElementCount(triggers, "LogonTrigger") > 0
|
|
522
|
+
&& taskXmlOptionalValueEquals(trigger, "Enabled", "true")
|
|
523
|
+
&& /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(principal)
|
|
524
|
+
&& taskXmlRunLevelAcceptable(principal)
|
|
525
|
+
&& taskXmlOptionalValueEquals(settings, "Enabled", "true")
|
|
526
|
+
&& /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
|
|
527
|
+
&& /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
|
|
528
|
+
// Compare decoded VALUES, not encodings: Task Scheduler canonicalizes the
|
|
529
|
+
// quotes we wrote as `"` back to literal `"` on export, so an escaped
|
|
530
|
+
// needle never matched and a healthy task read as permanently stale (#608).
|
|
531
|
+
// Case-insensitive: elevated `schtasks /create` may rewrite System32 casing.
|
|
532
|
+
// Lossy on purpose: both name paths under the user profile, which the query
|
|
533
|
+
// cannot carry when the profile is named outside the code page (#3064). Only
|
|
534
|
+
// unrepresentable characters are forgiven; every ASCII segment and every
|
|
535
|
+
// separator is still matched literally.
|
|
536
|
+
&& (allowLossyPaths
|
|
537
|
+
? taskXmlDecodedLossyValueEquals(action, "Command", wscript)
|
|
538
|
+
&& taskXmlDecodedLossyValueEquals(action, "Arguments", `/b /nologo "${launcher}"`)
|
|
539
|
+
: taskXmlDecodedValueEquals(action, "Command", wscript)
|
|
540
|
+
&& taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Validate the security/lifecycle-critical fields of the registered scheduler task. */
|
|
544
|
+
export function windowsTaskRegistrationHealthy(
|
|
545
|
+
xml: string,
|
|
546
|
+
wscript = windowsWscript(),
|
|
547
|
+
launcher = windowsLauncherVbsPath(),
|
|
548
|
+
expectedUserId: ExpectedWindowsTaskUserId | null = cachedWindowsTaskUserIds(),
|
|
549
|
+
): boolean {
|
|
550
|
+
const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
|
|
551
|
+
const triggers = taskXmlSection(scrubbed, "Triggers");
|
|
552
|
+
return windowsTaskRegistrationBaseHealthy(xml, wscript, launcher)
|
|
553
|
+
// Without these the task can only recover at the next logon, so a disconnected session
|
|
554
|
+
// leaves the proxy down indefinitely. Treating their absence as unhealthy is what lets
|
|
555
|
+
// an already-registered task from an older install get repaired instead of staying broken.
|
|
556
|
+
&& windowsTaskHasSessionRecoveryTriggers(triggers, expectedUserId ?? undefined);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The only stale definition repair may replace automatically: the previous OpenCodex task
|
|
561
|
+
* shape whose action/principal/settings are byte-exact and which has no session triggers yet.
|
|
562
|
+
* Arbitrary unhealthy or partially modified fixed-name tasks are preserved for manual review.
|
|
563
|
+
*/
|
|
564
|
+
export function windowsTaskRegistrationRefreshableLegacy(
|
|
565
|
+
xml: string,
|
|
566
|
+
wscript = windowsWscript(),
|
|
567
|
+
launcher = windowsLauncherVbsPath(),
|
|
568
|
+
): boolean {
|
|
569
|
+
const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
|
|
570
|
+
const triggers = taskXmlSection(scrubbed, "Triggers");
|
|
571
|
+
return windowsTaskRegistrationBaseHealthy(xml, wscript, launcher, false)
|
|
572
|
+
&& taskXmlElementCount(triggers, "SessionStateChangeTrigger") === 0
|
|
573
|
+
&& !taskXmlHasPrefixedTag(triggers, "SessionStateChangeTrigger");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export interface WindowsSchedulerXmlState {
|
|
577
|
+
installed: boolean;
|
|
578
|
+
enabled: boolean;
|
|
579
|
+
registrationHealthy: boolean;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Single source of truth for reading a registered task's XML. Both the status
|
|
584
|
+
* diagnostic and its tests go through here, so a partial fix cannot leave one
|
|
585
|
+
* caller on an older, stricter reading of the same document (#432).
|
|
586
|
+
*/
|
|
587
|
+
export function readWindowsSchedulerXmlState(
|
|
588
|
+
xml: string,
|
|
589
|
+
wscript?: string,
|
|
590
|
+
launcher?: string,
|
|
591
|
+
expectedUserId: ExpectedWindowsTaskUserId | null = cachedWindowsTaskUserIds(),
|
|
592
|
+
): WindowsSchedulerXmlState {
|
|
593
|
+
const installed = xml.length > 0;
|
|
594
|
+
if (!installed) return { installed: false, enabled: false, registrationHealthy: false };
|
|
595
|
+
const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
|
|
596
|
+
const hasData = taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data");
|
|
597
|
+
const settings = hasData ? "" : taskXmlSection(scrubbed, "Settings");
|
|
598
|
+
return {
|
|
599
|
+
installed: true,
|
|
600
|
+
enabled: !hasData && taskXmlOptionalValueEquals(settings, "Enabled", "true"),
|
|
601
|
+
registrationHealthy: windowsTaskRegistrationHealthy(xml, wscript, launcher, expectedUserId),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── macOS (launchd) ──
|
|
606
|
+
/** Read a file as UTF-8, or null when it is absent/unreadable. */
|
|
607
|
+
export function readTextOrNull(path: string): string | null {
|
|
608
|
+
try {
|
|
609
|
+
return readFileSync(path, "utf8");
|
|
610
|
+
} catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|