Skip to content

Codex/pr533 update recovery hardening - #803

Closed
batuchek68-ux wants to merge 16 commits into
lidge-jun:mainfrom
batuchek68-ux:codex/pr533-update-recovery-hardening
Closed

Codex/pr533 update recovery hardening#803
batuchek68-ux wants to merge 16 commits into
lidge-jun:mainfrom
batuchek68-ux:codex/pr533-update-recovery-hardening

Conversation

@batuchek68-ux

@batuchek68-ux batuchek68-ux commented Jul 31, 2026

Copy link
Copy Markdown

Summary

  • Explain the user-visible or maintainer-facing change.

Verification

  • List the commands or checks you ran.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Summary by CodeRabbit

  • New Features

    • Improved update reliability with npm cache ownership checks, bounded installer timeouts, and process-tree cleanup.
    • Added safer recovery handling that verifies package integrity, process identity, and proxy health before restarting services.
    • Updates now provide clearer failure details for timeouts, launch errors, interrupted processes, and cleanup failures.
    • Healthy running proxies are preserved when an update cannot complete safely.
  • Documentation

    • Updated CLI update documentation across supported languages with the new npm cache validation behavior.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The update flow adds npm cache ownership checks, process-tree installer execution, bounded cleanup, identity-verified proxy recovery, sanitized persisted state, and trusted recovery package validation. Tests and CLI documentation cover the new behavior.

Changes

Update safety and recovery

Layer / File(s) Summary
npm cache ownership preflight
src/update/npm-cache-preflight.mjs, src/update/npm-cache-preflight.d.mts, src/update/index.ts, bin/ocx.mjs, tests/update-npm-cache-preflight.test.ts, tests/update-stop-first.test.ts, docs-site/src/content/docs/*/reference/cli.md
Unix updates inspect npm cache ownership before proxy shutdown. The scan uses bounded traversal, worker isolation, symlink checks, sanitized errors, and platform-specific behavior.
Installer process-tree execution
src/update/install-process.mjs, src/update/install-process.d.mts, src/update/index.ts, bin/ocx.mjs, tests/update-install-process.test.ts, tests/ocx-launcher-source.test.ts
Installer commands use detached process trees with bounded output, timeout handling, signal handling, platform-specific termination, and cleanup verification.
Identity-checked proxy recovery
src/config.ts, src/update/job.ts, src/update/recovery-tree-scan.mjs, src/update/recovery-tree-scan.d.mts, tests/update-job.test.ts, tests/config.test.ts, tests/update-stop-first.test.ts
Recovery validates package trees, launchers, process identities, generations, liveness, and health before restarting or terminating processes.
Async orchestration and recovery documentation
bin/ocx.mjs, src/update/index.ts, src/update/job.ts, docs/adr/0001-gui-update-worker.md, docs-site/src/content/docs/*/reference/cli.md
The launcher awaits self-update completion. Update failures distinguish timeout, spawn, exit, and cleanup outcomes. The ADR and localized CLI pages document cache checks and recovery rules.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: bug

Suggested reviewers: wibias, ingwannu, lidge-jun

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies update recovery hardening, which matches the primary focus of the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch codex/pr533-update-recovery-hardening
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

Warning

⚠️ This pull request shows signs of AI-generated slop (description_diff_mismatch). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

@lidge-jun

Copy link
Copy Markdown
Owner

Closing. This pull request contains no work by its author.

The head commit here is byte-identical to a branch that already exists in this repository and was authored by the maintainer. All six of these pull requests have the same shape: fork the repository, push the upstream branches back unchanged, and open them as incoming contributions.

PR head SHA identical upstream branch
#798 eef9a86d codex/260728-tls-altname-diagnosis
#799 d0763bf7 codex/260729-security-md-reporting-path
#800 d380b1b0 codex/260731-pr-merge-round
#802 a64aa585 codex/app-server-restart
#803 b0434ea5 codex/pr533-update-recovery-hardening
#804 f0acc36a stack/gui-dense-workspaces

Every listed SHA resolves to the same commit on the upstream branch of the same name. The commit authors inside them are lidge-jun, Wibias, ahmetb, aljjang95, hanbinnoh and other existing contributors. The descriptions are the unmodified pull request template with every checklist item unchecked, and #798 additionally carries the maintainer's own commit message pasted in as if it were a summary. Several target main, which AGENTS.md reserves for maintainer-controlled promotion, and two are chained onto each other's heads in a cycle.

This is not a rebase, a resubmission of stalled work, or a fork that drifted. It burns maintainer review time and CI minutes on a diff that is already in the tree, and it presents other people's commits under a new author's pull request. Repository access is being revoked for this account.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b0434ea584

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +333 to +335
treeExited = result.status === 0
&& result.signal === null
&& posixTreeAfterRootExit !== "running";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow clean installer failures to reach recovery

When the installer exits nonzero without leaving any child process behind (for example, npm install returns 1 after the proxy was stopped), this assignment still forces treeExited to false solely because result.status !== 0. The callers check !treeExited before their normal failure handling/recovery (bin/ocx.mjs exits 75, and the GUI worker skips recoverFailedGuiUpdate), so ordinary clean installer failures are misclassified as unsafe cleanup and leave the proxy/tray recovery path unreachable. Base treeExited on the process-tree inspection result, and let the status decide success vs. recoverable failure separately.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bin/ocx.mjs`:
- Around line 246-250: Update the cleanup-failure messages in bin/ocx.mjs (lines
246-250) and src/update/index.ts (lines 271-275) to include the installer
result’s timeout, interruption signal, and status details before exiting. Use
res.timedOut, res.interruptedSignal, and res.status in the ocx updater, and
r.timedOut, r.interruptedSignal, and r.status in the launcher, keeping both
messages consistent while preserving the existing cleanup warning and exit code.

In `@src/update/index.ts`:
- Around line 264-269: The installer timeout is duplicated as raw values,
preventing the CLI deadline and GUI worker deadline from being validated
together. In src/update/index.ts#L264-L269, add a module-level named
installer-timeout constant and use it for runProcessTreeCommand; in
src/update/index.ts#L374-L379, derive the timeout failure text from that
constant. In tests/update-stop-first.test.ts#L120-L137, parse the CLI constant
and assert UPDATE_TIMEOUT_MS is at least 60,000 ms longer, and update the
literal timeout assertion at line 162.

In `@src/update/install-process.mjs`:
- Around line 291-296: Update both consumers of cleanupPromise in
terminateInstallerProcessTree and runProcessTreeCommand to handle rejections by
converting them to the module’s existing fail-closed cleanup result. Add
rejection handling to the detached then chain and ensure the awaited
cleanupPromise always resolves with the failure result, so signal handlers are
removed and runProcessTreeCommand does not propagate cleanup exceptions.

In `@src/update/job.ts`:
- Around line 127-148: The recovery-scan contract is duplicated in job.ts
instead of sourced from recovery-tree-scan.mjs. In src/update/job.ts lines
127-148, remove the local isPathInside, hasTrustedRecoveryOwner, and
hasTrustedRecoveryPermissions definitions; export and declare those symbols from
recovery-tree-scan.mjs and recovery-tree-scan.d.mts, then import them in job.ts.
In src/update/job.ts lines 42, 53-55, import DEFAULT_MAX_RECOVERY_TREE_ENTRIES
and DEFAULT_MAX_RECOVERY_TREE_SCAN_MS and derive MAX_NPM_RECOVERY_TREE_ENTRIES
and MAX_NPM_RECOVERY_TREE_SCAN_MS from them, preserving the existing worker
payload and spawnSync timeout usage.
- Around line 714-720: Update the restart path around readPidForRestart to
invoke the injected RestartIo.killProxyFn when available, falling back to the
imported killProxy otherwise, matching recoverFailedGuiUpdate. Add a focused
regression test alongside the existing restartAfterUpdateForTests tests that
verifies the injected function observes or blocks the verified old-PID kill
path.
- Around line 505-528: Update runLoggedProcessTreeCommand to detect result.error
from runProcessTreeCommand and append its message to the job log via updateJob,
preserving the existing stdout and stderr logging. Ensure spawn failures such as
missing binaries expose the underlying error to downstream worker and dashboard
output while retaining the existing InstallerCommandResult fields.
- Around line 324-331: Expand sanitizeUpdateJobText to redact Windows
user-profile paths such as C:\Users\<name>\..., including paths not containing
the existing npm/cache markers, while preserving the current POSIX and
specialized path sanitization. Update the UID/GID pattern to also redact
lowercase space-separated forms such as “uid 1001”. Add a focused regression
case beside the existing update-job tests verifying Windows command and log text
does not persist the username.
- Around line 1246-1266: Update the failure handling around the result-status
branch in the update job so status === 0 with treeExited === false is reported
as a successful install with unconfirmed process shutdown, while retaining the
fail-closed recovery decision and skip log. Avoid labeling this case as “update
command failed (0)”, preserve the existing failure behavior for nonzero
statuses, and add a focused regression test in the existing update-job tests
covering the specified result shape and asserting both the skip log and new
error text.
- Around line 256-276: Bound recovery candidates before expensive validation: in
the retired-directory collection around inspectNpmRecoveryPackage, first sort
matching directory names by cheap filesystem mtime, retain at most
MAX_NPM_RECOVERY_CANDIDATES, then call inspectNpmRecoveryPackage only for that
subset before probing. Update the relevant update-job test to verify
inspection/spawned scan count is also limited to the candidate cap, not merely
that two launchers are probed.

In `@src/update/npm-cache-preflight.d.mts`:
- Around line 7-24: Split NpmCachePreflightOptions into entry-point-specific
option types: keep access, lstat, readdir, realpath, and now only on the options
used by findForeignOwnedNpmCacheEntry, while scanNpmCacheOwnership and
checkNpmCacheOwnership accept a narrow type containing only options they
forward, such as cache configuration and scan limits. Update the walk function
declaration to use the filesystem-hook type and preserve the existing worker
payload behavior.

In `@src/update/npm-cache-preflight.mjs`:
- Around line 146-199: Update scanNpmCacheOwnership so the parent spawnSync
timeout exceeds the worker’s maxDurationMs by a small margin, using a shared
SCAN_SPAWN_MARGIN_MS constant declared with the other scan constants. Keep
maxDurationMs in the worker payload unchanged, and apply the margin only to the
parent timeout so the worker can report its own budget expiration.
- Around line 97-111: Update the accessMode selection in the npm cache preflight
check to use only constants.R_OK for non-directory entries, while retaining
constants.R_OK | constants.W_OK | constants.X_OK for directories. Revise the
non-directory error message to mention readability only, and add a regression
test covering a readable read-only cache file.

In `@tests/update-job.test.ts`:
- Around line 155-170: Set explicit extended timeouts on both recovery tests,
including the test identified by findNpmRecoveryLauncher and the additional test
around the related recovery flow. Append the timeout using the existing test
framework syntax, choosing a value consistent with neighboring subprocess-based
tests (such as 15,000 ms), without changing their assertions or setup.

In `@tests/update-npm-cache-preflight.test.ts`:
- Around line 249-270: Update the fake blocking npm executable created by the
“force-kills an npm cache lookup that ignores SIGTERM” test so it does not
depend on resolving node through PATH. Use the guaranteed Bun interpreter in the
shebang, or otherwise invoke Bun explicitly while preserving the existing
blocking and SIGTERM-ignoring behavior and the ETIMEDOUT assertion.
- Around line 272-299: Update the test “fails closed before shutdown when npm
cannot resolve its cache” to track scan-worker invocation rather than stubbing
`lstat`, since `checkNpmCacheOwnership` delegates scanning through `spawn`.
Configure the existing `spawn` mock to record calls and assert that no scan
invocation occurs when cache resolution fails, while preserving the current
failure result and formatted-message assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5d877796-67dd-4902-ad10-e5bf0e26f835

📥 Commits

Reviewing files that changed from the base of the PR and between 1adad35 and b0434ea.

📒 Files selected for processing (23)
  • bin/ocx.mjs
  • docs-site/src/content/docs/ja/reference/cli.md
  • docs-site/src/content/docs/ko/reference/cli.md
  • docs-site/src/content/docs/reference/cli.md
  • docs-site/src/content/docs/ru/reference/cli.md
  • docs-site/src/content/docs/zh-cn/reference/cli.md
  • docs/adr/0001-gui-update-worker.md
  • src/config.ts
  • src/update/index.ts
  • src/update/install-process.d.mts
  • src/update/install-process.mjs
  • src/update/job.ts
  • src/update/npm-cache-preflight.d.mts
  • src/update/npm-cache-preflight.mjs
  • src/update/recovery-tree-scan.d.mts
  • src/update/recovery-tree-scan.mjs
  • tests/config.test.ts
  • tests/ocx-launcher-source.test.ts
  • tests/update-install-process.test.ts
  • tests/update-job.test.ts
  • tests/update-npm-cache-preflight.test.ts
  • tests/update-stop-first.test.ts
  • tests/windows-deploy-close-regressions.test.ts

Comment thread bin/ocx.mjs
Comment on lines +246 to +250
if (!res.treeExited) {
console.error("\nopencodex: installer process-tree cleanup could not be confirmed; automatic recovery is unsafe until those processes exit.");
console.error(` The proxy is stopped. Once no '${npm}' installer processes remain, run 'ocx start' or re-run 'ocx update'.${trayBeforeUpdate.restoreOnFailure ? " The Windows tray also remains stopped; run 'ocx tray start' after the installer processes exit." : ""}`);
process.exit(INSTALLER_TREE_CLEANUP_FAILED_EXIT_CODE);
}

Copy link
Copy Markdown

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

Both updaters hide the timeout and interruption cause behind the tree-cleanup message. runProcessTreeCommand sets treeExited = process.platform === "win32" && knownGroupExited whenever cleanup ran (src/update/install-process.mjs lines 319-324), so on Linux and macOS a timeout or a forwarded signal always lands in the !treeExited branch. Both updaters exit there before the timedOut and interruptedSignal branches can report the cause.

  • bin/ocx.mjs#L246-L250: include res.timedOut, res.interruptedSignal, and res.status in the cleanup-failure message before process.exit(INSTALLER_TREE_CLEANUP_FAILED_EXIT_CODE).
  • src/update/index.ts#L271-L275: apply the same message change using r.timedOut, r.interruptedSignal, and r.status, so the CLI and the launcher report the identical cause.
📍 Affects 2 files
  • bin/ocx.mjs#L246-L250 (this comment)
  • src/update/index.ts#L271-L275
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/ocx.mjs` around lines 246 - 250, Update the cleanup-failure messages in
bin/ocx.mjs (lines 246-250) and src/update/index.ts (lines 271-275) to include
the installer result’s timeout, interruption signal, and status details before
exiting. Use res.timedOut, res.interruptedSignal, and res.status in the ocx
updater, and r.timedOut, r.interruptedSignal, and r.status in the launcher,
keeping both messages consistent while preserving the existing cleanup warning
and exit code.

Comment thread src/update/index.ts
Comment on lines +264 to 269
const r = await runProcessTreeCommand(target.bin, cmdArgs, {
stdio: installStdio,
encoding: installStdio === "pipe" ? "utf8" : undefined,
timeout: 180000,
timeoutMs: 180000,
windowsHide: true,
shell: target.shell,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

src/update/index.ts has no named installer-deadline constant, so the value is duplicated and the CLI path escapes the nesting invariant. bin/ocx.mjs declares NPM_INSTALL_TIMEOUT_MS, but the CLI updater repeats the raw value in the option and again in the failure text, and the test can therefore only verify the launcher.

  • src/update/index.ts#L264-L269: replace timeoutMs: 180000 with a module-level named constant, for example INSTALL_TIMEOUT_MS = 180_000.
  • src/update/index.ts#L374-L379: derive the "timed out after …s" text from that constant instead of the hardcoded 180s.
  • tests/update-stop-first.test.ts#L120-L137: parse the new CLI constant as well and assert UPDATE_TIMEOUT_MS >= cliInstallTimeoutMs + 60_000, so the GUI worker deadline is proven to outlive both nested installer deadlines. Update the literal assertion "timeoutMs: 180000" at line 162 in the same change.
🧰 Tools
🪛 ast-grep (0.45.0)

[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.
Context: import { spawn, spawnSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

📍 Affects 2 files
  • src/update/index.ts#L264-L269 (this comment)
  • src/update/index.ts#L374-L379
  • tests/update-stop-first.test.ts#L120-L137
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/update/index.ts` around lines 264 - 269, The installer timeout is
duplicated as raw values, preventing the CLI deadline and GUI worker deadline
from being validated together. In src/update/index.ts#L264-L269, add a
module-level named installer-timeout constant and use it for
runProcessTreeCommand; in src/update/index.ts#L374-L379, derive the timeout
failure text from that constant. In tests/update-stop-first.test.ts#L120-L137,
parse the CLI constant and assert UPDATE_TIMEOUT_MS is at least 60,000 ms
longer, and update the literal timeout assertion at line 162.

Comment on lines +291 to +296
void cleanupPromise.then(treeExited => {
if (treeExited) return;
try { child.kill("SIGKILL"); } catch { /* best-effort root cleanup */ }
reportCleanupFailure({ status: null, signal: null });
});
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A rejected cleanupPromise escapes runProcessTreeCommand and aborts the update mid-flight.

Two places consume cleanupPromise without a rejection handler. Line 291 uses void cleanupPromise.then(...), and line 320 uses await cleanupPromise.

terminateInstallerProcessTree can reject. It calls inspect(pid) at lines 169 and 183 outside any try, and inspect may be a caller-supplied inspectProcessGroup override or inspectPsProcessGroup, which calls spawnSync("/bin/ps", ...) and throws synchronously on some failures instead of returning a result object. A rejection at line 320 propagates out of runProcessTreeCommand. Two things then break: the forwarded signal handlers registered at line 305 are never removed, because line 337 is skipped, and the awaiting caller in src/update/index.ts line 264 or bin/ocx.mjs line 240 receives an exception at a point where the proxy is already stopped and no recovery guidance is printed. Line 291 additionally produces an unhandled rejection.

Map a rejection to the fail-closed result the module already uses for unproven cleanup.

🔒 Proposed fix to keep cleanup failures inside the result contract
-    cleanupPromise = terminateInstallerProcessTree(child.pid, {
-      terminationGraceMs,
-      forceWaitMs,
-      isOriginalLeader: () => child.exitCode === null && child.signalCode === null,
-      inspectProcessGroup: inspect,
-    });
+    cleanupPromise = terminateInstallerProcessTree(child.pid, {
+      terminationGraceMs,
+      forceWaitMs,
+      isOriginalLeader: () => child.exitCode === null && child.signalCode === null,
+      inspectProcessGroup: inspect,
+    // An inspection failure cannot prove the tree exited; report it fail-closed.
+    }).catch(() => false);
     void cleanupPromise.then(treeExited => {

With the .catch in place, the await cleanupPromise at line 320 always resolves and line 337 always runs.

Also applies to: 318-336

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/update/install-process.mjs` around lines 291 - 296, Update both consumers
of cleanupPromise in terminateInstallerProcessTree and runProcessTreeCommand to
handle rejections by converting them to the module’s existing fail-closed
cleanup result. Add rejection handling to the detached then chain and ensure the
awaited cleanupPromise always resolves with the failure result, so signal
handlers are removed and runProcessTreeCommand does not propagate cleanup
exceptions.

Comment thread src/update/job.ts
Comment on lines +127 to +148
function isPathInside(parent: string, child: string): boolean {
const fromParent = relative(parent, child);
return fromParent !== ""
&& fromParent !== ".."
&& !fromParent.startsWith(`..${sep}`)
&& !isAbsolute(fromParent);
}

function hasTrustedRecoveryOwner(uid: number): boolean {
const currentUid = process.getuid?.();
// Root-owned global installations are trusted and otherwise unrecoverable by
// an unprivileged GUI process; reject packages planted by every other account.
return currentUid === undefined || uid === currentUid || uid === 0;
}

function hasTrustedRecoveryPermissions(stat: { uid: number; mode: number }): boolean {
if (!hasTrustedRecoveryOwner(stat.uid)) return false;
// POSIX group/other write bits make even a trusted-owner entry mutable by a
// different local account. Windows ACLs are not represented by these bits;
// npm failed-root recovery is already disabled there when tree exit is unknown.
return process.getuid === undefined || (stat.mode & 0o022) === 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Make src/update/recovery-tree-scan.mjs the single source of truth for the recovery-scan contract.

src/update/job.ts re-declares both halves of the scan contract that src/update/recovery-tree-scan.mjs already defines: the three trust predicates and the traversal budgets. The shared root cause is that line 42 imports only RECOVERY_TREE_SCAN_WORKER_ARG from that module, so every other shared value was copied instead of imported. The two copies then form one trust boundary and one budget, with two places to change and no compiler link between them.

  • src/update/job.ts#L127-L148: remove the local isPathInside, hasTrustedRecoveryOwner, and hasTrustedRecoveryPermissions copies. Export the originals from src/update/recovery-tree-scan.mjs (lines 8-24), declare them in src/update/recovery-tree-scan.d.mts, and import them here. If one copy is hardened and the other is not, inspectNpmRecoveryPackage can accept a candidate that the hardened rule rejected, and probeNpmRecoveryLauncher then executes it.
  • src/update/job.ts#L42-L42: extend the import to also pull DEFAULT_MAX_RECOVERY_TREE_ENTRIES and DEFAULT_MAX_RECOVERY_TREE_SCAN_MS.
  • src/update/job.ts#L53-L55: define MAX_NPM_RECOVERY_TREE_ENTRIES and MAX_NPM_RECOVERY_TREE_SCAN_MS from those imported defaults instead of repeating 50_000 and 5_000. The parent passes these values both as the worker payload and as the spawnSync timeout (lines 171-176), so divergence changes the effective budget on only one side and silently contradicts docs/adr/0001-gui-update-worker.md line 30.
🧰 Tools
🪛 ast-grep (0.45.0)

[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.
Context: import { spawn, spawnSync, type ChildProcess } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

📍 Affects 1 file
  • src/update/job.ts#L127-L148 (this comment)
  • src/update/job.ts#L42-L42
  • src/update/job.ts#L53-L55
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/update/job.ts` around lines 127 - 148, The recovery-scan contract is
duplicated in job.ts instead of sourced from recovery-tree-scan.mjs. In
src/update/job.ts lines 127-148, remove the local isPathInside,
hasTrustedRecoveryOwner, and hasTrustedRecoveryPermissions definitions; export
and declare those symbols from recovery-tree-scan.mjs and
recovery-tree-scan.d.mts, then import them in job.ts. In src/update/job.ts lines
42, 53-55, import DEFAULT_MAX_RECOVERY_TREE_ENTRIES and
DEFAULT_MAX_RECOVERY_TREE_SCAN_MS and derive MAX_NPM_RECOVERY_TREE_ENTRIES and
MAX_NPM_RECOVERY_TREE_SCAN_MS from them, preserving the existing worker payload
and spawnSync timeout usage.

Comment thread src/update/job.ts
Comment on lines +256 to +276
try {
const retired = readdirSync(scopeRoot, { encoding: "utf8" })
.filter(name => name.startsWith(prefix) && /^[A-Za-z0-9]{8}$/.test(name.slice(prefix.length)))
.map(name => inspectNpmRecoveryPackage(join(scopeRoot, name), expectedVersion))
.filter((candidate): candidate is ValidatedNpmLauncher => candidate !== null)
.sort((a, b) => b.mtimeMs - a.mtimeMs);
candidates.push(...retired);
} catch {
// The current package can still be a valid recovery candidate when the scope
// directory itself cannot be enumerated.
}

const launchers: string[] = [];
for (const candidate of candidates.slice(0, MAX_NPM_RECOVERY_CANDIDATES)) {
try {
// `--version` loads the launcher's static imports, resolves the bundled Bun
// binary, and imports the complete CLI graph without starting or stopping a proxy.
if (await probeLauncher(candidate.launcher)) launchers.push(candidate.launcher);
} catch { /* an un-runnable candidate is equivalent to a partial package */ }
}
return launchers;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Bound candidate inspection, not only candidate probing.

Line 259 calls inspectNpmRecoveryPackage for every matching .opencodex-XXXXXXXX directory. Line 269 applies MAX_NPM_RECOVERY_CANDIDATES only afterwards, to the probe loop.

Failure mode, with exact cost: each inspectNpmRecoveryPackage call reaches line 204, which calls hasTrustedRecoveryTree. That runs a blocking spawnSync (line 163) bounded by MAX_NPM_RECOVERY_TREE_SCAN_MS = 5_000 ms and by a 50,000-entry walk of a full node_modules tree. npm accumulates retired sibling directories across failed installs, so with N retired copies the update worker blocks for up to (N + 1) * 5 seconds inside recovery, on the same thread that must still restore the proxy. Recovery already runs after a failed install, when the proxy is down, so this directly extends the outage.

This also contradicts docs/adr/0001-gui-update-worker.md line 47-48, which states that "Candidate inspection and restart are bounded to at most two candidates". Only restart is bounded today.

Concrete fix: order candidates by a cheap directory mtime, cut to the limit, and only then run the full validation.

🐛 Proposed fix
   const candidates: ValidatedNpmLauncher[] = [];
   const current = inspectNpmRecoveryPackage(packageRoot, expectedVersion);
   if (current) candidates.push(current);
   try {
-    const retired = readdirSync(scopeRoot, { encoding: "utf8" })
-      .filter(name => name.startsWith(prefix) && /^[A-Za-z0-9]{8}$/.test(name.slice(prefix.length)))
-      .map(name => inspectNpmRecoveryPackage(join(scopeRoot, name), expectedVersion))
-      .filter((candidate): candidate is ValidatedNpmLauncher => candidate !== null)
-      .sort((a, b) => b.mtimeMs - a.mtimeMs);
-    candidates.push(...retired);
+    // Order by a cheap lstat first. Each full inspection spawns a bounded but
+    // blocking tree-scan worker, so never run more than the candidate budget.
+    const ordered = readdirSync(scopeRoot, { encoding: "utf8" })
+      .filter(name => name.startsWith(prefix) && /^[A-Za-z0-9]{8}$/.test(name.slice(prefix.length)))
+      .map(name => {
+        const path = join(scopeRoot, name);
+        try { return { path, mtimeMs: lstatSync(path).mtimeMs }; } catch { return null; }
+      })
+      .filter((entry): entry is { path: string; mtimeMs: number } => entry !== null)
+      .sort((a, b) => b.mtimeMs - a.mtimeMs)
+      .slice(0, MAX_NPM_RECOVERY_CANDIDATES);
+    for (const entry of ordered) {
+      if (candidates.length >= MAX_NPM_RECOVERY_CANDIDATES) break;
+      const candidate = inspectNpmRecoveryPackage(entry.path, expectedVersion);
+      if (candidate) candidates.push(candidate);
+    }
   } catch {

Note that tests/update-job.test.ts lines 308-338 asserts only probed length 2, so it passes today with four full inspections. Extend that test to count inspections, for example by asserting the number of spawned scan workers.

🧰 Tools
🪛 ast-grep (0.45.0)

[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.
Context: import { spawn, spawnSync, type ChildProcess } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/update/job.ts` around lines 256 - 276, Bound recovery candidates before
expensive validation: in the retired-directory collection around
inspectNpmRecoveryPackage, first sort matching directory names by cheap
filesystem mtime, retain at most MAX_NPM_RECOVERY_CANDIDATES, then call
inspectNpmRecoveryPackage only for that subset before probing. Update the
relevant update-job test to verify inspection/spawned scan count is also limited
to the candidate cap, not merely that two launchers are probed.

Comment on lines +97 to +111
try {
const accessMode = stat.isDirectory()
? constants.R_OK | constants.W_OK | constants.X_OK
: constants.R_OK | constants.W_OK;
access(path, accessMode);
} catch (error) {
return {
kind: "error",
path,
reason: stat.isDirectory()
? `npm cache directory is not readable, writable, and searchable (${errorCode(error)})`
: `npm cache entry is not readable and writable (${errorCode(error)})`,
};
}
if (!stat.isDirectory()) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Show npm cacache content file modes and simulate the access gate.
set -uo pipefail

cache="$(npm config get cache 2>/dev/null)"
echo "cache=$cache"
if [ -d "$cache/_cacache/content-v2" ]; then
  find "$cache/_cacache/content-v2" -type f -printf '%m %p\n' 2>/dev/null | head -20
  echo "--- files without user write bit ---"
  find "$cache/_cacache/content-v2" -type f ! -writable 2>/dev/null | wc -l
fi

Repository: lidge-jun/opencodex

Length of output: 183


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target file ---'
sed -n '1,180p' src/update/npm-cache-preflight.mjs

printf '%s\n' '--- related tests ---'
sed -n '1,230p' tests/update-npm-cache-preflight.test.ts

printf '%s\n' '--- call sites ---'
rg -n -C 5 'checkNpmCacheOwnership|findForeignOwnedNpmCacheEntry|npm-cache-preflight' src bin tests

Repository: lidge-jun/opencodex

Length of output: 34873


🌐 Web query:

npm cacache content-v2 files read-only mode doctor new cacache sets content files to be read-only

💡 Result:

Cacache is designed to ensure data integrity by storing content files in a read-only state [1]. Because cacache is a content-addressable cache where files are identified by their integrity hashes (such as SHA-512), the stored content is immutable [1][2]. The only valid operation on existing content is deletion, which requires write permissions on the parent directory rather than the file itself [1]. Consequently, cacache marks these content files as read-only to signal to the operating system that they should not be modified [1]. The reference to "doctor: new cacache sets content files to be read-only" specifically refers to a change in the npm CLI and the cacache library (introduced around 2017) to formalize this read-only behavior for content files within the content-v2 storage directory [1][3]. The npm doctor command, which checks for potential issues in an npm installation, was updated to account for this behavior [3][4]. npm doctor verifies that the cache directory and its contents are accessible, and it ensures that permissions are correctly configured so that npm can properly manage the cache without encountering errors related to these read-only files [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- repository npm version/configuration references ---'
rg -n -i -C 3 'npm|cacache|content-v2' package.json package-lock.json npm-shrinkwrap.json .npmrc README.md 2>/dev/null || true

printf '%s\n' '--- isolated access(2)-equivalent permission probe ---'
python3 - <<'PY'
import os
import pathlib
import tempfile

with tempfile.TemporaryDirectory() as root:
    path = pathlib.Path(root) / "content"
    path.write_bytes(b"cache")
    os.chmod(path, 0o444)
    print(f"mode={oct(path.stat().st_mode & 0o777)}")
    print(f"readable={os.access(path, os.R_OK)}")
    print(f"writable={os.access(path, os.W_OK)}")
    print(f"read_write_gate={os.access(path, os.R_OK | os.W_OK)}")
PY

Repository: lidge-jun/opencodex

Length of output: 4294


Do not require write access on npm cache files

npm cacache stores content-v2 blobs as read-only files. Deletion requires write access to the containing directory. At src/update/npm-cache-preflight.mjs:97-111, W_OK can therefore reject a healthy cache and abort updates at src/update/index.ts:173-176 and bin/ocx.mjs:142-145. Use constants.R_OK for non-directory entries, keep R_OK | W_OK | X_OK for directories, update the file error message, and add a regression test for a read-only cache file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/update/npm-cache-preflight.mjs` around lines 97 - 111, Update the
accessMode selection in the npm cache preflight check to use only constants.R_OK
for non-directory entries, while retaining constants.R_OK | constants.W_OK |
constants.X_OK for directories. Revise the non-directory error message to
mention readability only, and add a regression test covering a readable
read-only cache file.

Comment on lines +146 to +199
function parseOwnershipIssue(output, cachePath) {
try {
const parsed = JSON.parse(output);
if (parsed === null) return null;
if (!parsed || typeof parsed !== "object" || typeof parsed.path !== "string") throw new Error("invalid issue");
if (parsed.kind === "foreign-owner" && Number.isInteger(parsed.actualUid)) return parsed;
if (parsed.kind === "error" && typeof parsed.reason === "string") return parsed;
} catch { /* convert malformed worker output into a fail-closed result below */ }
return {
kind: "error",
path: resolve(cachePath),
reason: "npm cache inspection returned invalid output",
};
}

/** Run the blocking filesystem walk out-of-process so its wall-clock deadline is enforceable. */
function scanNpmCacheOwnership(cachePath, expectedUid, options) {
const maxEntries = positiveInteger(options.maxEntries, DEFAULT_MAX_CACHE_ENTRIES);
const maxDurationMs = positiveInteger(options.maxDurationMs, DEFAULT_CACHE_SCAN_TIMEOUT_MS);
const scanSpawn = options.scanSpawn ?? spawnSync;
let result;
try {
result = scanSpawn(
options.scanBin ?? process.execPath,
[options.scanScript ?? fileURLToPath(import.meta.url), CACHE_SCAN_WORKER_ARG],
{
encoding: "utf8",
input: JSON.stringify({ cachePath, expectedUid, maxEntries, maxDurationMs }),
timeout: maxDurationMs,
killSignal: "SIGKILL",
windowsHide: true,
shell: false,
},
);
} catch (error) {
return {
kind: "error",
path: resolve(cachePath),
reason: `could not inspect npm cache (${errorCode(error)})`,
};
}
if (result.status !== 0) {
const timedOut = result.status === null || errorCode(result.error) === "ETIMEDOUT";
return {
kind: "error",
path: resolve(cachePath),
reason: timedOut
? `npm cache inspection exceeded its ${maxDurationMs}ms time budget`
: `could not inspect npm cache (${result.error ? errorCode(result.error) : `status ${result.status}`})`,
};
}
const output = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : String(result.stdout ?? "");
return parseOwnershipIssue(output, cachePath);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The scan timeout and the in-worker time budget use the same value, so the worker rarely reports its own budget message.

Line 164 computes maxDurationMs, line 173 passes it into the worker payload, and line 174 sets the spawnSync timeout to that same maxDurationMs. The parent deadline and the child deadline expire at the same instant, and the parent also pays process spawn time. The parent therefore usually kills the worker with SIGKILL before the worker can print its own "exceeded its Nms time budget" result. The outcome text is identical in both paths, so behavior stays correct, but the child budget is effectively dead code and the scan can be cut off before it finishes an otherwise-in-budget walk.

Give the parent a small margin over the child budget.

♻️ Proposed margin between the two deadlines
-        timeout: maxDurationMs,
+        // The worker enforces `maxDurationMs` itself; give the parent a margin so
+        // spawn latency does not preempt an in-budget walk.
+        timeout: maxDurationMs + SCAN_SPAWN_MARGIN_MS,

Declare the margin with the other constants:

const SCAN_SPAWN_MARGIN_MS = 2_000;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function parseOwnershipIssue(output, cachePath) {
try {
const parsed = JSON.parse(output);
if (parsed === null) return null;
if (!parsed || typeof parsed !== "object" || typeof parsed.path !== "string") throw new Error("invalid issue");
if (parsed.kind === "foreign-owner" && Number.isInteger(parsed.actualUid)) return parsed;
if (parsed.kind === "error" && typeof parsed.reason === "string") return parsed;
} catch { /* convert malformed worker output into a fail-closed result below */ }
return {
kind: "error",
path: resolve(cachePath),
reason: "npm cache inspection returned invalid output",
};
}
/** Run the blocking filesystem walk out-of-process so its wall-clock deadline is enforceable. */
function scanNpmCacheOwnership(cachePath, expectedUid, options) {
const maxEntries = positiveInteger(options.maxEntries, DEFAULT_MAX_CACHE_ENTRIES);
const maxDurationMs = positiveInteger(options.maxDurationMs, DEFAULT_CACHE_SCAN_TIMEOUT_MS);
const scanSpawn = options.scanSpawn ?? spawnSync;
let result;
try {
result = scanSpawn(
options.scanBin ?? process.execPath,
[options.scanScript ?? fileURLToPath(import.meta.url), CACHE_SCAN_WORKER_ARG],
{
encoding: "utf8",
input: JSON.stringify({ cachePath, expectedUid, maxEntries, maxDurationMs }),
timeout: maxDurationMs,
killSignal: "SIGKILL",
windowsHide: true,
shell: false,
},
);
} catch (error) {
return {
kind: "error",
path: resolve(cachePath),
reason: `could not inspect npm cache (${errorCode(error)})`,
};
}
if (result.status !== 0) {
const timedOut = result.status === null || errorCode(result.error) === "ETIMEDOUT";
return {
kind: "error",
path: resolve(cachePath),
reason: timedOut
? `npm cache inspection exceeded its ${maxDurationMs}ms time budget`
: `could not inspect npm cache (${result.error ? errorCode(result.error) : `status ${result.status}`})`,
};
}
const output = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : String(result.stdout ?? "");
return parseOwnershipIssue(output, cachePath);
}
function parseOwnershipIssue(output, cachePath) {
try {
const parsed = JSON.parse(output);
if (parsed === null) return null;
if (!parsed || typeof parsed !== "object" || typeof parsed.path !== "string") throw new Error("invalid issue");
if (parsed.kind === "foreign-owner" && Number.isInteger(parsed.actualUid)) return parsed;
if (parsed.kind === "error" && typeof parsed.reason === "string") return parsed;
} catch { /* convert malformed worker output into a fail-closed result below */ }
return {
kind: "error",
path: resolve(cachePath),
reason: "npm cache inspection returned invalid output",
};
}
/** Run the blocking filesystem walk out-of-process so its wall-clock deadline is enforceable. */
function scanNpmCacheOwnership(cachePath, expectedUid, options) {
const maxEntries = positiveInteger(options.maxEntries, DEFAULT_MAX_CACHE_ENTRIES);
const maxDurationMs = positiveInteger(options.maxDurationMs, DEFAULT_CACHE_SCAN_TIMEOUT_MS);
const scanSpawn = options.scanSpawn ?? spawnSync;
let result;
try {
result = scanSpawn(
options.scanBin ?? process.execPath,
[options.scanScript ?? fileURLToPath(import.meta.url), CACHE_SCAN_WORKER_ARG],
{
encoding: "utf8",
input: JSON.stringify({ cachePath, expectedUid, maxEntries, maxDurationMs }),
// The worker enforces `maxDurationMs` itself; give the parent a margin so
// spawn latency does not preempt an in-budget walk.
timeout: maxDurationMs + SCAN_SPAWN_MARGIN_MS,
killSignal: "SIGKILL",
windowsHide: true,
shell: false,
},
);
} catch (error) {
return {
kind: "error",
path: resolve(cachePath),
reason: `could not inspect npm cache (${errorCode(error)})`,
};
}
if (result.status !== 0) {
const timedOut = result.status === null || errorCode(result.error) === "ETIMEDOUT";
return {
kind: "error",
path: resolve(cachePath),
reason: timedOut
? `npm cache inspection exceeded its ${maxDurationMs}ms time budget`
: `could not inspect npm cache (${result.error ? errorCode(result.error) : `status ${result.status}`})`,
};
}
const output = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : String(result.stdout ?? "");
return parseOwnershipIssue(output, cachePath);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/update/npm-cache-preflight.mjs` around lines 146 - 199, Update
scanNpmCacheOwnership so the parent spawnSync timeout exceeds the worker’s
maxDurationMs by a small margin, using a shared SCAN_SPAWN_MARGIN_MS constant
declared with the other scan constants. Keep maxDurationMs in the worker payload
unchanged, and apply the margin only to the parent timeout so the worker can
report its own budget expiration.

Comment thread tests/update-job.test.ts
Comment on lines +155 to +170
test("finds a validated npm-retired launcher when the current package is partial", async () => {
const scopeRoot = join(dir, "global", "@bitkyc08");
const currentRoot = join(scopeRoot, "opencodex");
const retiredRoot = join(scopeRoot, ".opencodex-Ab12Cd34");
for (const [root, name, version] of [
[currentRoot, "@bitkyc08/opencodex", "2.7.41"],
[retiredRoot, "@bitkyc08/opencodex", "2.7.40"],
] as const) {
mkdirSync(join(root, "bin"), { recursive: true });
writeFileSync(join(root, "package.json"), JSON.stringify({ name, version }));
writeFileSync(join(root, "bin", "ocx.mjs"), "#!/usr/bin/env node\n");
}

expect(await findNpmRecoveryLauncher(join(currentRoot, "bin", "ocx.mjs"), "2.7.40"))
.toBe(realpathSync(join(retiredRoot, "bin", "ocx.mjs")));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Set explicit timeouts on the recovery tests that spawn real subprocesses.

These two tests omit the probeLauncher argument, so findNpmRecoveryLaunchers uses the default probeNpmRecoveryLauncher (src/update/job.ts lines 232-238). Each accepted candidate then costs real subprocess work:

  • one blocking spawnSync tree-scan worker per inspected candidate (src/update/job.ts line 163), each bounded by 5_000 ms;
  • one runProcessTreeCommand launcher probe per candidate (src/update/job.ts line 233), each bounded by 10_000 ms.

For the test at line 155, both roots are valid, so the flow spawns two scan workers plus two --version probes. Bun's default per-test timeout is 5_000 ms, which is below the single-probe budget alone. On a loaded CI runner these tests can fail for timing reasons rather than for a behavior regression, and that noise lands on the exact security-sensitive path this PR adds.

The neighbouring tests already do this: line 356 uses }, 5_000) and tests/update-install-process.test.ts lines 165, 197, 230 use }, 15_000).

💚 Proposed fix
     expect(await findNpmRecoveryLauncher(join(currentRoot, "bin", "ocx.mjs"), "2.7.40"))
       .toBe(realpathSync(join(retiredRoot, "bin", "ocx.mjs")));
-  });
+  }, 30_000);
     expect(await findNpmRecoveryLaunchers(join(currentRoot, "bin", "ocx.mjs"), "2.7.40"))
       .toEqual([realpathSync(join(retiredRoot, "bin", "ocx.mjs"))]);
-  });
+  }, 30_000);

Also applies to: 290-306

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/update-job.test.ts` around lines 155 - 170, Set explicit extended
timeouts on both recovery tests, including the test identified by
findNpmRecoveryLauncher and the additional test around the related recovery
flow. Append the timeout using the existing test framework syntax, choosing a
value consistent with neighboring subprocess-based tests (such as 15,000 ms),
without changing their assertions or setup.

Comment on lines +249 to +270
test("force-kills an npm cache lookup that ignores SIGTERM", () => {
const uid = process.getuid?.();
if (uid === undefined) return;
const blockingNpm = join(dir, "blocking-npm.mjs");
writeFileSync(
blockingNpm,
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => {});\nsetInterval(() => {}, 60_000);\n",
);
chmodSync(blockingNpm, 0o755);

const startedAt = Date.now();
const result = checkNpmCacheOwnership({
getuid: () => uid,
lookupTimeoutMs: 250,
npmBin: blockingNpm,
});
expect(Date.now() - startedAt).toBeLessThan(2_000);
expect(result).toMatchObject({
ok: false,
reason: "could not resolve the npm cache (ETIMEDOUT)",
});
}, 5_000);

Copy link
Copy Markdown

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

This test needs node on PATH, which a Bun-only environment may not provide.

Line 255 writes the fake npm binary with the shebang #!/usr/bin/env node, and line 263 passes it as npmBin while checkNpmCacheOwnership spawns with shell: false. The kernel then resolves node through PATH. If the CI image ships Bun only, spawnSync fails with ENOENT, the result reason becomes "could not resolve the npm cache (ENOENT)", and the assertion on line 268 fails for an environment reason rather than a behavior regression.

Point the shebang at the interpreter that is guaranteed to exist, or spawn it explicitly.

🔧 Proposed fix to remove the `node` dependency
     writeFileSync(
       blockingNpm,
-      "#!/usr/bin/env node\nprocess.on('SIGTERM', () => {});\nsetInterval(() => {}, 60_000);\n",
+      `#!${process.execPath}\nprocess.on('SIGTERM', () => {});\nsetInterval(() => {}, 60_000);\n`,
     );

As per path instructions "Runtime is Bun-native TypeScript (no separate compile step). Flag Node-only APIs that break under Bun".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("force-kills an npm cache lookup that ignores SIGTERM", () => {
const uid = process.getuid?.();
if (uid === undefined) return;
const blockingNpm = join(dir, "blocking-npm.mjs");
writeFileSync(
blockingNpm,
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => {});\nsetInterval(() => {}, 60_000);\n",
);
chmodSync(blockingNpm, 0o755);
const startedAt = Date.now();
const result = checkNpmCacheOwnership({
getuid: () => uid,
lookupTimeoutMs: 250,
npmBin: blockingNpm,
});
expect(Date.now() - startedAt).toBeLessThan(2_000);
expect(result).toMatchObject({
ok: false,
reason: "could not resolve the npm cache (ETIMEDOUT)",
});
}, 5_000);
test("force-kills an npm cache lookup that ignores SIGTERM", () => {
const uid = process.getuid?.();
if (uid === undefined) return;
const blockingNpm = join(dir, "blocking-npm.mjs");
writeFileSync(
blockingNpm,
`#!${process.execPath}\nprocess.on('SIGTERM', () => {});\nsetInterval(() => {}, 60_000);\n`,
);
chmodSync(blockingNpm, 0o755);
const startedAt = Date.now();
const result = checkNpmCacheOwnership({
getuid: () => uid,
lookupTimeoutMs: 250,
npmBin: blockingNpm,
});
expect(Date.now() - startedAt).toBeLessThan(2_000);
expect(result).toMatchObject({
ok: false,
reason: "could not resolve the npm cache (ETIMEDOUT)",
});
}, 5_000);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/update-npm-cache-preflight.test.ts` around lines 249 - 270, Update the
fake blocking npm executable created by the “force-kills an npm cache lookup
that ignores SIGTERM” test so it does not depend on resolving node through PATH.
Use the guaranteed Bun interpreter in the shebang, or otherwise invoke Bun
explicitly while preserving the existing blocking and SIGTERM-ignoring behavior
and the ETIMEDOUT assertion.

Source: Path instructions

Comment on lines +272 to +299
test("fails closed before shutdown when npm cannot resolve its cache", () => {
let inspected = false;
const result = checkNpmCacheOwnership({
platform: "linux",
getuid: () => 501,
spawn: (() => ({
status: null,
stdout: "",
stderr: "",
error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }),
pid: 1,
output: [],
signal: "SIGTERM",
})) as never,
lstat: () => {
inspected = true;
throw new Error("must not inspect without a resolved cache path");
},
});
expect(result).toMatchObject({
ok: false,
expectedUid: 501,
reason: "could not resolve the npm cache (ETIMEDOUT)",
});
expect(inspected).toBe(false);
if (result.ok !== false) throw new Error("expected lookup failure");
expect(formatNpmCacheOwnershipFailure(result)).toContain("before stopping the proxy");
});

Copy link
Copy Markdown

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

The inspected assertion cannot fail, so this test does not prove the claimed behavior.

The test stubs lstat on line 286 and asserts inspected === false on line 296. checkNpmCacheOwnership never calls lstat. It delegates the walk to the out-of-process worker and forwards only cachePath, expectedUid, maxEntries, and maxDurationMs (see src/update/npm-cache-preflight.mjs lines 168-179). The hook would stay unused even if the function scanned the cache after a failed lookup. Assert on the scan spawn instead, because that is the observable the code actually uses.

💚 Proposed fix to assert on the real observable
       })) as never,
-      lstat: () => {
-        inspected = true;
-        throw new Error("must not inspect without a resolved cache path");
-      },
+      scanSpawn: (() => {
+        inspected = true;
+        throw new Error("must not inspect without a resolved cache path");
+      }) as never,
     });

As per path instructions "A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("fails closed before shutdown when npm cannot resolve its cache", () => {
let inspected = false;
const result = checkNpmCacheOwnership({
platform: "linux",
getuid: () => 501,
spawn: (() => ({
status: null,
stdout: "",
stderr: "",
error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }),
pid: 1,
output: [],
signal: "SIGTERM",
})) as never,
lstat: () => {
inspected = true;
throw new Error("must not inspect without a resolved cache path");
},
});
expect(result).toMatchObject({
ok: false,
expectedUid: 501,
reason: "could not resolve the npm cache (ETIMEDOUT)",
});
expect(inspected).toBe(false);
if (result.ok !== false) throw new Error("expected lookup failure");
expect(formatNpmCacheOwnershipFailure(result)).toContain("before stopping the proxy");
});
test("fails closed before shutdown when npm cannot resolve its cache", () => {
let inspected = false;
const result = checkNpmCacheOwnership({
platform: "linux",
getuid: () => 501,
spawn: (() => ({
status: null,
stdout: "",
stderr: "",
error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }),
pid: 1,
output: [],
signal: "SIGTERM",
})) as never,
scanSpawn: (() => {
inspected = true;
throw new Error("must not inspect without a resolved cache path");
}) as never,
});
expect(result).toMatchObject({
ok: false,
expectedUid: 501,
reason: "could not resolve the npm cache (ETIMEDOUT)",
});
expect(inspected).toBe(false);
if (result.ok !== false) throw new Error("expected lookup failure");
expect(formatNpmCacheOwnershipFailure(result)).toContain("before stopping the proxy");
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/update-npm-cache-preflight.test.ts` around lines 272 - 299, Update the
test “fails closed before shutdown when npm cannot resolve its cache” to track
scan-worker invocation rather than stubbing `lstat`, since
`checkNpmCacheOwnership` delegates scanning through `spawn`. Configure the
existing `spawn` mock to record calls and assert that no scan invocation occurs
when cache resolution fails, while preserving the current failure result and
formatted-message assertions.

Source: Path instructions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants