-
Notifications
You must be signed in to change notification settings - Fork 530
fix(update): harden npm cache recovery preflight logs #557
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
base: dev
Are you sure you want to change the base?
Changes from all commits
e332d22
7efed6c
6db4dc7
5c35865
55d72e6
f1f75fa
e3a837e
07f0748
ea27540
5564b03
1fdc443
a9daa12
eb9f89f
8dfe46d
d215cbe
b0434ea
e09b7b9
c297dba
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,43 @@ | ||
| # PR #557 finalize — research (2026-08-02) | ||
|
|
||
| Branch `codex/pr533-update-recovery-hardening`, worktree `260727-pr533-current`. | ||
| Policy decision (maintainer, 2026-08-02): **안 A fail-closed** — Wibias recommendation. | ||
|
|
||
| ## Facts established at P | ||
|
|
||
| - Branch is 16 ahead / 768 behind origin/dev; merge-base `800ebc931`. | ||
| - dev carries NONE of this work: `sanitizeUpdateJobText` / `npm-cache-preflight` | ||
| count on origin/dev:src/update/job.ts = 0. The branch is the sole carrier. | ||
| - Merge surface: 14 files changed in both (37 conflict markers): `src/update/job.ts`, | ||
| `src/update/index.ts`, `src/config.ts`, tests ×5 (`update-job`, `update-stop-first`, | ||
| `ocx-launcher-source`, `config`, `windows-deploy-close-regressions`), | ||
| docs-site cli reference (zh-cn, ru), others. 599 paths merge clean; 166 removed | ||
| in remote (dev deletions, branch untouched). | ||
| - Open Wibias blocker (sanitizer): `src/update/job.ts:324-330` regexes use | ||
| `[^\s"'<>]`, so `C:\Users\Alice Smith\.npm\_cacache\tmp\entry` leaks whole and | ||
| `/Users/Alice Smith/.npm/_cacache/tmp` half-redacts. Wibias ran the exact | ||
| regexes against those inputs — reproduction is authoritative. | ||
| - Closed blocker (preflight): `src/update/npm-cache-preflight.mjs:98-113` | ||
| `accessSync` R/W/X fail-closed — DO NOT TOUCH. | ||
| - Auto-recovery path to remove (policy A): `recoverFailedGuiUpdate` | ||
| (`src/update/job.ts:908`) step 4 — `findNpmRecoveryLaunchers` + restart-candidate | ||
| loop + unhealthy-candidate kill. Detection/reporting steps 1-3 (healthy probe, | ||
| oldPid identity, replacement-became-healthy) stay: they only observe and refuse, | ||
| never start a process. The success-path `restartAfterUpdate` stays untouched. | ||
| - Attribution obligation (Wibias): PR description and final merge commit must | ||
| credit #533 and @WZBbiao (imported commits keep the `wzb` author identity but | ||
| GitHub does not link the email). | ||
|
|
||
| ## Claim ledger | ||
|
|
||
| | # | Claim | Source | Status | | ||
| |---|-------|--------|--------| | ||
| | 1 | Sanitizer leaks space-containing profile paths | Wibias review on #557 (ran the regexes) | verified by reviewer; re-prove red in B | | ||
| | 2 | Preflight blocker closed | Wibias review | verified; non-goal | | ||
| | 3 | Auto-recovery races a live installer (policy rationale) | Wibias recommendation; maintainer decision A | decision recorded | | ||
| | 4 | dev has no part of this branch's feature code | `git show origin/dev:src/update/job.ts` grep = 0 | verified | | ||
|
|
||
| ## Out of scope | ||
|
|
||
| - Force-push / rebase. Merge origin/dev in (regular push afterwards). | ||
| - Any change to the fail-closed preflight (blocker 1). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| # PR #557 finalize — implementation (one cycle) | ||
|
|
||
| ## Step order (dependency-ordered) | ||
|
|
||
| 1. **Merge origin/dev into the branch** and resolve the 14 both-changed files | ||
| semantically: branch feature semantics on top of dev's evolution. Never drop | ||
| either side's behavior silently; every conflict hunk gets a named decision in | ||
| the commit message or this doc's amendment. | ||
| 2. **Sanitizer fix (red-green first)** in `src/update/job.ts` | ||
| `sanitizeUpdateJobText`. | ||
| 3. **Fail-closed policy A** in `recoverFailedGuiUpdate` + type/test updates. | ||
| 4. **Docs**: manual recovery path (docs-site troubleshooting, EN; locale files | ||
| must not contradict). | ||
| 5. **Gates + push + PR update** (body, attribution, undraft). | ||
|
|
||
| ## Step 2 — sanitizer (anchor-based, space-tolerant) | ||
|
|
||
| Rewrite rules in order: | ||
|
|
||
| - npm/cache paths: `(?:[A-Za-z]:[\\/]|\/)[^"'<>\n]*?(?:\.npm|_cacache|_npx|\.opencodex-)` | ||
| with prefix allowing spaces (non-greedy up to first anchor), then consume | ||
| space-free trailing segments `([\\/][^\s"'<>]+)*` → `[redacted npm path]`. | ||
| npm cache internals have no spaces; the space risk lives in the profile prefix. | ||
| - ocx.mjs launcher paths: prefix allows spaces → `ocx.mjs` (keep `\b`, do not | ||
| swallow trailing args). | ||
| - user-path fallback (no anchor): redact the username component including ONE | ||
| optional space segment: `(\/(?:Users|home)\/)[^/\s"'<>]+(?:\s+[^/\s"'<>]+)?` | ||
| → `$1[redacted]`. Anchor rule runs first, so `.npm`-carrying cases never reach | ||
| this rule half-matched. | ||
| - uid/gid rules unchanged. | ||
|
|
||
| Red-green tests (port Wibias's exact inputs): | ||
|
|
||
| - `C:\Users\Alice Smith\.npm\_cacache\tmp\entry` → `[redacted npm path]` | ||
| - `C:\Users\AliceSmith\.npm\_cacache\tmp\entry` → `[redacted npm path]` | ||
| - `/Users/Alice Smith/.npm/_cacache/tmp` → `[redacted npm path]` (whole) | ||
| - `/home/alice/.npm/_cacache/tmp` → `[redacted npm path]` | ||
| - `/Users/Alice Smith/some/other.log` → `/Users/[redacted]/some/other.log` | ||
| - uid/gid lines unchanged behavior. | ||
|
Comment on lines
+26
to
+39
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. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
file='devlog/_plan/260802_pr557_finalize/010_implementation.md'
printf '%s\n' '--- target file ---'
cat -n "$file" | sed -n '1,90p'
printf '%s\n' '--- related path-redaction references ---'
rg -n -C 3 'Users|home|redact|redaction|npm|recovery|automatic' --glob '!node_modules' .Repository: lidge-jun/opencodex Length of output: 50378 🏁 Script executed: #!/bin/bash
set -eu
file='devlog/_plan/260802_pr557_finalize/010_implementation.md'
printf '%s\n' '--- target lines 1-90 ---'
cat -n "$file" | sed -n '1,90p'
printf '%s\n' '--- implementation and test files containing redactUserPath ---'
rg -l 'redactUserPath|redactUser|redactSecretString' --glob '!devlog/**' --glob '!node_modules/**' | sortRepository: lidge-jun/opencodex Length of output: 5300 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- sanitizer definitions and call sites ---'
rg -n -C 5 'sanitizeUpdateJobText|redact|npm|ocx\.mjs' src/update tests --glob '*.ts' --glob '*.js' --glob '*.mjs' 2>/dev/null | head -n 300
printf '%s\n' '--- recovery symbols ---'
rg -n -C 3 'recoverFailedGuiUpdate|findNpmRecoveryLaunchers|recoveryLaunchersFn|FailedUpdateRecovery|recoverFailedGuiUpdateForTests' src tests --glob '*.ts' --glob '*.js' --glob '*.mjs' 2>/dev/null | head -n 300Repository: lidge-jun/opencodex Length of output: 26009 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- sanitizer tests ---'
rg -n -C 8 'redacted npm path|Alice Smith|AliceSmith|sanitizeUpdateJobText|some/other\.log|Users/' tests src --glob '*.ts' --glob '*.js' --glob '*.mjs' | head -n 400
printf '%s\n' '--- sanitizer implementation ---'
cat -n src/update/job.ts | sed -n '260,290p'
printf '%s\n' '--- standalone current-regex probe ---'
node - <<'JS'
const sanitize = (value) => value
.replace(/(?:[A-Za-z]:[\\/]|\/)[^"'<>\n]*?ocx\.mjs\b/g, "ocx.mjs")
.replace(
/(?:(?:[A-Za-z]:[\\/]|\/)(?:Users|home)[\\/][^\\/]+[\\/]|(?:[A-Za-z]:[\\/]|\/))[^\s"'<>]*?(?:\.npm|_cacache|_npx|\.opencodex-)(?:[\\/][^\s"'<>]+)*/g,
"[redacted npm path]",
)
.replace(/(\/(?:Users|home)\/)[^/\s"'<>]+(?:\s+[^/\s"'<>]+)?/g, "$1[redacted]")
.replace(/\b(uid|gid)\s*[:=]\s*\d+\b/gi, "$1=[redacted]")
.replace(/\b(UID|GID)\s+\d+\b/g, "$1 [redacted]");
const proposed = /((?:[A-Za-z]:[\\/]|\/)(?:Users|home)[\\/])[^\/\\\s"'<>]+(?:\s+[^\/\\\s"'<>]+)*/g;
const inputs = [
String.raw`C:\Users\Alice Smith\Desktop\error.log`,
String.raw`C:\Users\Alice Smith\.npm\_cacache\tmp\entry`,
`/Users/Alice Mary Jane/some/other.log`,
`/Users/Alice Smith/some/other.log`,
`/home/alice/.npm/_cacache/tmp`,
];
for (const input of inputs) {
console.log(JSON.stringify({input, current: sanitize(input), proposed: input.replace(proposed, "$1[redacted]")}));
}
JSRepository: lidge-jun/opencodex Length of output: 33625 🏁 Script executed: #!/bin/bash
set -eu
node - <<'JS'
const sanitize = (value) => value
.replace(/(?:[A-Za-z]:[\\/]|\/)[^"'<>\n]*?ocx\.mjs\b/g, "ocx.mjs")
.replace(
/(?:(?:[A-Za-z]:[\\/]|\/)(?:Users|home)[\\/][^\\/]+[\\/]|(?:[A-Za-z]:[\\/]|\/))[^\s"'<>]*?(?:\.npm|_cacache|_npx|\.opencodex-)(?:[\\/][^\s"'<>]+)*/g,
"[redacted npm path]",
)
.replace(
/((?:[A-Za-z]:[\\/]|\/)(?:Users|home)[\\/])[^\/\\\s"'<>]+(?:\s+[^\/\\\s"'<>]+)*/g,
"$1[redacted]",
)
.replace(/\b(uid|gid)\s*[:=]\s*\d+\b/gi, "$1=[redacted]")
.replace(/\b(UID|GID)\s+\d+\b/g, "$1 [redacted]");
const cases = new Map([
[String.raw`C:\Users\Alice Smith\Desktop\error.log`, String.raw`C:\Users\[redacted]\Desktop\error.log`],
[String.raw`C:\Users\Alice Smith\.npm\_cacache\tmp\entry`, "[redacted npm path]"],
[String.raw`C:\Users\AliceSmith\.npm\_cacache\tmp\entry`, "[redacted npm path]"],
["/Users/Alice Smith/.npm/_cacache/tmp", "[redacted npm path]"],
["/home/alice/.npm/_cacache/tmp", "[redacted npm path]"],
["/Users/Alice Mary Jane/some/other.log", "/Users/[redacted]/some/other.log"],
["/Users/Alice Smith/some/other.log", "/Users/[redacted]/some/other.log"],
]);
for (const [input, expected] of cases) {
const actual = sanitize(input);
if (actual !== expected) throw new Error(`${input}\nexpected: ${expected}\nactual: ${actual}`);
console.log(`ok: ${input} -> ${actual}`);
}
JSRepository: lidge-jun/opencodex Length of output: 657 Make fallback path redaction platform-complete and space-tolerant. In 🤖 Prompt for AI Agents |
||
|
|
||
| ## Step 3 — fail-closed (policy A) | ||
|
|
||
| In `recoverFailedGuiUpdate` (`src/update/job.ts:908`): | ||
|
|
||
| - KEEP steps 1-3 (probe-healthy, oldPid-identity, replacement-healthy) — pure | ||
| observation/refusal, no process start. | ||
| - REPLACE step 4 (launcher discovery + restart loop + candidate kill) with: | ||
| log `Update command failed after the proxy stopped. Automatic recovery is | ||
| disabled: the failed installer may still be mutating the global package tree. | ||
| Restore the package, then run 'ocx service install' or 'ocx start --port N'.` | ||
| and return `"failed"`. | ||
| - REMOVE the now-dead recovery machinery ONLY where nothing else references it | ||
| (map callers first): `findNpmRecoveryLaunchers`, validated-launcher helpers, | ||
| `recoveryLaunchersFn` io hook, `FailedUpdateRecovery = "restarted"` variant, | ||
| `recoverFailedGuiUpdateForTests` recovery-candidate cases. KEEP | ||
| `packageLauncherPath` / `restartAfterUpdate` (success path) and | ||
| `npm-cache-preflight.mjs` / `recovery-tree-scan.mjs` (preflight path). | ||
| - Update branch tests to fail-closed expectations: recovery-restart tests become | ||
| "job failed, no start attempted, manual instruction logged" tests; the | ||
| step 1-3 observation tests stay. | ||
|
Comment on lines
+47
to
+60
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 | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 12 \
'recoverFailedGuiUpdate|installerFailureAllowsRecovery|findNpmRecoveryLaunchers' \
src/update/job.ts tests
rg -n -C 5 \
'no automatic recovery|автоматическое восстановление|自動復旧|자동 복구' \
docs-site/src/content/docsRepository: lidge-jun/opencodex Length of output: 31216 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- runtime recovery symbols and call sites ---'
rg -n -C 8 \
'type FailedUpdateRecovery|FailedUpdateRecovery|findNpmRecovery|recoveryLaunch|recoverFailedGuiUpdate|installerFailureAllowsRecovery|restartAfterUpdate' \
src/update/job.ts src tests
printf '%s\n' '--- plan and ADR ---'
sed -n '15,35p' devlog/_plan/260802_pr557_finalize/000_plan.md
sed -n '35,65p' devlog/_plan/260802_pr557_finalize/010_implementation.md
sed -n '35,60p' docs/adr/0001-gui-update-worker.md
printf '%s\n' '--- lifecycle wording ---'
sed -n '308,325p' docs-site/src/content/docs/reference/cli/lifecycle.md
sed -n '195,210p' docs-site/src/content/docs/ja/reference/cli/lifecycle.md
sed -n '260,275p' docs-site/src/content/docs/ko/reference/cli/lifecycle.md
sed -n '282,296p' docs-site/src/content/docs/ru/reference/cli/lifecycle.mdRepository: lidge-jun/opencodex Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- exact obsolete recovery references ---'
rg -n \
'findNpmRecoveryLauncher|findNpmRecoveryLaunchers|recoveryLaunchersFn|recoveryLauncher|installerFailureAllowsRecovery|mayRecover|recoverFailedGuiUpdateForTests|FailedUpdateRecovery' \
src/update/job.ts tests/update-job.test.ts tests/update-stop-first.test.ts
printf '%s\n' '--- failed-update implementation ---'
sed -n '1328,1372p' src/update/job.ts
sed -n '1638,1665p' src/update/job.ts
printf '%s\n' '--- plan and implementation instructions ---'
sed -n '20,30p' devlog/_plan/260802_pr557_finalize/000_plan.md
sed -n '45,62p' devlog/_plan/260802_pr557_finalize/010_implementation.md
printf '%s\n' '--- ADR recovery policy ---'
sed -n '40,58p' docs/adr/0001-gui-update-worker.md
printf '%s\n' '--- focused source/test symbol counts ---'
python3 - <<'PY'
from pathlib import Path
terms = [
"findNpmRecoveryLauncher",
"findNpmRecoveryLaunchers",
"recoveryLaunchersFn",
"recoveryLauncher",
"installerFailureAllowsRecovery",
"mayRecover",
"recoverFailedGuiUpdateForTests",
]
for path in [
Path("src/update/job.ts"),
Path("tests/update-job.test.ts"),
Path("tests/update-stop-first.test.ts"),
]:
text = path.read_text()
print(path)
for term in terms:
count = text.count(term)
if count:
print(f" {term}: {count}")
PYRepository: lidge-jun/opencodex Length of output: 10420 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
source = Path("src/update/job.ts").read_text()
test = Path("tests/update-job.test.ts").read_text()
name = "findNpmRecoveryLauncher"
source_defs = re.findall(
rf'^\s*(?:export\s+)?(?:async\s+)?function\s+{name}\b|^\s*export\s+(?:const|let|var)\s+{name}\b',
source,
re.MULTILINE,
)
imports = re.findall(rf'\b{name}\b', test)
print(f"src/update/job.ts definitions of {name}: {len(source_defs)}")
print(f"tests/update-job.test.ts references to {name}: {len(imports)}")
print("import block:")
for i, line in enumerate(test.splitlines()[:25], 1):
print(f"{i}: {line}")
PY
printf '%s\n' '--- all repository references ---'
rg -n 'findNpmRecoveryLauncher|findNpmRecoveryLaunchers|recoveryLaunchersFn|recoveryLauncher' .Repository: lidge-jun/opencodex Length of output: 2031 Remove the stale recovery import and update the ADR.
📍 Affects 7 files
🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| ## Step 4 — docs | ||
|
|
||
| - docs-site troubleshooting (EN): "update failed, proxy is down" → the fail-closed | ||
| rationale (one line) + manual path: verify/reinstall package, then | ||
| `ocx service install` or `ocx start --port N`; job log names the exact command. | ||
| - Check zh-cn/ja/ru/ko troubleshooting pages for auto-recovery claims; amend any | ||
| contradiction (the branch already touches docs-site cli reference in 2 locales — | ||
| keep consistent). | ||
|
|
||
| ## Activation scenarios (C) | ||
|
|
||
| 1. Red: Wibias's four sanitizer inputs leak before the fix (assert exact current | ||
| bad outputs), pass after. | ||
| 2. Fail-closed: a GUI npm update whose installer exits nonzero after a proxy stop | ||
| results in job.status failed, zero calls to the restart io hooks, and the | ||
| manual instruction in the sanitized job log. Activation: io-spy test. | ||
| 3. Observation preserved: replacement-becomes-healthy still yields "still-running" | ||
| with no restart. Activation: existing step 1-3 tests green. | ||
| 4. Full `bun run test`, `bun run typecheck`, `bun run privacy:scan` on the merged | ||
| branch. | ||
|
|
||
| ## Push plan (user-approved for this PR) | ||
|
|
||
| Regular `git push` of the merge+fix commits (no force). Then `gh pr ready 557`, | ||
| PR body update (policy decision + evidence + `Refs #533` + @WZBbiao credit), and | ||
| a summary comment. If the remote rejects non-FF → STOP and ask (UNSAFE). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -263,6 +263,11 @@ npm에서 opencodex를 자체 업데이트합니다. 안정판 설치는 `@lates | |
| 않습니다. 실행 중인 프록시가 있으면 파일을 교체하기 전에 중지합니다. 설치된 서비스는 자동으로 다시 | ||
| 빌드해 시작하며, 포그라운드 설치에서는 다음 단계로 `ocx start`를 출력합니다. | ||
|
|
||
| Unix에서는 업데이트 전에 설정된 npm 캐시가 현재 사용자 소유인지 확인합니다. 다른 소유자의 항목이 | ||
| 있거나 캐시를 검사할 수 없으면 실행 중인 프록시를 중지하기 전에 업데이트를 중단합니다. | ||
|
|
||
| 프록시가 중지된 뒤 업데이트 명령 자체가 실패하면 자동 복구를 시도하지 않습니다. 실패한 설치 프로그램이 전역 패키지 트리를 아직 바꾸는 중일 수 있기 때문입니다. 업데이트 작업은 실패로 남고, 로그에 수동 복구 경로(패키지를 복원한 뒤 `ocx service install` 또는 `ocx start --port <port>`)가 안납니다. | ||
|
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.
For Korean readers, AGENTS.md reference: docs-site/AGENTS.md:L7-L10 Useful? React with 👍 / 👎. 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. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Fix the Korean manual-recovery sentence.
As per path instructions, translated 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| ```bash | ||
| ocx update | ||
| ocx update --tag preview | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Both update paths check
!treeExitedbeforeinterruptedSignal, andrunProcessTreeCommandforcestreeExited === falseon POSIX once cleanup starts — so the interruption and timeout branches are dead code on Linux and macOS, and the tests that cover them only match source text.The root cause is in
src/update/install-process.mjslines 203-351. Any forwarded signal or the timeout timer callsstartCleanup(), which assignscleanupPromisewhile the child is alive. The final computation is thentreeExited = process.platform === "win32" && knownGroupExited, which is unconditionallyfalseoff Windows. Both callers branch on!treeExitedfirst, so on POSIX a Ctrl+C and a 180s timeout both land in the unconfirmed-cleanup branch and exit75. The fail-closed behavior is correct; the problem is that both callers present three distinct outcomes and deliver one, and both test files assert the unreachable branches by grepping source text.bin/ocx.mjs#L246-L265: report the interruption cause inside the!res.treeExitedbranch at line 247, usingres.interruptedSignalandres.timedOut. The POSIXprocess.kill(process.pid, res.interruptedSignal)re-raise at line 262 is unreachable; either delete it or reorder the two checks and remove the tray restart from the interruption branch.src/update/index.ts#L289-L304: apply the same message change at line 290 so the operator learns whether they interrupted the update or it timed out; note that"timed out after 180s"at line 419 currently renders only on Windows.tests/ocx-launcher-source.test.ts#L26-L41: replace the text assertions at lines 38-39 with a behavioral assertion on the outcome classification, or add a comment recording that the interruption block is Windows-only in practice.tests/update-stop-first.test.ts#L113-L151: add a behavioral test that maps a{ treeExited, interruptedSignal, status }triple to the selected branch on each platform; keep the timeout-margin check at lines 136-142, which already asserts a real invariant.📍 Affects 4 files
bin/ocx.mjs#L246-L265(this comment)src/update/index.ts#L289-L304tests/ocx-launcher-source.test.ts#L26-L41tests/update-stop-first.test.ts#L113-L151🤖 Prompt for AI Agents