-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(update): avoid PATH lookup for systemd-run #6037
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
5de306c
2c25f12
23199b7
164325c
10259af
7d65ec2
fa92e6c
4bceb80
331441a
75f8497
8f220ad
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 |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| import { spawnSync } from "node:child_process"; | ||
| import { spawn, spawnSync } from "node:child_process"; | ||
| import { accessSync, constants, realpathSync, statSync } from "node:fs"; | ||
| import { dirname, isAbsolute } from "node:path"; | ||
|
|
||
| /** | ||
| * How to launch the dashboard update worker on POSIX. | ||
|
|
@@ -16,19 +18,157 @@ export const SYSTEMD_SCOPE_ARGS = ["--user", "--scope", "--quiet", "--collect", | |
| export interface WorkerLaunchContext { | ||
| platform?: NodeJS.Platform; | ||
| env?: NodeJS.ProcessEnv; | ||
| hasSystemdRun?: () => boolean; | ||
| resolveSystemdRun?: () => string | undefined; | ||
| } | ||
|
|
||
| let systemdRunProbe: boolean | undefined; | ||
| // Absolute install paths only — PATH is never consulted, so a caller-controlled entry cannot | ||
| // redirect the launch. `/usr/local/bin` is where systemd lands when built or stowed outside the | ||
| // distro layout, and `/run/current-system/sw/bin` is the NixOS layout, where the binary lives | ||
| // nowhere else even though the user bus works. A candidate only counts when the binary and its | ||
| // directory are root-owned and not group/world-writable, so a lower-trust local actor cannot | ||
| // plant the launcher the scope probe execs. | ||
| const TRUSTED_SYSTEMD_RUN_PATHS = [ | ||
| "/usr/bin/systemd-run", "/bin/systemd-run", "/usr/local/bin/systemd-run", | ||
| "/run/current-system/sw/bin/systemd-run", | ||
| ] as const; | ||
|
|
||
| function probeSystemdRun(): boolean { | ||
| export interface SystemdRunHooks { | ||
| isExecutableFile: (path: string) => boolean; | ||
| probeScope: (path: string) => boolean; | ||
| /** Async variant of probeScope; resolveSystemdRunAsync prefers it when present. */ | ||
| probeScopeAsync?: (path: string) => Promise<boolean>; | ||
| } | ||
|
|
||
| const GROUP_OR_WORLD_WRITE = 0o022; | ||
|
|
||
| // stat (follow) rather than lstat: a root-owned symlink to a user-writable directory must fail | ||
| // on the target's mode, not pass on the symlink's (mirrors isTrustedSystemPath in | ||
| // src/codex/desktop-app/linux.ts). | ||
| export interface SystemdRunTrustDeps { | ||
| /** Test seam: canonicalizes the candidate before its substitution chain is checked. */ | ||
| realpathSync?: (path: string) => string; | ||
| /** Test seam: stats a resolved path for ownership and mode. */ | ||
| statSync?: (path: string) => { isFile(): boolean; uid: number; mode: number }; | ||
| /** Test seam: checks the candidate's executable bit. */ | ||
| accessSync?: (path: string, mode: number) => void; | ||
| } | ||
|
|
||
| function rootOnlyWritable(path: string, stat: SystemdRunTrustDeps["statSync"] = statSync): boolean { | ||
| try { | ||
| const st = stat!(path); | ||
| return st.uid === 0 && (st.mode & GROUP_OR_WORLD_WRITE) === 0; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| // "Executable" here includes trust: the binary and its directory must be root-owned and not | ||
| // group/world-writable. /usr/local/bin is group-writable on some systems, and a planted or | ||
| // replaced systemd-run there would be exec'd by the scope probe under the service account; | ||
| // the fallback is the plain detached spawn, so nothing breaks when it is skipped. | ||
| // Exported for unit tests. | ||
| export function isTrustedSystemdRunFile(path: string, deps: SystemdRunTrustDeps = {}): boolean { | ||
| try { | ||
| if (!isAbsolute(path)) return false; | ||
| (deps.accessSync ?? accessSync)(path, constants.X_OK); | ||
| // The lexical path may be a symlink. Checking the link's own parent only proves | ||
| // the *entry* is pinned; the file it resolves to — and every ancestor able to | ||
| // substitute that resolved file — is what the scope probe will actually exec. | ||
| const realpath = deps.realpathSync ?? realpathSync; | ||
| const resolved = realpath(path); | ||
| const stat = deps.statSync ?? statSync; | ||
| const st = stat(resolved); | ||
| if (!(st.isFile() && st.uid === 0 && (st.mode & GROUP_OR_WORLD_WRITE) === 0)) { | ||
| return false; | ||
| } | ||
| for (const start of [dirname(path), dirname(resolved)]) { | ||
| for (let dir = start, previous = ""; dir !== previous; previous = dir, dir = dirname(dir)) { | ||
| if (!rootOnlyWritable(dir, stat)) return false; | ||
| } | ||
| } | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** Scope discovery gets only user-bus identity, never inherited management credentials. */ | ||
| function scopeProbeEnvironment(): NodeJS.ProcessEnv { | ||
| const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" }; | ||
| for (const name of ["HOME", "USER", "LOGNAME", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"]) { | ||
| if (process.env[name] !== undefined) env[name] = process.env[name]; | ||
| } | ||
| return env; | ||
| } | ||
|
|
||
| const systemdRunHooks: SystemdRunHooks = { | ||
| isExecutableFile: isTrustedSystemdRunFile, | ||
| // Run a real scope with the same absolute binary as its harmless version payload. | ||
| // Probing only the outer --version would not verify the user bus. | ||
| probeScope: path => { | ||
| const probe = spawnSync(path, [...SYSTEMD_SCOPE_ARGS, path, "--version"], { stdio: "ignore", timeout: 5_000, env: scopeProbeEnvironment() }); | ||
| return !probe.error && probe.status === 0; | ||
| }, | ||
| probeScopeAsync: path => new Promise<boolean>(resolve => { | ||
| const probe = spawn(path, [...SYSTEMD_SCOPE_ARGS, path, "--version"], { stdio: "ignore", env: scopeProbeEnvironment() }); | ||
| probe.unref(); | ||
| const timer = setTimeout(() => { | ||
| try { probe.kill("SIGKILL"); } catch { /* failed termination is not a successful probe */ } | ||
| resolve(false); | ||
| }, 5_000); | ||
| timer.unref(); | ||
| probe.once("error", () => { clearTimeout(timer); resolve(false); }); | ||
| probe.once("close", code => { clearTimeout(timer); resolve(code === 0); }); | ||
| }), | ||
|
Comment on lines
+112
to
+122
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. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: git rev-parse 06d7914e6a736b0ab5b112c1198efbfd683b9bc1 8f220ad4a77b0dc6ed10dabd1c79e9a9d586d256
git show 06d7914e6a736b0ab5b112c1198efbfd683b9bc1:src/update/worker-launch.ts | sed -n '1,135p'
sed -n '95,190p' src/update/worker-launch.tsRepository: lidge-jun/opencodex Length of output: 6152 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- worker-launch diff ---'
git diff --unified=80 06d7914e6a736b0ab5b112c1198efbfd683b9bc1 8f220ad4a77b0dc6ed10dabd1c79e9a9d586d256 -- src/update/worker-launch.ts
printf '%s\n' '--- bound callers and route references ---'
rg -n -C 5 'resolveSystemdRun|guiUpdateWorkerCommand|hasSystemdRun|update worker|dashboard' src structure tests 2>/dev/null | head -n 500
printf '%s\n' '--- service documentation around cited section ---'
sed -n '315,350p' structure/ops/service-and-sidecars.md
printf '%s\n' '--- systemd-run availability and scope help ---'
command -v systemd-run || true
systemd-run --version 2>/dev/null || true
systemd-run --help 2>/dev/null | grep -A4 -B2 -E -- '--scope|--collect|--user' || trueRepository: lidge-jun/opencodex Length of output: 42784 🏁 Script executed: #!/bin/bash
set -e
base=06d7914e6a736b0ab5b112c1198efbfd683b9bc1
head=8f220ad4a77b0dc6ed10dabd1c79e9a9d586d256
printf '%s\n' '--- changed files ---'
git diff --name-only "$base" "$head" | grep -E '(^src/update/|^src/server/|^tests/update/|service-and-sidecars)' || true
printf '%s\n' '--- exact async resolver references at head ---'
git grep -n -C 8 'resolveSystemdRunAsync\|guiUpdateWorkerCommand' "$head" -- src tests structure/ops/service-and-sidecars.md
printf '%s\n' '--- exact old launcher references at base ---'
git grep -n -C 8 'guiUpdateWorkerCommand\|hasSystemdRun\|probeSystemdRun' "$base" -- src tests
printf '%s\n' '--- head update worker launch callers ---'
git grep -n -C 12 'guiUpdateWorkerCommand\|resolveSystemdRunAsync' "$head" -- src/update src/serverRepository: lidge-jun/opencodex Length of output: 41822 🏁 Script executed: #!/bin/bash
set -e
base=06d7914e6a736b0ab5b112c1198efbfd683b9bc1
head=8f220ad4a77b0dc6ed10dabd1c79e9a9d586d256
printf '%s\n' '--- route diff ---'
git diff --unified=35 "$base" "$head" -- src/server/management/config-routes.ts
printf '%s\n' '--- base route ---'
git show "$base":src/server/management/config-routes.ts | sed -n '750,825p'
printf '%s\n' '--- head route ---'
git show "$head":src/server/management/config-routes.ts | sed -n '750,825p'
printf '%s\n' '--- startUpdateJob definition and worker conditions ---'
rg -n -C 12 'function startUpdateJob|export function startUpdateJob|spawnWorkerFn|checkForUpdateFn|worker' src/update/job.ts | head -n 260Repository: lidge-jun/opencodex Length of output: 25241 Avoid caching a failed When The base revision also cached a negative result, but only when the worker-launch path performed the probe. This PR broadens the cache-filling path to requests that can fail before worker creation. Suggested fix const found = await systemdRunProbePending;
+ systemdRunProbePending = undefined;
// Honor a cache the sync resolver may have filled while the probe ran — the
// older observation wins so every caller converges on one launcher.
- if (systemdRunProbe === undefined) systemdRunProbe = found;
+ if (systemdRunProbe === undefined && found !== null) systemdRunProbe = found;
return systemdRunProbe ?? undefined;If repeated probe cost is a concern, give negative results a short TTL instead of caching them for the process lifetime. 🧰 Tools🪛 ast-grep (0.45.3)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||
| }; | ||
|
|
||
| let systemdRunProbe: string | null | undefined; | ||
| let systemdRunProbePending: Promise<string | null> | undefined; | ||
|
|
||
| export function resolveSystemdRun(hooks: SystemdRunHooks = systemdRunHooks): string | undefined { | ||
| if (systemdRunProbe === undefined) { | ||
| // Run a real no-op scope rather than `--version`: a present binary without a reachable user | ||
| // bus would otherwise pass the probe and then fail to start the worker at all. | ||
| const probe = spawnSync("systemd-run", [...SYSTEMD_SCOPE_ARGS, "true"], { stdio: "ignore", timeout: 5_000 }); | ||
| systemdRunProbe = !probe.error && probe.status === 0; | ||
| systemdRunProbe = null; | ||
| for (const command of TRUSTED_SYSTEMD_RUN_PATHS) { | ||
| if (!hooks.isExecutableFile(command)) continue; | ||
| if (hooks.probeScope(command)) { | ||
| systemdRunProbe = command; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| return systemdRunProbe; | ||
| return systemdRunProbe ?? undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Management-request path variant. The synchronous resolver blocks the shared | ||
| * event loop for up to four sequential five-second scope probes on first use; | ||
| * the dashboard update route awaits this instead, so probing overlaps other | ||
| * requests. Concurrent first callers share one probe pass. | ||
| */ | ||
| export async function resolveSystemdRunAsync(hooks: SystemdRunHooks = systemdRunHooks): Promise<string | undefined> { | ||
| if (systemdRunProbe !== undefined) return systemdRunProbe ?? undefined; | ||
| if (!systemdRunProbePending) { | ||
| systemdRunProbePending = (async () => { | ||
| const probeScope = hooks.probeScopeAsync ?? (async (path: string) => hooks.probeScope(path)); | ||
| for (const command of TRUSTED_SYSTEMD_RUN_PATHS) { | ||
| if (!hooks.isExecutableFile(command)) continue; | ||
| if (await probeScope(command)) { | ||
| return command; | ||
| } | ||
| } | ||
| return null; | ||
| })(); | ||
| } | ||
| const found = await systemdRunProbePending; | ||
| // Honor a cache the sync resolver may have filled while the probe ran — the | ||
| // older observation wins so every caller converges on one launcher. | ||
| if (systemdRunProbe === undefined) systemdRunProbe = found; | ||
| return systemdRunProbe ?? undefined; | ||
| } | ||
|
|
||
| export function resetSystemdRunProbeForTests(): void { | ||
| systemdRunProbe = undefined; | ||
| systemdRunProbePending = undefined; | ||
| } | ||
|
|
||
| export function guiUpdateWorkerCommand( | ||
|
|
@@ -39,8 +179,9 @@ export function guiUpdateWorkerCommand( | |
| const platform = context.platform ?? process.platform; | ||
| const env = context.env ?? process.env; | ||
| const underSystemd = platform === "linux" && Boolean(env.INVOCATION_ID); | ||
| if (underSystemd && (context.hasSystemdRun ?? probeSystemdRun)()) { | ||
| return { command: "systemd-run", argv: [...SYSTEMD_SCOPE_ARGS, execPath, ...args] }; | ||
| const systemdRun = underSystemd ? (context.resolveSystemdRun ?? resolveSystemdRun)() : undefined; | ||
| if (systemdRun) { | ||
| return { command: systemdRun, argv: [...SYSTEMD_SCOPE_ARGS, execPath, ...args] }; | ||
| } | ||
| return { command: execPath, argv: [...args] }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.