-
Notifications
You must be signed in to change notification settings - Fork 866
fix(probe,codex): stop fencing native-main on a probe that could not answer #2130
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
b6b219c
7e95fc6
e95b8cf
82fd810
9d49763
a674b90
50386c1
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 |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| # 170 — #2114 and #2108: the fence that could not re-ask | ||
|
|
||
| Both reports are the same shape wearing two operating systems: a probe that | ||
| could not get an answer, a verdict of `unknown`, and a fence that holds for the | ||
| whole process. Every native request 503s and only `ocx restart` clears it. | ||
|
|
||
| Shipped as PR #2130, five commits. | ||
|
|
||
| ## The three changes | ||
|
|
||
| **#2114 — the Linux session bus.** `inspectSystemd` called every non-zero exit | ||
| `unknown`. Its own comment was right that a non-zero status means the question | ||
| never reached the bus — and that is exactly why the verdict was wrong. It is | ||
| evidence about the bus, not about who owns this home. | ||
|
|
||
| Widening on the exit code alone fails open: with the bus down `systemctl` | ||
| cannot see a foreign unit either, so "no answer" would read as "no owner" on a | ||
| machine that has one. The classification asks the disk instead, which needs no | ||
| bus. | ||
|
|
||
| **#2108 (a) — an unaskable WinSW query.** WinSW is optional, and a | ||
| scheduler-only install has neither its XML nor its exe on disk. A timed-out | ||
| `sc.exe query` still returned `unknown`, which outranks the disk. With both | ||
| assets gone there is nothing for a registration to belong to. | ||
|
|
||
| **#2108 (b) — the fence re-asks.** `startServer` takes the verdict once and | ||
| holds it, which is why waiting never helped and restart always did. Correct for | ||
| `foreign-ownership` — a foreign owner is a fact. Wrong for | ||
| `ownership-unknown`, which says the probe could not answer. | ||
|
|
||
| ## What the audit caught, three rounds running | ||
|
|
||
| This is the part worth keeping. The functional idea was right in round one; the | ||
| **ownership model was wrong three times in a row**, and each time the symptom | ||
| was identical — a fence with no way to re-ask, which is #2108 returning by | ||
| another route. | ||
|
|
||
| | Round | Defect | Why it slipped | | ||
| |---|---|---| | ||
| | 1 | `inspectSystemdOffline` checked ONE path, so a foreign unit in `~/.local/share/systemd/user` or an XDG override was invisible → `ownership: owned` on a foreign host | The bus-up path never had the hole; I reused its constant, not its coverage | | ||
| | 2 | The probe decremented the refcount AND the fence's own `release()` decremented again → a fence another holder owned got lifted | `Math.max(0, ...)` floored it, so it under-counted silently instead of going negative | | ||
| | 2 | A spent hook stayed in the map, so no LATER fence could install one | The "do not reset the budget" guard was right; its scope was not | | ||
| | 3 | Owner-identity guard was correct but **untested** — replacing it with `if (true)` left every suite green | A third route to the same wedge, with nothing pinning it | | ||
|
|
||
| Round 1's fail-open is the one that mattered: I wrote "a unit naming a foreign | ||
| home stays present and still blocks" in a commit message, and the auditor proved | ||
| it end-to-end as `ownership = owned`. The claim was true only for a unit at the | ||
| canonical path. | ||
|
|
||
| The fix that finally held is smaller than either attempt: the hook delegates to | ||
| the fence's own idempotent `release()`. One fence, one payment, whoever triggers | ||
| it. Both earlier versions were modelling the ownership in the wrong place. | ||
|
|
||
| ## Guards, and the ones that were not guards | ||
|
|
||
| Every branch was ablated. Three that looked like guards were not: | ||
|
|
||
| - the multi-unit conflict branch — a fail-closed decision with nothing asserting | ||
| it; ablating it left the suite green | ||
| - "foreign never retries" — double-implemented, so removing either half alone | ||
| stayed green; only removing both failed | ||
| - the owner-identity check — round 3's finding, above | ||
|
|
||
| All three now have tests driven red against the real code. | ||
|
|
||
| ## Stated rather than hidden | ||
|
|
||
| **Locale.** systemd localizes the bus-failure strings, so a non-English host | ||
| will not match and keeps the old `unknown`. That fences rather than admits, | ||
| which is the safe direction, but the #2114 fix does not reach every affected | ||
| user. Forcing `LC_ALL=C` on the probe would remove the caveat and is the | ||
| obvious follow-up; it is not done here because it changes every `systemctl` | ||
| call the module makes. | ||
|
|
||
| **Coverage.** These are Windows and Linux platform paths. The suites that prove | ||
| them ran on macOS and on `ssh lidge`; the platform CI legs are the check that | ||
| actually matters. | ||
|
|
||
| **A hostile `XDG_CONFIG_HOME`** redirects the offline check to an | ||
| attacker-chosen directory. The auditor raised it and then cleared it: reaching | ||
| that requires controlling the proxy's own environment, at which point | ||
| `CODEX_HOME` is equally controllable and the comparison is moot. No privilege | ||
| boundary is crossed. | ||
|
|
||
| ## Coordination with #2029 | ||
|
|
||
| Draft PR #2029 edits the same function for #1939 and classifies two other bus | ||
| messages as `absent`. This branch never touched it or its branch. The two | ||
| agree in direction; if it lands first this reconciles rather than replaces. | ||
|
|
||
| ## Verification | ||
|
|
||
| ``` | ||
| full suite on ssh lidge 13,524 pass / 15 skip / 0 fail across 855 files | ||
| probe + startup + boundary 96 pass / 0 fail | ||
| bun x tsc --noEmit exit 0 | ||
| bun run privacy:scan exit 0 | ||
| ``` | ||
|
|
||
| One CI failure was investigated and dismissed on evidence: `keyring ubuntu` | ||
| stalled eight minutes on an apt mirror and was cancelled by the job timeout — | ||
| an infrastructure fault with no relation to the diff. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -179,6 +179,86 @@ function unitEnvValue(body: string, key: string): string | null { | |
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Did `systemctl --user` fail because the session bus could not be reached at all? | ||
| * | ||
| * These are the shapes reported on #2114 and #1939. The distinction that matters is | ||
| * "the question never left the machine" versus "systemd answered and said no" — only | ||
| * the former licenses reading the disk instead. | ||
| * | ||
| * **Locale caveat, stated rather than hidden:** systemd localizes these strings, so a | ||
| * non-English host will not match and keeps the old `unknown`. That is the safe | ||
| * direction — it fences rather than admits — but it does mean the fix does not reach | ||
| * every affected user. Forcing `LC_ALL=C` on the probe would remove the caveat and is | ||
| * the obvious follow-up; it is not done here because it changes every systemctl call | ||
| * this module makes, not just this branch. | ||
| */ | ||
| function busUnreachable(stderr: string): boolean { | ||
| const err = stderr.trim(); | ||
| return err.includes("Failed to connect to bus") | ||
| || err.includes("Failed to connect to user scope bus") | ||
| || err.includes("Failed to get D-Bus connection") | ||
| || err.includes("DBUS_SESSION_BUS_ADDRESS") | ||
| || err.includes("System has not been booted with systemd"); | ||
| } | ||
|
|
||
| /** | ||
| * Ownership from the unit file alone, for when the bus cannot answer (#2114). | ||
| * | ||
| * A unit file is proof of installation that does not require a running bus, and the homes | ||
| * it names are what ownership is actually decided on. What the disk cannot tell us is | ||
| * whether systemd has the unit LOADED, so this reports `registration: "absent"` — the | ||
| * honest reading of "no running manager has it" — rather than inventing a live state. | ||
| * | ||
| * A foreign home therefore still blocks, which is the whole reason this consults the disk | ||
| * instead of widening the exit code. | ||
| */ | ||
| function systemdUserUnitSearchPaths(home: string): string[] { | ||
| // systemd's user search path is not one directory. Checking only the canonical one and | ||
| // calling the rest absent is a fail-open: with the bus down a foreign unit in any other | ||
| // search dir is invisible, and "no answer" would be read as "no owner". | ||
| const xdgConfig = process.env.XDG_CONFIG_HOME?.trim(); | ||
| const xdgData = process.env.XDG_DATA_HOME?.trim(); | ||
| const dirs = [ | ||
| xdgConfig ? join(xdgConfig, "systemd", "user") : join(home, ".config", "systemd", "user"), | ||
| join(home, ".config", "systemd", "user"), | ||
| xdgData ? join(xdgData, "systemd", "user") : join(home, ".local", "share", "systemd", "user"), | ||
| join(home, ".local", "share", "systemd", "user"), | ||
| ]; | ||
| return [...new Set(dirs)].map(dir => join(dir, `${TASK}.service`)); | ||
|
Comment on lines
+216
to
+228
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 | 🏗️ Heavy lift Model the systemd user-unit load path in 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| function inspectSystemdOffline(home: string): ServiceManagerInstallation { | ||
| const candidates = systemdUserUnitSearchPaths(home); | ||
| const found = candidates.filter(path => artifactPresence(path) === "present"); | ||
| if (candidates.some(path => artifactPresence(path) === "unreadable")) { | ||
| return unknown("the session bus is unreachable and a systemd unit could not be read"); | ||
| } | ||
| if (found.length === 0) return { kind: "absent" }; | ||
| if (found.length > 1) { | ||
| return unknown("the session bus is unreachable and more than one systemd unit file claims this proxy"); | ||
| } | ||
| const definitionPath = found[0]!; | ||
| let body: string; | ||
| try { | ||
| body = readFileSync(definitionPath, "utf-8"); | ||
| } catch (error) { | ||
| return unknown(`the session bus is unreachable and the systemd unit could not be read: ${String(error)}`); | ||
| } | ||
| return { | ||
| kind: "present", | ||
| claims: [{ | ||
| backend: "systemd", | ||
| definitionPath, | ||
| homes: { | ||
| codexHome: unitEnvValue(body, "CODEX_HOME"), | ||
| opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"), | ||
| }, | ||
| registration: "absent", | ||
| }], | ||
| }; | ||
| } | ||
|
|
||
| function inspectLaunchd(deps: Required<Pick<ProbeDeps, "run" | "uid" | "home">>): ServiceManagerInstallation { | ||
| const definitionPath = join(deps.home, "Library", "LaunchAgents", `${LABEL}.plist`); | ||
|
|
||
|
|
@@ -269,6 +349,15 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic | |
| if (shown.status !== 0) { | ||
| // A missing unit still exits ZERO and says not-found; a non-zero status means | ||
| // the question never reached the bus. | ||
| // | ||
| // That is evidence about the BUS, not evidence that a foreign service owns this home | ||
| // (#2114). Calling it `unknown` fences native-main for the whole process, so a laptop | ||
| // with no session bus answers every native request with a 503 until `ocx restart`. | ||
| // | ||
| // Widening on the exit code alone would fail open, because with the bus down systemctl | ||
| // cannot see a foreign unit either. So ask the disk, which needs no bus, and fall back | ||
| // to `unknown` for every other non-zero exit. | ||
| if (busUnreachable(shown.stderr)) return inspectSystemdOffline(deps.home); | ||
| return unknown(`systemctl show exited ${String(shown.status)}: ${shown.stderr.trim()}`); | ||
| } | ||
|
|
||
|
|
@@ -729,6 +818,16 @@ function walkWinswChain( | |
| const registration = probeWinswRegistration(deps); | ||
|
|
||
| if (xml === "absent" && exe === "absent" && registration === "absent") return { kind: "absent" }; | ||
| // A query we could not ask is a question about a service that cannot exist: WinSW is an | ||
| // optional backend, and with neither its XML nor its exe on disk there is nothing for a | ||
| // registration to belong to. Fencing here on an `sc.exe` timeout is one of the two | ||
| // triggers behind #2108, where a scheduler-only install answers 503 until `ocx restart`. | ||
| // | ||
| // The disk outranks the unaskable query only when BOTH assets are gone. Either one | ||
| // present means a real install may be there and the old `unknown` still holds. | ||
| if (registration === "unknown" && xml === "absent" && exe === "absent") { | ||
| return { kind: "absent" }; | ||
| } | ||
| if (registration === "unknown") { | ||
| return unknown("the native WinSW service registration could not be verified"); | ||
| } | ||
|
|
||
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.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Specify a language for the verification block.
The opening fence at Line 93 has no language.
markdownlint-cli2reports MD040. Change it totextorconsole.🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 93-93: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Source: Linters/SAST tools