Repository navigation
fix(service): accept standalone Windows wrappers in ownership probe #6238
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bd59688
14f88eb
5dbf827
ba5d182
6ecb247
a9d76ea
0401a98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,10 @@ import { | |
| } from "./lib/windows-elevation"; | ||
| import { decodeWindowsTextBytes } from "./lib/windows-text"; | ||
| import { WINSW_SERVICE_ID } from "./lib/winsw"; | ||
| import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "./lib/bun-runtime"; | ||
| import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./service/windows-wrapper-exit"; | ||
| import { buildWindowsServiceScript, windowsTaskActionMatches } from "./service/windows-taskxml"; | ||
| import { inspectServiceStateEvidence, serviceStatePathsForOpenCodexHome } from "./service/state"; | ||
|
|
||
| /** Short: this runs inside admission, and a slow answer is the same as none. */ | ||
| export const SERVICE_PROBE_TIMEOUT_MS = 2_000; | ||
|
|
@@ -192,6 +196,8 @@ export interface ProbeDeps { | |
| readonly windowsLocale?: string; | ||
| /** Startup-local full-listing cache; targeted task queries always bypass it. */ | ||
| readonly windowsTaskListingCache?: WindowsTaskListingCache; | ||
| /** Service-state evidence paths; production derives them from the effective config home. */ | ||
| readonly statePaths?: readonly string[]; | ||
| } | ||
|
|
||
| const LABEL = "com.opencodex.proxy"; | ||
|
|
@@ -559,9 +565,95 @@ function decodeBatchPathValue( | |
| .replaceAll(escapedPercent, "%"); | ||
| } | ||
|
|
||
| /** Validate the generated wrapper before interpreting omitted optional homes. */ | ||
| function wrapperLooksGenerated(body: string): boolean { | ||
| return /:loop\s*[\s\S]*^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/im.test(body); | ||
| /** One generated quoted assignment, rejecting unquoted and duplicate forms. */ | ||
| function generatedBatchSetValue(body: string, name: string): string | null { | ||
| const assignments = body.split(/\r?\n/).filter(line => new RegExp(`^\\s*@?set\\s+"?${name}=`, "i").test(line)); | ||
| return assignments.length === 1 ? batchSetValue(assignments[0]!, name) : null; | ||
| } | ||
|
|
||
| /** Compare executable lines with the actual standalone generator's ordered script. */ | ||
| function matchesGeneratedStandaloneControlFlow(body: string, port: number): boolean { | ||
| const scriptLines = (script: string): string[] => { | ||
| const lines = script.replace(/\r\n/g, "\n").split("\n"); | ||
| if (lines.at(-1) === "") lines.pop(); | ||
| return lines; | ||
| }; | ||
| const lines = scriptLines(body); | ||
| const expected = scriptLines(buildWindowsServiceScript({ | ||
| bun: "C:\\OpenCodex\\ocx.exe", bunRuntimeSource: "standalone", cli: null, | ||
| }, port, [])); | ||
| const tokenBlock = 'if exist "%OCX_API_TOKEN_FILE%" ('; | ||
| const boundary = lines.indexOf(tokenBlock); | ||
| const expectedBoundary = expected.indexOf(tokenBlock); | ||
| if (boundary < 0 || expectedBoundary < 0) return false; | ||
| const allowed = [ | ||
| "OCX_SERVICE", WINDOWS_WRAPPER_PROTOCOL_ENV, BUN_RUNTIME_SOURCE_ENV, | ||
| BUN_RUNTIME_PATH_ENV, "PATH", "CODEX_HOME", "CODEX_SQLITE_HOME", | ||
| "OPENCODEX_HOME", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", | ||
| "OCX_API_TOKEN_FILE", "OCX_SERVICE_LOG", "OCX_BUN", | ||
| ]; | ||
| const required = [ | ||
| "OCX_SERVICE", WINDOWS_WRAPPER_PROTOCOL_ENV, BUN_RUNTIME_SOURCE_ENV, | ||
| BUN_RUNTIME_PATH_ENV, "OCX_API_TOKEN_FILE", | ||
| "OCX_SERVICE_LOG", "OCX_BUN", | ||
| ]; | ||
| let previous = -1; | ||
| const seen = new Set<string>(); | ||
| for (const line of lines.slice(0, boundary)) { | ||
| if (line === 'set "ERRORLEVEL="') continue; | ||
| if (!line.startsWith('set "')) continue; | ||
| const name = /^set "([A-Z_]+)=[^"\r\n]*"$/.exec(line)?.[1]; | ||
| const index = name ? allowed.indexOf(name) : -1; | ||
| if (index <= previous || !name) return false; | ||
| previous = index; | ||
| seen.add(name); | ||
| } | ||
| if (required.some(name => !seen.has(name))) return false; | ||
| const withoutPrefixSets = (scriptLines: string[], end: number): string[] => | ||
| scriptLines.filter((line, index) => index >= end || line === 'set "ERRORLEVEL="' || !line.startsWith('set "')); | ||
| const actualFlow = withoutPrefixSets(lines, boundary); | ||
| const generatedFlow = withoutPrefixSets(expected, expectedBoundary); | ||
| return actualFlow.length === generatedFlow.length | ||
| && actualFlow.every((line, index) => line === generatedFlow[index]); | ||
| } | ||
|
|
||
| /** Validate the generated launch shape before interpreting omitted optional homes. */ | ||
| function wrapperLaunchShape(body: string): "source" | { standaloneBun: string } | null { | ||
| if (!/^:loop\s*$/im.test(body)) return null; | ||
| const launchLines = body.split(/\r?\n/).filter(line => /^\s*"%OCX_BUN%"/i.test(line)); | ||
| if (launchLines.length !== 1) return null; | ||
| const launch = launchLines[0]!.trim(); | ||
| const sourceLaunch = /^"%OCX_BUN%" "%OCX_CLI%" start --port ([0-9]{1,5}) >>"%OCX_SERVICE_LOG%" 2>&1$/i; | ||
| const standaloneLaunch = /^"%OCX_BUN%" start --port ([0-9]{1,5}) >>"%OCX_SERVICE_LOG%" 2>&1$/i; | ||
| const bun = generatedBatchSetValue(body, "OCX_BUN"); | ||
| if (!bun) return null; | ||
| const cliAssignments = body.split(/\r?\n/).filter(line => /^\s*@?set\s+"?OCX_CLI=/i.test(line)); | ||
| if (cliAssignments.length > 0) { | ||
| const port = sourceLaunch.exec(launch)?.[1]; | ||
| return cliAssignments.length === 1 && Boolean(generatedBatchSetValue(body, "OCX_CLI")) | ||
| && Boolean(port) && Number(port) >= 1 && Number(port) <= 65535 | ||
| ? "source" : null; | ||
| } | ||
| const port = standaloneLaunch.exec(launch)?.[1]; | ||
| if (!port || Number(port) < 1 || Number(port) > 65535 || !/^@echo off\s*$/im.test(body) | ||
| || !/^setlocal EnableExtensions DisableDelayedExpansion\s*$/im.test(body) | ||
| || !new RegExp(`^if "%ERRORLEVEL%"=="${WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE}" goto stopped\\s*$`, "im").test(body) | ||
| || !/^:stopped\s*$/im.test(body)) return null; | ||
| if (!matchesGeneratedStandaloneControlFlow(body, Number(port))) return null; | ||
| for (const [name, value] of [ | ||
| ["OCX_SERVICE", "1"], | ||
| [WINDOWS_WRAPPER_PROTOCOL_ENV, "1"], | ||
| [BUN_RUNTIME_SOURCE_ENV, "standalone"], | ||
| ]) { | ||
| if (generatedBatchSetValue(body, name) !== value) return null; | ||
| } | ||
| const runtimePath = generatedBatchSetValue(body, BUN_RUNTIME_PATH_ENV); | ||
| if (!bun || !runtimePath || normalizeWindowsPath(decodeBatchPathValue(bun)) !== normalizeWindowsPath(decodeBatchPathValue(runtimePath))) return null; | ||
| for (const name of ["CODEX_HOME", "OPENCODEX_HOME"]) { | ||
| const assignments = body.split(/\r?\n/).filter(line => new RegExp(`^\\s*@?set\\s+"?${name}=`, "i").test(line)); | ||
| if (assignments.length > 0 && generatedBatchSetValue(body, name) === null) return null; | ||
| } | ||
| return { standaloneBun: decodeBatchPathValue(bun) }; | ||
| } | ||
|
|
||
| function normalizeWindowsPath(value: string): string { | ||
|
|
@@ -711,7 +803,7 @@ function probeWinswRegistration( | |
|
|
||
| function inspectWindows( | ||
| deps: Required<Pick<ProbeDeps, "runRaw" | "home">> | ||
| & Pick<ProbeDeps, "configDir" | "winswStatus" | "windowsLocale" | "windowsTaskListingCache">, | ||
| & Pick<ProbeDeps, "configDir" | "winswStatus" | "windowsLocale" | "windowsTaskListingCache" | "statePaths">, | ||
| ): ServiceManagerInstallation { | ||
| const configDir = windowsConfigDirPath(deps); | ||
| const taskXmlPath = join(configDir, "opencodex-service-task.xml"); | ||
|
|
@@ -802,6 +894,10 @@ function inspectWindows( | |
| const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath); | ||
| if (registeredWalk.kind !== "present") return registeredWalk; | ||
| const registeredClaim = registeredWalk.claims[0]; | ||
| const registeredLauncher = /"([^"]+)"/.exec(windowsTaskArguments(registration.registeredXml) ?? "")?.[1]; | ||
| if (!registeredLauncher || !windowsTaskActionMatches(registration.registeredXml, registeredLauncher)) { | ||
| return unknown("the registered scheduled-task action does not match the generated launcher action"); | ||
|
Comment on lines
+897
to
+899
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Compare the registered action with the staged launcher. If the staged task names launcher A and the registered task names launcher B, both chains can pass Extract the launcher from the staged XML and require the registered action to name that launcher. Keep the separate registered-chain validation to check the files behind the action. Add a regression test with two valid launchers under the configuration directory that name the same homes. 🧰 Tools🪛 OpenGrep (1.30.0)[ERROR] 897-897: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead. (coderabbit.command-injection.exec-js) 🤖 Prompt for AI Agents |
||
| } | ||
| if (!homesEqual(registeredClaim.homes, stagedClaim.homes)) { | ||
| return unknown("the registered scheduled task names different homes than the staged task definition"); | ||
| } | ||
|
|
@@ -837,7 +933,7 @@ function homesEqual( | |
| * generated service-asset directory. | ||
| */ | ||
| function walkWindowsChain( | ||
| deps: Required<Pick<ProbeDeps, "home">> & Pick<ProbeDeps, "configDir" | "windowsLocale">, | ||
| deps: Required<Pick<ProbeDeps, "home">> & Pick<ProbeDeps, "configDir" | "windowsLocale" | "statePaths">, | ||
| xml: string, | ||
| definitionPath: string, | ||
| ): ServiceManagerInstallation { | ||
|
|
@@ -885,9 +981,21 @@ function walkWindowsChain( | |
| return unknown(`the launcher wrapper could not be read: ${String(error)}`); | ||
| } | ||
|
|
||
| if (!wrapperLooksGenerated(wrapperBody)) { | ||
| const launchShape = wrapperLaunchShape(wrapperBody); | ||
| if (launchShape === null) { | ||
| return unknown(`the launcher wrapper does not look like a generated opencodex service wrapper: ${wrapperPath}`); | ||
| } | ||
| if (typeof launchShape !== "string") { | ||
| const executable = launchShape.standaloneBun; | ||
| const evidence = inspectServiceStateEvidence(deps.statePaths ?? serviceStatePathsForOpenCodexHome(configDir)); | ||
| const valid = evidence.filter(e => e.kind === "valid"); | ||
| if (!win32Path.isAbsolute(executable) || win32Path.extname(executable).toLowerCase() !== ".exe" | ||
| || valid.length === 0 || evidence.some(e => e.kind === "invalid" || e.kind === "unreadable") | ||
| || valid.some(e => e.state.version !== 2 || e.state.backend !== "scheduler" || e.state.cliPath !== null | ||
| || !e.state.bunPath || normalizeWindowsPath(e.state.bunPath) !== normalizeWindowsPath(executable))) { | ||
| return unknown("the standalone service wrapper executable is not bound to recorded scheduler install state"); | ||
| } | ||
| } | ||
|
|
||
| const rawCodexHome = batchSetValue(wrapperBody, "CODEX_HOME"); | ||
| const rawOpencodexHome = batchSetValue(wrapperBody, "OPENCODEX_HOME"); | ||
|
|
@@ -992,6 +1100,7 @@ export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): Service | |
| winswStatus: deps.winswStatus, | ||
| windowsLocale: deps.windowsLocale, | ||
| windowsTaskListingCache: deps.windowsTaskListingCache, | ||
| statePaths: deps.statePaths, | ||
| }); | ||
| } | ||
| return unknown(`no service manager probe for platform ${platform}`); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reject whitespace that changes the standalone executable path.
If the wrapper contains
set "OCX_BUN= C:\OpenCodex\ocx.exe",generatedBatchSetValuetrims the leading space. The probe can then matchC:\OpenCodex\ocx.exeagainst the runtime assignment and scheduler state, although the batch variable contains a different executable path. Reject leading or trailing whitespace inOCX_BUNand the compared runtime path before normalizing them. Add a regression case with otherwise valid scheduler state.🤖 Prompt for AI Agents