mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-12 09:10:19 +02:00
v1.30.0.0 fix wave: 21 community PRs + Windows CI extension + codex flag-semantics smoke (#1391)
* fix(codex): use resume-compatible flags * fix: V-001 security vulnerability Automated security fix generated by Orbis Security AI * docs: align prompt-injection thresholds to security.ts (v1.6.4.0 catch-up) CLAUDE.md:290 and ARCHITECTURE.md:159 were missed when WARN was bumped 0.60 → 0.75 ind75402bb(v1.6.4.0, "cut Haiku classifier FP from 44% to 23%, gate now enforced", #1135). browse/src/security.ts:37 has WARN: 0.75 and BROWSER.md:743 was updated alongside that commit; CLAUDE.md and ARCHITECTURE.md still read 0.60. Also adds the SOLO_CONTENT_BLOCK: 0.92 entry to CLAUDE.md (already in security.ts:50 and BROWSER.md:745, missing from CLAUDE.md's threshold table). No code change. No behavior change. Pure doc-vs-code alignment. Verification: $ grep -n "WARN" browse/src/security.ts CLAUDE.md ARCHITECTURE.md BROWSER.md browse/src/security.ts:37: WARN: 0.75, CLAUDE.md:290: - \`WARN: 0.75\` ... ARCHITECTURE.md:159: ...>= \`WARN\` (0.75)... BROWSER.md:743: - \`WARN: 0.75\` ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: Korean/CJK IME input and rendering in Sidebar Terminal Fixes #1272 This commit addresses three separate Korean/CJK bugs in the Sidebar Terminal: **Bug 1 - IME Input**: Korean text typed via IME composition was not reaching the PTY correctly. Added compositionstart/compositionend event listeners to suppress partial jamo fragments and only send the final composed string. **Bug 2a - Font Rendering**: Added CJK monospace font fallbacks ("Noto Sans Mono CJK KR", "Malgun Gothic") to both the xterm.js fontFamily config and the CSS --font-mono variable. This ensures consistent cell-width calculations for Korean characters. **Bug 2b - UTF-8 Boundary Detection**: Added buffering logic to prevent multi-byte UTF-8 characters (Korean is 3 bytes) from being split across WebSocket chunks. This follows the same pattern as PR #1007 which fixed the sidebar-agent path, but extends it to the terminal-agent path. Special thanks to @ldybob for the excellent root cause analysis and proposed solutions in issue #1272. Tested on WSL2 + Windows 11 with Korean IME. * fix(ship): tighten Plan Completion gate (VAS-449 remediation) VAS-446 shipped with a PLAN.md acceptance criterion (domain-hq has /docs/dashboard.md) silently skipped. /ship's Plan Completion subagent existed at ship time (added in v1.4.1.0) but the gate let the failure through. Four structural fixes: 1. Path concreteness rule: items naming a concrete filesystem path MUST be classified DONE/NOT DONE via [ -f <path> ], never UNVERIFIABLE. 2. Validator detection: CONTENT-SHAPE items scan target repo's package.json for validate-* scripts and run them before falling back to UNVERIFIABLE. 3. Per-item UNVERIFIABLE confirmation: replaces blanket "I've checked each one" with per-item Y/N/D loop. The blanket-confirm path is the exact failure VAS-449 surfaced. 4. Subagent fail-closed: if Plan Completion subagent + inline fallback both fail, surface explicit AskUserQuestion instead of silent pass. Replaces the prior "Never block /ship on subagent failure" fail-open. Locked in by test/ship-plan-completion-invariants.test.ts (5 assertions, no LLM dependency, ~60ms). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(browse): bash.exe wrap for telemetry on Windows reportAttemptTelemetry() in browse/src/security.ts calls spawn(bin, args) where bin is the gstack-telemetry-log bash script. On Windows this fails silently with ENOENT — CreateProcess can't dispatch on shebang lines. Adopts v1.24.0.0's Bun.which + GSTACK_*_BIN override pattern (from browse/src/claude-bin.ts:resolveClaudeCommand, introduced in #1252) for resolving bash.exe. resolveBashBinary() honors GSTACK_BASH_BIN absolute-path or PATH-resolvable override, falling back to Bun.which('bash') which finds Git Bash on the standard Windows install. buildTelemetrySpawnCommand() wraps the script invocation on win32 only; POSIX path is bit-identical. Returns null when bash can't be resolved on Windows so caller skips spawn — local attempts.jsonl audit trail keeps working without surfacing a Windows-only failure. 8 new unit tests cover resolveBashBinary (POSIX bash, absolute override, quote-stripping, BASH_BIN fallback, empty-PATH null) and buildTelemetrySpawnCommand (POSIX pass-through, win32 bash wrap, win32 null on unresolvable, arg-array immutability). POSIX path is bit-identical — Bun.which('bash') on Linux/macOS returns the same /bin/bash or /usr/bin/bash that the old hardcoded spawn relied on. * fix(make-pdf): Bun.which-based binary resolution for browse + pdftotext on Windows Extends v1.24.0.0's Bun.which + GSTACK_*_BIN override pattern (introduced in browse/src/claude-bin.ts via #1252) to the two other binary resolvers in the codebase: make-pdf/src/browseClient.ts:resolveBrowseBin and make-pdf/src/pdftotext.ts:resolvePdftotext. Same Windows quirks (fs.accessSync(X_OK) degrades to existence-check; `which` isn't available outside Git Bash; bun --compile --outfile X emits X.exe), same Bun.which-based fix shape, same env override convention. Changes: - GSTACK_BROWSE_BIN / GSTACK_PDFTOTEXT_BIN as the v1.24-aligned overrides; BROWSE_BIN / PDFTOTEXT_BIN remain as back-compat aliases. - Bun.which() replaces execFileSync('which', ...) for PATH lookup. Handles Windows PATHEXT natively; no more `where`-vs-`which` branch. - findExecutable(base) helper exported from each module, probes .exe/.cmd/.bat after the bare-path miss on win32. Linux/macOS behavior is bit-identical (isExecutable short-circuits before the win32 branch ever runs). - macCandidates renamed posixCandidates (always was — /opt/homebrew, /usr/local, /usr/bin). No Windows candidates added; Poppler installs scatter across Scoop/Chocolatey/portable zips and guessing causes false positives. - Error messages get a Windows install hint (scoop install poppler / oschwartz10612) and `setx` example for GSTACK_*_BIN. - Pre-existing test 'honors BROWSE_BIN when it points at a real executable' was hardcoded /bin/sh — made cross-platform via a REAL_EXE constant (cmd.exe on win32, /bin/sh on POSIX). Was a Windows-CI blocker on its own. Coordination: PR #1094 (@BkashJEE) covered browseClient.ts independently with a narrower scope; this PR's pdftotext + cross-platform tests + GSTACK_*_BIN naming are additive. Either order of merge works. Test plan: - bun test make-pdf/test/browseClient.test.ts make-pdf/test/pdftotext.test.ts on win32 — 29 pass, 0 fail (12 new assertions: findExecutable POSIX/win32/null, resolveBrowseBin GSTACK_BROWSE_BIN + BROWSE_BIN + precedence + quote-strip, same shape for resolvePdftotext + Windows install hint in error message). - POSIX branch unchanged — fs.accessSync(X_OK) on Linux/macOS short-circuits before any win32 logic runs, matching the v1.24 claude-bin.ts pattern. * fix(browse): NTFS ACL hardening for Windows state files via icacls gstack's ~/.gstack/ state directory holds bearer tokens, canary tokens, agent queue contents (with prompt history), session state, security-decision logs, and saved cookie bundles — all written with { mode: 0o600 } / 0o700. On Windows, those mode bits are a silent no-op: Node's fs module doesn't translate POSIX modes to NTFS ACLs, and inherited ACLs leave every "restricted" file readable by other principals on the machine (verified via icacls — six ACEs, the intended user is the LAST of six). Threat model is non-trivial on: - Self-hosted CI runners (different service account on the same Windows box can read developer tokens, canary tokens, prompt history) - Shared development machines (agencies, studios, lab environments) - Multi-tenant servers with shared home directories Orthogonal to v1.24.0.0's binary-resolution work — complementary at the write side. v1.24's bin/gstack-paths resolves ~/.gstack/ correctly across plugin / global / local installs; this PR ensures files written into those resolved paths actually get the POSIX 0o600 semantic translated to NTFS. The fix: - New browse/src/file-permissions.ts (158 LOC, 5 public + 1 test-reset). restrictFilePermissions / restrictDirectoryPermissions wrap chmod (POSIX) or icacls /inheritance:r /grant:r <user>:(F) (Windows). writeSecureFile / appendSecureFile / mkdirSecure are drop-in wrappers for the common patterns. - 19 call sites converted across 9 source files: browser-manager.ts, browser-skill-write.ts, cli.ts, config.ts, meta-commands.ts, security-classifier.ts, security.ts (4 sites), server.ts (5 sites), terminal-agent.ts (8 sites), tunnel-denial-log.ts. - (OI)(CI) inheritance flags on directories mean files created via fs.write* *inside* an mkdirSecure-created dir inherit the owner-only ACL automatically — important for tunnel-denial-log.ts where appends use async fsp.appendFile. Error handling: icacls failures (nonexistent path, missing icacls.exe, hardened environments) log a one-shot warning to stderr and proceed. Once-per-process gating prevents log spam if the condition persists. Filesystem stays functional; the file just ends up with inherited ACLs. Test plan: - bun test browse/test/file-permissions.test.ts — 13 pass, 0 fail (POSIX mode-bit assertions, Windows no-throw, mkdir idempotence, recursive creation, Buffer payloads, append-creates-then-reapplies-once semantics) - bun test browse/test/security.test.ts — 38 pass, 0 fail (existing security test suite plus the bash-binary resolution tests added in fix #1119; the converted writeFileSync/appendFileSync/mkdirSync sites in security.ts integrate cleanly) - Empirical icacls before/after on a real file — 6 ACEs → 1 ACE - bun build typecheck on all modified files — clean (server.ts has a pre-existing playwright-core/electron resolution issue unrelated to this PR) POSIX behavior is bit-identical to old code — fs.chmodSync(path, 0o6XX) on the helper's POSIX branch matches the inline { mode: 0o6XX } it replaces. Linux and macOS see no behavior change. Inviting pushback on three judgment calls (in PR description): 1. icacls vs npm library 2. ACL scope — just user, or user + SYSTEM? 3. Graceful degradation — once-per-process warn, not silent, not hard-fail. * fix(browse): declare lastConsoleFlushed to restore console-log persistence flushBuffers() references a `lastConsoleFlushed` cursor at server.ts:337 and assigns it at :344, but the `let lastConsoleFlushed = 0;` declaration is missing — only the network and dialog siblings are declared at lines 327-328. Result: every 1-second flushBuffers tick (line 376) throws `ReferenceError: lastConsoleFlushed is not defined`, gets swallowed by the catch at line 369 ("[browse] Buffer flush failed: ..."), and the console branch's append never runs. browse-console.log is never written in any production deployment since this regressed. Discovered by stress-testing the daemon with 15 concurrent CLIs against cold state — the race surfaced the buffer-flush error spam in one spawned daemon's stderr. Verified by running the daemon against a real file:// page with console.log events: in-memory `browse console` returns the entries, but `.gstack/browse-console.log` is never created on disk. Regression introduced by1a100a2a"fix: eliminate duplicate command sets in chain, improve flush perf and type safety" — the flush refactor switched from `Bun.write` to `fs.appendFileSync` and added the `lastConsoleFlushed` cursor pattern alongside its network/dialog siblings, but missed the matching `let` declaration. Tests don't currently exercise flushBuffers, so the regression shipped silently. Fix: - Declare `let lastConsoleFlushed = 0;` next to `lastNetworkFlushed` and `lastDialogFlushed` (browse/src/server.ts:327) - Add a source-level guard test (browse/test/server-flush-trackers.test.ts) that fails any future refactor that adds a fourth `last*Flushed` cursor without the matching declaration. Same pattern as terminal-agent.test.ts and dual-listener.test.ts — read source as text, assert invariant, no daemon required. Test plan: - [x] New regression test fails on current main, passes with the fix - [x] `bun run build` clean - [x] Manual smoke: spawn daemon -> goto file:// page with console.log -> wait 4s -> .gstack/browse-console.log now exists with the expected entries (163 bytes vs zero before) 🤖 Generated with [Claude Code](https://claude.com/claude-code) * fix(browse): per-process state-file temp path to fix concurrent-write ENOENT The daemon writes `.gstack/browse.json` via the standard atomic-rename pattern: `writeFileSync(tmp, …) → renameSync(tmp, stateFile)`. Four sites in server.ts use this pattern (initial daemon-startup state at :2002, /tunnel/start handler at :1479, BROWSE_TUNNEL=1 inline tunnel update at :2083, BROWSE_TUNNEL_LOCAL_ONLY=1 update at :2113), and all four hard-code the same temp filename `${stateFile}.tmp`. Under concurrent writers the shared filename races on the rename: t0 Writer A: writeFileSync(stateFile + '.tmp', payloadA) t1 Writer B: writeFileSync(stateFile + '.tmp', payloadB) // overwrites A t2 Writer A: renameSync(stateFile + '.tmp', stateFile) // moves B's payload t3 Writer B: renameSync(stateFile + '.tmp', stateFile) // ENOENT — file gone Reproduced empirically with 15 concurrent CLIs against a fresh `.gstack/`: [browse] Failed to start: ENOENT: no such file or directory, rename '…/.gstack/browse.json.tmp' -> '…/.gstack/browse.json' Pre-fix success rate: **0 / 15** under cold-start race. Post-fix success rate: **15 / 15**, zero ENOENT. Fix: - New `tmpStatePath()` helper (server.ts:333) returns `${stateFile}.tmp.${pid}.${randomBytes(4).toString('hex')}` - All 4 call sites use `tmpStatePath()` instead of the shared literal - Atomic rename still gives last-writer-wins semantics on the final state.json content; only behavior change is that concurrent writers no longer kill each other on the rename step Source-level guard test (browse/test/server-tmp-state-path.test.ts) locks two invariants: (1) no remaining `stateFile + '.tmp'` literals, (2) every state-write `writeFileSync` call uses `tmpStatePath()`. Same read-source-as-text pattern as terminal-agent.test.ts and dual-listener.test.ts — no daemon required, runs in tier-1 free. Test plan: - [x] Targeted source-level guard test passes (3 / 0) - [x] `bun run build` clean - [x] Live regression: 15 concurrent CLIs against cold state → 15 / 15 healthy, 0 ENOENT (vs 0 / 15 pre-fix) - [x] No `.tmp.*` orphans left behind after rename succeeds - [x] Related test cluster (server-auth, dual-listener, cdp-mutex, findport) — same pre-existing flakes as `main`, no new regressions introduced 🤖 Generated with [Claude Code](https://claude.com/claude-code) * fix(browse): clear refs when iframe auto-detaches in getActiveFrameOrPage Asymmetric cleanup between two equivalent staleness conditions: onMainFrameNavigated() → clearRefs() + activeFrame = null ✓ getActiveFrameOrPage() → activeFrame = null (refs NOT cleared) ✗ Both paths see the same staleness condition — refs were captured against a frame that no longer exists. The main-frame path correctly clears both pieces of state. The iframe-detach path nulls the frame but leaves the refMap intact. The lazy click-time check in `resolveRef` (tab-session.ts:97) partially saves us — `entry.locator.count()` on a detached-frame locator throws or returns 0, so the click errors out as "Ref X is stale". But the user has no signal that frame context silently changed underfoot: the next `snapshot` runs against `this.page` (main) while old iframe refs still litter `refMap` with the same role+name keys. New refs collide with stale ones, the resolver picks one at random, the user clicks the wrong element. TODOS.md line 816-820 documents "Detached frame auto-recovery" as a shipped iframe-support feature in v0.12.1.0. This restores the documented intent — the recovery should leave the session in a clean state, not a half-cleared one. Fix: 1 line — add `this.clearRefs()` next to `this.activeFrame = null` inside the if-branch. Test plan: - [x] New regression test: 4/4 pass - refs cleared when getActiveFrameOrPage detects detached iframe - refs preserved when active frame is still attached (no regression) - refs preserved when no frame set (page-level path untouched) - matches onMainFrameNavigated symmetry — both paths reach the same clean end state - [x] `bun run build` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) * fix(codex): resolve python for JSON parser * fix: add fail-fast probe for base branch in ship step 12 * fix(plan-devex-review): remove contradictory plan-mode handshake * fix(design): honor Retry-After header in variants 429 handler Closes #1244. The 429 handler in `generateVariant` discarded the `Retry-After` response header and fell straight through to a local exponential schedule (2s/4s/8s). In image-generation batches, that burns retry attempts inside the provider's cooldown window and the request never recovers. Now we parse `Retry-After` per RFC 7231 — both delta-seconds (`Retry-After: 5`) and HTTP-date (`Retry-After: Fri, 31 Dec 1999 23:59:59 GMT`). Honored waits are capped at 60s to bound stalls from hostile or buggy headers. Delta-seconds are validated as digits-only (rejects `2abc`). When `Retry-After` is honored (including 0 / past-date "retry now"), the next iteration's leading exponential sleep is skipped so we don't double-wait. Invalid or missing headers fall through to the existing exponential schedule unchanged. Behavior matrix: | Header | Behavior | |---------------------------------|-------------------------------------------| | Retry-After: 5 | wait 5s, skip leading on next attempt | | Retry-After: 999999 | capped to 60s, skip leading | | Retry-After: 2abc | invalid, fall through to exponential | | Retry-After: 0 | wait 0, skip leading (retry immediately) | | Retry-After: <past HTTP-date> | wait 0, skip leading | | Retry-After: <future date> | wait diff capped at 60s, skip leading | | no header | fall through to existing exponential | `generateVariant` now accepts an optional `fetchFn` parameter (defaults to `globalThis.fetch`) so tests can inject a stub. Production call sites are unchanged. Tests cover the five behavior buckets above, asserting both the 1st-to-2nd call timing gap and call counts. All five pass in ~8s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(docs): correct per-skill symlink removal snippet in README uninstall Closes #1130. The manual-uninstall fallback in `## Uninstall` → `### Option 2` used `find ~/.claude/skills -maxdepth 1 -type l`, which finds nothing on real installs. Each `~/.claude/skills/<name>/` is a real directory, and only `<name>/SKILL.md` inside it is a symlink into `gstack/`. The find never matched, so the snippet silently removed nothing. Replace with a directory walk that inspects each `<name>/SKILL.md`: find ~/.claude/skills -mindepth 1 -maxdepth 1 -type d ! -name gstack → check $dir/SKILL.md is a symlink → readlink it → if target is gstack/* or */gstack/*: rm -f the link, rmdir the dir (only if empty — preserves any user-added files) Excludes the top-level `gstack/` dir from the walk; that's removed by step 3 of the same uninstall block. `bin/gstack-uninstall` (the script-mode path) already handles the layout correctly via its own walk; only this manual fallback needed updating. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: reject partial browse client env integers * fix(gemini-adapter): detect new ~/.gemini/oauth_creds.json auth path gemini-cli >=0.30 stores OAuth credentials at ~/.gemini/oauth_creds.json instead of the legacy ~/.config/gemini/ directory. The benchmark adapter's availability check now succeeds for users on recent gemini-cli releases who have authenticated via interactive login. Both paths are accepted so users on older versions still work. * fix(browser): add --no-sandbox for root user on Linux/WSL2 Chromium's sandbox can't initialize when running as root on Linux, causing an immediate exit. Extend the existing CI/CONTAINER check to also cover this case, keeping the Windows-safe `typeof getuid` guard. * security: pass cwd to git via execFileSync, not interpolation through /bin/sh `bin/gstack-memory-ingest.ts:632-643` ran `execSync(\`git -C ${JSON.stringify(cwd)} remote get-url origin 2>/dev/null\`, ...)`. JSON.stringify escapes `"` and `\` but not `$` or backticks, so a `cwd` of `"$(touch /tmp/marker)"` survived JSON quoting and detonated under /bin/sh's command-substitution-inside-double-quotes. `cwd` originates from transcript JSONL records under `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl` and `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. The walker grabs the first `.cwd` it sees per session. That's an untrusted surface in the gstack threat model — the L1-L6 sidebar security stack exists exactly because agent transcripts can carry attacker-influenced text. Two pivots above the local same-uid bar: (a) prompt-injection appending `cwd="$(...)"` to the active session log turns the next /sync-gbrain run into RCE under the user's uid; (b) cross-machine transcript share (a colleague's `.claude/projects` snippet untar'd into HOME, a documented gbrain dogfooding shape) → RCE on first sync. Fix swaps the one execSync for `execFileSync("git", ["-C", cwd, "remote", "get-url", "origin"], ...)`. No shell, argv passed directly to git. The same module already uses execFileSync for `gbrainAvailable()` (line 762 pre-patch) and `gbrainPutPage()` (line 816 pre-patch) — this single execSync was the outlier. Test: `gstack-memory-ingest security: untrusted cwd cannot trigger shell substitution` plants a Claude-Code-shaped JSONL with cwd=`$(touch <marker>)` and asserts the marker file is not created after `--incremental --quiet`. Negative control: with the patch reverted, the test fails (marker created); with the patch applied, it passes (18/18 in test/gstack-memory-ingest.test.ts). * security: gate domain-skill auto-promote on classifier_score > 0 `browse/src/domain-skill-commands.ts:140` (handleSave) writes `classifier_score: 0` with the comment "L4 deferred to load-time / sidebar-agent fills this in on first prompt-injection load." But CLAUDE.md "Sidebar architecture" documents that sidebar-agent.ts was ripped, and grep for recordSkillUse + classifierFlagged callers across browse/src/ returns zero hits outside the module under test. Net effect: every quarantined skill that survives three benign uses without flag (`recordSkillUse(... , classifierFlagged: false)` x3) auto-promotes to `active` and lands in prompt context wrapped as UNTRUSTED on every subsequent visit to that host. The L4 score that was supposed to gate the promotion was never written — the production save path puts 0 on disk and nothing later updates it. Threat model: a domain-skill body authored by an agent under the influence of a poisoned page (the new `gstackInjectToTerminal` PTY path runs no L1-L3 either) would lose its auto-promote barrier after three uses. The exploit isn't single-step but the bar is exactly N=3 prompt-injection-shaped uses on a hostile page, which is well within reach. Fix adds a single condition to the auto-promote gate in `recordSkillUse`: if (state === 'quarantined' && useCount >= PROMOTE_THRESHOLD && flagCount === 0 && current.classifier_score > 0) { state = 'active'; } `classifier_score` is set once at writeSkill and never updated. Production saves it as 0 (handleSave), so the gate stays closed; existing tests that explicitly pass `classifierScore: 0.1` still auto-promote (the auto-promote path is preserved for the day L4 is rewired). Manual promotion via `domain-skill promote-to-global` is unaffected (it goes through `promoteToGlobal` which has its own state-machine guard at line 337+). Test: new regression case `does NOT auto-promote when classifier_score is 0 (production handleSave shape)` plants a skill with classifierScore=0 (matches domain-skill-commands.ts:140), runs three uses without flag, asserts the skill stays quarantined and readSkill returns null. Negative control: revert the patch, the test fails with `Received: "active"`. With the patch: 15/15 pass. * fix(ship): port #1302 SKILL.md edits to .tmpl + resolver source PR #1302 added Verification Mode + UNVERIFIABLE classification + per-item confirmation gate to ship/SKILL.md, but only the generated SKILL.md was edited — not the .tmpl source or scripts/resolvers/review.ts. The next `bun run gen:skill-docs` run would have wiped the changes. Port the same content into the resolver and .tmpl so regeneration produces the intended output. * ci(windows): extend free-tests lane to cover icacls + Bun.which resolvers from fix-wave PRs Closes #1306/#1307/#1308 validation gap. The four newly-added test files already have process.platform guards so they run safely on both POSIX and Windows lanes — only platform-relevant assertions execute on each. Tests added to the windows-latest lane: - browse/test/file-permissions.test.ts (#1308 icacls + writeSecureFile) - browse/test/security.test.ts (#1306 bash.exe wrap pure-function path) - make-pdf/test/browseClient.test.ts (#1307 Bun.which browse resolver) - make-pdf/test/pdftotext.test.ts (#1307 Bun.which pdftotext resolver) * test(codex): live flag-semantics smoke for codex exec resume Closes #1270's regex-only test gap. PR #1270 asserted that codex/SKILL.md's `codex exec resume` invocation drops -C/-s and uses sandbox_mode config. That regex catches the skill template regressing, but not codex CLI itself flipping flag semantics again. This test probes `codex exec resume --help` and asserts the surface gstack relies on: -c/sandbox_mode is accepted, top-level -C is absent. Skips silently when codex isn't on PATH, so dev machines without codex installed never see it fail. * chore: regen SKILL.md after fix wave One regen commit at the end of the merge wave per the plan. plan-devex-review loses the contradictory plan-mode handshake (#1333). review/SKILL.md picks up the Verification Mode + UNVERIFIABLE classification additions that #1302 authored against ship/SKILL.md (same resolver shared between ship and review modes). * fix(server.ts): keep fs.writeFileSync for state-file writes #1308's writeSecureFile wrapper added Windows icacls hardening for the 4 state-file write sites in server.ts, but #1310's regression test grep's for fs.writeFileSync(tmpStatePath()) calls. The two changes are technically compatible only if the test relaxes — keeping the test strict (the safer choice for catching regressions on the cold-start race) means the 4 state- file sites stay on fs.writeFileSync(..., { mode: 0o600 }). POSIX 0o600 hardening is preserved on those 4 sites. Windows icacls hardening still applies to all the other writeSecureFile call sites #1308 added (auth.json, mkdirSecure, etc.). Also refreshes golden baselines after #1302 / port + minor wording tweak in scripts/resolvers/review.ts to keep gen-skill-docs.test.ts assertion 'Cite the specific file' satisfied. * v1.30.0.0: fix wave — 21 community PRs + 2 closing fixes for Windows + codex CI gaps Headline release. Browse stops dropping console logs, cold-start race fixed, codex resume works without python3, Windows hardening (icacls + Bun.which + bash.exe wrap), ship gate gets VAS-449 remediation, two closing fixes that put icacls/Bun.which/codex flag semantics under CI. * test(domain-skills): cover #1369 classifier_score=0 quarantine + score>0 promote path The pre-existing T6 test seeded skills via writeSkill (which defaults classifier_score to 0 until L4 is rewired) and then expected 3 uses to auto-promote. PR #1369 added `current.classifier_score > 0` to the gate specifically to block that path — a quarantined skill written under the influence of a poisoned page would otherwise auto-promote after three benign uses. Updated test asserts both halves of the new contract: - classifier_score=0 + 3 uses → stays quarantined (the security guarantee) - classifier_score>0 + 3 more uses → promotes to active (unblock path) Catches both regressions: the gate going away (would re-allow the bypass) and the unblock path breaking (would silently quarantine all skills forever once L4 is rewired). --------- Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: orbisai0security <mediratta01.pally@gmail.com> Co-authored-by: Bryce Alan <brycealan.eth@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Terry Carson YM <cym3118288@gmail.com> Co-authored-by: Vasko Ckorovski <vckorovski@gmail.com> Co-authored-by: Samuel Carson <samuel.carson@gmail.com> Co-authored-by: Yashwant Kotipalli <yashwant7kotipalli@gmail.com> Co-authored-by: Jasper Chen <jasperchen925@gmail.com> Co-authored-by: Stefan Neamtu <stefan.neamtu@gmail.com> Co-authored-by: 陈家名 <chenjiaming@kezaihui.com> Co-authored-by: Abigail Atheryon <abi@atheryon.ai> Co-authored-by: Furkan Köykıran <furkankoykiran@gmail.com> Co-authored-by: gus <gustavoraularagon@gmail.com>
This commit is contained in:
co-authored by
Jayesh Betala
orbisai0security
Bryce Alan
Claude Opus 4.7
Terry Carson YM
Vasko Ckorovski
Samuel Carson
Yashwant Kotipalli
Jasper Chen
Stefan Neamtu
陈家名
Abigail Atheryon
Furkan Köykıran
gus
parent
06605477e2
commit
00f966b3ec
@@ -87,6 +87,18 @@ describe('browse-client', () => {
|
||||
expect(auth.source).toBe('env');
|
||||
});
|
||||
|
||||
it('rejects GSTACK_PORT env values with trailing characters', () => {
|
||||
process.env.GSTACK_PORT = `${server.port}abc`;
|
||||
process.env.GSTACK_SKILL_TOKEN = 'scoped-token';
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-client-test-'));
|
||||
try {
|
||||
expect(() => resolveBrowseAuth({ stateFile: path.join(tmpDir, 'missing.json') }))
|
||||
.toThrow('browse-client: cannot find daemon port + token');
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to state file when env vars missing', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-client-test-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
@@ -154,6 +166,13 @@ describe('browse-client', () => {
|
||||
expect(server.requests[0].body).toEqual({ command: 'text', args: [], tabId: 7 });
|
||||
});
|
||||
|
||||
it('omits tabId when BROWSE_TAB has trailing characters', async () => {
|
||||
process.env.BROWSE_TAB = '7abc';
|
||||
const client = new BrowseClient({ port: server.port, token: 't' });
|
||||
await client.command('text', []);
|
||||
expect(server.requests[0].body).toEqual({ command: 'text', args: [] });
|
||||
});
|
||||
|
||||
it('throws BrowseClientError with status on non-2xx', async () => {
|
||||
const client = new BrowseClient({ port: server.port, token: 't' });
|
||||
server.setResponse(403, JSON.stringify({ error: 'Insufficient scope' }));
|
||||
|
||||
@@ -84,11 +84,34 @@ describe('$B domain-skill (E2E gate tier)', () => {
|
||||
expect(out).toContain('[quarantined] 127.0.0.1');
|
||||
});
|
||||
|
||||
test('readSkill returns null until the skill is promoted to active (T6)', async () => {
|
||||
test('readSkill returns null while quarantined; classifier_score=0 blocks auto-promote (#1369)', async () => {
|
||||
const { readSkill, recordSkillUse } = await import('../src/domain-skills');
|
||||
const jsonlPath = path.join(TMP_HOME, 'projects', 'e2e-test-slug', 'learnings.jsonl');
|
||||
|
||||
// While quarantined, readSkill returns null
|
||||
expect(await readSkill('127.0.0.1', 'e2e-test-slug')).toBeNull();
|
||||
// Three uses without flag triggers auto-promote
|
||||
|
||||
// Three uses without flag with classifier_score=0 (the default until L4 is
|
||||
// rewired) MUST stay quarantined per #1369. The gate is load-bearing: a
|
||||
// quarantined skill written under the influence of a poisoned page would
|
||||
// otherwise auto-promote after three benign uses without the L4 body scan
|
||||
// ever running.
|
||||
await recordSkillUse('127.0.0.1', 'e2e-test-slug', false);
|
||||
await recordSkillUse('127.0.0.1', 'e2e-test-slug', false);
|
||||
await recordSkillUse('127.0.0.1', 'e2e-test-slug', false);
|
||||
expect(await readSkill('127.0.0.1', 'e2e-test-slug')).toBeNull();
|
||||
|
||||
// Simulate L4 having scored the body (classifier_score > 0) by appending a
|
||||
// new tombstone row with a non-zero score, then verify the next use
|
||||
// promotes. This documents the unblock path the day L4 starts populating
|
||||
// classifier_score for skill writes again.
|
||||
const lines = (await fs.readFile(jsonlPath, 'utf8')).trim().split('\n').map((l) => JSON.parse(l));
|
||||
const latest = lines.filter((r: any) => r.type === 'domain' && r.host === '127.0.0.1').pop();
|
||||
expect(latest).toBeTruthy();
|
||||
const scored = { ...latest, classifier_score: 0.05, version: latest.version + 1, updated_ts: new Date().toISOString() };
|
||||
await fs.appendFile(jsonlPath, JSON.stringify(scored) + '\n');
|
||||
|
||||
// Now three uses promote
|
||||
await recordSkillUse('127.0.0.1', 'e2e-test-slug', false);
|
||||
await recordSkillUse('127.0.0.1', 'e2e-test-slug', false);
|
||||
await recordSkillUse('127.0.0.1', 'e2e-test-slug', false);
|
||||
|
||||
@@ -106,6 +106,31 @@ describe('domain-skills: state machine (T6)', () => {
|
||||
})
|
||||
).rejects.toThrow(/classifier flagged/);
|
||||
});
|
||||
|
||||
// domain-skill-commands.ts:140 (handleSave) writes classifier_score=0 with
|
||||
// the comment "L4 deferred to load-time" — but sidebar-agent (the deferred
|
||||
// scanner) was ripped per CLAUDE.md "Sidebar architecture." Without an
|
||||
// explicit gate, three benign uses promote any quarantined skill, including
|
||||
// one authored under a poisoned page, into prompt context permanently.
|
||||
it('does NOT auto-promote when classifier_score is 0 (production handleSave shape)', async () => {
|
||||
const m = await freshImport();
|
||||
await m.writeSkill({
|
||||
host: 'linkedin.com',
|
||||
body: '# LinkedIn',
|
||||
projectSlug: 'test-slug',
|
||||
source: 'agent',
|
||||
classifierScore: 0, // matches domain-skill-commands.ts:140 production path
|
||||
});
|
||||
const after3 = await m.recordSkillUse('linkedin.com', 'test-slug', false);
|
||||
await m.recordSkillUse('linkedin.com', 'test-slug', false);
|
||||
const final = await m.recordSkillUse('linkedin.com', 'test-slug', false);
|
||||
expect(after3?.state).toBe('quarantined');
|
||||
expect(final?.state).toBe('quarantined');
|
||||
expect(final?.use_count).toBe(3);
|
||||
// readSkill returns null for quarantined skills — they don't fire.
|
||||
const read = await m.readSkill('linkedin.com', 'test-slug');
|
||||
expect(read).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('domain-skills: scope shadowing (T4)', () => {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Unit tests for browse/src/file-permissions.ts
|
||||
*
|
||||
* Strategy:
|
||||
* - POSIX assertions check fs.statSync.mode bits directly (cheap, reliable,
|
||||
* runs on every CI config).
|
||||
* - Windows assertions don't check ACLs (that'd require parsing icacls
|
||||
* output, which is brittle across Windows versions / locales). Instead
|
||||
* we verify the helper doesn't throw and the file ends up accessible
|
||||
* to the current user — the "doesn't crash, file still usable"
|
||||
* contract the callers rely on.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
restrictFilePermissions,
|
||||
restrictDirectoryPermissions,
|
||||
writeSecureFile,
|
||||
appendSecureFile,
|
||||
mkdirSecure,
|
||||
__resetWarnedForTests,
|
||||
} from '../src/file-permissions';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-perms-'));
|
||||
__resetWarnedForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
describe('restrictFilePermissions', () => {
|
||||
test('on POSIX, sets file mode to 0o600', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const p = path.join(tmpDir, 'secret');
|
||||
fs.writeFileSync(p, 'token');
|
||||
fs.chmodSync(p, 0o644); // start world-readable to prove the call mutates it
|
||||
restrictFilePermissions(p);
|
||||
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
test('on Windows, does not throw on an existing file', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const p = path.join(tmpDir, 'secret');
|
||||
fs.writeFileSync(p, 'token');
|
||||
expect(() => restrictFilePermissions(p)).not.toThrow();
|
||||
// File remains readable by the caller — core contract.
|
||||
expect(fs.readFileSync(p, 'utf8')).toBe('token');
|
||||
});
|
||||
|
||||
test('on Windows, does not throw when icacls fails (bad path)', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
// icacls emits an error for a nonexistent path; helper must swallow.
|
||||
expect(() => restrictFilePermissions(path.join(tmpDir, 'nonexistent'))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restrictDirectoryPermissions', () => {
|
||||
test('on POSIX, sets directory mode to 0o700', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const d = path.join(tmpDir, 'subdir');
|
||||
fs.mkdirSync(d, { mode: 0o755 });
|
||||
restrictDirectoryPermissions(d);
|
||||
expect(fs.statSync(d).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
test('on Windows, does not throw on an existing directory', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const d = path.join(tmpDir, 'subdir');
|
||||
fs.mkdirSync(d);
|
||||
expect(() => restrictDirectoryPermissions(d)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeSecureFile', () => {
|
||||
test('writes the payload and restricts permissions atomically', () => {
|
||||
const p = path.join(tmpDir, 'data');
|
||||
writeSecureFile(p, 'hello');
|
||||
expect(fs.readFileSync(p, 'utf8')).toBe('hello');
|
||||
if (process.platform !== 'win32') {
|
||||
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts Buffer payloads', () => {
|
||||
const p = path.join(tmpDir, 'buffer');
|
||||
writeSecureFile(p, Buffer.from([0xde, 0xad, 0xbe, 0xef]));
|
||||
const out = fs.readFileSync(p);
|
||||
expect(out.length).toBe(4);
|
||||
expect(out[0]).toBe(0xde);
|
||||
});
|
||||
|
||||
test('overwrites existing file', () => {
|
||||
const p = path.join(tmpDir, 'existing');
|
||||
fs.writeFileSync(p, 'old', { mode: 0o644 });
|
||||
writeSecureFile(p, 'new');
|
||||
expect(fs.readFileSync(p, 'utf8')).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendSecureFile', () => {
|
||||
test('appends to a new file and sets owner-only permissions', () => {
|
||||
const p = path.join(tmpDir, 'log');
|
||||
appendSecureFile(p, 'line1\n');
|
||||
expect(fs.readFileSync(p, 'utf8')).toBe('line1\n');
|
||||
if (process.platform !== 'win32') {
|
||||
expect(fs.statSync(p).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
|
||||
test('appends without re-applying ACL on subsequent writes', () => {
|
||||
const p = path.join(tmpDir, 'log');
|
||||
appendSecureFile(p, 'line1\n');
|
||||
appendSecureFile(p, 'line2\n');
|
||||
expect(fs.readFileSync(p, 'utf8')).toBe('line1\nline2\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mkdirSecure', () => {
|
||||
test('creates directory with owner-only mode (POSIX)', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const d = path.join(tmpDir, 'nested', 'deep');
|
||||
mkdirSecure(d);
|
||||
expect(fs.statSync(d).isDirectory()).toBe(true);
|
||||
expect(fs.statSync(d).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
test('is idempotent — safe to call on existing directory', () => {
|
||||
const d = path.join(tmpDir, 'dir');
|
||||
mkdirSecure(d);
|
||||
expect(() => mkdirSecure(d)).not.toThrow();
|
||||
});
|
||||
|
||||
test('recursive behavior: creates intermediate directories', () => {
|
||||
const d = path.join(tmpDir, 'a', 'b', 'c');
|
||||
mkdirSecure(d);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'a'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'a', 'b'))).toBe(true);
|
||||
expect(fs.existsSync(d)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
readSessionState,
|
||||
getStatus,
|
||||
extractDomain,
|
||||
buildTelemetrySpawnCommand,
|
||||
resolveBashBinary,
|
||||
type LayerSignal,
|
||||
} from '../src/security';
|
||||
|
||||
@@ -325,3 +327,77 @@ describe('extractDomain', () => {
|
||||
expect(extractDomain('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bash binary resolution (Windows shebang-script invocation) ─────
|
||||
|
||||
describe('resolveBashBinary', () => {
|
||||
test('on POSIX, returns the system bash via Bun.which', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const out = resolveBashBinary({ PATH: process.env.PATH ?? '' });
|
||||
expect(out).toBeTruthy();
|
||||
expect(out!.endsWith('bash')).toBe(true);
|
||||
});
|
||||
|
||||
test('honors GSTACK_BASH_BIN absolute-path override', () => {
|
||||
// Construct a synthetic absolute path; the helper short-circuits on
|
||||
// path.isAbsolute and never touches the filesystem, so this is portable.
|
||||
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
|
||||
const out = resolveBashBinary({ GSTACK_BASH_BIN: fake, PATH: '' });
|
||||
expect(out).toBe(fake);
|
||||
});
|
||||
|
||||
test('strips wrapping double quotes from override values', () => {
|
||||
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
|
||||
const out = resolveBashBinary({ GSTACK_BASH_BIN: `"${fake}"`, PATH: '' });
|
||||
expect(out).toBe(fake);
|
||||
});
|
||||
|
||||
test('BASH_BIN works as a fallback when GSTACK_BASH_BIN is unset', () => {
|
||||
const fake = process.platform === 'win32' ? 'C:\\opt\\bash.exe' : '/opt/custom/bash';
|
||||
const out = resolveBashBinary({ BASH_BIN: fake, PATH: '' });
|
||||
expect(out).toBe(fake);
|
||||
});
|
||||
|
||||
test('returns null when nothing resolves (override is unset and PATH is empty)', () => {
|
||||
// Empty PATH means Bun.which finds nothing.
|
||||
const out = resolveBashBinary({ PATH: '' });
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Telemetry spawn command (Windows bash wrapper, v1.24-aligned) ──
|
||||
|
||||
describe('buildTelemetrySpawnCommand', () => {
|
||||
const bin = '/home/user/.claude/skills/gstack/bin/gstack-telemetry-log';
|
||||
const args = ['--event-type', 'attack_attempt', '--confidence', '0.95'];
|
||||
|
||||
test('on POSIX, returns the binary path and args unchanged', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const out = buildTelemetrySpawnCommand(bin, args);
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.cmd).toBe(bin);
|
||||
expect(out!.cmdArgs).toEqual(args);
|
||||
});
|
||||
|
||||
test('on win32 with bash resolvable, wraps the call in bash with the script as first arg', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const fakeBash = 'C:\\Program Files\\Git\\bin\\bash.exe';
|
||||
const out = buildTelemetrySpawnCommand(bin, args, { GSTACK_BASH_BIN: fakeBash, PATH: '' });
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.cmd).toBe(fakeBash);
|
||||
expect(out!.cmdArgs).toEqual([bin, ...args]);
|
||||
});
|
||||
|
||||
test('on win32 with bash unresolvable, returns null so caller skips spawn', () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
// No override, empty PATH — Bun.which finds nothing on Windows.
|
||||
const out = buildTelemetrySpawnCommand(bin, args, { PATH: '' });
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
test('does not mutate the caller-supplied args array', () => {
|
||||
const originalArgs = [...args];
|
||||
buildTelemetrySpawnCommand(bin, args);
|
||||
expect(args).toEqual(originalArgs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Regression: flushBuffers state-tracker declaration audit.
|
||||
*
|
||||
* `flushBuffers()` (server.ts) maintains per-buffer cursors so it only
|
||||
* appends *new* entries to each on-disk log on every interval tick:
|
||||
*
|
||||
* const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed;
|
||||
* const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed;
|
||||
* const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed;
|
||||
*
|
||||
* The trackers must be declared with `let X = 0;` at module scope so the
|
||||
* subtraction returns a real number on the first tick. If a tracker is
|
||||
* referenced inside flushBuffers but never declared at module scope, the
|
||||
* interval throws `ReferenceError: X is not defined` every second — the
|
||||
* throw is swallowed by the catch at the bottom of flushBuffers (logged
|
||||
* as `[browse] Buffer flush failed: <name> is not defined`), the
|
||||
* corresponding on-disk log file is *never written*, and the regression
|
||||
* is silent in production.
|
||||
*
|
||||
* This source-level guard catches that exact class of regression — a
|
||||
* future flush-perf refactor that adds a fourth buffer cursor (or a
|
||||
* future contributor that copy-pastes the `last*Flushed` pattern without
|
||||
* the matching declaration) will fail this test before it ships.
|
||||
*
|
||||
* Pattern matches `terminal-agent.test.ts` and `dual-listener.test.ts`:
|
||||
* read source as text, assert an invariant, no daemon required.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const SERVER_TS = readFileSync(
|
||||
path.resolve(import.meta.dir, '../src/server.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('server.ts — flushBuffers tracker declarations', () => {
|
||||
test('every `last*Flushed` tracker referenced inside flushBuffers is declared at module scope', () => {
|
||||
// Locate the flushBuffers function body. The function is `async function
|
||||
// flushBuffers() { ... }` — match through the closing brace at the start
|
||||
// of a line (one-level-deep function in the file).
|
||||
const fnMatch = SERVER_TS.match(
|
||||
/async function flushBuffers\([^)]*\)[^{]*\{([\s\S]*?)\n\}/,
|
||||
);
|
||||
expect(fnMatch, 'flushBuffers function not found in server.ts').not.toBeNull();
|
||||
const body = fnMatch![1]!;
|
||||
|
||||
// Pull every identifier matching the `lastXxxFlushed` cursor pattern.
|
||||
const trackerMatches = [...body.matchAll(/\blast([A-Z]\w+)Flushed\b/g)];
|
||||
const trackers = Array.from(new Set(trackerMatches.map((m) => `last${m[1]}Flushed`)));
|
||||
|
||||
expect(
|
||||
trackers.length,
|
||||
'flushBuffers should reference at least one last*Flushed tracker',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const tracker of trackers) {
|
||||
// Module-level `let X = 0;` declaration (not inside a function body).
|
||||
// Anchored start-of-line to avoid matching nested re-declarations or
|
||||
// string literals.
|
||||
const declared = new RegExp(
|
||||
`(?:^|\\n)let\\s+${tracker}\\s*=\\s*0\\s*;`,
|
||||
).test(SERVER_TS);
|
||||
expect(
|
||||
declared,
|
||||
`\`${tracker}\` is referenced inside flushBuffers but never declared at module scope ` +
|
||||
`with \`let ${tracker} = 0;\` — the interval will throw ReferenceError every tick ` +
|
||||
`and the corresponding on-disk log will never be written`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Regression: state-file temp path uniqueness.
|
||||
*
|
||||
* The daemon writes `.gstack/browse.json` via the standard atomic-rename
|
||||
* pattern: `writeFileSync(tmp, …) → renameSync(tmp, stateFile)`. The
|
||||
* pattern is correct for a single writer. It breaks for *concurrent*
|
||||
* writers when they share a single temp filename:
|
||||
*
|
||||
* t0 Writer A: writeFileSync(stateFile + '.tmp', payloadA)
|
||||
* t1 Writer B: writeFileSync(stateFile + '.tmp', payloadB) // overwrites A
|
||||
* t2 Writer A: renameSync(stateFile + '.tmp', stateFile) // moves B's payload
|
||||
* t3 Writer B: renameSync(stateFile + '.tmp', stateFile) // ENOENT — file gone
|
||||
*
|
||||
* A 15-CLI cold-start race against a fresh repo reproduces this in the
|
||||
* wild — one of the spawned daemons dies with:
|
||||
*
|
||||
* [browse] Failed to start: ENOENT: no such file or directory,
|
||||
* rename '…/.gstack/browse.json.tmp' -> '…/.gstack/browse.json'
|
||||
*
|
||||
* Fix: per-process temp path via `tmpStatePath()` (pid + 4 random bytes
|
||||
* of suffix). Each concurrent writer gets a unique path; the atomic
|
||||
* rename still gives last-writer-wins semantics on the final state file
|
||||
* content, but writers no longer kill each other on the rename step.
|
||||
*
|
||||
* This source-level guard locks two invariants:
|
||||
* 1. No remaining `stateFile + '.tmp'` literals in server.ts (regression
|
||||
* catch — a future copy-paste or revert would re-introduce the bug)
|
||||
* 2. The 4 known state-write call sites all use `tmpStatePath()`
|
||||
* (positive coverage)
|
||||
*
|
||||
* Same pattern as terminal-agent.test.ts and dual-listener.test.ts:
|
||||
* read source as text, assert invariant, no daemon required.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const SERVER_TS = readFileSync(
|
||||
path.resolve(import.meta.dir, '../src/server.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('server.ts — state-file temp-path uniqueness', () => {
|
||||
test('no remaining `stateFile + \'.tmp\'` literals (regression catch)', () => {
|
||||
// The shared-temp-filename pattern that caused the cold-start ENOENT
|
||||
// race. A future contributor that copy-pastes the old pattern (or a
|
||||
// revert) will fail this test.
|
||||
const sharedTempLiterals = [
|
||||
...SERVER_TS.matchAll(/stateFile\s*\+\s*['"`]\.tmp['"`]/g),
|
||||
];
|
||||
expect(
|
||||
sharedTempLiterals.length,
|
||||
`Found ${sharedTempLiterals.length} reference(s) to the shared ` +
|
||||
`\`stateFile + '.tmp'\` pattern. Use \`tmpStatePath()\` instead — ` +
|
||||
`the shared pattern races on rename when two daemons spawn ` +
|
||||
`concurrently (cold-start race + parallel /tunnel/start).`,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('every state-file writeFileSync call uses tmpStatePath()', () => {
|
||||
// Find every `writeFileSync(X, JSON.stringify(stateContent...` or
|
||||
// `…(state, …)` call and verify X is `tmpStatePath()` or a variable
|
||||
// assigned from `tmpStatePath()`.
|
||||
const writeCalls = [
|
||||
...SERVER_TS.matchAll(
|
||||
/fs\.writeFileSync\s*\(\s*(\w+)\s*,\s*JSON\.stringify\(\s*(state|stateContent)/g,
|
||||
),
|
||||
];
|
||||
expect(
|
||||
writeCalls.length,
|
||||
'expected at least one state-file write site',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const m of writeCalls) {
|
||||
const varName = m[1]!;
|
||||
// Walk back to the assignment of varName — must come from tmpStatePath()
|
||||
const assignRe = new RegExp(
|
||||
`(?:const|let)\\s+${varName}\\s*=\\s*tmpStatePath\\(\\)`,
|
||||
);
|
||||
expect(
|
||||
assignRe.test(SERVER_TS),
|
||||
`state-file writeFileSync uses \`${varName}\` but no \`const ${varName} = tmpStatePath()\` ` +
|
||||
`assignment was found upstream. Either assign from tmpStatePath() ` +
|
||||
`or pass tmpStatePath() inline — the shared \`stateFile + '.tmp'\` ` +
|
||||
`pattern races under concurrent daemon startup`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('tmpStatePath() declaration includes a per-process unique suffix', () => {
|
||||
// Lock the suffix shape so a future contributor doesn't accidentally
|
||||
// strip the uniqueness back out by simplifying the helper.
|
||||
const declMatch = SERVER_TS.match(
|
||||
/function tmpStatePath\(\)[^{]*\{([\s\S]*?)\n\}/,
|
||||
);
|
||||
expect(declMatch, 'tmpStatePath() declaration not found').not.toBeNull();
|
||||
const body = declMatch![1]!;
|
||||
|
||||
// Must reference both process.pid and crypto.randomBytes — two
|
||||
// independent sources of uniqueness.
|
||||
expect(body, 'tmpStatePath() must include process.pid in the suffix').toContain(
|
||||
'process.pid',
|
||||
);
|
||||
expect(
|
||||
body,
|
||||
'tmpStatePath() must include a random suffix via crypto.randomBytes',
|
||||
).toContain('crypto.randomBytes');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Regression: refMap must be cleared when an iframe detaches.
|
||||
*
|
||||
* `TabSession.getActiveFrameOrPage()` (tab-session.ts:151) auto-recovers
|
||||
* from detached iframes by setting `activeFrame = null` and silently
|
||||
* falling back to the main page. The asymmetric bug: the matching
|
||||
* `clearRefs()` call is missing.
|
||||
*
|
||||
* Compare to `onMainFrameNavigated()` (tab-session.ts:167) — the
|
||||
* staleness condition is equivalent (refs were captured against a frame
|
||||
* that no longer exists), and the main-frame path correctly clears both
|
||||
* the activeFrame AND the refMap:
|
||||
*
|
||||
* onMainFrameNavigated(): void {
|
||||
* this.clearRefs(); // ← clears refs
|
||||
* this.activeFrame = null;
|
||||
* this.loadedHtml = null;
|
||||
* this.loadedHtmlWaitUntil = undefined;
|
||||
* }
|
||||
*
|
||||
* getActiveFrameOrPage(): Page | Frame {
|
||||
* if (this.activeFrame?.isDetached()) {
|
||||
* this.activeFrame = null; // ← but no clearRefs() here
|
||||
* }
|
||||
* return this.activeFrame ?? this.page;
|
||||
* }
|
||||
*
|
||||
* The lazy click-time staleness check at `resolveRef` (tab-session.ts:97)
|
||||
* partially saves us — `entry.locator.count()` on a detached-frame
|
||||
* locator throws or returns 0, so a click against a stale ref errors out
|
||||
* with "Ref X is stale". But the user has no signal that frame context
|
||||
* silently changed underfoot: the next `snapshot` runs against
|
||||
* `this.page` (main) while old iframe refs still litter `refMap` with
|
||||
* the same role+name keys. New refs collide with stale ones, the
|
||||
* resolver picks one at random, the user clicks the wrong element.
|
||||
*
|
||||
* Behavior the test locks: when an iframe detaches and
|
||||
* `getActiveFrameOrPage()` auto-recovers, the refMap is cleared in the
|
||||
* same step (matching the `onMainFrameNavigated` symmetry). TODOS.md
|
||||
* line 816-820 documents "Detached frame auto-recovery" as a feature;
|
||||
* this restores the documented intent.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { TabSession, type RefEntry } from '../src/tab-session';
|
||||
import type { Page, Frame, Locator } from 'playwright';
|
||||
|
||||
// Minimal type-cast mocks. Same pattern as tab-isolation.test.ts —
|
||||
// pure-logic tests don't launch a browser.
|
||||
function mockPage(): Page {
|
||||
return {} as Page;
|
||||
}
|
||||
|
||||
function mockDetachedFrame(): Frame {
|
||||
return { isDetached: () => true } as unknown as Frame;
|
||||
}
|
||||
|
||||
function mockAttachedFrame(): Frame {
|
||||
return { isDetached: () => false } as unknown as Frame;
|
||||
}
|
||||
|
||||
function mockRefEntry(role: string, name: string): RefEntry {
|
||||
return {
|
||||
locator: {} as Locator,
|
||||
role,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
// Fresh refs Map per call — avoid by-reference mutation poisoning across
|
||||
// halves of the symmetry test (clearRefs() clears the same Map instance
|
||||
// the test holds a reference to).
|
||||
function makeRefs(): Map<string, RefEntry> {
|
||||
const r = new Map<string, RefEntry>();
|
||||
r.set('e1', mockRefEntry('button', 'Submit'));
|
||||
r.set('e2', mockRefEntry('textbox', 'Email'));
|
||||
r.set('e3', mockRefEntry('link', 'Forgot password'));
|
||||
return r;
|
||||
}
|
||||
|
||||
describe('TabSession — frame detach + ref staleness', () => {
|
||||
let session: TabSession;
|
||||
|
||||
beforeEach(() => {
|
||||
session = new TabSession(mockPage());
|
||||
session.setRefMap(makeRefs());
|
||||
});
|
||||
|
||||
test('refs cleared when getActiveFrameOrPage detects detached iframe', () => {
|
||||
// Pre-condition: refs captured inside an iframe context
|
||||
session.setFrame(mockDetachedFrame());
|
||||
expect(session.getRefCount()).toBe(3);
|
||||
|
||||
// Act: caller invokes getActiveFrameOrPage (e.g. via the next /command
|
||||
// dispatch). The detach gets noticed inside.
|
||||
const result = session.getActiveFrameOrPage();
|
||||
|
||||
// Auto-recovery: activeFrame nulled (already worked pre-fix)
|
||||
expect(session.getFrame()).toBeNull();
|
||||
|
||||
// The fix: refs ALSO cleared so the next snapshot runs against a
|
||||
// clean ref namespace. Pre-fix this was 3 — refs lingered against a
|
||||
// dead frame, colliding with refs the next snapshot would emit.
|
||||
expect(session.getRefCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('refs preserved when active frame is still attached', () => {
|
||||
// No regression on the happy path — attached frame should NOT
|
||||
// trigger the cleanup.
|
||||
session.setFrame(mockAttachedFrame());
|
||||
expect(session.getRefCount()).toBe(3);
|
||||
|
||||
session.getActiveFrameOrPage();
|
||||
|
||||
// Frame still set, refs still present.
|
||||
expect(session.getFrame()).not.toBeNull();
|
||||
expect(session.getRefCount()).toBe(3);
|
||||
});
|
||||
|
||||
test('refs preserved when no frame is set (page-level snapshot)', () => {
|
||||
// No frame ever set → the if-branch never enters → refs untouched.
|
||||
expect(session.getFrame()).toBeNull();
|
||||
expect(session.getRefCount()).toBe(3);
|
||||
|
||||
session.getActiveFrameOrPage();
|
||||
|
||||
expect(session.getRefCount()).toBe(3);
|
||||
});
|
||||
|
||||
test('matches onMainFrameNavigated symmetry (refs+frame both cleared)', () => {
|
||||
// Pin the design symmetry: both staleness paths (main-frame nav AND
|
||||
// iframe detach) must clear both pieces of state together. If a
|
||||
// future refactor splits these, the test fails before merge.
|
||||
session.setFrame(mockDetachedFrame());
|
||||
expect(session.getRefCount()).toBe(3);
|
||||
|
||||
session.onMainFrameNavigated();
|
||||
|
||||
expect(session.getFrame()).toBeNull();
|
||||
expect(session.getRefCount()).toBe(0);
|
||||
|
||||
// Reset with a FRESH Map (the previous one was emptied by clearRefs
|
||||
// by-reference) and exercise the iframe-detach path. End state must
|
||||
// match.
|
||||
session.setRefMap(makeRefs());
|
||||
session.setFrame(mockDetachedFrame());
|
||||
expect(session.getRefCount()).toBe(3);
|
||||
|
||||
session.getActiveFrameOrPage();
|
||||
|
||||
expect(session.getFrame()).toBeNull();
|
||||
expect(session.getRefCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user