Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,11 @@ jobs:
bun run build

- name: Test
run: bun test --isolate tests --shard=${{ matrix.shard }}/4
# --timeout: the Linux batches and the macOS control both pass 60000; this leg was
# the only one left on Bun's 5s default, and it is the slowest hardware on the board.
# Three of its failures were the default firing on tests that had not hung — the
# composed-acceptance cases spawn a real `ocx start` and were still working at 41s.
run: bun test --isolate --timeout 60000 tests --shard=${{ matrix.shard }}/4

- name: CLI help smoke
run: bun run src/cli/index.ts help
Expand Down
91 changes: 91 additions & 0 deletions devlog/_plan/260819_unclaimed_bug_selection/180_windows_leg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 180 — the Windows leg: five defects, and one I created

PR #2143. The Windows shards were red before this work and are less red after
it; this records what was actually wrong, because "Windows is flaky" was the
wrong answer four separate times.

## The correction that matters most

I told the user twice that the Windows failures predated this release range.
The first half was true — the leg was red on `e446607c8` (2026-08-18). The
second half was not, and I stated it anyway.

**Log Guard is new in `main...dev`.** It has never worked on Windows. Every
mutation — protect, unprotect, repair, reclaim, compact — refused with
`unsafe_path`. Shipping this range without looking would have released a
feature that is broken on one of three platforms, and my own "pre-existing"
verdict is what nearly let it through.

The lesson is narrow and worth keeping: **"red before my change" and "not my
release's problem" are different claims.** A feature that landed on `dev` two
days earlier is still in the release.

Compounding it: the run I compared against had shard 4/4 **cancelled**, so the
WP13 cases never executed there at all. I read "no failures listed" as "passed".

## What was actually wrong

| # | Defect | Where | Effect |
|---|---|---|---|
| 1 | `realpathSync.native` expands 8.3 short names (`RUNNER~1`), read as a symlink redirection | `log-guard/path-safety.ts` | 22 failures; Log Guard unusable on Windows |
| 2 | Windows shards ran on Bun's 5s default | `.github/workflows/ci.yml` | 3 failures on tests that had not hung |
| 3 | Test fixture's own `Bun.serve` used the default 10s idleTimeout | `codex-composed-acceptance.test.ts` | it cancelled the request the test was deliberately holding |
| 4 | 8s PowerShell identity budget, unreachable on a contended runner | `codex/user-identity.ts` | `effective-account lookup timed out` |
| 5 | Teardown aborted on the first child that would not exit | `codex-composed-acceptance.test.ts` | survivors killed by Bun's between-file sweep → the NEXT case failed with 143 |

Number 5 is why the failures looked like a moving target: one slow case was
being charged to unrelated ones.

## The defect I introduced

My first fix for #1 re-canonicalized the requested path and compared the two
canonical forms. The caller already passes `realpathSync.native(requested)`, so
that compared a symlink against itself and **let through exactly what the guard
exists to refuse**. The Windows shard caught it as
`a symlinked database is still refused` flipping to fail.

Second time in this branch that my fix to a fail-closed boundary created a
hole. Both were caught by the platform leg rather than by me.

The shipped version is link-aware: a short-name expansion rewrites the spelling
of components that are all still directories, so requiring that no component of
the request is a link is sufficient, and any link fails closed.

## Diagnostics were the actual unlock

Two rounds produced only `timed out waiting for runtime-port record`. That is
the symptom. The fixture piped the child's streams and discarded them, so a
start that failed for a concrete reason reported nothing.

Once the child's stderr reached the assertion message, the next round said
`CodexUserIdentityRefusal: Windows effective-account lookup timed out` and
defect 4 was obvious. Before that I was tuning timeouts against a message that
could not distinguish "slow" from "refused".

Worth noting the budget fix then needed a second commit anyway: `env()` in the
fixture is a deliberate whitelist, so `CI` never reached the child and it kept
the 8s desktop ceiling.

## Result

| | before | after |
|---|---|---|
| shard 3/4 | failure | **success** |
| Log Guard | 22 fail | **0** |
| 5s-default | 3 fail | **0** |
| identity lookup | 4 fail | **0** |
| WP13 | 6 fail | 3 fail |

## What is still red, and why it is not this range

- **WP13 (3)** — zero commits in `main..dev`, and no Windows run has ever
executed them to completion. Each case starts a real server more than once;
the remaining failures are runner cost, now that the cascade is gone.
- **npm cache preflight (3)** — zero commits in `main..dev`. Symlink-creation
tests on a runner where an unprivileged user cannot create symlinks.
- **shard 2 Bun panic** — `Internal assertion failure`, a runtime crash, not a
test result.

Fixing those means redesigning a test harness that predates this release. That
is a real piece of work and it is not this one.

55 changes: 52 additions & 3 deletions src/codex/log-guard/path-safety.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { realpathSync } from "node:fs";
import { resolve, sep } from "node:path";
import { lstatSync, realpathSync } from "node:fs";
import { dirname, resolve, sep } from "node:path";

import { samePathIdentity } from "../user-identity";

Expand Down Expand Up @@ -35,5 +35,54 @@ export function normalizeTrustedDarwinSystemAlias(path: string): string {
* Arbitrary ancestor symlinks remain refused.
*/
export function sameLogGuardPathIdentity(realPath: string, requestedPath: string): boolean {
return samePathIdentity(realPath, normalizeTrustedDarwinSystemAlias(requestedPath));
const requested = normalizeTrustedDarwinSystemAlias(requestedPath);
if (samePathIdentity(realPath, requested)) return true;
return sameWindowsCanonicalPath(realPath, requested);
}

/**
* On Windows, is the difference between these two spellings the OS canonicalizing the
* request rather than a redirection?
*
* `realpathSync.native` expands 8.3 short components — the `RUNNER~1` form that appears
* throughout `%TEMP%` — so the canonical path and the requested path can disagree as
* strings while naming the same file. Reading that as an ancestor-symlink redirection made
* every Log Guard mutation refuse with `unsafe_path` on Windows, which is what the CI shards
* were reporting.
*
* The first version of this re-canonicalized the requested path and compared the two
* canonical forms. That was wrong, and the Windows shard proved it: the caller already
* passes `realpathSync.native(requested)` as `realPath`, so re-resolving the request
* produced the same value on BOTH sides and a symlinked database compared equal. The
* widening let through exactly what the guard exists to refuse.
*
* The comparison is therefore link-aware. A short-name expansion rewrites the spelling of
* components that are all still directories on the same chain, so it is enough to require
* that no component of the request is a link: with none present, any remaining difference
* is the OS's own canonical spelling. A symlink or junction anywhere in the chain fails
* closed as before.
*/
function sameWindowsCanonicalPath(realPath: string, requestedPath: string): boolean {
if (process.platform !== "win32") return false;
try {
if (pathChainContainsLink(requestedPath)) return false;
return samePathIdentity(realPath, realpathSync.native(requestedPath));
} catch {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return false;
}
}

/** Is any component of this path a symlink or junction? Fails closed on an unreadable one. */
function pathChainContainsLink(path: string): boolean {
let current = resolve(path);
for (;;) {
try {
if (lstatSync(current).isSymbolicLink()) return true;
} catch {
return true;
}
const parent = dirname(current);
if (parent === current) return false;
current = parent;
}
}
22 changes: 21 additions & 1 deletion src/codex/user-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
*/
const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS = 8_000;

/**
* The same budget, widened for a contended CI runner.
*
* 8s is a generous ceiling for `powershell.exe -Command` on a real desktop and is not one
* on a GitHub Windows runner executing a quarter of this suite: the composed-acceptance
* cases fail there with "Windows effective-account lookup timed out" while the child is
* still starting. That is runner contention, not a hung lookup, and the budget exists to
* bound the latter.
*
* Gated on `CI` alone. A user's machine keeps the 8s ceiling exactly as before, so the
* recoverable-refusal contract this budget protects is unchanged where it matters.
*/
const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_CI_MS = 30_000;

function windowsIdentityLookupTimeoutMs(): number {
return process.env.CI === "true"
? WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_CI_MS
: WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS;
}

/**
* FOLDERID_LocalAppData, and the flag that makes the lookup ignore the caller's
* environment.
Expand Down Expand Up @@ -108,7 +128,7 @@ function windowsIdentityPowerShellSpawnOptions(): {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
timeout: WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS,
timeout: windowsIdentityLookupTimeoutMs(),
windowsHide: true,
};
}
Expand Down
6 changes: 5 additions & 1 deletion tests/ci-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,11 @@ describe("GitHub Actions hardening", () => {
// the runner's disk and the suite passes against a tree that no longer
// exists in git.
const winSteps = (ci.jobs?.["platform-windows"] as { steps?: { if?: string; run?: string }[] })?.steps ?? [];
const windowsTestCommand = `bun test --isolate tests --shard=\${{ matrix.shard }}/${windowsShards.length}`;
// --timeout is part of the contract, not incidental: this leg ran on Bun's 5s default
// while Linux and macOS both pass 60000, and it is the slowest hardware on the board.
// Three composed-acceptance failures were that default firing on tests still working
// at 41s. Pin the flag so the leg cannot silently drift back to the default.
const windowsTestCommand = `bun test --isolate --timeout 60000 tests --shard=\${{ matrix.shard }}/${windowsShards.length}`;
expect(hasExactShellCommand(`echo ${windowsTestCommand}`, windowsTestCommand)).toBe(false);
// Binding the assertion to an executable line is only half the guarantee: a
// step carrying the exact command still runs nothing under `if: false`, and
Expand Down
Loading
Loading