mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-05 23:57:53 +02:00
Merge origin/main into gbrowser-anti-detection
Brings the branch up to date with main (v1.40.0.2 -> v1.58.1.0). Conflict resolutions: - VERSION: take main's 1.58.1.0 (branch re-bumps at /ship time). - CHANGELOG.md: keep main's full history; slot the branch's unique v1.40.0.2 entry into descending-order position (no content lost). - browse/src/browser-manager.ts: keep main's GSTACK_CHROMIUM_NO_SANDBOX override and onDisconnect(exitCode) signature; branch's buildGStackLaunchArgs / STEALTH_IGNORE_DEFAULT_ARGS wiring preserved. - browse/test/browser-manager-unit.test.ts: keep main's override + exit-code propagation tests alongside the branch's Cmd+Q cause-resolver tests. - browse/src/stealth.ts: blend the two stealth designs. Layer C (buildStealthScript) is the always-on consistency-first default; main's GSTACK_STEALTH=extended (EXTENDED_STEALTH_SCRIPT) remains an opt-in layer applied on top. Both public APIs and both test suites (stealth-layer-c + stealth-extended) preserved; the two applyStealth wiring assertions updated to reflect the Layer C default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+97
-16
@@ -2,13 +2,7 @@
|
||||
name: browse
|
||||
preamble-tier: 1
|
||||
version: 1.1.0
|
||||
description: |
|
||||
Fast headless browser for QA testing and site dogfooding. Navigate any URL, interact with
|
||||
elements, verify page state, diff before/after actions, take annotated screenshots, check
|
||||
responsive layouts, test forms and uploads, handle dialogs, and assert element states.
|
||||
~100ms per command. Use when you need to test a feature, verify a deployment, dogfood a
|
||||
user flow, or file a bug with evidence. Use when asked to "open in browser", "test the
|
||||
site", "take a screenshot", or "dogfood this". (gstack)
|
||||
description: Fast headless browser for QA testing and site dogfooding. (gstack)
|
||||
triggers:
|
||||
- browse a page
|
||||
- headless browser
|
||||
@@ -22,6 +16,16 @@ allowed-tools:
|
||||
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
|
||||
<!-- Regenerate: bun run gen:skill-docs -->
|
||||
|
||||
|
||||
## When to invoke this skill
|
||||
|
||||
Navigate any URL, interact with
|
||||
elements, verify page state, diff before/after actions, take annotated screenshots, check
|
||||
responsive layouts, test forms and uploads, handle dialogs, and assert element states.
|
||||
~100ms per command. Use when you need to test a feature, verify a deployment, dogfood a
|
||||
user flow, or file a bug with evidence. Use when asked to "open in browser", "test the
|
||||
site", "take a screenshot", or "dogfood this".
|
||||
|
||||
## Preamble (run first)
|
||||
|
||||
```bash
|
||||
@@ -42,6 +46,16 @@ echo "SKILL_PREFIX: $_SKILL_PREFIX"
|
||||
source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true
|
||||
REPO_MODE=${REPO_MODE:-unknown}
|
||||
echo "REPO_MODE: $REPO_MODE"
|
||||
_SESSION_KIND=$(~/.claude/skills/gstack/bin/gstack-session-kind 2>/dev/null || echo "interactive")
|
||||
case "$_SESSION_KIND" in spawned|headless|interactive) ;; *) _SESSION_KIND="interactive" ;; esac
|
||||
echo "SESSION_KIND: $_SESSION_KIND"
|
||||
# Conductor host: AskUserQuestion is unreliable here (native disabled, MCP
|
||||
# variant flaky), so skills render decisions as prose instead of calling the
|
||||
# tool. Gated on !headless so an eval/CI run INSIDE Conductor (GSTACK_HEADLESS)
|
||||
# still BLOCKs rather than rendering prose to nobody.
|
||||
if [ "$_SESSION_KIND" != "headless" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then
|
||||
echo "CONDUCTOR_SESSION: true"
|
||||
fi
|
||||
_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no")
|
||||
echo "LAKE_INTRO: $_LAKE_SEEN"
|
||||
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true)
|
||||
@@ -57,7 +71,7 @@ _QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
echo '{"skill":"browse","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
echo '{"skill":"browse","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
fi
|
||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||
if [ -f "$_PF" ]; then
|
||||
@@ -99,6 +113,19 @@ _CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode
|
||||
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
|
||||
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
|
||||
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
|
||||
# Plan-mode hint for skills like /spec that branch behavior on plan-mode state.
|
||||
# Claude Code exposes plan mode via system reminders; we detect best-effort
|
||||
# from CLAUDE_PLAN_FILE (set by the harness when plan mode is active) and
|
||||
# fall back to "inactive". Codex hosts and Claude execution mode both end up
|
||||
# inactive, which is the safe default (defaults to file+execute pipeline).
|
||||
if [ -n "${CLAUDE_PLAN_FILE:-}${GSTACK_PLAN_MODE_FORCE:-}" ]; then
|
||||
export GSTACK_PLAN_MODE="active"
|
||||
elif [ "${GSTACK_PLAN_MODE:-}" = "active" ]; then
|
||||
export GSTACK_PLAN_MODE="active"
|
||||
else
|
||||
export GSTACK_PLAN_MODE="inactive"
|
||||
fi
|
||||
echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE"
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
|
||||
```
|
||||
|
||||
@@ -108,7 +135,7 @@ In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`co
|
||||
|
||||
## Skill Invocation During Plan Mode
|
||||
|
||||
If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If no variant is callable, the skill is BLOCKED — stop and report `BLOCKED — AskUserQuestion unavailable` per the AskUserQuestion Format rule. At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
|
||||
If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
|
||||
|
||||
If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?"
|
||||
|
||||
@@ -143,7 +170,7 @@ touch ~/.gstack/.writing-style-prompted
|
||||
|
||||
Skip if `WRITING_STYLE_PENDING` is `no`.
|
||||
|
||||
If `LAKE_INTRO` is `no`: say "gstack follows the **Boil the Lake** principle — do the complete thing when AI makes marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean" Offer to open:
|
||||
If `LAKE_INTRO` is `no`: say "gstack follows the **Boil the Ocean** principle — do the complete thing when AI makes marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean" Offer to open:
|
||||
|
||||
```bash
|
||||
open https://garryslist.org/posts/boil-the-ocean
|
||||
@@ -154,7 +181,7 @@ Only run `open` if yes. Always run `touch`.
|
||||
|
||||
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
|
||||
|
||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
|
||||
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
|
||||
|
||||
Options:
|
||||
- A) Help gstack get better! (recommended)
|
||||
@@ -230,6 +257,7 @@ Key routing rules:
|
||||
- Ship/deploy/PR → invoke /ship or /land-and-deploy
|
||||
- Save progress → invoke /context-save
|
||||
- Resume context → invoke /context-restore
|
||||
- Author a backlog-ready spec/issue → invoke /spec
|
||||
```
|
||||
|
||||
Then commit the change: `git add CLAUDE.md && git commit -m "chore: add gstack skill routing rules to CLAUDE.md"`
|
||||
@@ -474,9 +502,7 @@ Replace `SKILL_NAME`, `OUTCOME`, and `USED_BROWSE` before running.
|
||||
|
||||
## Plan Status Footer
|
||||
|
||||
In plan mode before ExitPlanMode: if the plan file lacks `## GSTACK REVIEW REPORT`, run `~/.claude/skills/gstack/bin/gstack-review-read` and append the standard runs/status/findings table. With `NO_REVIEWS` or empty, append a 5-row placeholder with verdict "NO REVIEWS YET — run `/autoplan`". If a richer report exists, skip.
|
||||
|
||||
PLAN MODE EXCEPTION — always allowed (it's the plan file).
|
||||
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
|
||||
|
||||
# browse: QA Testing & Dogfooding
|
||||
|
||||
@@ -625,6 +651,51 @@ $B screenshot /tmp/out.png --selector .tweet-card
|
||||
```
|
||||
Scale must be 1-3 (gstack policy cap). Changing `--scale` recreates the browser context; refs from `snapshot` are invalidated (rerun `snapshot`), but `load-html` content is replayed automatically. Not supported in headed mode.
|
||||
|
||||
### 14. Offline render mode (rasterize your own HTML/JSON, zero network)
|
||||
|
||||
This is the blessed path for "I just want to turn my own local HTML or JSON into a
|
||||
PNG/PDF/bytes on disk" — Excalidraw diagrams, tweet/quote cards, og-images,
|
||||
report rasterization. It is **plain headless, shared Chromium, no proxy, no Xvfb,
|
||||
no anti-bot stealth**. Default `$B` is already exactly this; you do not pass
|
||||
`--headed` or `--proxy`. One Chromium per box, shared by every skill — **do not
|
||||
`npm i puppeteer` and ship a second browser** (see the note under the cheatsheet).
|
||||
|
||||
Two output shapes, pick by what you have:
|
||||
|
||||
**A) Visual output → `screenshot --selector` (preferred).** If the thing you want
|
||||
is a picture of something on the page, screenshot it. The PNG is written from the
|
||||
browser process straight to disk — the image bytes never cross the CDP wire.
|
||||
|
||||
```bash
|
||||
echo '<div id="card" style="width:400px;height:200px;background:#1da1f2;color:#fff;padding:20px">hi</div>' > /tmp/card.html
|
||||
$B viewport 480x600 --scale 2
|
||||
$B load-html /tmp/card.html
|
||||
$B screenshot /tmp/card.png --selector '#card' # disk path — no megabytes over CDP
|
||||
```
|
||||
(Use the disk path, NOT `screenshot --base64` — base64 serializes the bytes back
|
||||
through the command channel, which is the cost you're trying to avoid.)
|
||||
|
||||
**B) Bytes a function returns → `js --out` / `eval --out`.** When a library hands
|
||||
you the result as a return value (a base64 data URL, a blob, computed JSON) rather
|
||||
than painting a stable element — e.g. Excalidraw's export function returns a PNG
|
||||
data URL — write the evaluate result straight to disk. `--out` decodes a
|
||||
`data:*;base64,...` result to raw bytes automatically (pass `--raw` to write the
|
||||
literal string). The payload is written by the daemon and never serialized back
|
||||
out to the CLI/stdout.
|
||||
|
||||
```bash
|
||||
# Load the render bundle, signal readiness, then render-to-file.
|
||||
$B load-html /tmp/excalidraw-export.html # bundle sets window.__render + a #done flag
|
||||
$B wait '#done' # deterministic ready handshake
|
||||
$B js "window.__render(SCENE_JSON)" --out /tmp/diagram.png # data URL → decoded PNG on disk
|
||||
```
|
||||
|
||||
`--out` is a WRITE: it needs the `write` scope and is never allowed over the
|
||||
pair-agent tunnel (a remote agent can't write to your disk). Parent directories
|
||||
are created; malformed base64 errors instead of writing corrupt bytes. Pick A when
|
||||
you can (no CDP transfer at all); reach for B only when the bytes come back as a
|
||||
return value.
|
||||
|
||||
## Puppeteer → browse cheatsheet
|
||||
|
||||
Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow:
|
||||
@@ -638,6 +709,8 @@ Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow:
|
||||
| `await (await page.$('.x')).screenshot({path})` | `$B screenshot <path> --selector .x` |
|
||||
| `await page.screenshot({fullPage: true, path})` | `$B screenshot <path>` (full page default) |
|
||||
| `await page.screenshot({clip: {x, y, w, h}, path})` | `$B screenshot <path> --clip x,y,w,h` |
|
||||
| `const r = await page.evaluate(fn)` | `$B js "<expr>"` (result to stdout) |
|
||||
| `fs.writeFileSync(out, Buffer.from(dataUrl.split(',')[1],'base64'))` | `$B js "<expr>" --out <file>` (data URL auto-decoded) |
|
||||
|
||||
Worked example (the tweet-renderer flow — Puppeteer → browse):
|
||||
|
||||
@@ -652,6 +725,13 @@ $B screenshot /tmp/out.png --selector .tweet-card
|
||||
|
||||
Aliases: typing `setcontent` or `set-content` routes to `load-html` automatically. Typing a typo (`load-htm`) returns `Did you mean 'load-html'?`.
|
||||
|
||||
**Don't bundle your own puppeteer/Chromium.** `browse` is the one shared Chromium
|
||||
per box. Skills that need to rasterize local HTML/JSON (diagrams, cards, og-images)
|
||||
should route through `browse` — `screenshot --selector` for visual output,
|
||||
`load-html` + `js --out` for bytes a function returns — instead of
|
||||
`npm i puppeteer` and downloading a second Chromium that drifts out of version sync.
|
||||
One install to pin, one daemon's lifecycle to manage.
|
||||
|
||||
## User Handoff
|
||||
|
||||
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
||||
@@ -856,10 +936,10 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
|
||||
| `cookies` | All cookies as JSON |
|
||||
| `css <sel> <prop>` | Computed CSS value |
|
||||
| `dialog [--clear]` | Dialog messages |
|
||||
| `eval <file>` | Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners. |
|
||||
| `eval <file> [--out <file>] [--raw]` | Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners. With --out <file>, the result is written to disk (base64 data URL decoded to bytes unless --raw); --out makes the invocation a WRITE (needs write scope, never allowed over the tunnel). |
|
||||
| `inspect [selector] [--all] [--history]` | Deep CSS inspection via CDP — full rule cascade, box model, computed styles |
|
||||
| `is <prop> <sel|@ref>` | State check on element. Valid <prop> values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). <sel> accepts a CSS selector OR an @ref token from a prior snapshot (e.g. @e3, @c1) — refs are interchangeable with selectors anywhere a selector is expected. |
|
||||
| `js <expr>` | Run inline JavaScript expression in the page context and return result as string. Same JS sandbox as eval; the only difference is js takes an inline expr while eval reads from a file. |
|
||||
| `js <expr> [--out <file>] [--raw]` | Run inline JavaScript expression in the page context and return result as string. Same JS sandbox as eval; the only difference is js takes an inline expr while eval reads from a file. With --out <file>, the result is written to disk instead of returned (a base64 data URL is decoded to raw bytes unless --raw is given) — ideal for rasterizing local renders to PNG without serializing megabytes back through the CLI. --out makes the invocation a WRITE (needs write scope, never allowed over the tunnel). |
|
||||
| `network [--clear]` | Network requests |
|
||||
| `perf` | Page load timings |
|
||||
| `storage | storage set <key> <value>` | Read both localStorage and sessionStorage as JSON. With "set <key> <value>", write to localStorage only (sessionStorage is read-only via this command — set it with `js sessionStorage.setItem(...)`). |
|
||||
@@ -905,6 +985,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
|
||||
| `disconnect` | Disconnect headed browser, return to headless mode |
|
||||
| `focus [@ref]` | Bring headed browser window to foreground (macOS) |
|
||||
| `handoff [message]` | Open visible Chrome at current page for user takeover |
|
||||
| `memory [--json]` | Snapshot Bun heap + per-tab JS heap + Chromium process tree + bounded buffer sizes. JSON output with --json. |
|
||||
| `restart` | Restart server |
|
||||
| `resume` | Re-snapshot after user takeover, return control to AI |
|
||||
| `state save|load <name>` | Save/load browser state (cookies + URLs) |
|
||||
|
||||
@@ -135,6 +135,51 @@ $B screenshot /tmp/out.png --selector .tweet-card
|
||||
```
|
||||
Scale must be 1-3 (gstack policy cap). Changing `--scale` recreates the browser context; refs from `snapshot` are invalidated (rerun `snapshot`), but `load-html` content is replayed automatically. Not supported in headed mode.
|
||||
|
||||
### 14. Offline render mode (rasterize your own HTML/JSON, zero network)
|
||||
|
||||
This is the blessed path for "I just want to turn my own local HTML or JSON into a
|
||||
PNG/PDF/bytes on disk" — Excalidraw diagrams, tweet/quote cards, og-images,
|
||||
report rasterization. It is **plain headless, shared Chromium, no proxy, no Xvfb,
|
||||
no anti-bot stealth**. Default `$B` is already exactly this; you do not pass
|
||||
`--headed` or `--proxy`. One Chromium per box, shared by every skill — **do not
|
||||
`npm i puppeteer` and ship a second browser** (see the note under the cheatsheet).
|
||||
|
||||
Two output shapes, pick by what you have:
|
||||
|
||||
**A) Visual output → `screenshot --selector` (preferred).** If the thing you want
|
||||
is a picture of something on the page, screenshot it. The PNG is written from the
|
||||
browser process straight to disk — the image bytes never cross the CDP wire.
|
||||
|
||||
```bash
|
||||
echo '<div id="card" style="width:400px;height:200px;background:#1da1f2;color:#fff;padding:20px">hi</div>' > /tmp/card.html
|
||||
$B viewport 480x600 --scale 2
|
||||
$B load-html /tmp/card.html
|
||||
$B screenshot /tmp/card.png --selector '#card' # disk path — no megabytes over CDP
|
||||
```
|
||||
(Use the disk path, NOT `screenshot --base64` — base64 serializes the bytes back
|
||||
through the command channel, which is the cost you're trying to avoid.)
|
||||
|
||||
**B) Bytes a function returns → `js --out` / `eval --out`.** When a library hands
|
||||
you the result as a return value (a base64 data URL, a blob, computed JSON) rather
|
||||
than painting a stable element — e.g. Excalidraw's export function returns a PNG
|
||||
data URL — write the evaluate result straight to disk. `--out` decodes a
|
||||
`data:*;base64,...` result to raw bytes automatically (pass `--raw` to write the
|
||||
literal string). The payload is written by the daemon and never serialized back
|
||||
out to the CLI/stdout.
|
||||
|
||||
```bash
|
||||
# Load the render bundle, signal readiness, then render-to-file.
|
||||
$B load-html /tmp/excalidraw-export.html # bundle sets window.__render + a #done flag
|
||||
$B wait '#done' # deterministic ready handshake
|
||||
$B js "window.__render(SCENE_JSON)" --out /tmp/diagram.png # data URL → decoded PNG on disk
|
||||
```
|
||||
|
||||
`--out` is a WRITE: it needs the `write` scope and is never allowed over the
|
||||
pair-agent tunnel (a remote agent can't write to your disk). Parent directories
|
||||
are created; malformed base64 errors instead of writing corrupt bytes. Pick A when
|
||||
you can (no CDP transfer at all); reach for B only when the bytes come back as a
|
||||
return value.
|
||||
|
||||
## Puppeteer → browse cheatsheet
|
||||
|
||||
Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow:
|
||||
@@ -148,6 +193,8 @@ Migrating from Puppeteer? Here's the 1:1 mapping for the core workflow:
|
||||
| `await (await page.$('.x')).screenshot({path})` | `$B screenshot <path> --selector .x` |
|
||||
| `await page.screenshot({fullPage: true, path})` | `$B screenshot <path>` (full page default) |
|
||||
| `await page.screenshot({clip: {x, y, w, h}, path})` | `$B screenshot <path> --clip x,y,w,h` |
|
||||
| `const r = await page.evaluate(fn)` | `$B js "<expr>"` (result to stdout) |
|
||||
| `fs.writeFileSync(out, Buffer.from(dataUrl.split(',')[1],'base64'))` | `$B js "<expr>" --out <file>` (data URL auto-decoded) |
|
||||
|
||||
Worked example (the tweet-renderer flow — Puppeteer → browse):
|
||||
|
||||
@@ -162,6 +209,13 @@ $B screenshot /tmp/out.png --selector .tweet-card
|
||||
|
||||
Aliases: typing `setcontent` or `set-content` routes to `load-html` automatically. Typing a typo (`load-htm`) returns `Did you mean 'load-html'?`.
|
||||
|
||||
**Don't bundle your own puppeteer/Chromium.** `browse` is the one shared Chromium
|
||||
per box. Skills that need to rasterize local HTML/JSON (diagrams, cards, og-images)
|
||||
should route through `browse` — `screenshot --selector` for visual output,
|
||||
`load-html` + `js --out` for bytes a function returns — instead of
|
||||
`npm i puppeteer` and downloading a second Chromium that drifts out of version sync.
|
||||
One install to pin, one daemon's lifecycle to manage.
|
||||
|
||||
## User Handoff
|
||||
|
||||
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
||||
|
||||
+211
-21
@@ -18,9 +18,12 @@
|
||||
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||
import { emitActivity } from './activity';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import { TabSession, type RefEntry } from './tab-session';
|
||||
import { resolveChromiumProfile, cleanSingletonLocks } from './config';
|
||||
import { withCdpSession } from './cdp-bridge';
|
||||
import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot';
|
||||
|
||||
/**
|
||||
* Detect whether GSTACK_CHROMIUM_PATH points at a custom Chromium build that
|
||||
@@ -59,6 +62,13 @@ export function isCustomChromium(): boolean {
|
||||
*/
|
||||
export function shouldEnableChromiumSandbox(): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
// Explicit user override for Ubuntu/AppArmor and similar environments where
|
||||
// unprivileged Chromium sandboxing is blocked even for normal users (the
|
||||
// sandbox needs unprivileged user namespaces that the host policy denies,
|
||||
// so /qa hangs without --no-sandbox). Setting GSTACK_CHROMIUM_NO_SANDBOX=1
|
||||
// forces the sandbox off without changing the default for everyone else.
|
||||
// See #1562.
|
||||
if (process.env.GSTACK_CHROMIUM_NO_SANDBOX === '1') return false;
|
||||
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
return !(process.env.CI || process.env.CONTAINER || isRoot);
|
||||
}
|
||||
@@ -187,11 +197,60 @@ export class BrowserManager {
|
||||
private connectionMode: 'launched' | 'headed' = 'launched';
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
// ─── Tab Count Guardrail (D5 + Codex single-tab flag) ───────
|
||||
// Idempotent threshold trackers: each guardrail fires exactly once per
|
||||
// upward crossing of its threshold and re-arms when the tab count drops
|
||||
// back below. Pre-guardrail, nothing tracked tab count growth and a
|
||||
// user could accumulate hundreds of tabs (each holding 50–300 MB of
|
||||
// Chromium-side RSS) without warning until the OS OOM-killer fired.
|
||||
// The toast UX lives in the sidebar (extension/sidepanel.js); the
|
||||
// server-side responsibility is the audit-trail activity entry that
|
||||
// appears in the activity feed even when the sidebar is closed.
|
||||
private static readonly TAB_GUARDRAIL_SOFT = 50;
|
||||
private static readonly TAB_GUARDRAIL_HARD = 200;
|
||||
private tabGuardrailSoftHit = false;
|
||||
private tabGuardrailHardHit = false;
|
||||
|
||||
/**
|
||||
* Called from context.on('page') after a new tab is tracked. Emits at
|
||||
* most one activity entry per upward crossing of each threshold.
|
||||
*/
|
||||
private checkTabGuardrails(): void {
|
||||
const total = this.pages.size;
|
||||
if (!this.tabGuardrailSoftHit && total >= BrowserManager.TAB_GUARDRAIL_SOFT) {
|
||||
this.tabGuardrailSoftHit = true;
|
||||
const msg = `Tab count crossed ${BrowserManager.TAB_GUARDRAIL_SOFT} (now ${total}). Consider closing unused tabs — each Chromium tab holds 50–300 MB.`;
|
||||
console.warn(`[browse] ${msg}`);
|
||||
emitActivity({ type: 'error', command: 'tab-guardrail', error: msg, tabs: total });
|
||||
}
|
||||
if (!this.tabGuardrailHardHit && total >= BrowserManager.TAB_GUARDRAIL_HARD) {
|
||||
this.tabGuardrailHardHit = true;
|
||||
const msg = `Tab count crossed ${BrowserManager.TAB_GUARDRAIL_HARD} (now ${total}). OOM risk imminent. Open the sidebar to see top RAM consumers.`;
|
||||
console.error(`[browse] ${msg}`);
|
||||
emitActivity({ type: 'error', command: 'tab-guardrail', error: msg, tabs: total });
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from page.on('close') so the guardrails re-arm. */
|
||||
private recheckTabGuardrailsOnClose(): void {
|
||||
const total = this.pages.size;
|
||||
if (this.tabGuardrailSoftHit && total < BrowserManager.TAB_GUARDRAIL_SOFT) {
|
||||
this.tabGuardrailSoftHit = false;
|
||||
}
|
||||
if (this.tabGuardrailHardHit && total < BrowserManager.TAB_GUARDRAIL_HARD) {
|
||||
this.tabGuardrailHardHit = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Called when the headed browser disconnects without intentional teardown
|
||||
// (user closed the window). Wired up by server.ts to run full cleanup
|
||||
// (sidebar-agent, state file, profile locks) before exiting with code 2.
|
||||
// Returns void or a Promise; rejections are caught and fall back to exit(2).
|
||||
public onDisconnect: (() => void | Promise<void>) | null = null;
|
||||
// `exitCode` is the resolved process exit code from the disconnect cause:
|
||||
// 0 on clean user-initiated quit (e.g., Cmd+Q on headed Chromium), 2 on
|
||||
// crash/signal-kill. Callers (server.ts) forward it to their shutdown
|
||||
// pipeline so process supervisors (gbrowser's gbd) read the right signal.
|
||||
public onDisconnect: ((exitCode?: number) => void | Promise<void>) | null = null;
|
||||
|
||||
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
|
||||
|
||||
@@ -296,12 +355,16 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
if (extensionsDir) {
|
||||
launchArgs.push(
|
||||
`--disable-extensions-except=${extensionsDir}`,
|
||||
`--load-extension=${extensionsDir}`,
|
||||
'--window-position=-9999,-9999',
|
||||
'--window-size=1,1',
|
||||
);
|
||||
// Skip --load-extension when running against a custom Chromium build that
|
||||
// already bakes the extension in (e.g., GBrowser / GStack Browser.app).
|
||||
// Loading it twice causes a ServiceWorkerState::SetWorkerId DCHECK crash.
|
||||
if (!isCustomChromium()) {
|
||||
launchArgs.push(
|
||||
`--disable-extensions-except=${extensionsDir}`,
|
||||
`--load-extension=${extensionsDir}`,
|
||||
);
|
||||
}
|
||||
launchArgs.push('--window-position=-9999,-9999', '--window-size=1,1');
|
||||
useHeadless = false; // extensions require headed mode; off-screen window simulates headless
|
||||
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
|
||||
}
|
||||
@@ -621,6 +684,7 @@ export class BrowserManager {
|
||||
// Inject indicator on the new tab
|
||||
page.evaluate(indicatorScript).catch(() => {});
|
||||
console.log(`[browse] New tab detected (id=${id}, total=${this.pages.size})`);
|
||||
this.checkTabGuardrails();
|
||||
});
|
||||
|
||||
// Persistent context opens a default page — adopt it instead of creating a new one
|
||||
@@ -666,7 +730,7 @@ export class BrowserManager {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = this.onDisconnect();
|
||||
const result = this.onDisconnect(exitCode);
|
||||
if (result && typeof (result as Promise<void>).catch === 'function') {
|
||||
(result as Promise<void>).catch((err) => {
|
||||
console.error('[browse] onDisconnect rejected:', err);
|
||||
@@ -1005,6 +1069,116 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic for `$B memory` and the /memory endpoint.
|
||||
*
|
||||
* Collects:
|
||||
* - Bun process memory (cross-platform, accurate, no shelling).
|
||||
* - Per-tab JS heap via CDP Performance.getMetrics — the most portable
|
||||
* per-tab signal CDP exposes. Misses native/GPU/Skia/cache memory
|
||||
* (Codex flag on the eng-review; see follow-up TODO "native/GPU
|
||||
* memory breakdown").
|
||||
* - Chromium process tree via SystemInfo.getProcessInfo — PID + type
|
||||
* + CPU time. Per-process RSS is NOT exposed via CDP and the eng
|
||||
* review (D2 USE_CDP) explicitly chose CDP over shelling to `ps`,
|
||||
* so RSS columns are absent and `notes[]` says why.
|
||||
*
|
||||
* `structures` is passed in by the caller (read-commands / server) so
|
||||
* browser-manager doesn't take a hard dep on every buffer-owning module.
|
||||
*/
|
||||
async getMemorySnapshot(structures: MemoryStructureStats): Promise<MemorySnapshot> {
|
||||
const bunMem = process.memoryUsage();
|
||||
const notes: string[] = [];
|
||||
|
||||
// Per-tab JS heap. Lazy: only the pages we already track. A target
|
||||
// that died mid-snapshot is omitted, never throws.
|
||||
const tabs: MemoryTabSnapshot[] = [];
|
||||
for (const [id, page] of this.pages) {
|
||||
try {
|
||||
const url = (() => { try { return page.url(); } catch { return ''; } })();
|
||||
const title = await page.title().catch(() => '');
|
||||
const metrics = await withCdpSession(page, async (session) => {
|
||||
await session.send('Performance.enable').catch(() => undefined);
|
||||
const result = await session.send('Performance.getMetrics');
|
||||
return ((result as { metrics?: Array<{ name: string; value: number }> }).metrics) ?? [];
|
||||
});
|
||||
const mm: Record<string, number> = {};
|
||||
for (const m of metrics) mm[m.name] = m.value;
|
||||
tabs.push({
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
jsHeapUsed: mm.JSHeapUsedSize ?? 0,
|
||||
jsHeapTotal: mm.JSHeapTotalSize ?? 0,
|
||||
documents: mm.Documents ?? 0,
|
||||
nodes: mm.Nodes ?? 0,
|
||||
listeners: mm.JSEventListeners ?? 0,
|
||||
});
|
||||
} catch {
|
||||
// Target died or CDP unavailable mid-snapshot — skip this tab.
|
||||
}
|
||||
}
|
||||
|
||||
// Chromium process tree. Browser handle may be on the `browser` field
|
||||
// (launched mode) or accessible via `context.browser()` (persistent
|
||||
// context / headed mode); try both.
|
||||
let processes: MemoryProcess[] | null = null;
|
||||
const browser: Browser | null = this.browser ?? (this.context ? this.context.browser() : null);
|
||||
if (browser) {
|
||||
try {
|
||||
// `newBrowserCDPSession` is browser-wide. Not exposed on every
|
||||
// Playwright TypeScript surface, but present at runtime on the
|
||||
// Browser instance — use a typed cast to avoid the @ts-expect-error.
|
||||
type BrowserWithCDP = Browser & {
|
||||
newBrowserCDPSession?: () => Promise<{
|
||||
send: (method: string, params?: unknown) => Promise<unknown>;
|
||||
detach: () => Promise<void>;
|
||||
}>;
|
||||
};
|
||||
const maybeFactory = (browser as BrowserWithCDP).newBrowserCDPSession;
|
||||
if (typeof maybeFactory === 'function') {
|
||||
const browserSession = await maybeFactory.call(browser);
|
||||
try {
|
||||
const info = (await browserSession.send('SystemInfo.getProcessInfo')) as {
|
||||
processInfo?: Array<{ id: number; type: string; cpuTime: number }>;
|
||||
};
|
||||
processes = (info.processInfo ?? []).map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
cpuTime: p.cpuTime,
|
||||
}));
|
||||
notes.push(
|
||||
'Per-Chromium-process RSS not collected — SystemInfo.getProcessInfo exposes PID+type+CPU only. ' +
|
||||
'See follow-up TODO "native/GPU memory breakdown" for the deferred fix.',
|
||||
);
|
||||
} finally {
|
||||
await browserSession.detach().catch(() => undefined);
|
||||
}
|
||||
} else {
|
||||
notes.push('Playwright build does not expose newBrowserCDPSession; per-process info skipped.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
notes.push(`CDP browser session unavailable: ${err?.message ?? String(err)}`);
|
||||
}
|
||||
} else {
|
||||
notes.push('Browser handle unavailable (server connection mode); per-process info skipped.');
|
||||
}
|
||||
|
||||
return {
|
||||
bunServer: {
|
||||
rss: bunMem.rss,
|
||||
heapUsed: bunMem.heapUsed,
|
||||
heapTotal: bunMem.heapTotal,
|
||||
external: bunMem.external,
|
||||
},
|
||||
tabs,
|
||||
processes,
|
||||
structures,
|
||||
capturedAt: Date.now(),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Ref Map (delegates to active session) ──────────────────
|
||||
setRefMap(refs: Map<string, RefEntry>) {
|
||||
this.getActiveSession().setRefMap(refs);
|
||||
@@ -1533,6 +1707,7 @@ export class BrowserManager {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.recheckTabGuardrailsOnClose();
|
||||
});
|
||||
|
||||
// Clear ref map on navigation — refs point to stale elements after page change
|
||||
@@ -1601,23 +1776,38 @@ export class BrowserManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Capture response sizes via response finished
|
||||
// Capture response sizes via requestfinished — but DO NOT call
|
||||
// response.body() here. Pre-fix, this listener materialized every
|
||||
// response body across CDP just to read .length: multi-GB/hour of
|
||||
// Buffer churn on long-lived headed Chromium with media-heavy
|
||||
// pages, the primary Bun-side accelerant on the gbrowser-OOM
|
||||
// investigation. req.sizes() pulls from the Network.loadingFinished
|
||||
// event Chromium already emits — accurate for chunked transfer,
|
||||
// gzip-compressed responses, and streaming media, all the cases
|
||||
// where the previous Content-Length-header approach would have
|
||||
// missed the size.
|
||||
//
|
||||
// The "single context-level CDP listener" architecture (D10's
|
||||
// stretch goal — would reduce per-page listener count from N to 1
|
||||
// via Target.setAutoAttach) is deferred. TODOS.md tracks it.
|
||||
page.on('requestfinished', async (req) => {
|
||||
try {
|
||||
const res = await req.response();
|
||||
if (res) {
|
||||
const url = req.url();
|
||||
const body = await res.body().catch(() => null);
|
||||
const size = body ? body.length : 0;
|
||||
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
||||
const entry = networkBuffer.get(i);
|
||||
if (entry && entry.url === url && !entry.size) {
|
||||
networkBuffer.set(i, { ...entry, size });
|
||||
break;
|
||||
}
|
||||
const sizes = await req.sizes().catch(() => null);
|
||||
if (!sizes) return;
|
||||
const url = req.url();
|
||||
const size = sizes.responseBodySize ?? 0;
|
||||
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
||||
const entry = networkBuffer.get(i);
|
||||
if (entry && entry.url === url && !entry.size) {
|
||||
networkBuffer.set(i, { ...entry, size });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Best-effort: requestfinished fires for aborted/cached requests too,
|
||||
// where sizes() is unavailable. Missing size is acceptable; an
|
||||
// unbounded throw would noise the console for every cache hit.
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,18 +25,84 @@ import { logTelemetry } from './telemetry';
|
||||
const CDP_TIMEOUT_MS = 5000;
|
||||
const CDP_ACQUIRE_TIMEOUT_MS = 5000;
|
||||
|
||||
// Per-page CDPSession cache. Created lazily on first allow-listed call,
|
||||
// cleaned up when the page closes.
|
||||
// ─── CDP session lifecycle helpers ─────────────────────────────
|
||||
//
|
||||
// Every direct `newCDPSession(page)` call needs a matching `session.detach()`
|
||||
// to release the Chromium-side CDP target. Forgetting the detach leaves the
|
||||
// target attached until the underlying transport drops (often process exit),
|
||||
// which on a long-lived headed browser shows up as steadily-climbing
|
||||
// browser-process RSS. To make the leak class unforgettable, callers should
|
||||
// go through one of these two helpers and a static-grep test
|
||||
// (browse/test/cdp-session-cleanup.test.ts) fails CI if any source file
|
||||
// calls `newCDPSession(` outside this module.
|
||||
|
||||
/**
|
||||
* Ephemeral CDP session with try/finally detach. Use for one-shot CDP work
|
||||
* where the caller doesn't need session reuse — e.g. archive snapshots,
|
||||
* `$B memory`, a single `Page.captureScreenshot`. The session is detached
|
||||
* in `finally` regardless of whether `fn` threw, so the Chromium target
|
||||
* doesn't leak on the error path.
|
||||
*
|
||||
* For repeated use of the same page (e.g. the `$B cdp` bridge or the
|
||||
* inspector), use `getOrCreateCdpSession` instead — it caches and detaches
|
||||
* on page close.
|
||||
*/
|
||||
export async function withCdpSession<T>(
|
||||
page: Page,
|
||||
fn: (session: any) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
return await fn(session);
|
||||
} finally {
|
||||
try {
|
||||
await session.detach();
|
||||
} catch {
|
||||
// Best-effort cleanup. Session may already be detached (target closed,
|
||||
// context recreated, browser disconnect). Swallowing all errors is the
|
||||
// correct cleanup posture per CLAUDE.md "best-effort cleanup paths".
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached long-lived CDP session keyed by Page. First call creates the
|
||||
* session and registers a `page.once('close', ...)` hook that removes the
|
||||
* cache entry AND calls `session.detach()`. Pre-helper code only removed
|
||||
* the cache entry, leaving the Chromium-side target attached.
|
||||
*
|
||||
* Pass a caller-owned WeakMap so this helper doesn't impose a single global
|
||||
* cache — the `$B cdp` bridge and the inspector each keep their own session
|
||||
* pool with different invariants (e.g. the inspector also detaches on
|
||||
* `framenavigated` because DOM/CSS domain state is tied to the document).
|
||||
*/
|
||||
export async function getOrCreateCdpSession(
|
||||
page: Page,
|
||||
cache: WeakMap<Page, any>,
|
||||
): Promise<any> {
|
||||
let session = cache.get(page);
|
||||
if (session) return session;
|
||||
session = await page.context().newCDPSession(page);
|
||||
cache.set(page, session);
|
||||
page.once('close', () => {
|
||||
cache.delete(page);
|
||||
session.detach().catch(() => {
|
||||
// Best-effort cleanup — see withCdpSession finally block.
|
||||
});
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
// ─── $B cdp bridge ─────────────────────────────────────────────
|
||||
|
||||
// Per-page CDPSession cache. Lifecycle delegated to getOrCreateCdpSession
|
||||
// which registers a close hook that BOTH removes the cache entry AND calls
|
||||
// session.detach() — pre-helper code only did the former, leaving the
|
||||
// Chromium-side target attached.
|
||||
const sessionCache: WeakMap<Page, any> = new WeakMap();
|
||||
|
||||
async function getCdpSession(page: Page): Promise<any> {
|
||||
let s = sessionCache.get(page);
|
||||
if (s) return s;
|
||||
s = await page.context().newCDPSession(page);
|
||||
sessionCache.set(page, s);
|
||||
// Clear cache on detach so we don't hold a stale handle.
|
||||
page.once('close', () => sessionCache.delete(page));
|
||||
return s;
|
||||
return getOrCreateCdpSession(page, sessionCache);
|
||||
}
|
||||
|
||||
export interface CdpDispatchInput {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import type { Page } from 'playwright';
|
||||
import { getOrCreateCdpSession } from './cdp-bridge';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -106,15 +107,23 @@ async function getOrCreateSession(page: Page): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
session = await page.context().newCDPSession(page);
|
||||
cdpSessions.set(page, session);
|
||||
session = await getOrCreateCdpSession(page, cdpSessions);
|
||||
|
||||
// Enable DOM and CSS domains
|
||||
await session.send('DOM.enable');
|
||||
await session.send('CSS.enable');
|
||||
initializedPages.add(page);
|
||||
// Enable DOM and CSS domains on first init for this page. The session
|
||||
// itself is cached + close-detached by getOrCreateCdpSession; the
|
||||
// initializedPages WeakSet is inspector-layer state that needs its
|
||||
// own close hook to stay in sync.
|
||||
if (!initializedPages.has(page)) {
|
||||
await session.send('DOM.enable');
|
||||
await session.send('CSS.enable');
|
||||
initializedPages.add(page);
|
||||
page.once('close', () => initializedPages.delete(page));
|
||||
}
|
||||
|
||||
// Auto-detach on navigation
|
||||
// Auto-detach on navigation — DOM/CSS domain state is tied to the
|
||||
// document. Close-detach (from getOrCreateCdpSession) handles the
|
||||
// tab-close case; framenavigated catches in-tab navigation that
|
||||
// invalidates inspector state without closing the tab.
|
||||
page.once('framenavigated', () => {
|
||||
try {
|
||||
session.detach().catch(() => {});
|
||||
@@ -130,7 +139,41 @@ async function getOrCreateSession(page: Page): Promise<any> {
|
||||
|
||||
// ─── Modification History ───────────────────────────────────────
|
||||
|
||||
// Bounded FIFO of style modifications. Pre-cap, this was an unbounded
|
||||
// module-scoped array that grew for every CSS edit made through $B css
|
||||
// across the whole browser session — small per-entry footprint but no
|
||||
// upper bound, the kind of slow leak that compounds over multi-day
|
||||
// inspector use. The cap is 200 because per-session undo workflows
|
||||
// rarely walk back more than a handful of edits, and a user who really
|
||||
// wants to roll a long change back can `$B css reset` to revert all of
|
||||
// them. totalPushed is monotonic across the session so undoModification
|
||||
// can tell the user when their target index has been evicted, instead
|
||||
// of just "no modification at index N".
|
||||
const MOD_HISTORY_CAP = 200;
|
||||
const modificationHistory: StyleModification[] = [];
|
||||
let modHistoryTotalPushed = 0;
|
||||
|
||||
function pushModification(mod: StyleModification): void {
|
||||
modificationHistory.push(mod);
|
||||
modHistoryTotalPushed++;
|
||||
while (modificationHistory.length > MOD_HISTORY_CAP) {
|
||||
modificationHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only entry: exposes the history-cap mechanics (push, reset, cap value)
|
||||
// without requiring a CDP-driven Page. Production code must go through
|
||||
// modifyStyle / undoModification / resetModifications.
|
||||
export const __testInternals = {
|
||||
pushModification,
|
||||
MOD_HISTORY_CAP,
|
||||
getRawHistory: () => modificationHistory.slice(),
|
||||
getTotalPushed: () => modHistoryTotalPushed,
|
||||
resetForTest: () => {
|
||||
modificationHistory.length = 0;
|
||||
modHistoryTotalPushed = 0;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Specificity Calculation ────────────────────────────────────
|
||||
|
||||
@@ -559,7 +602,7 @@ export async function modifyStyle(
|
||||
method,
|
||||
};
|
||||
|
||||
modificationHistory.push(modification);
|
||||
pushModification(modification);
|
||||
return modification;
|
||||
}
|
||||
|
||||
@@ -569,7 +612,12 @@ export async function modifyStyle(
|
||||
export async function undoModification(page: Page, index?: number): Promise<void> {
|
||||
const idx = index ?? modificationHistory.length - 1;
|
||||
if (idx < 0 || idx >= modificationHistory.length) {
|
||||
throw new Error(`No modification at index ${idx}. History has ${modificationHistory.length} entries.`);
|
||||
const evictedNote = modHistoryTotalPushed > MOD_HISTORY_CAP
|
||||
? ` (most recent ${MOD_HISTORY_CAP} only — ${modHistoryTotalPushed - MOD_HISTORY_CAP} earlier entries evicted at the cap)`
|
||||
: '';
|
||||
throw new Error(
|
||||
`No modification at index ${idx}. History has ${modificationHistory.length} entries${evictedNote}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const mod = modificationHistory[idx];
|
||||
@@ -622,6 +670,23 @@ export function getModificationHistory(): StyleModification[] {
|
||||
return [...modificationHistory];
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic accessor for the $B memory snapshot. Returns current buffer
|
||||
* occupancy, the cap, and how many entries have been evicted since the
|
||||
* last reset.
|
||||
*/
|
||||
export function getModificationHistoryStats(): {
|
||||
current: number;
|
||||
cap: number;
|
||||
evicted: number;
|
||||
} {
|
||||
return {
|
||||
current: modificationHistory.length,
|
||||
cap: MOD_HISTORY_CAP,
|
||||
evicted: Math.max(0, modHistoryTotalPushed - MOD_HISTORY_CAP),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all modifications, restoring original values.
|
||||
*/
|
||||
@@ -648,6 +713,7 @@ export async function resetModifications(page: Page): Promise<void> {
|
||||
}
|
||||
}
|
||||
modificationHistory.length = 0;
|
||||
modHistoryTotalPushed = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+262
-92
@@ -11,11 +11,13 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn as nodeSpawn } from 'child_process';
|
||||
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
|
||||
import { redactProxyUrl } from './proxy-redact';
|
||||
import { spawnTerminalAgent } from './terminal-agent-control';
|
||||
|
||||
const config = resolveConfig();
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
@@ -209,6 +211,86 @@ function cleanupLegacyState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Chromium profile lock helpers (#1781) ─────────────────────
|
||||
/** Profile dir used by headed/connect Chromium sessions. */
|
||||
function chromiumProfileDir(): string {
|
||||
return path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
}
|
||||
|
||||
/** Remove Chromium SingletonLock/Socket/Cookie so a relaunch can acquire the
|
||||
* profile. Safe to call when absent. */
|
||||
function cleanChromiumProfileLocks(profileDir: string = chromiumProfileDir()): void {
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
safeUnlinkQuiet(path.join(profileDir, lockFile));
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill an orphaned Chromium that still holds the profile's SingletonLock. The
|
||||
* lock symlink target is "hostname-PID"; killing that PID tears down its
|
||||
* renderer tree so the next launch starts clean. No-op when absent/stale. */
|
||||
async function killOrphanChromium(profileDir: string = chromiumProfileDir()): Promise<void> {
|
||||
try {
|
||||
const lockTarget = fs.readlinkSync(path.join(profileDir, 'SingletonLock')); // "hostname-12345"
|
||||
const orphanPid = parseInt(lockTarget.split('-').pop() || '', 10);
|
||||
if (orphanPid && isProcessAlive(orphanPid)) {
|
||||
safeKill(orphanPid, 'SIGTERM');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
if (isProcessAlive(orphanPid)) {
|
||||
safeKill(orphanPid, 'SIGKILL');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT' && err?.code !== 'EINVAL') throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bounded /health probe. Returns true if the server answers within `attempts`
|
||||
* tries spaced `backoffMs` apart — distinguishes a busy-but-alive daemon from a
|
||||
* dead one (#1781) so a slow server isn't killed and restarted into a crash-loop. */
|
||||
async function probeHealthWithBackoff(port: number, attempts = 3, backoffMs = 250): Promise<boolean> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
if (await isServerHealthy(port)) return true;
|
||||
if (i < attempts - 1) await Bun.sleep(backoffMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the env for an auto-restart after a crash. headed/proxy/configHash are
|
||||
* reapplied from THIS invocation OR the persisted server state, so a restart
|
||||
* triggered by a plain command (goto/status, no --headed flag) never silently
|
||||
* downgrades a headed session to headless (#1781). Pure + exported for tests.
|
||||
*/
|
||||
export function buildRestartEnv(
|
||||
globalFlags: GlobalFlags | null | undefined,
|
||||
oldState: ServerState | null,
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (globalFlags?.proxyUrl) env.BROWSE_PROXY_URL = globalFlags.proxyUrl;
|
||||
if (globalFlags?.headed || oldState?.mode === 'headed') env.BROWSE_HEADED = '1';
|
||||
const configHash = globalFlags?.configHash || oldState?.configHash;
|
||||
if (configHash) env.BROWSE_CONFIG_HASH = configHash;
|
||||
return env;
|
||||
}
|
||||
|
||||
/** macOS only: pull the headed Chromium window to the user's current Space.
|
||||
* "Google Chrome for Testing" frequently opens behind the active window or on
|
||||
* another Space — the first thing users read as "I can't see the browser"
|
||||
* (#1781). Best-effort, fire-and-forget, never throws. The app name is a fixed
|
||||
* literal (no interpolation). */
|
||||
function raiseHeadedWindowMacOS(): void {
|
||||
if (process.platform !== 'darwin') return;
|
||||
try {
|
||||
nodeSpawn('osascript', ['-e', 'tell application "Google Chrome for Testing" to activate'], {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
}).unref();
|
||||
} catch {
|
||||
// osascript missing or app not present — non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server Lifecycle ──────────────────────────────────────────
|
||||
async function startServer(extraEnv?: Record<string, string>): Promise<ServerState> {
|
||||
ensureStateDir(config);
|
||||
@@ -217,7 +299,12 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
safeUnlink(config.stateFile);
|
||||
safeUnlink(path.join(config.stateDir, 'browse-startup-error.log'));
|
||||
|
||||
let proc: any = null;
|
||||
// #1781: clear a stale Chromium profile lock (and kill the orphan still
|
||||
// holding it) before launch, so an auto-restart after an abrupt kill isn't
|
||||
// blocked by the previous Chromium's SingletonLock — the self-inflicted
|
||||
// crash-loop. Previously only the manual connect preamble did this.
|
||||
await killOrphanChromium();
|
||||
cleanChromiumProfileLocks();
|
||||
|
||||
// Allow the caller to opt out of the parent-process watchdog by setting
|
||||
// BROWSE_PARENT_PID=0 in the environment. Useful for CI, non-interactive
|
||||
@@ -240,12 +327,22 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
`${extraEnvStr})}).unref()`;
|
||||
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
} else {
|
||||
// macOS/Linux: Bun.spawn + unref works correctly
|
||||
proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
|
||||
// loop — it does NOT call setsid(), so the spawned server stays in the
|
||||
// parent's process session. When the CLI runs inside a session-managed
|
||||
// shell (e.g. Claude Code's per-command Bash sandbox, Conductor, CI
|
||||
// step runners), the session leader's exit sends SIGHUP to every PID in
|
||||
// the session, killing the bun server (and its Chromium grandchildren).
|
||||
// Even with BROWSE_PARENT_PID=0 disabling the watchdog, SIGHUP still
|
||||
// reaps the server. Use Node's child_process.spawn with detached:true,
|
||||
// which calls setsid() so the server becomes its own session leader
|
||||
// (PPID=1, STAT=Ss) and survives the spawning shell's exit. Mirrors
|
||||
// the Windows path's rationale — same root cause, different OS API.
|
||||
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
|
||||
});
|
||||
proc.unref();
|
||||
}).unref();
|
||||
}
|
||||
|
||||
// Wait for server to become healthy.
|
||||
@@ -260,27 +357,17 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
|
||||
// Server didn't start in time — try to get error details
|
||||
if (proc?.stderr) {
|
||||
// macOS/Linux: read stderr from the spawned process
|
||||
const reader = proc.stderr.getReader();
|
||||
const { value } = await reader.read();
|
||||
if (value) {
|
||||
const errText = new TextDecoder().decode(value);
|
||||
throw new Error(`Server failed to start:\n${errText}`);
|
||||
}
|
||||
} else {
|
||||
// Windows: check startup error log (server writes errors to disk since
|
||||
// stderr is unavailable due to stdio: 'ignore' for detachment)
|
||||
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
|
||||
try {
|
||||
const errorLog = fs.readFileSync(errorLogPath, 'utf-8').trim();
|
||||
if (errorLog) {
|
||||
throw new Error(`Server failed to start:\n${errorLog}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
// Server didn't start in time — check the on-disk startup error log.
|
||||
// Both platforms now spawn with stdio: 'ignore', so the server writes
|
||||
// errors to disk for the CLI to read (see server.ts start().catch).
|
||||
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
|
||||
try {
|
||||
const errorLog = fs.readFileSync(errorLogPath, 'utf-8').trim();
|
||||
if (errorLog) {
|
||||
throw new Error(`Server failed to start:\n${errorLog}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
|
||||
}
|
||||
@@ -486,26 +573,42 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
console.error('[browse] Command timed out after 30s');
|
||||
// #1781: a 30s timeout on a heavy page usually means busy, not dead.
|
||||
// Don't kill a live server (that's what triggered the crash-loop) — report
|
||||
// and exit so the user can retry rather than losing their (headed) window.
|
||||
const ts = readState();
|
||||
const alive = ts?.pid ? isProcessAlive(ts.pid) : false;
|
||||
console.error(alive
|
||||
? '[browse] Command timed out after 30s (server still alive — busy, not restarting). Retry, or raise load.'
|
||||
: '[browse] Command timed out after 30s');
|
||||
process.exit(1);
|
||||
}
|
||||
// Connection error — server may have crashed
|
||||
// Connection error — server may have crashed, OR may just be busy.
|
||||
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
|
||||
const oldState = readState();
|
||||
// #1781 busy-vs-dead: a single-threaded daemon under beacon/extension load
|
||||
// can briefly stop answering HTTP while still alive. Before declaring a
|
||||
// crash, if the process is alive give /health a bounded chance to recover
|
||||
// and just retry the command — never kill+restart a live-but-busy server.
|
||||
if (oldState?.pid && isProcessAlive(oldState.pid) && await probeHealthWithBackoff(oldState.port)) {
|
||||
if (retries >= 1) throw new Error('[browse] Server unresponsive after retry — aborting');
|
||||
console.error('[browse] Server was briefly unresponsive (busy); retrying command...');
|
||||
return sendCommand(oldState, command, args, retries + 1);
|
||||
}
|
||||
// Truly dead (or health never recovered) → restart.
|
||||
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
|
||||
console.error('[browse] Server connection lost. Restarting...');
|
||||
// Kill the old server to avoid orphaned chromium processes
|
||||
const oldState = readState();
|
||||
if (oldState && oldState.pid) {
|
||||
await killServer(oldState.pid);
|
||||
}
|
||||
// Reapply --proxy / --headed flags from this invocation when restarting
|
||||
// after a crash. Without this, a proxied daemon that dies mid-command
|
||||
// would silently restart in default direct/headless mode and bypass
|
||||
// the SOCKS bridge.
|
||||
const restartEnv: Record<string, string> = {};
|
||||
if (_globalFlags?.proxyUrl) restartEnv.BROWSE_PROXY_URL = _globalFlags.proxyUrl;
|
||||
if (_globalFlags?.headed) restartEnv.BROWSE_HEADED = '1';
|
||||
if (_globalFlags?.configHash) restartEnv.BROWSE_CONFIG_HASH = _globalFlags.configHash;
|
||||
// startServer() now clears the Chromium SingletonLock + reaps the orphan,
|
||||
// so the relaunch isn't blocked by the dead Chromium's profile lock (#1781).
|
||||
//
|
||||
// Reapply --proxy / --headed when restarting. headed comes from THIS
|
||||
// invocation OR the persisted server mode, so a restart triggered by a
|
||||
// plain command (goto/status, no --headed) never silently downgrades a
|
||||
// headed session to headless (#1781). Same for proxy/configHash.
|
||||
const restartEnv = buildRestartEnv(_globalFlags, oldState);
|
||||
const newState = await startServer(Object.keys(restartEnv).length ? restartEnv : undefined);
|
||||
return sendCommand(newState, command, args, retries + 1);
|
||||
}
|
||||
@@ -966,30 +1069,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
}
|
||||
}
|
||||
|
||||
// Kill orphaned Chromium processes that may still hold the profile lock.
|
||||
// The server PID is the Bun process; Chromium is a child that can outlive it
|
||||
// if the server is killed abruptly (SIGKILL, crash, manual rm of state file).
|
||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
try {
|
||||
const singletonLock = path.join(profileDir, 'SingletonLock');
|
||||
const lockTarget = fs.readlinkSync(singletonLock); // e.g. "hostname-12345"
|
||||
const orphanPid = parseInt(lockTarget.split('-').pop() || '', 10);
|
||||
if (orphanPid && isProcessAlive(orphanPid)) {
|
||||
safeKill(orphanPid, 'SIGTERM');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
if (isProcessAlive(orphanPid)) {
|
||||
safeKill(orphanPid, 'SIGKILL');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT' && err?.code !== 'EINVAL') throw err;
|
||||
}
|
||||
|
||||
// Clean up Chromium profile locks (can persist after crashes)
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
safeUnlinkQuiet(path.join(profileDir, lockFile));
|
||||
}
|
||||
// Kill an orphaned Chromium still holding the profile lock (the Bun server
|
||||
// PID's Chromium child can outlive an abrupt kill/crash), then clear the
|
||||
// lock files so the launch is clean. Shared with the auto-restart path (#1781).
|
||||
await killOrphanChromium();
|
||||
cleanChromiumProfileLocks();
|
||||
|
||||
// Delete stale state file
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
@@ -1027,38 +1111,29 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
});
|
||||
const status = await resp.text();
|
||||
console.log(`Connected to real Chrome\n${status}`);
|
||||
// #1781: surface the window — it often opens behind/on another Space.
|
||||
raiseHeadedWindowMacOS();
|
||||
if (process.platform === 'darwin') {
|
||||
console.log('(If you still don\'t see it, check Mission Control / other Spaces.)');
|
||||
}
|
||||
|
||||
// sidebar-agent.ts spawn was here. Ripped alongside the chat queue —
|
||||
// the Terminal pane runs an interactive PTY now, no more one-shot
|
||||
// claude -p subprocesses to multiplex.
|
||||
|
||||
// Auto-start terminal agent (non-compiled bun process). Owns the PTY
|
||||
// WebSocket for the sidebar Terminal pane.
|
||||
let termAgentScript = path.resolve(__dirname, 'terminal-agent.ts');
|
||||
if (!fs.existsSync(termAgentScript)) {
|
||||
termAgentScript = path.resolve(path.dirname(process.execPath), '..', 'src', 'terminal-agent.ts');
|
||||
}
|
||||
// WebSocket for the sidebar Terminal pane. Routes through the shared
|
||||
// spawnTerminalAgent helper so the CLI cold-start path and the
|
||||
// server.ts watchdog respawn path share one implementation. The
|
||||
// helper handles prior-PID cleanup, script lookup, and env wiring.
|
||||
try {
|
||||
if (fs.existsSync(termAgentScript)) {
|
||||
// Kill old terminal-agents so a stale port file can't trick the
|
||||
// server into routing /pty-session at a dead listener.
|
||||
try {
|
||||
const { spawnSync } = require('child_process');
|
||||
spawnSync('pkill', ['-f', 'terminal-agent\\.ts'], { stdio: 'ignore', timeout: 3000 });
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') throw err;
|
||||
}
|
||||
const termProc = Bun.spawn(['bun', 'run', termAgentScript], {
|
||||
cwd: config.projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_STATE_FILE: config.stateFile,
|
||||
BROWSE_SERVER_PORT: String(newState.port),
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
termProc.unref();
|
||||
console.log(`[browse] Terminal agent started (PID: ${termProc.pid})`);
|
||||
const newPid = spawnTerminalAgent({
|
||||
stateFile: config.stateFile,
|
||||
serverPort: newState.port,
|
||||
cwd: config.projectDir,
|
||||
});
|
||||
if (newPid) {
|
||||
console.log(`[browse] Terminal agent started (PID: ${newPid})`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Non-fatal: chat still works without the terminal agent.
|
||||
@@ -1068,6 +1143,96 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
console.error(`[browse] Connect failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ─── Outer Supervisor (v1.44+, opt-in) ──────────────────────────
|
||||
//
|
||||
// Default: fire-and-forget (CLI exits, server runs detached). This is
|
||||
// the contract every existing call site relies on, including Claude
|
||||
// Code's Bash tool which expects `$B connect` to return promptly.
|
||||
//
|
||||
// Opt-in via `--supervise` flag or BROWSE_SUPERVISE=1 env: the CLI
|
||||
// stays attached, polls the spawned server's PID every 30s, and
|
||||
// respawns it through the same headed-mode startServer path on
|
||||
// unexpected exit. Crash-loop guard: 5 respawns inside 5 min →
|
||||
// give up and exit 1 with a clear error. SIGINT / SIGTERM cleanly
|
||||
// tear down the supervised server before exit.
|
||||
//
|
||||
// Out of scope for v1.44 minimum: routing the Chromium-disconnect
|
||||
// exit-code-1 path back through this supervisor. The terminal-agent
|
||||
// watchdog (T5) already covers the highest-frequency restart case;
|
||||
// Chromium-crash-respawn is documented as a follow-up so the
|
||||
// supervisor stays a tight, testable primitive.
|
||||
const superviseRequested = commandArgs.includes('--supervise')
|
||||
|| process.env.BROWSE_SUPERVISE === '1';
|
||||
if (!superviseRequested) {
|
||||
process.exit(0);
|
||||
}
|
||||
console.log('[browse] Supervisor mode: monitoring server. Ctrl-C to stop.');
|
||||
let supervisorExiting = false;
|
||||
const teardownAndExit = (signal: string) => {
|
||||
if (supervisorExiting) return;
|
||||
supervisorExiting = true;
|
||||
console.log(`\n[browse] ${signal} received — stopping server.`);
|
||||
const state = readState();
|
||||
if (state?.pid && isProcessAlive(state.pid)) {
|
||||
safeKill(state.pid, 'SIGTERM');
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', () => teardownAndExit('SIGINT'));
|
||||
process.on('SIGTERM', () => teardownAndExit('SIGTERM'));
|
||||
|
||||
const SUPERVISOR_TICK_MS = parseInt(
|
||||
process.env.GSTACK_SUPERVISOR_TICK_MS || '30000',
|
||||
10,
|
||||
);
|
||||
const SUPERVISOR_GUARD_WINDOW_MS = 5 * 60_000;
|
||||
const SUPERVISOR_GUARD_MAX = 5;
|
||||
const SUPERVISOR_BACKOFF_MS = (process.env.GSTACK_SUPERVISOR_BACKOFF || '1000,2000,4000,8000,30000')
|
||||
.split(',').map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n));
|
||||
const respawns: number[] = [];
|
||||
|
||||
while (!supervisorExiting) {
|
||||
await new Promise(resolve => setTimeout(resolve, SUPERVISOR_TICK_MS));
|
||||
if (supervisorExiting) break;
|
||||
const state = readState();
|
||||
if (state?.pid && isProcessAlive(state.pid)) continue;
|
||||
// Server died. Prune rolling window and check guard.
|
||||
const now = Date.now();
|
||||
while (respawns.length && now - respawns[0] > SUPERVISOR_GUARD_WINDOW_MS) {
|
||||
respawns.shift();
|
||||
}
|
||||
if (respawns.length >= SUPERVISOR_GUARD_MAX) {
|
||||
console.error(
|
||||
`[browse] Supervisor: ${SUPERVISOR_GUARD_MAX} crashes in ${SUPERVISOR_GUARD_WINDOW_MS / 1000}s — giving up.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const attempt = respawns.length;
|
||||
respawns.push(now);
|
||||
const backoff = SUPERVISOR_BACKOFF_MS[Math.min(attempt, SUPERVISOR_BACKOFF_MS.length - 1)] ?? 30_000;
|
||||
console.warn(`[browse] Supervisor: server PID gone — respawning in ${backoff}ms (attempt ${attempt + 1}/${SUPERVISOR_GUARD_MAX})...`);
|
||||
await new Promise(resolve => setTimeout(resolve, backoff));
|
||||
if (supervisorExiting) break;
|
||||
try {
|
||||
const respawned = await startServer(serverEnv);
|
||||
console.log(`[browse] Supervisor: server respawned (PID ${respawned.pid}, port ${respawned.port}).`);
|
||||
// Re-spawn the terminal-agent too; same env wiring as the initial connect.
|
||||
try {
|
||||
spawnTerminalAgent({
|
||||
stateFile: config.stateFile,
|
||||
serverPort: respawned.port,
|
||||
cwd: config.projectDir,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.warn(`[browse] Supervisor: terminal-agent respawn failed: ${err?.message || err}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[browse] Supervisor: server respawn failed: ${err?.message || err}`);
|
||||
// Let the next tick try again — the crash-loop guard already
|
||||
// bounded the retries via the rolling window.
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -1118,11 +1283,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
safeKill(existingState.pid, 'SIGKILL');
|
||||
}
|
||||
}
|
||||
// Clean profile locks and state file
|
||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
safeUnlinkQuiet(path.join(profileDir, lockFile));
|
||||
}
|
||||
// #1781: killing the daemon can orphan its Chromium child tree, which keeps
|
||||
// holding the SingletonLock and makes the next `connect` fail to launch.
|
||||
// Reap the orphan via the lock, then clear the lock files + state.
|
||||
await killOrphanChromium();
|
||||
cleanChromiumProfileLocks();
|
||||
// Xvfb orphan cleanup: if the recorded PID still matches our Xvfb (by
|
||||
// cmdline AND start-time), kill it. PID-only would risk killing a
|
||||
// recycled PID belonging to an unrelated process.
|
||||
@@ -1182,6 +1347,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
}
|
||||
|
||||
await sendCommand(state, command, commandArgs);
|
||||
|
||||
// #1781: `focus` means "show me the window". The server-side focus activates
|
||||
// the page via CDP, but on macOS the app can still sit on another Space — pull
|
||||
// it to the user's current Space too.
|
||||
if (command === 'focus') raiseHeadedWindowMacOS();
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
|
||||
@@ -45,6 +45,7 @@ export const META_COMMANDS = new Set([
|
||||
'domain-skill',
|
||||
'skill',
|
||||
'cdp',
|
||||
'memory',
|
||||
]);
|
||||
|
||||
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
||||
@@ -89,6 +90,7 @@ export function wrapUntrustedContent(result: string, url: string): string {
|
||||
|
||||
export const COMMAND_DESCRIPTIONS: Record<string, { category: string; description: string; usage?: string }> = {
|
||||
// Navigation
|
||||
'memory': { category: 'Server', description: 'Snapshot Bun heap + per-tab JS heap + Chromium process tree + bounded buffer sizes. JSON output with --json.', usage: 'memory [--json]' },
|
||||
'goto': { category: 'Navigation', description: 'Navigate to URL (http://, https://, or file:// scoped to cwd/TEMP_DIR)', usage: 'goto <url>' },
|
||||
'load-html': { category: 'Navigation', description: 'Load HTML via setContent. Accepts a file path under safe-dirs (validated), OR --from-file <payload.json> with {"html":"...","waitUntil":"..."} for large inline HTML (Windows argv safe).', usage: 'load-html <file> [--wait-until load|domcontentloaded|networkidle] [--tab-id <N>] | load-html --from-file <payload.json> [--tab-id <N>]' },
|
||||
'back': { category: 'Navigation', description: 'History back' },
|
||||
@@ -104,8 +106,8 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'media': { category: 'Reading', description: 'All media elements (images, videos, audio) with URLs, dimensions, types', usage: 'media [--images|--videos|--audio] [selector]' },
|
||||
'data': { category: 'Reading', description: 'Structured data: JSON-LD, Open Graph, Twitter Cards, meta tags', usage: 'data [--jsonld|--og|--meta|--twitter]' },
|
||||
// Inspection
|
||||
'js': { category: 'Inspection', description: 'Run inline JavaScript expression in the page context and return result as string. Same JS sandbox as eval; the only difference is js takes an inline expr while eval reads from a file.', usage: 'js <expr>' },
|
||||
'eval': { category: 'Inspection', description: 'Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners.', usage: 'eval <file>' },
|
||||
'js': { category: 'Inspection', description: 'Run inline JavaScript expression in the page context and return result as string. Same JS sandbox as eval; the only difference is js takes an inline expr while eval reads from a file. With --out <file>, the result is written to disk instead of returned (a base64 data URL is decoded to raw bytes unless --raw is given) — ideal for rasterizing local renders to PNG without serializing megabytes back through the CLI. --out makes the invocation a WRITE (needs write scope, never allowed over the tunnel).', usage: 'js <expr> [--out <file>] [--raw]' },
|
||||
'eval': { category: 'Inspection', description: 'Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners. With --out <file>, the result is written to disk (base64 data URL decoded to bytes unless --raw); --out makes the invocation a WRITE (needs write scope, never allowed over the tunnel).', usage: 'eval <file> [--out <file>] [--raw]' },
|
||||
'css': { category: 'Inspection', description: 'Computed CSS value', usage: 'css <sel> <prop>' },
|
||||
'attrs': { category: 'Inspection', description: 'Element attributes as JSON', usage: 'attrs <sel|@ref>' },
|
||||
'is': { category: 'Inspection', description: 'State check on element. Valid <prop> values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). <sel> accepts a CSS selector OR an @ref token from a prior snapshot (e.g. @e3, @c1) — refs are interchangeable with selectors anywhere a selector is expected.', usage: 'is <prop> <sel|@ref>' },
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Outputs the absolute path to the browse binary on stdout, or exits 1 if not found.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { accessSync, constants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
@@ -24,6 +24,35 @@ function getGitRoot(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Probe a path for executability. accessSync(X_OK) checks the executable
|
||||
// bit on Linux/macOS and degrades to an existence check on Windows (no
|
||||
// true execute bit). Mirrors make-pdf/src/browseClient.ts:159 /
|
||||
// make-pdf/src/pdftotext.ts:117.
|
||||
function isExecutable(p: string): boolean {
|
||||
try {
|
||||
accessSync(p, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a bare binary path to the actual file on disk. On Windows, `bun
|
||||
// build --compile` appends `.exe` to the output filename, so `browse` on
|
||||
// disk is actually `browse.exe`. After a bare-path probe, try the Windows
|
||||
// extensions. Linux/macOS behavior is unchanged. Mirrors the helper in
|
||||
// make-pdf/src/browseClient.ts:89 and make-pdf/src/pdftotext.ts:52.
|
||||
function findExecutable(base: string): string | null {
|
||||
if (isExecutable(base)) return base;
|
||||
if (process.platform === 'win32') {
|
||||
for (const ext of ['.exe', '.cmd', '.bat']) {
|
||||
const withExt = base + ext;
|
||||
if (isExecutable(withExt)) return withExt;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function locateBinary(): string | null {
|
||||
const root = getGitRoot();
|
||||
const home = homedir();
|
||||
@@ -33,14 +62,26 @@ export function locateBinary(): string | null {
|
||||
if (root) {
|
||||
for (const m of markers) {
|
||||
const local = join(root, m, 'skills', 'gstack', 'browse', 'dist', 'browse');
|
||||
if (existsSync(local)) return local;
|
||||
const found = findExecutable(local);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
// Source-checkout fallback (no installed skill layout — the binary
|
||||
// lives directly at <repo>/browse/dist/browse[.exe]). Hit by:
|
||||
// - gstack repo dev workflow before `./setup` runs
|
||||
// - the windows-setup-e2e.yml CI workflow which builds binaries
|
||||
// in place but never installs them under a marker dir
|
||||
// - make-pdf consumers running from a sibling source checkout
|
||||
const sourceCheckout = join(root, 'browse', 'dist', 'browse');
|
||||
const sourceFound = findExecutable(sourceCheckout);
|
||||
if (sourceFound) return sourceFound;
|
||||
}
|
||||
|
||||
// Global fallback
|
||||
for (const m of markers) {
|
||||
const global = join(home, m, 'skills', 'gstack', 'browse', 'dist', 'browse');
|
||||
if (existsSync(global)) return global;
|
||||
const found = findExecutable(global);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* find-security-sidecar — resolve the Node entry that runs the L4 ML
|
||||
* classifier sidecar.
|
||||
*
|
||||
* The sidecar can't be bundled into the compiled browse binary because
|
||||
* onnxruntime-node fails to dlopen from Bun's compile extract dir. It runs
|
||||
* as a separate Node subprocess instead. This module resolves the right
|
||||
* path + interpreter on each platform:
|
||||
*
|
||||
* 1. Prefer node on PATH + a bundled JS entry at
|
||||
* browse/dist/security-sidecar.js (built by package.json's
|
||||
* build:security-sidecar script).
|
||||
* 2. Dev fallback: node + browse/src/security-sidecar-entry.ts via tsx
|
||||
* (only available in the source checkout, not the compiled install).
|
||||
* 3. If Node is missing or no entry resolves, return null. The /pty-inject-scan
|
||||
* endpoint then responds with l4 { available: false } and the extension
|
||||
* degrades to WARN+confirm (D7).
|
||||
*/
|
||||
|
||||
import { existsSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
|
||||
export interface SidecarLocation {
|
||||
node: string;
|
||||
entry: string;
|
||||
/** "compiled" if running from browse/dist/, "dev" if running from src */
|
||||
mode: "compiled" | "dev";
|
||||
}
|
||||
|
||||
function nodeOnPath(): string | null {
|
||||
try {
|
||||
execFileSync("node", ["--version"], { stdio: "ignore", timeout: 2000 });
|
||||
return "node";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function browseRoot(): string {
|
||||
// When running compiled, __dirname (via import.meta.dir) points at the
|
||||
// Bun extract temp. Walk up until we find a directory containing
|
||||
// browse/dist/ or browse/src/.
|
||||
let candidate = dirname(import.meta.path || "");
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
if (existsSync(join(candidate, "browse", "dist", "security-sidecar.js"))) {
|
||||
return candidate;
|
||||
}
|
||||
if (existsSync(join(candidate, "src", "security-sidecar-entry.ts"))) {
|
||||
return candidate;
|
||||
}
|
||||
const next = dirname(candidate);
|
||||
if (next === candidate) break;
|
||||
candidate = next;
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
export function findSecuritySidecar(): SidecarLocation | null {
|
||||
const node = nodeOnPath();
|
||||
if (!node) return null;
|
||||
|
||||
const root = browseRoot();
|
||||
|
||||
const compiled = join(root, "browse", "dist", "security-sidecar.js");
|
||||
if (existsSync(compiled)) {
|
||||
return { node, entry: compiled, mode: "compiled" };
|
||||
}
|
||||
|
||||
// Dev fallback. Compiled installs won't have src/ on disk so this only
|
||||
// resolves when running from the source checkout.
|
||||
const devEntry = join(root, "src", "security-sidecar-entry.ts");
|
||||
if (existsSync(devEntry)) {
|
||||
return { node, entry: devEntry, mode: "dev" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// `$B memory` — diagnostic snapshot of Bun heap + per-tab JS heap +
|
||||
// Chromium process tree + bounded buffer sizes. Lives in its own file
|
||||
// because the meta-commands dispatcher imports it lazily — projects
|
||||
// that never run the diagnostic don't pay the import-graph cost (CDP
|
||||
// bridge, memory-snapshot types, buffer accessors).
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { formatBytes, type MemorySnapshot, type MemoryStructureStats } from './memory-snapshot';
|
||||
import { getModificationHistoryStats } from './cdp-inspector';
|
||||
import { getSubscriberCount as getActivitySubscriberCount } from './activity';
|
||||
import { getInspectorSubscriberCount } from './server';
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
|
||||
import { getCaptureBuffer } from './network-capture';
|
||||
|
||||
/**
|
||||
* Assemble the MemoryStructureStats from the modules that own each buffer.
|
||||
* Browser-manager doesn't take a hard dep on every buffer-owning module —
|
||||
* the snapshot caller passes them in.
|
||||
*/
|
||||
function collectStructureStats(): MemoryStructureStats {
|
||||
return {
|
||||
modificationHistory: getModificationHistoryStats(),
|
||||
activitySubscribers: getActivitySubscriberCount(),
|
||||
inspectorSubscribers: getInspectorSubscriberCount(),
|
||||
consoleBufferLen: consoleBuffer.length,
|
||||
networkBufferLen: networkBuffer.length,
|
||||
dialogBufferLen: dialogBuffer.length,
|
||||
captureBufferBytes: getCaptureBuffer().byteSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-print the snapshot for terminal output. JSON mode (--json) goes
|
||||
* straight through JSON.stringify so the extension footer and any test
|
||||
* harness can consume it programmatically.
|
||||
*/
|
||||
function formatSnapshotText(s: MemorySnapshot): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`Bun server: RSS: ${formatBytes(s.bunServer.rss)} ` +
|
||||
`heap: ${formatBytes(s.bunServer.heapUsed)} / ${formatBytes(s.bunServer.heapTotal)} ` +
|
||||
`external: ${formatBytes(s.bunServer.external)}`,
|
||||
);
|
||||
|
||||
if (s.processes && s.processes.length > 0) {
|
||||
// Group by type so the user sees "renderer: 12" vs listing 12 separate rows.
|
||||
const byType: Record<string, number> = {};
|
||||
for (const p of s.processes) byType[p.type] = (byType[p.type] ?? 0) + 1;
|
||||
const typeSummary = Object.entries(byType)
|
||||
.map(([t, n]) => `${t}=${n}`)
|
||||
.join(' ');
|
||||
lines.push(`Chromium processes: ${s.processes.length} total (${typeSummary})`);
|
||||
} else if (s.processes === null) {
|
||||
lines.push('Chromium processes: (unavailable — see notes)');
|
||||
} else {
|
||||
lines.push('Chromium processes: 0');
|
||||
}
|
||||
|
||||
if (s.tabs.length > 0) {
|
||||
// Sort by JS heap descending; show top 10 plus "...N more" tail.
|
||||
const sorted = [...s.tabs].sort((a, b) => b.jsHeapUsed - a.jsHeapUsed);
|
||||
const shown = sorted.slice(0, 10);
|
||||
lines.push(`Renderers: ${s.tabs.length} tabs (top by JS heap):`);
|
||||
for (const t of shown) {
|
||||
const urlShort = t.url.length > 80 ? t.url.slice(0, 77) + '...' : t.url;
|
||||
lines.push(
|
||||
` [${formatBytes(t.jsHeapUsed).padStart(8)} JS, ` +
|
||||
`${String(t.nodes).padStart(6)} nodes, ` +
|
||||
`${String(t.listeners).padStart(5)} listeners] ` +
|
||||
`tab #${t.id} — ${urlShort}`,
|
||||
);
|
||||
}
|
||||
if (sorted.length > shown.length) {
|
||||
lines.push(` ...and ${sorted.length - shown.length} more`);
|
||||
}
|
||||
} else {
|
||||
lines.push('Renderers: (no tabs tracked)');
|
||||
}
|
||||
|
||||
lines.push('─────────────────────────────────────────────────');
|
||||
lines.push('In-memory structures (Bun side):');
|
||||
const m = s.structures.modificationHistory;
|
||||
lines.push(
|
||||
` modificationHistory: ${m.current} / ${m.cap} entries` +
|
||||
(m.evicted > 0 ? ` (${m.evicted} evicted since reset)` : ''),
|
||||
);
|
||||
lines.push(` inspectorSubscribers: ${s.structures.inspectorSubscribers}`);
|
||||
lines.push(` activitySubscribers: ${s.structures.activitySubscribers}`);
|
||||
lines.push(` consoleBuffer: ${s.structures.consoleBufferLen} entries`);
|
||||
lines.push(` networkBuffer: ${s.structures.networkBufferLen} entries`);
|
||||
lines.push(` dialogBuffer: ${s.structures.dialogBufferLen} entries`);
|
||||
lines.push(` captureBuffer: ${formatBytes(s.structures.captureBufferBytes)}`);
|
||||
|
||||
if (s.notes.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Notes:');
|
||||
for (const n of s.notes) lines.push(` - ${n}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function handleMemoryCommand(args: string[], bm: BrowserManager): Promise<string> {
|
||||
const jsonMode = args.includes('--json');
|
||||
const structures = collectStructureStats();
|
||||
const snapshot = await bm.getMemorySnapshot(structures);
|
||||
if (jsonMode) return JSON.stringify(snapshot);
|
||||
return formatSnapshotText(snapshot);
|
||||
}
|
||||
|
||||
/** Entry point used by the /memory HTTP endpoint — same data, always JSON. */
|
||||
export async function buildMemorySnapshotJson(bm: BrowserManager): Promise<MemorySnapshot> {
|
||||
const structures = collectStructureStats();
|
||||
return bm.getMemorySnapshot(structures);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Shared types for the $B memory diagnostic command and the /memory
|
||||
// endpoint. Lives in its own module so server.ts, read-commands.ts, and
|
||||
// the extension footer poll can import without taking a circular dep on
|
||||
// browser-manager.ts.
|
||||
//
|
||||
// Background: the gbrowser-OOM investigation (160 GB Activity Monitor
|
||||
// reading on a friend's machine) needed a diagnostic that could land
|
||||
// before the next incident — measurement comes first, fixes come after.
|
||||
// $B memory is that diagnostic.
|
||||
|
||||
/** Counts/bytes for the bounded in-memory structures on the Bun side. */
|
||||
export interface MemoryStructureStats {
|
||||
modificationHistory: { current: number; cap: number; evicted: number };
|
||||
activitySubscribers: number;
|
||||
inspectorSubscribers: number;
|
||||
consoleBufferLen: number;
|
||||
networkBufferLen: number;
|
||||
dialogBufferLen: number;
|
||||
captureBufferBytes: number;
|
||||
}
|
||||
|
||||
/** Per-tab JS heap snapshot (CDP Performance.getMetrics). */
|
||||
export interface MemoryTabSnapshot {
|
||||
id: number;
|
||||
url: string;
|
||||
title: string;
|
||||
jsHeapUsed: number;
|
||||
jsHeapTotal: number;
|
||||
documents: number;
|
||||
nodes: number;
|
||||
listeners: number;
|
||||
}
|
||||
|
||||
/** Chromium process metadata via CDP SystemInfo.getProcessInfo. */
|
||||
export interface MemoryProcess {
|
||||
/** Chromium-internal process id (not OS PID). */
|
||||
id: number;
|
||||
/** 'browser' | 'renderer' | 'gpu' | 'utility' | 'extension' | ... */
|
||||
type: string;
|
||||
/** CPU time accumulated since process start (seconds). */
|
||||
cpuTime: number;
|
||||
}
|
||||
|
||||
export interface MemorySnapshot {
|
||||
bunServer: {
|
||||
rss: number;
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
external: number;
|
||||
};
|
||||
tabs: MemoryTabSnapshot[];
|
||||
/**
|
||||
* Chromium process tree. `null` when no browser handle is available
|
||||
* (server in connection mode, or browser not yet launched).
|
||||
*
|
||||
* Per-process RSS is NOT included: SystemInfo.getProcessInfo returns
|
||||
* id+type+cpuTime but Chromium does not expose RSS via CDP. The
|
||||
* `notes[]` field tells the caller why — see the follow-up TODO
|
||||
* "native/GPU memory breakdown" for the deferred fix.
|
||||
*/
|
||||
processes: MemoryProcess[] | null;
|
||||
structures: MemoryStructureStats;
|
||||
capturedAt: number;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** Format bytes as a short human string ("1.4 GB", "312 MB", "84 KB"). */
|
||||
export function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { handleSkillCommand } from './browser-skill-commands';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import { checkScope, type TokenInfo } from './token-registry';
|
||||
import { validateOutputPath, validateReadPath, SAFE_DIRECTORIES, escapeRegExp } from './path-security';
|
||||
import { guardScreenshotBuffer, guardScreenshotPath } from './screenshot-size-guard';
|
||||
// Re-export for backward compatibility (tests import from meta-commands)
|
||||
export { validateOutputPath, escapeRegExp } from './path-security';
|
||||
import * as Diff from 'diff';
|
||||
@@ -136,7 +137,7 @@ function parsePdfArgs(args: string[]): ParsedPdfArgs {
|
||||
return result;
|
||||
}
|
||||
|
||||
function parsePdfFromFile(payloadPath: string): ParsedPdfArgs {
|
||||
export function parsePdfFromFile(payloadPath: string): ParsedPdfArgs {
|
||||
// Parity with load-html --from-file (browse/src/write-commands.ts) and
|
||||
// the direct load-html <file> path: every caller-supplied file path
|
||||
// must pass validateReadPath so the safe-dirs policy can't be skirted
|
||||
@@ -149,7 +150,16 @@ function parsePdfFromFile(payloadPath: string): ParsedPdfArgs {
|
||||
);
|
||||
}
|
||||
const raw = fs.readFileSync(payloadPath, 'utf8');
|
||||
const json = JSON.parse(raw);
|
||||
let json: any;
|
||||
try {
|
||||
json = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`pdf: --from-file ${payloadPath} is not valid JSON (${msg}).`);
|
||||
}
|
||||
if (json === null || typeof json !== 'object' || Array.isArray(json)) {
|
||||
throw new Error(`pdf: --from-file ${payloadPath} must be a JSON object, got ${Array.isArray(json) ? 'array' : typeof json}.`);
|
||||
}
|
||||
const out: ParsedPdfArgs = {
|
||||
output: json.output || `${TEMP_DIR}/browse-page.pdf`,
|
||||
format: json.format,
|
||||
@@ -497,6 +507,10 @@ export async function handleMetaCommand(
|
||||
buffer = await page.screenshot({ clip: clipRect });
|
||||
} else {
|
||||
buffer = await page.screenshot({ fullPage: !viewportOnly });
|
||||
// Guard the most common API-bricking case (fullPage). Element /
|
||||
// clip captures usually stay within the cap; we still guard the
|
||||
// path-mode below for fullPage writes.
|
||||
({ buffer } = await guardScreenshotBuffer(buffer));
|
||||
}
|
||||
if (buffer.length > 10 * 1024 * 1024) {
|
||||
throw new Error('Screenshot too large for --base64 (>10MB). Use disk path instead.');
|
||||
@@ -517,6 +531,7 @@ export async function handleMetaCommand(
|
||||
}
|
||||
|
||||
await page.screenshot({ path: outputPath, fullPage: !viewportOnly });
|
||||
if (!viewportOnly) await guardScreenshotPath(outputPath);
|
||||
return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`;
|
||||
}
|
||||
|
||||
@@ -567,6 +582,7 @@ export async function handleMetaCommand(
|
||||
const screenshotPath = `${prefix}-${vp.name}.png`;
|
||||
validateOutputPath(screenshotPath);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
await guardScreenshotPath(screenshotPath);
|
||||
results.push(`${vp.name} (${vp.width}x${vp.height}): ${screenshotPath}`);
|
||||
}
|
||||
|
||||
@@ -1145,6 +1161,13 @@ export async function handleMetaCommand(
|
||||
return await handleCdpCommand(args, bm);
|
||||
}
|
||||
|
||||
case 'memory': {
|
||||
// Lazy import — pulls in cdp-bridge + memory-snapshot + buffer accessors
|
||||
// that aren't useful for projects that never run the diagnostic.
|
||||
const { handleMemoryCommand } = await import('./memory-command');
|
||||
return await handleMemoryCommand(args, bm);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown meta command: ${command}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* PTY session lease registry (v1.44+).
|
||||
*
|
||||
* Separates two concerns that pre-v1.44 were conflated under one token:
|
||||
*
|
||||
* - **sessionId** — stable, non-secret identifier for a single PTY session.
|
||||
* Safe to log, safe to include in URLs and server access logs, safe to
|
||||
* keep in DevTools. Identifies "this terminal," not "you're allowed to
|
||||
* use this terminal."
|
||||
*
|
||||
* - **attachToken** — secret, short-lived (30 s) bearer credential that
|
||||
* grants the WS upgrade for ONE attach attempt against a session. Minted
|
||||
* on every /pty-session and /pty-session/reattach call; revoked when
|
||||
* the WS upgrade consumes it. Kept out of logs.
|
||||
*
|
||||
* - **lease** — server-side bookkeeping that maps sessionId → expiresAt.
|
||||
* Re-attach within the lease window resumes the same PTY (and replays
|
||||
* the ring buffer from terminal-agent). Lease expiry tears down the
|
||||
* session.
|
||||
*
|
||||
* Codex outside-voice (T1 of the eng review) pushed for this separation:
|
||||
* "the auth token IS the session id" collapsed identity into a secret,
|
||||
* meaning re-attach URLs and logs carry the bearer credential. The lease
|
||||
* model fixes that without changing the user experience.
|
||||
*
|
||||
* Mint cadence:
|
||||
* - Initial /pty-session: mint sessionId + lease + attachToken (one round trip).
|
||||
* - /pty-session/reattach: validate sessionId/lease, mint fresh attachToken.
|
||||
* - /pty-restart: revoke old lease, mint fresh sessionId + lease + attachToken.
|
||||
* - /pty-dispose: revoke lease (and the terminal-agent disposes the PTY).
|
||||
*
|
||||
* Lease TTL is env-overridable so v1.44 e2e tests can compress detach
|
||||
* windows to 1 s instead of waiting 30 minutes per assertion.
|
||||
*/
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
interface Lease {
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const LEASE_TTL_MS = parseInt(
|
||||
process.env.GSTACK_PTY_LEASE_TTL_MS || `${30 * 60 * 1000}`,
|
||||
10,
|
||||
); // 30 minutes default; covers idle-but-engaged user sessions
|
||||
const MAX_LEASES = 10_000;
|
||||
const leases = new Map<string, Lease>();
|
||||
|
||||
/**
|
||||
* Mint a fresh sessionId + lease. Returns the non-secret sessionId and
|
||||
* the expiry timestamp (caller surfaces both to the client). Never throws.
|
||||
*/
|
||||
export function mintLease(): { sessionId: string; expiresAt: number } {
|
||||
const sessionId = crypto.randomBytes(32).toString('base64url');
|
||||
const now = Date.now();
|
||||
const expiresAt = now + LEASE_TTL_MS;
|
||||
leases.set(sessionId, { createdAt: now, expiresAt });
|
||||
pruneExpired(now);
|
||||
return { sessionId, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a lease is still valid (exists AND not expired). Returns
|
||||
* the current expiresAt for valid leases; null otherwise. Lazily prunes
|
||||
* stale entries.
|
||||
*/
|
||||
export function validateLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } {
|
||||
if (!sessionId) return { ok: false };
|
||||
const lease = leases.get(sessionId);
|
||||
if (!lease) {
|
||||
pruneExpired(Date.now());
|
||||
return { ok: false };
|
||||
}
|
||||
if (Date.now() > lease.expiresAt) {
|
||||
leases.delete(sessionId);
|
||||
pruneExpired(Date.now());
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, expiresAt: lease.expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the lease's expiresAt to `now + LEASE_TTL_MS`. Caller should
|
||||
* gate refresh on `expiresAt - now < REFRESH_THRESHOLD` (D10 lazy
|
||||
* refresh: avoid refreshing on every keepalive when the lease is
|
||||
* comfortably far from expiry).
|
||||
*
|
||||
* Returns `{ ok: true, expiresAt }` on success, `{ ok: false }` if the
|
||||
* lease is unknown or already expired (the agent must close the WS and
|
||||
* surface auth-invalid). Critical security invariant: never resurrect
|
||||
* an expired lease — the 30-min TTL is what bounds blast radius for a
|
||||
* leaked attach token whose lease should have been GC'd.
|
||||
*/
|
||||
export function refreshLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } {
|
||||
if (!sessionId) return { ok: false };
|
||||
const lease = leases.get(sessionId);
|
||||
if (!lease) return { ok: false };
|
||||
const now = Date.now();
|
||||
if (now > lease.expiresAt) {
|
||||
leases.delete(sessionId);
|
||||
return { ok: false };
|
||||
}
|
||||
lease.expiresAt = now + LEASE_TTL_MS;
|
||||
return { ok: true, expiresAt: lease.expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a lease. Called on explicit dispose (/pty-dispose, /pty-restart,
|
||||
* WS close with code 4001) and on session timeout in terminal-agent.
|
||||
*/
|
||||
export function revokeLease(sessionId: string | null | undefined): void {
|
||||
if (!sessionId) return;
|
||||
leases.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Returns the lease count — test + observability helper. */
|
||||
export function leaseCount(): number {
|
||||
return leases.size;
|
||||
}
|
||||
|
||||
/** Test-only reset. */
|
||||
export function __resetLeases(): void {
|
||||
leases.clear();
|
||||
}
|
||||
|
||||
function pruneExpired(now: number): void {
|
||||
let checked = 0;
|
||||
for (const [sessionId, lease] of leases) {
|
||||
if (checked++ >= 20) break;
|
||||
if (lease.expiresAt <= now) leases.delete(sessionId);
|
||||
}
|
||||
while (leases.size > MAX_LEASES) {
|
||||
const first = leases.keys().next().value;
|
||||
if (!first) break;
|
||||
leases.delete(first);
|
||||
}
|
||||
}
|
||||
+130
-7
@@ -13,7 +13,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { TEMP_DIR } from './platform';
|
||||
import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';
|
||||
import { validateReadPath } from './path-security';
|
||||
import { validateReadPath, validateOutputPath } from './path-security';
|
||||
import { stripLoneSurrogates } from './sanitize';
|
||||
// Re-export for backward compatibility (tests import from read-commands)
|
||||
export { validateReadPath } from './path-security';
|
||||
@@ -46,6 +46,117 @@ function wrapForEvaluate(code: string): string {
|
||||
: `(async()=>(${trimmed}))()`;
|
||||
}
|
||||
|
||||
/** Flags split out of `js`/`eval` args by parseOutArgs. */
|
||||
export interface OutArgs {
|
||||
outPath?: string;
|
||||
raw: boolean;
|
||||
rest: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `--out <path>` / `--out=<path>` and `--raw` / `--raw=true|false` out of an
|
||||
* arg list, returning the flags plus the remaining positional args (`rest`).
|
||||
*
|
||||
* Single source of truth shared by the js/eval handlers and the write-capability
|
||||
* gate in server.ts, so the two never disagree on what counts as an `--out`
|
||||
* invocation. Throws on malformed usage (repeated `--out`, missing value, bad
|
||||
* `--raw` value) so the user gets a clear error instead of a silent misparse.
|
||||
*/
|
||||
export function parseOutArgs(args: string[]): OutArgs {
|
||||
let outPath: string | undefined;
|
||||
let raw = false;
|
||||
const rest: string[] = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--out') {
|
||||
if (outPath !== undefined) throw new Error('--out specified more than once');
|
||||
const val = args[i + 1];
|
||||
if (val === undefined || val.startsWith('--')) throw new Error('--out requires a file path');
|
||||
outPath = val;
|
||||
i++;
|
||||
} else if (a.startsWith('--out=')) {
|
||||
if (outPath !== undefined) throw new Error('--out specified more than once');
|
||||
const val = a.slice('--out='.length);
|
||||
if (val === '') throw new Error('--out requires a file path');
|
||||
outPath = val;
|
||||
} else if (a === '--raw') {
|
||||
raw = true;
|
||||
} else if (a.startsWith('--raw=')) {
|
||||
const v = a.slice('--raw='.length).toLowerCase();
|
||||
if (v !== 'true' && v !== 'false') throw new Error('--raw must be true or false');
|
||||
raw = v === 'true';
|
||||
} else {
|
||||
rest.push(a);
|
||||
}
|
||||
}
|
||||
return { outPath, raw, rest };
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff an arg list contains an `--out` flag in any accepted form
|
||||
* (`--out <path>` or `--out=<path>`). Used by the write-capability gate to
|
||||
* decide whether an otherwise-read command (`js`/`eval`) is actually a write
|
||||
* invocation. Mirrors parseOutArgs's `--out` recognition exactly. Never throws —
|
||||
* a malformed `--out=` still counts as an out attempt (fail safe: gate it).
|
||||
*/
|
||||
export function hasOutArg(args: string[]): boolean {
|
||||
return args.some(a => a === '--out' || a.startsWith('--out='));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an evaluate() result to its string form — the exact conversion `js`/`eval`
|
||||
* used inline before `--out` existed. Kept byte-for-byte: `typeof === 'object'`
|
||||
* (which includes `null`) goes through JSON.stringify (so `null` → `"null"`);
|
||||
* everything else via `String(result ?? '')` (so `undefined` → `''`). JSON.stringify
|
||||
* still throws on circular / BigInt-bearing results, same as before.
|
||||
*/
|
||||
export function resultToString(result: unknown): string {
|
||||
return typeof result === 'object'
|
||||
? JSON.stringify(result, null, 2)
|
||||
: String(result ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an evaluate result string to disk for `--out`, returning bytes written.
|
||||
*
|
||||
* When the result is a base64 data URL (`data:<type>;...;base64,<payload>`) and
|
||||
* `raw` is false, decode the payload to raw bytes — this is the Excalidraw / og-image
|
||||
* path where a render function returns a PNG data URL. The header is parsed
|
||||
* case-insensitively and split on the FIRST comma (data URLs can contain commas in
|
||||
* the payload). The payload is validated against the base64 charset before decoding,
|
||||
* because `Buffer.from(_, 'base64')` silently drops invalid characters and would
|
||||
* otherwise write corrupted bytes. `--raw` forces a literal write even for data URLs.
|
||||
*
|
||||
* Non-base64 strings are surrogate-sanitized (matching what the stdout egress path
|
||||
* did before) and written as UTF-8. Parent directories are created — validateOutputPath
|
||||
* gates the location but does not mkdir.
|
||||
*/
|
||||
export function writeEvalResult(outPath: string, str: string, opts: { raw: boolean }): number {
|
||||
validateOutputPath(outPath);
|
||||
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
||||
|
||||
if (!opts.raw && str.startsWith('data:')) {
|
||||
const comma = str.indexOf(',');
|
||||
if (comma !== -1) {
|
||||
const header = str.slice('data:'.length, comma);
|
||||
const tokens = header.split(';').map(t => t.trim().toLowerCase());
|
||||
if (tokens.includes('base64')) {
|
||||
const payload = str.slice(comma + 1).replace(/\s+/g, '');
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) {
|
||||
throw new Error('--out: malformed base64 in data URL (decode would corrupt output)');
|
||||
}
|
||||
const buf = Buffer.from(payload, 'base64');
|
||||
fs.writeFileSync(outPath, buf);
|
||||
return buf.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buf = Buffer.from(stripLoneSurrogates(str), 'utf-8');
|
||||
fs.writeFileSync(outPath, buf);
|
||||
return buf.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract clean text from a page (strips script/style/noscript/svg).
|
||||
* Exported for DRY reuse in meta-commands (diff).
|
||||
@@ -179,24 +290,36 @@ export async function handleReadCommand(
|
||||
}
|
||||
|
||||
case 'js': {
|
||||
const expr = args[0];
|
||||
if (!expr) throw new Error('Usage: browse js <expression>');
|
||||
const { outPath, raw, rest } = parseOutArgs(args);
|
||||
const expr = rest[0];
|
||||
if (!expr) throw new Error('Usage: browse js <expression> [--out <file>] [--raw]');
|
||||
if (bm) assertJsOriginAllowed(bm, page.url());
|
||||
const wrapped = wrapForEvaluate(expr);
|
||||
const result = await target.evaluate(wrapped);
|
||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||
const str = resultToString(result);
|
||||
if (outPath) {
|
||||
const n = writeEvalResult(outPath, str, { raw });
|
||||
return `JS result written: ${outPath} (${n} bytes)`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
case 'eval': {
|
||||
const filePath = args[0];
|
||||
if (!filePath) throw new Error('Usage: browse eval <js-file>');
|
||||
const { outPath, raw, rest } = parseOutArgs(args);
|
||||
const filePath = rest[0];
|
||||
if (!filePath) throw new Error('Usage: browse eval <js-file> [--out <file>] [--raw]');
|
||||
if (bm) assertJsOriginAllowed(bm, page.url());
|
||||
validateReadPath(filePath);
|
||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||
const code = fs.readFileSync(filePath, 'utf-8');
|
||||
const wrapped = wrapForEvaluate(code);
|
||||
const result = await target.evaluate(wrapped);
|
||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||
const str = resultToString(result);
|
||||
if (outPath) {
|
||||
const n = writeEvalResult(outPath, str, { raw });
|
||||
return `Eval result written: ${outPath} (${n} bytes)`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
case 'css': {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Screenshot size guard — keep full-page screenshots ≤ 2000px max-dim.
|
||||
*
|
||||
* The Anthropic vision API rejects images whose longest dimension exceeds
|
||||
* 2000 image-pixels (post deviceScaleFactor). Full-page screenshots of long
|
||||
* pages routinely exceed that, silently bricking the session: the agent
|
||||
* burns turns on a base64 blob that errors model-side with no useful
|
||||
* stderr surfacing on the browse side.
|
||||
*
|
||||
* This module centralizes the "after page.screenshot, check dimensions and
|
||||
* downscale if too big" path so every full-page caller in browse/src can
|
||||
* share the same enforcement. The cap is image-pixels, not CSS pixels,
|
||||
* matching the Anthropic API's own threshold.
|
||||
*
|
||||
* Used by: snapshot.ts (annotated, heatmap), meta-commands.ts (screenshot),
|
||||
* write-commands.ts (prettyscreenshot). See test/snapshot-meta-write-guard.test.ts.
|
||||
*
|
||||
* Closes #1214.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync } from "fs";
|
||||
|
||||
const MAX_DIMENSION_PX = 2000;
|
||||
|
||||
export interface SizeGuardResult {
|
||||
/** True if the input image exceeded MAX_DIMENSION_PX and was downscaled. */
|
||||
resized: boolean;
|
||||
/** Final width and height (pixels) of the image as written/returned. */
|
||||
width: number;
|
||||
height: number;
|
||||
/** Original dimensions before any downscale. */
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect an image buffer and downscale if its longest side exceeds the
|
||||
* 2000px Anthropic vision API cap. Preserves aspect ratio. Encodes back
|
||||
* to PNG. Returns the resulting buffer plus a diagnostic shape.
|
||||
*
|
||||
* Imports sharp lazily so the module load cost only hits screenshot paths
|
||||
* (sharp's native binding is non-trivial to initialize).
|
||||
*/
|
||||
export async function guardScreenshotBuffer(input: Buffer): Promise<{ buffer: Buffer; result: SizeGuardResult }> {
|
||||
const sharpModule = await import("sharp");
|
||||
const sharp = sharpModule.default ?? sharpModule;
|
||||
const image = sharp(input);
|
||||
const metadata = await image.metadata();
|
||||
const width = metadata.width ?? 0;
|
||||
const height = metadata.height ?? 0;
|
||||
|
||||
const longest = Math.max(width, height);
|
||||
if (longest <= MAX_DIMENSION_PX) {
|
||||
return {
|
||||
buffer: input,
|
||||
result: {
|
||||
resized: false,
|
||||
width,
|
||||
height,
|
||||
originalWidth: width,
|
||||
originalHeight: height,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const scale = MAX_DIMENSION_PX / longest;
|
||||
const newWidth = Math.round(width * scale);
|
||||
const newHeight = Math.round(height * scale);
|
||||
|
||||
const resized = await image
|
||||
.resize(newWidth, newHeight, { fit: "inside" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
process.stderr.write(
|
||||
`[screenshot-size-guard] image ${width}x${height} exceeded ${MAX_DIMENSION_PX}px max-dim; ` +
|
||||
`downscaled to ${newWidth}x${newHeight} to fit Anthropic vision API\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
buffer: resized,
|
||||
result: {
|
||||
resized: true,
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
originalWidth: width,
|
||||
originalHeight: height,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* File-mode variant: read the image at the given path, downscale if
|
||||
* needed, and write the result back to the same path. Returns the
|
||||
* diagnostic shape. Use this after `await page.screenshot({ path, ... })`.
|
||||
*/
|
||||
export async function guardScreenshotPath(filePath: string): Promise<SizeGuardResult> {
|
||||
const input = readFileSync(filePath);
|
||||
const { buffer, result } = await guardScreenshotBuffer(input);
|
||||
if (result.resized) {
|
||||
writeFileSync(filePath, buffer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const SCREENSHOT_MAX_DIMENSION_PX = MAX_DIMENSION_PX;
|
||||
@@ -135,7 +135,7 @@ export function getClassifierStatus(): ClassifierStatus {
|
||||
|
||||
// ─── Model download + staging ────────────────────────────────
|
||||
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
export async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
|
||||
@@ -144,16 +144,30 @@ async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const writer = fs.createWriteStream(tmp);
|
||||
// @ts-ignore — Node stream compat
|
||||
const reader = res.body.getReader();
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) { done = true; break; }
|
||||
writer.write(chunk.value);
|
||||
try {
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) { done = true; break; }
|
||||
writer.write(chunk.value);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writer.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
fs.renameSync(tmp, dest);
|
||||
} catch (err) {
|
||||
// Drop the half-written tmp so we don't ship a truncated model file to
|
||||
// a retry's renameSync. Wait for the writer to close fully before
|
||||
// unlinking: Node's createWriteStream lazily opens the FD and flushes
|
||||
// buffered writes during destroy(), so a naive unlinkSync hits ENOENT
|
||||
// first and the writer re-creates the file on the next tick.
|
||||
await new Promise<void>((resolve) => {
|
||||
writer.once('close', () => resolve());
|
||||
writer.destroy();
|
||||
});
|
||||
try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ }
|
||||
throw err;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writer.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
fs.renameSync(tmp, dest);
|
||||
}
|
||||
|
||||
async function ensureTestsavantStaged(onProgress?: (msg: string) => void): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Security sidecar client — IPC layer for the Node L4 classifier subprocess.
|
||||
*
|
||||
* Spawn model: lazy. First call to scan() spawns the sidecar, warms it (the
|
||||
* sidecar's loadTestsavant call on first scan-page-content), and reuses
|
||||
* the same process for every subsequent scan. The process dies when the
|
||||
* browse server exits (Node's stdin-close behavior).
|
||||
*
|
||||
* Reliability:
|
||||
* - 5s default timeout per scan. Caller can override per-call.
|
||||
* - 64KB request cap. Larger payloads short-circuit with `payload-too-large`.
|
||||
* - Respawn capped at 3 failures within 10 minutes; further failures
|
||||
* trip a circuit breaker that returns `available: false` until reset.
|
||||
* - Parent-exit cleanup: process.on('exit') sends SIGTERM to the child.
|
||||
*
|
||||
* Failure semantics:
|
||||
* - Node not on PATH → available() returns false; caller (the
|
||||
* /pty-inject-scan endpoint) returns l4: { available: false } and the
|
||||
* extension degrades to WARN + user confirm.
|
||||
* - Scan throws or times out → caller treats as L4-unavailable for that
|
||||
* request and falls through to L1-L3-only verdict.
|
||||
*
|
||||
* Single-process singleton. Multiple callers within the same browse
|
||||
* process share one sidecar.
|
||||
*/
|
||||
|
||||
import { ChildProcessByStdio, spawn } from "child_process";
|
||||
import { Readable, Writable } from "stream";
|
||||
import { findSecuritySidecar } from "./find-security-sidecar";
|
||||
|
||||
const REQUEST_CAP_BYTES = 64 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const RESPAWN_WINDOW_MS = 10 * 60 * 1000;
|
||||
const RESPAWN_LIMIT = 3;
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (response: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface SidecarState {
|
||||
child: ChildProcessByStdio<Writable, Readable, Readable> | null;
|
||||
pending: Map<string, PendingRequest>;
|
||||
buffer: string;
|
||||
failures: number[]; // timestamps of recent failures
|
||||
available: boolean;
|
||||
/** True after circuit-breaker tripped; stays true until reset() */
|
||||
brokenCircuit: boolean;
|
||||
nextId: number;
|
||||
}
|
||||
|
||||
let state: SidecarState | null = null;
|
||||
|
||||
function getState(): SidecarState {
|
||||
if (!state) {
|
||||
state = {
|
||||
child: null,
|
||||
pending: new Map(),
|
||||
buffer: "",
|
||||
failures: [],
|
||||
available: true,
|
||||
brokenCircuit: false,
|
||||
nextId: 1,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function recordFailure(): void {
|
||||
const s = getState();
|
||||
const now = Date.now();
|
||||
s.failures = s.failures.filter((t) => now - t < RESPAWN_WINDOW_MS);
|
||||
s.failures.push(now);
|
||||
if (s.failures.length >= RESPAWN_LIMIT) {
|
||||
s.brokenCircuit = true;
|
||||
s.available = false;
|
||||
}
|
||||
}
|
||||
|
||||
function processBuffer(): void {
|
||||
const s = getState();
|
||||
let idx = s.buffer.indexOf("\n");
|
||||
while (idx !== -1) {
|
||||
const line = s.buffer.slice(0, idx).trim();
|
||||
s.buffer = s.buffer.slice(idx + 1);
|
||||
idx = s.buffer.indexOf("\n");
|
||||
if (!line) continue;
|
||||
let parsed: { id?: string; ok?: boolean; verdict?: unknown; status?: unknown; error?: string };
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
// Malformed line — record as failure but don't reject any specific
|
||||
// pending request (we don't know which one this was meant for).
|
||||
recordFailure();
|
||||
continue;
|
||||
}
|
||||
const id = typeof parsed.id === "string" ? parsed.id : null;
|
||||
if (!id) continue;
|
||||
const pending = s.pending.get(id);
|
||||
if (!pending) continue;
|
||||
s.pending.delete(id);
|
||||
clearTimeout(pending.timer);
|
||||
if (parsed.ok) {
|
||||
pending.resolve(parsed);
|
||||
} else {
|
||||
recordFailure();
|
||||
pending.reject(new Error(parsed.error ?? "sidecar-error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shutdownChild(): void {
|
||||
const s = getState();
|
||||
if (!s.child) return;
|
||||
try {
|
||||
s.child.kill("SIGTERM");
|
||||
} catch {
|
||||
// Already dead.
|
||||
}
|
||||
s.child = null;
|
||||
for (const [, p] of s.pending) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(new Error("sidecar-died"));
|
||||
}
|
||||
s.pending.clear();
|
||||
}
|
||||
|
||||
function spawnSidecar(): boolean {
|
||||
const s = getState();
|
||||
if (s.brokenCircuit) return false;
|
||||
const location = findSecuritySidecar();
|
||||
if (!location) {
|
||||
s.available = false;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const child = spawn(location.node, [location.entry], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
s.buffer += chunk.toString("utf-8");
|
||||
processBuffer();
|
||||
});
|
||||
child.on("exit", () => {
|
||||
shutdownChild();
|
||||
});
|
||||
child.on("error", () => {
|
||||
recordFailure();
|
||||
shutdownChild();
|
||||
});
|
||||
s.child = child;
|
||||
s.available = true;
|
||||
return true;
|
||||
} catch {
|
||||
recordFailure();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort parent-exit cleanup. Node's "exit" event blocks async work, so
|
||||
// we send SIGTERM synchronously and let the OS reap the child.
|
||||
process.on("exit", () => shutdownChild());
|
||||
|
||||
export interface SidecarAvailability {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function isSidecarAvailable(): SidecarAvailability {
|
||||
const s = getState();
|
||||
if (s.brokenCircuit) return { available: false, reason: "circuit-broken" };
|
||||
if (s.child) return { available: true };
|
||||
// Probe via findSecuritySidecar without spawning. If the resolver returns
|
||||
// null (no node on PATH, no entry on disk), we're permanently unavailable
|
||||
// until a setup re-run.
|
||||
const location = findSecuritySidecar();
|
||||
if (!location) return { available: false, reason: "no-node-or-entry" };
|
||||
return { available: true };
|
||||
}
|
||||
|
||||
export async function scanWithSidecar(text: string, opts?: { timeoutMs?: number }): Promise<{ verdict: unknown }> {
|
||||
const s = getState();
|
||||
if (s.brokenCircuit) {
|
||||
throw new Error("sidecar-circuit-broken");
|
||||
}
|
||||
if (Buffer.byteLength(text, "utf-8") > REQUEST_CAP_BYTES) {
|
||||
throw new Error("payload-too-large");
|
||||
}
|
||||
if (!s.child) {
|
||||
if (!spawnSidecar()) {
|
||||
throw new Error("sidecar-spawn-failed");
|
||||
}
|
||||
}
|
||||
const id = String(s.nextId++);
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
s.pending.delete(id);
|
||||
recordFailure();
|
||||
reject(new Error("sidecar-timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
s.pending.set(id, {
|
||||
resolve: (response: unknown) => {
|
||||
const r = response as { verdict?: unknown };
|
||||
resolve({ verdict: r.verdict });
|
||||
},
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
|
||||
const payload = JSON.stringify({ id, op: "scan-page-content", text }) + "\n";
|
||||
try {
|
||||
s.child!.stdin.write(payload);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
s.pending.delete(id);
|
||||
recordFailure();
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Reset the circuit breaker. Test-only escape hatch. */
|
||||
export function resetSidecarForTests(): void {
|
||||
shutdownChild();
|
||||
state = null;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Security sidecar entry — Node script that hosts the L4 ML classifier on
|
||||
* behalf of the compiled browse server.
|
||||
*
|
||||
* Why a sidecar:
|
||||
* - browse/src/security-classifier.ts depends on @huggingface/transformers
|
||||
* which loads onnxruntime-node, a native module that fails to `dlopen`
|
||||
* from Bun's compile-binary temp extraction dir (CLAUDE.md "Sidebar
|
||||
* security stack" section). Importing the classifier into server.ts
|
||||
* would brick the compiled binary at startup.
|
||||
* - sidebar-agent.ts (the previous host of the classifier) was removed
|
||||
* when the PTY proved out. The classifier file still ships but had no
|
||||
* caller — exactly the gap codex flagged in #1370.
|
||||
*
|
||||
* This entry runs under plain Node (resolved by find-security-sidecar.ts).
|
||||
* It reads NDJSON requests from stdin and writes NDJSON responses to stdout.
|
||||
*
|
||||
* Protocol (one JSON object per line, both directions):
|
||||
* request: { id: string, op: "scan-page-content" | "ping", text?: string }
|
||||
* response: { id: string, ok: true, verdict: LayerSignal } |
|
||||
* { id: string, ok: false, error: string }
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Spawned lazily by security-sidecar-client.ts on first /pty-inject-scan
|
||||
* - Exits when stdin closes (parent gone) — standard Node behavior
|
||||
* - Exits on SIGTERM cleanly
|
||||
*
|
||||
* Failure modes:
|
||||
* - Model download fails → reply { ok: false, error: "model-load" } and
|
||||
* keep the loop alive for the next request (caller decides whether to
|
||||
* retry or fail-safe to L1-L3-only)
|
||||
*/
|
||||
|
||||
import * as readline from "readline";
|
||||
import { scanPageContent, getClassifierStatus, loadTestsavant } from "./security-classifier";
|
||||
|
||||
interface Request {
|
||||
id: string;
|
||||
op: "scan-page-content" | "ping" | "status";
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface OkResponse {
|
||||
id: string;
|
||||
ok: true;
|
||||
verdict?: unknown;
|
||||
status?: unknown;
|
||||
}
|
||||
|
||||
interface ErrResponse {
|
||||
id: string;
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
function write(obj: OkResponse | ErrResponse): void {
|
||||
process.stdout.write(JSON.stringify(obj) + "\n");
|
||||
}
|
||||
|
||||
async function handle(req: Request): Promise<void> {
|
||||
if (!req || typeof req.id !== "string") {
|
||||
// Drop unidentifiable requests silently — protocol invariant.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (req.op === "ping") {
|
||||
write({ id: req.id, ok: true, verdict: { layer: "ping", verdict: "alive", score: 0 } });
|
||||
return;
|
||||
}
|
||||
if (req.op === "status") {
|
||||
write({ id: req.id, ok: true, status: getClassifierStatus() });
|
||||
return;
|
||||
}
|
||||
if (req.op === "scan-page-content") {
|
||||
if (typeof req.text !== "string") {
|
||||
write({ id: req.id, ok: false, error: "missing-text" });
|
||||
return;
|
||||
}
|
||||
// Warm the classifier once per process; subsequent scans are fast.
|
||||
await loadTestsavant().catch(() => {
|
||||
// loadTestsavant degrades gracefully; scanPageContent below will
|
||||
// return a fail-open verdict if the model never loaded.
|
||||
});
|
||||
const verdict = await scanPageContent(req.text);
|
||||
write({ id: req.id, ok: true, verdict });
|
||||
return;
|
||||
}
|
||||
write({ id: req.id, ok: false, error: `unknown-op:${(req as { op?: unknown }).op}` });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
write({ id: req.id, ok: false, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
// readline buffers stdin into one-line chunks. Stay alive until stdin
|
||||
// closes (parent gone) — Node exits naturally then.
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
let req: Request;
|
||||
try {
|
||||
req = JSON.parse(line) as Request;
|
||||
} catch {
|
||||
// Malformed line — write a generic error without an id, callers can
|
||||
// detect via missing id and trip the circuit breaker.
|
||||
write({ id: "<malformed>", ok: false, error: "malformed-json" });
|
||||
return;
|
||||
}
|
||||
// Fire-and-forget; concurrent requests get id-correlated responses.
|
||||
void handle(req);
|
||||
});
|
||||
rl.on("close", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
process.on("SIGTERM", () => process.exit(0));
|
||||
process.on("SIGINT", () => process.exit(0));
|
||||
}
|
||||
|
||||
main();
|
||||
+750
-173
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import * as Diff from 'diff';
|
||||
import { TEMP_DIR, isPathWithin } from './platform';
|
||||
import { escapeEnvelopeSentinels } from './content-security';
|
||||
import { stripLoneSurrogates } from './sanitize';
|
||||
import { guardScreenshotPath } from './screenshot-size-guard';
|
||||
|
||||
// Roles considered "interactive" for the -i flag
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
@@ -418,6 +419,7 @@ export async function handleSnapshot(
|
||||
}, boxes);
|
||||
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
await guardScreenshotPath(screenshotPath);
|
||||
|
||||
// Always remove overlays
|
||||
await page.evaluate(() => {
|
||||
@@ -538,6 +540,7 @@ export async function handleSnapshot(
|
||||
}, boxes);
|
||||
|
||||
await page.screenshot({ path: heatmapPath, fullPage: true });
|
||||
await guardScreenshotPath(heatmapPath);
|
||||
|
||||
// Remove heatmap overlays
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// SSE endpoint helper — shared cleanup contract for stream endpoints.
|
||||
//
|
||||
// Pre-helper, /activity/stream and /inspector/events implemented the same
|
||||
// pattern in parallel and both leaked subscribers when enqueue failed
|
||||
// without a corresponding abort signal (e.g. Chromium MV3 service-worker
|
||||
// suspend dropped the TCP without an abort edge). The subscriber closure
|
||||
// stayed in the Set, capturing the ReadableStreamDefaultController plus
|
||||
// any payloads queued behind it. Over a multi-day sidebar session this
|
||||
// compounded into multi-MB of retained controllers per dead connection.
|
||||
//
|
||||
// Centralizing the cleanup contract here means any future SSE endpoint
|
||||
// inherits the invariant — cleanup runs on abort, enqueue failure, AND
|
||||
// heartbeat failure, exactly once, regardless of which edge fires first.
|
||||
|
||||
import { stripLoneSurrogates } from './sanitize';
|
||||
|
||||
/**
|
||||
* JSON.stringify replacer that strips lone UTF-16 surrogates from string
|
||||
* values before they get escape-encoded. Pair with stringify when the
|
||||
* consumer will JSON.parse the payload back into JS strings (SSE clients
|
||||
* do this). Required at every SSE egress that ships page-content-derived
|
||||
* fields — see CLAUDE.md "Unicode sanitization at server egress".
|
||||
*/
|
||||
function sanitizeReplacer(_key: string, value: unknown): unknown {
|
||||
return typeof value === 'string' ? stripLoneSurrogates(value) : value;
|
||||
}
|
||||
|
||||
/** Send an SSE event. Handles JSON encoding + lone-surrogate sanitization. */
|
||||
export type SseSender = (event: string, data: unknown) => void;
|
||||
|
||||
export interface SseEndpointConfig<T> {
|
||||
/**
|
||||
* Optional. Runs once after the stream opens, before subscribing for live
|
||||
* events. Use for initial event replay (activity gap detection, history
|
||||
* burst) or a current-state snapshot (inspector). The `send` helper
|
||||
* handles JSON encoding with sanitizeReplacer and SSE framing; pass
|
||||
* any event name and any payload object.
|
||||
*/
|
||||
initialReplay?: (send: SseSender) => void;
|
||||
|
||||
/**
|
||||
* Subscribe to the live event source. Receives a `notify` callback;
|
||||
* returns an unsubscribe function. The callback routes through the
|
||||
* helper's safeEnqueue + cleanup-on-throw, so a dead consumer ends up
|
||||
* removed from the subscriber set on the very next event (instead of
|
||||
* waiting for an abort that may never fire).
|
||||
*/
|
||||
subscribe: (notify: (entry: T) => void) => () => void;
|
||||
|
||||
/**
|
||||
* SSE event name for live events. `data: <JSON.stringify(entry)>\n\n`
|
||||
* is wrapped automatically. /activity/stream uses 'activity';
|
||||
* /inspector/events uses 'inspector'.
|
||||
*/
|
||||
liveEventName: string;
|
||||
|
||||
/** Heartbeat interval in ms. Default: 15000. */
|
||||
heartbeatMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a streaming Response that owns the cleanup contract:
|
||||
* - safeEnqueue catches enqueue throws → cleanup
|
||||
* - 15s heartbeat catches dead peers; failure → cleanup
|
||||
* - req.signal abort → cleanup
|
||||
* - cleanup is idempotent (clearInterval + unsubscribe + try close)
|
||||
*/
|
||||
export function createSseEndpoint<T>(
|
||||
req: Request,
|
||||
config: SseEndpointConfig<T>,
|
||||
): Response {
|
||||
const heartbeatMs = config.heartbeatMs ?? 15000;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
let cleanedUp = false;
|
||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
if (heartbeat !== null) {
|
||||
clearInterval(heartbeat);
|
||||
heartbeat = null;
|
||||
}
|
||||
if (unsubscribe !== null) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Expected: stream already closed by the consumer.
|
||||
}
|
||||
};
|
||||
|
||||
const send: SseSender = (event, data) => {
|
||||
if (cleanedUp) return;
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`event: ${event}\ndata: ${JSON.stringify(data, sanitizeReplacer)}\n\n`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Consumer disconnected mid-write. Tear down so this subscriber
|
||||
// doesn't sit in the set forever.
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
// Initial replay (caller-provided).
|
||||
if (config.initialReplay) {
|
||||
try {
|
||||
config.initialReplay(send);
|
||||
} catch {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
if (cleanedUp) return;
|
||||
}
|
||||
|
||||
// Subscribe for live events.
|
||||
unsubscribe = config.subscribe((entry) => {
|
||||
send(config.liveEventName, entry);
|
||||
});
|
||||
|
||||
// Heartbeat keeps NAT boxes and proxies from dropping idle SSE,
|
||||
// and serves as a liveness probe: an enqueue failure here is the
|
||||
// cheapest way to learn the consumer is gone without waiting for
|
||||
// an abort signal that may never arrive.
|
||||
heartbeat = setInterval(() => {
|
||||
if (cleanedUp) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
}, heartbeatMs);
|
||||
|
||||
req.signal.addEventListener('abort', cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
+146
-3
@@ -239,18 +239,156 @@ export function buildStealthScript(hw: HostProfile): string {
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended-mode init script — six detection-vector patches. Applied
|
||||
* AFTER the default mask, so the property-getter version remains in
|
||||
* place if any of the deletion paths fail.
|
||||
*
|
||||
* Self-contained string so it can be passed to addInitScript({ content })
|
||||
* without bundling concerns.
|
||||
*/
|
||||
export const EXTENDED_STEALTH_SCRIPT = `
|
||||
(() => {
|
||||
try {
|
||||
// 1. Fully delete navigator.webdriver from the prototype so
|
||||
// \`"webdriver" in navigator\` returns false (not just falsy).
|
||||
delete Object.getPrototypeOf(navigator).webdriver;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
// 2. WebGL renderer spoof — SwiftShader is the canonical software-GPU
|
||||
// tell. Spoof to a plausible Apple M1 Pro string.
|
||||
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
||||
WebGLRenderingContext.prototype.getParameter = function (parameter) {
|
||||
// UNMASKED_VENDOR_WEBGL (37445) → 'Apple Inc.'
|
||||
if (parameter === 37445) return 'Apple Inc.';
|
||||
// UNMASKED_RENDERER_WEBGL (37446) → realistic Apple silicon string
|
||||
if (parameter === 37446) return 'Apple M1 Pro, OpenGL 4.1';
|
||||
return getParameter.call(this, parameter);
|
||||
};
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
// 3. navigator.plugins: real PluginArray with MimeType objects.
|
||||
const makePlugin = (name, filename, desc, mimes) => {
|
||||
const p = Object.create(Plugin.prototype);
|
||||
Object.defineProperties(p, {
|
||||
name: { get: () => name },
|
||||
filename: { get: () => filename },
|
||||
description: { get: () => desc },
|
||||
length: { get: () => mimes.length },
|
||||
});
|
||||
mimes.forEach((m, i) => { p[i] = m; });
|
||||
p.item = (i) => mimes[i];
|
||||
p.namedItem = (n) => mimes.find((m) => m.type === n);
|
||||
return p;
|
||||
};
|
||||
const makeMime = (type, suffixes, desc) => {
|
||||
const m = Object.create(MimeType.prototype);
|
||||
Object.defineProperties(m, {
|
||||
type: { get: () => type },
|
||||
suffixes: { get: () => suffixes },
|
||||
description: { get: () => desc },
|
||||
});
|
||||
return m;
|
||||
};
|
||||
const pdfMime = makeMime('application/pdf', 'pdf', '');
|
||||
const cpdfMime = makeMime('application/x-google-chrome-pdf', 'pdf', 'Portable Document Format');
|
||||
const plugins = [
|
||||
makePlugin('PDF Viewer', 'internal-pdf-viewer', '', [pdfMime]),
|
||||
makePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', '', [cpdfMime]),
|
||||
makePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', '', [cpdfMime]),
|
||||
];
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => {
|
||||
const arr = Object.create(PluginArray.prototype);
|
||||
Object.defineProperty(arr, 'length', { get: () => plugins.length });
|
||||
plugins.forEach((p, i) => { arr[i] = p; });
|
||||
arr.item = (i) => plugins[i];
|
||||
arr.namedItem = (n) => plugins.find((p) => p.name === n);
|
||||
arr.refresh = () => {};
|
||||
return arr;
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
// 4. window.chrome shape — chrome.app + chrome.runtime + loadTimes/csi.
|
||||
if (!window.chrome) {
|
||||
window.chrome = {};
|
||||
}
|
||||
if (!window.chrome.runtime) {
|
||||
window.chrome.runtime = { OnInstalledReason: {}, OnRestartRequiredReason: {} };
|
||||
}
|
||||
if (!window.chrome.app) {
|
||||
window.chrome.app = {
|
||||
isInstalled: false,
|
||||
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
|
||||
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
|
||||
};
|
||||
}
|
||||
if (!window.chrome.loadTimes) {
|
||||
window.chrome.loadTimes = function () {
|
||||
return { commitLoadTime: Date.now() / 1000, finishLoadTime: Date.now() / 1000 };
|
||||
};
|
||||
}
|
||||
if (!window.chrome.csi) {
|
||||
window.chrome.csi = function () {
|
||||
return { startE: Date.now(), onloadT: Date.now(), pageT: 0, tran: 15 };
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
// 5. mediaDevices — some headless builds drop it entirely.
|
||||
if (!navigator.mediaDevices) {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
get: () => ({ enumerateDevices: () => Promise.resolve([]) }),
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
// 6. CDP cdc_* property cleanup. Chromium under CDP sets cdc_*-prefixed
|
||||
// globals (driver injection markers); a bot detector finds them by
|
||||
// iterating window keys. Strip all matching keys.
|
||||
for (const k of Object.keys(window)) {
|
||||
if (k.startsWith('cdc_')) {
|
||||
try { delete window[k]; } catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
`;
|
||||
|
||||
function extendedModeEnabled(): boolean {
|
||||
const v = process.env.GSTACK_STEALTH;
|
||||
return v === 'extended' || v === '1' || v === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stealth patches to a fresh BrowserContext (or persistent context).
|
||||
* Called by browser-manager.launch() and launchHeaded().
|
||||
*
|
||||
* Resolves the host profile from process.env at call time so per-install
|
||||
* Always applies the always-on Layer C stealth script (built from the
|
||||
* per-install host profile) — the consistency-first default. When
|
||||
* GSTACK_STEALTH=extended is set, layers the opt-in EXTENDED_STEALTH_SCRIPT
|
||||
* on top: its window.chrome.* patches are `if (!...)`-guarded, so Layer C's
|
||||
* richer shapes win, while the extended-only additions (WebGL spoof, faked
|
||||
* navigator.plugins, mediaDevices, cdc_* cleanup) apply on top. Extended
|
||||
* mode actively LIES about the browser and can break sites that reflect on
|
||||
* these properties, so it stays off by default.
|
||||
*
|
||||
* Host profile is resolved from process.env at call time so per-install
|
||||
* values bake into the script before Playwright sends it to Chromium via
|
||||
* Page.addScriptToEvaluateOnNewDocument.
|
||||
*/
|
||||
export async function applyStealth(context: BrowserContext): Promise<void> {
|
||||
const hw = readHostProfile();
|
||||
const script = buildStealthScript(hw);
|
||||
await context.addInitScript({ content: script });
|
||||
await context.addInitScript({ content: buildStealthScript(hw) });
|
||||
if (extendedModeEnabled()) {
|
||||
await context.addInitScript({ content: EXTENDED_STEALTH_SCRIPT });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,3 +497,8 @@ export const STEALTH_IGNORE_DEFAULT_ARGS = [
|
||||
'--disable-component-update',
|
||||
'--disable-default-apps',
|
||||
];
|
||||
|
||||
/** Test-only helper: report whether extended mode is currently active. */
|
||||
export function isExtendedStealthEnabled(): boolean {
|
||||
return extendedModeEnabled();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* terminal-agent process-control primitives shared by cli.ts spawn site,
|
||||
* server.ts shutdown teardown, and the v1.44 watchdog/respawn loop.
|
||||
*
|
||||
* Why this exists: pre-v1.44 used `pkill -f terminal-agent\.ts`, which
|
||||
* matches any process whose argv contains the string and would kill
|
||||
* sibling gstack sessions on the same host. The agent now writes a
|
||||
* structured `terminal-agent-pid` record (`{pid, gen, startedAt}`) and
|
||||
* every kill site routes through `killAgentByRecord` here — identity-based,
|
||||
* no regex.
|
||||
*
|
||||
* The `gen` field is a per-boot generation counter. Loopback /internal/*
|
||||
* calls from the parent server include `X-Browse-Gen` so a slow agent that
|
||||
* the watchdog respawned around can't accidentally service a stale grant
|
||||
* from the old generation.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { safeUnlink, safeKill, isProcessAlive } from './error-handling';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
|
||||
/**
|
||||
* Locate the terminal-agent script on disk. In dev (cli.ts running via
|
||||
* `bun run`), it lives next to this file in browse/src. In a compiled
|
||||
* binary, Bun's --compile bakes the source into the executable and
|
||||
* exposes it relative to process.execPath. Either path must work or
|
||||
* the agent can't be spawned at all.
|
||||
*/
|
||||
export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null {
|
||||
const meta = searchHints.metaDir || __dirname;
|
||||
const exec = searchHints.execPath || process.execPath;
|
||||
const candidates = [
|
||||
path.resolve(meta, 'terminal-agent.ts'),
|
||||
path.resolve(path.dirname(exec), '..', 'src', 'terminal-agent.ts'),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a fresh terminal-agent as a detached child. Handles the standard
|
||||
* three steps: kill any prior agent recorded at `<stateDir>/terminal-agent-pid`,
|
||||
* clear the stale record, then `Bun.spawn(['bun', 'run', script], ...)` with
|
||||
* env wiring. Returns the PID of the new agent on success, null when the
|
||||
* agent script can't be located.
|
||||
*
|
||||
* Used by both the CLI cold-start path (cli.ts) and the v1.44 watchdog in
|
||||
* server.ts. Centralizing here removes a copy-paste between them and means
|
||||
* future spawn-env additions (e.g. BROWSE_OWNER_PID for the generation
|
||||
* counter rollout) land in one place.
|
||||
*/
|
||||
export function spawnTerminalAgent(opts: {
|
||||
stateFile: string;
|
||||
serverPort: number;
|
||||
cwd?: string;
|
||||
/** Optional extra env vars to add to the agent's process env. */
|
||||
extraEnv?: Record<string, string>;
|
||||
/** Override script lookup for tests. */
|
||||
scriptPath?: string;
|
||||
}): number | null {
|
||||
const stateDir = path.dirname(opts.stateFile);
|
||||
const prior = readAgentRecord(stateDir);
|
||||
if (prior) {
|
||||
killAgentByRecord(prior, 'SIGTERM');
|
||||
clearAgentRecord(stateDir);
|
||||
}
|
||||
const script = opts.scriptPath || resolveTerminalAgentScript();
|
||||
if (!script || !fs.existsSync(script)) return null;
|
||||
const proc = (Bun as any).spawn(['bun', 'run', script], {
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSE_STATE_FILE: opts.stateFile,
|
||||
BROWSE_SERVER_PORT: String(opts.serverPort),
|
||||
...(opts.extraEnv || {}),
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
proc.unref?.();
|
||||
return proc.pid ?? null;
|
||||
}
|
||||
|
||||
export interface AgentRecord {
|
||||
pid: number;
|
||||
/** Random per-boot identifier. Loopback /internal/* sees X-Browse-Gen: <gen>. */
|
||||
gen: string;
|
||||
/** ms since epoch. Reserved for future PID-reuse guards. */
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export function agentRecordPath(stateDir: string): string {
|
||||
return path.join(stateDir, 'terminal-agent-pid');
|
||||
}
|
||||
|
||||
/** Read the current record. Returns null on missing/malformed file. */
|
||||
export function readAgentRecord(stateDir: string): AgentRecord | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(agentRecordPath(stateDir), 'utf-8');
|
||||
const j = JSON.parse(raw);
|
||||
if (typeof j?.pid === 'number' && typeof j?.gen === 'string' && typeof j?.startedAt === 'number') {
|
||||
return j as AgentRecord;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomic write. Caller must ensure stateDir exists; agent does this at boot. */
|
||||
export function writeAgentRecord(stateDir: string, record: AgentRecord): void {
|
||||
try { mkdirSecure(stateDir); } catch {}
|
||||
const target = agentRecordPath(stateDir);
|
||||
const tmp = `${target}.tmp-${process.pid}`;
|
||||
writeSecureFile(tmp, JSON.stringify(record));
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
|
||||
export function clearAgentRecord(stateDir: string): void {
|
||||
safeUnlink(agentRecordPath(stateDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the agent identified by `record`. Signal defaults to SIGTERM (give
|
||||
* the agent a chance to run its own SIGTERM cleanup). Returns true if a
|
||||
* signal was actually sent to a live PID; false if the PID was already
|
||||
* dead (no-op). Never throws — ESRCH is swallowed by safeKill.
|
||||
*
|
||||
* Validates liveness BEFORE signaling so a PID-reuse race (the recorded
|
||||
* PID was reaped and a brand-new unrelated process now holds it) can't
|
||||
* cause us to kill the wrong process. This is a best-effort defense:
|
||||
* Linux/macOS don't expose process-start-time cheaply, and the gap
|
||||
* between record-write and watchdog-tick is small (60s max).
|
||||
*/
|
||||
export function killAgentByRecord(
|
||||
record: AgentRecord,
|
||||
signal: NodeJS.Signals = 'SIGTERM',
|
||||
): boolean {
|
||||
if (!isProcessAlive(record.pid)) return false;
|
||||
safeKill(record.pid, signal);
|
||||
return true;
|
||||
}
|
||||
+509
-73
@@ -25,16 +25,47 @@ import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { safeUnlink } from './error-handling';
|
||||
import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control';
|
||||
|
||||
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
|
||||
const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port');
|
||||
const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10);
|
||||
const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check
|
||||
const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn
|
||||
/**
|
||||
* Per-boot generation identifier. Loopback /internal/* callers include
|
||||
* `X-Browse-Gen: <CURRENT_GEN>` so a slow agent the watchdog respawned
|
||||
* around can't service a stale grant from the prior generation. Absent
|
||||
* header means "legacy caller" and is accepted (backward compat); a
|
||||
* present-but-mismatched header returns 409 stale generation.
|
||||
*/
|
||||
const CURRENT_GEN = crypto.randomBytes(16).toString('base64url');
|
||||
|
||||
// In-memory cookie token registry. Parent posts /internal/grant after
|
||||
// /pty-session; we validate WS cookies against this set.
|
||||
const validTokens = new Set<string>();
|
||||
// In-memory attach-token registry. Parent posts /internal/grant after
|
||||
// /pty-session; we validate WS upgrades against this map.
|
||||
//
|
||||
// v1.44+: each token is bound to a v1.44 sessionId (the stable, non-secret
|
||||
// identifier from browse/src/pty-session-lease.ts). The token grants ONE
|
||||
// attach for ONE session — re-attach within the lease window comes through
|
||||
// /pty-session/reattach, which mints a fresh token for the same sessionId.
|
||||
//
|
||||
// Legacy callers can still pass `{token}` without sessionId (the value
|
||||
// stays null and the WS upgrade still works); those callers don't get
|
||||
// re-attach because there's no stable identifier to match against.
|
||||
const validTokens = new Map<string, string | null>(); // token → sessionId
|
||||
|
||||
/**
|
||||
* Reverse index for re-attach lookups: sessionId → live PtySession.
|
||||
* Populated when a WS first attaches with a known sessionId; cleared when
|
||||
* the session is disposed or the lease expires. Used by:
|
||||
* - /ws upgrade: if the incoming attachToken maps to a sessionId that
|
||||
* already has a live session, REPLACE its ws ref instead of spawning.
|
||||
* - /internal/restart: enumerate by sessionId, dispose that one session.
|
||||
*
|
||||
* Kept separate from the WeakMap<ws,PtySession> so re-attach can find the
|
||||
* session by id even after the original ws has gone.
|
||||
*/
|
||||
const sessionsById = new Map<string, PtySession>();
|
||||
|
||||
// Active PTY session per WS. One terminal per connection. Codex finding #4:
|
||||
// uncaught handlers below catch bugs in framing/cleanup so they don't kill
|
||||
@@ -46,12 +77,154 @@ process.on('unhandledRejection', (reason) => {
|
||||
console.error('[terminal-agent] unhandledRejection:', reason);
|
||||
});
|
||||
|
||||
interface PtySession {
|
||||
export interface PtySession {
|
||||
proc: any | null; // Bun.Subprocess once spawned
|
||||
cols: number;
|
||||
rows: number;
|
||||
cookie: string;
|
||||
/**
|
||||
* Current attached websocket. Swapped on re-attach (Commit 3): when a new
|
||||
* WS upgrade matches this session's sessionId, the old liveWs is gone
|
||||
* and the new ws takes its place. The PTY on-data callback closes over
|
||||
* `session`, not the original `ws`, so it always writes to the current
|
||||
* liveWs (or skips the write when detached and liveWs is null).
|
||||
*/
|
||||
liveWs: any | null;
|
||||
/**
|
||||
* v1.44+ stable session identifier (from pty-session-lease). Null for
|
||||
* legacy /internal/grant callers that didn't pass one. Used for
|
||||
* targeted /internal/restart and Commit 3 re-attach lookups.
|
||||
*/
|
||||
sessionId: string | null;
|
||||
spawned: boolean;
|
||||
/**
|
||||
* 25s server-side WS keepalive interval (v1.44+). Set in the WS `open`
|
||||
* handler, cleared in `close`. We send `{type:"ping",ts}` text frames so
|
||||
* NAT boxes, proxies, and Chrome's MV3 panel-suspend heuristics see the
|
||||
* connection as active; the client either replies with `{type:"pong"}`
|
||||
* or fires its own 25s `{type:"keepalive"}` cycle. Either path keeps
|
||||
* the underlying TCP from being silently dropped.
|
||||
*/
|
||||
pingInterval: ReturnType<typeof setInterval> | null;
|
||||
/**
|
||||
* Commit 3 scrollback ring buffer. Each PTY write appends a frame; the
|
||||
* total byte count is capped at RING_BUFFER_MAX_BYTES with oldest frames
|
||||
* evicted first. On re-attach, the surviving frames are replayed as a
|
||||
* single binary frame (prefixed with the v1.44 reset sequence) so the
|
||||
* user sees their last screen of output. Frame boundaries preserve UTF-8
|
||||
* + ANSI-CSI boundaries because each frame is the exact buffer that
|
||||
* spawnClaude's on-data callback emitted.
|
||||
*/
|
||||
ringBuffer: Buffer[];
|
||||
ringBufferBytes: number;
|
||||
/**
|
||||
* Tracks whether the PTY is currently in xterm alt-screen mode. claude's
|
||||
* TUI enters alt-screen (CSI ?1049h) during tool calls and exits (CSI
|
||||
* ?1049l) when returning to the main prompt. On re-attach, the replay
|
||||
* prelude must re-enter alt-screen if the original PTY left it active,
|
||||
* otherwise the replay renders against the main screen and the cursor
|
||||
* + colors end up in the wrong place.
|
||||
*/
|
||||
altScreenActive: boolean;
|
||||
/**
|
||||
* Detach state machine (Commit 3). When the WS closes for a reason OTHER
|
||||
* than the v1.44 intentional-restart code (4001), we keep the PtySession
|
||||
* alive for the detach window (default 60s) so a re-attach within the
|
||||
* window can resume the same PTY and replay the ring buffer. The timer
|
||||
* disposes the session if no re-attach arrives in time.
|
||||
*/
|
||||
detached: boolean;
|
||||
detachTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WS keepalive interval. 25s is comfortably under the lowest common NAT
|
||||
* idle timeout (typically 30-60s) and shorter than Chromium's WebSocket
|
||||
* dead-peer threshold. Test-overridable via env so the v1.44 e2e tests
|
||||
* can compress idle-window assertions to <1s without waiting half a
|
||||
* minute per assertion.
|
||||
*/
|
||||
const KEEPALIVE_INTERVAL_MS = parseInt(
|
||||
process.env.GSTACK_PTY_KEEPALIVE_INTERVAL_MS || '25000',
|
||||
10,
|
||||
);
|
||||
|
||||
/**
|
||||
* Commit 3 scrollback ring buffer cap. 1 MB is enough for a full screen
|
||||
* of dense claude output (including a recent tool result), small enough
|
||||
* that a worst-case 10 detached sessions only cost ~10 MB of RSS.
|
||||
* Env-overridable so e2e tests can verify eviction without writing 1 MB
|
||||
* of fixture data per assertion.
|
||||
*/
|
||||
const RING_BUFFER_MAX_BYTES = parseInt(
|
||||
process.env.GSTACK_PTY_RING_BUFFER_BYTES || `${1024 * 1024}`,
|
||||
10,
|
||||
);
|
||||
|
||||
/**
|
||||
* Commit 3 detach window — how long to keep a session alive after WS
|
||||
* close (with any code other than 4001 intentional-restart) so a
|
||||
* re-attach can resume the same PTY. 60s is long enough to cover a
|
||||
* Chrome MV3 service-worker suspend cycle, a wifi blip, or a brief
|
||||
* laptop sleep; short enough that genuinely-closed sessions don't
|
||||
* stack up unbounded.
|
||||
*/
|
||||
const DETACH_WINDOW_MS = parseInt(
|
||||
process.env.GSTACK_PTY_DETACH_WINDOW_MS || '60000',
|
||||
10,
|
||||
);
|
||||
|
||||
/**
|
||||
* Append a frame to a session's ring buffer, evicting oldest frames if
|
||||
* the total byte count exceeds RING_BUFFER_MAX_BYTES. Eviction is at
|
||||
* frame boundaries (one PTY write = one frame), so we never cut a
|
||||
* multi-byte UTF-8 sequence or a partial ANSI CSI in half — claude's
|
||||
* on-data callback emits coherent frames.
|
||||
*
|
||||
* Side effect: scans the appended chunk for alt-screen enter/exit
|
||||
* sequences (CSI ?1049h / CSI ?1049l) and updates session.altScreenActive
|
||||
* so the re-attach prelude knows whether to re-enter alt-screen.
|
||||
*/
|
||||
export function appendToRingBuffer(session: PtySession, frame: Buffer): void {
|
||||
session.ringBuffer.push(frame);
|
||||
session.ringBufferBytes += frame.length;
|
||||
while (session.ringBufferBytes > RING_BUFFER_MAX_BYTES && session.ringBuffer.length > 1) {
|
||||
const evicted = session.ringBuffer.shift()!;
|
||||
session.ringBufferBytes -= evicted.length;
|
||||
}
|
||||
// Alt-screen tracking. Scan for the canonical xterm enter/exit pairs.
|
||||
// We do this on every append (not just on attach) so the state is
|
||||
// correct even if many frames have flowed since the last attach.
|
||||
const ascii = frame.toString('latin1'); // single-byte view is enough — the codes are 7-bit ASCII
|
||||
// Use lastIndexOf so trailing state wins when both appear in one frame
|
||||
// (e.g., a quick tool-call open+close inside one render pass).
|
||||
const enterIdx = ascii.lastIndexOf('\x1b[?1049h');
|
||||
const exitIdx = ascii.lastIndexOf('\x1b[?1049l');
|
||||
if (enterIdx >= 0 && enterIdx > exitIdx) session.altScreenActive = true;
|
||||
else if (exitIdx >= 0 && exitIdx > enterIdx) session.altScreenActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the re-attach replay payload: server-side reset prelude + the
|
||||
* accumulated ring buffer. The client side writes RIS (`\x1bc`) to xterm
|
||||
* BEFORE feeding this payload in, so the layout is:
|
||||
*
|
||||
* 1. Client: `\x1bc` (RIS — full reset, clears pre-blip xterm content)
|
||||
* 2. Server: `\x1b[!p` (DECSTR soft reset — re-defaults char attributes)
|
||||
* 3. Server: optional `\x1b[?1049h` if we were in alt-screen at detach
|
||||
* 4. Server: ring buffer contents, in append order
|
||||
*
|
||||
* The client coordinates the order by waiting for a `{type:"reattach-begin"}`
|
||||
* text frame before treating the next binary frame as replay. That separation
|
||||
* is what lets us prepend reset codes without clobbering the live stream
|
||||
* that resumes immediately after.
|
||||
*/
|
||||
export function buildReplayPayload(session: PtySession): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
parts.push(Buffer.from('\x1b[!p'));
|
||||
if (session.altScreenActive) parts.push(Buffer.from('\x1b[?1049h'));
|
||||
for (const frame of session.ringBuffer) parts.push(frame);
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
const sessions = new WeakMap<any, PtySession>(); // ws -> session
|
||||
@@ -201,6 +374,118 @@ function disposeSession(session: PtySession): void {
|
||||
*
|
||||
* Everything else returns 404. The listener binds 127.0.0.1 only.
|
||||
*/
|
||||
/**
|
||||
* Validate a loopback /internal/* request. Returns null when the request
|
||||
* is allowed; otherwise returns the Response to send back. Centralizes
|
||||
* bearer auth + the v1.44 X-Browse-Gen generation check so adding a new
|
||||
* /internal/* route is a one-liner.
|
||||
*/
|
||||
function checkInternalAuth(req: Request): Response | null {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth !== `Bearer ${INTERNAL_TOKEN}`) {
|
||||
return new Response('forbidden', { status: 403 });
|
||||
}
|
||||
const headerGen = req.headers.get('x-browse-gen');
|
||||
if (headerGen && headerGen !== CURRENT_GEN) {
|
||||
return new Response('stale generation', { status: 409 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a JSON-bodied /internal/* handler with the standard bearer-auth +
|
||||
* generation-check + json-parse + error-response boilerplate. The handler
|
||||
* `fn` is called with the parsed body; whatever it returns is JSON-stringified
|
||||
* into a 200 Response, or the handler can return a Response directly to
|
||||
* customize status / headers. Throwing from `fn` collapses to a 400 "bad".
|
||||
*
|
||||
* Centralizing the dance kills the copy-paste pattern of bearer + gen check
|
||||
* + req.json().then(...).catch(...) that every /internal/* route needs.
|
||||
* New routes become a single call to internalHandler.
|
||||
*/
|
||||
async function internalHandler<T>(
|
||||
req: Request,
|
||||
fn: (body: any) => T | Promise<T> | Response | Promise<Response>,
|
||||
): Promise<Response> {
|
||||
const denied = checkInternalAuth(req);
|
||||
if (denied) return denied;
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return new Response('bad', { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await fn(body);
|
||||
if (result instanceof Response) return result;
|
||||
if (result === undefined || result === null) return new Response('ok');
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch {
|
||||
return new Response('bad', { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the claude PTY for a session if it hasn't been spawned yet.
|
||||
* Used by both the legacy binary-frame spawn trigger and the v1.44 explicit
|
||||
* `{type:"start"}` text-frame trigger. Idempotent on `session.spawned`.
|
||||
*
|
||||
* Returns true if claude is now running, false if spawn failed (e.g. claude
|
||||
* binary not on PATH). On failure, the caller is expected to have already
|
||||
* surfaced the error to the client (or will via the next frame).
|
||||
*/
|
||||
function maybeSpawnPty(ws: any, session: PtySession): boolean {
|
||||
if (session.spawned) return true;
|
||||
session.spawned = true;
|
||||
let leftover = Buffer.alloc(0);
|
||||
const proc = spawnClaude(session.cols, session.rows, (chunk) => {
|
||||
const combined = Buffer.concat([leftover, Buffer.from(chunk)]);
|
||||
// UTF-8 boundary detection (issue #1272). Look back at most 3 bytes
|
||||
// for the start of an incomplete multibyte sequence and defer it.
|
||||
let safeEnd = combined.length;
|
||||
for (let i = combined.length - 1; i >= Math.max(0, combined.length - 3); i--) {
|
||||
const b = combined[i];
|
||||
if ((b & 0x80) === 0) { safeEnd = i + 1; break; }
|
||||
if ((b & 0xC0) === 0x80) continue;
|
||||
const expected = (b & 0xE0) === 0xC0 ? 2 : (b & 0xF0) === 0xE0 ? 3 : 4;
|
||||
safeEnd = (combined.length - i >= expected) ? combined.length : i;
|
||||
break;
|
||||
}
|
||||
const flush = combined.slice(0, safeEnd);
|
||||
leftover = combined.slice(safeEnd);
|
||||
if (flush.length) {
|
||||
// Always record into the ring buffer (Commit 3) so re-attach can
|
||||
// replay. session.liveWs is what changes across re-attaches — we
|
||||
// close over `session`, not the original `ws`, so the write always
|
||||
// goes to whichever ws is currently attached (or is skipped when
|
||||
// detached and liveWs is null).
|
||||
appendToRingBuffer(session, flush);
|
||||
if (session.liveWs) {
|
||||
try { session.liveWs.sendBinary(flush); } catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!proc) {
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
code: 'CLAUDE_NOT_FOUND',
|
||||
message: 'claude CLI not on PATH. Install: https://docs.anthropic.com/en/docs/claude-code',
|
||||
}));
|
||||
ws.close(4404, 'claude not found');
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
session.proc = proc;
|
||||
proc.exited?.then?.(() => {
|
||||
try { session.liveWs?.close(1000, 'pty exited'); } catch {}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildServer() {
|
||||
return Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
@@ -211,29 +496,66 @@ function buildServer() {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// /internal/grant — loopback-only handshake from parent server.
|
||||
// v1.44+: accepts `{token, sessionId?}`. The sessionId binding lets
|
||||
// the agent route re-attach attempts (same sessionId, fresh token)
|
||||
// back to the same PtySession. Legacy callers passing just `{token}`
|
||||
// still work — sessionId becomes null and re-attach is unavailable
|
||||
// for that grant.
|
||||
if (url.pathname === '/internal/grant' && req.method === 'POST') {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth !== `Bearer ${INTERNAL_TOKEN}`) {
|
||||
return new Response('forbidden', { status: 403 });
|
||||
}
|
||||
return req.json().then((body: any) => {
|
||||
return internalHandler(req, (body) => {
|
||||
if (typeof body?.token === 'string' && body.token.length > 16) {
|
||||
validTokens.add(body.token);
|
||||
const sid = typeof body?.sessionId === 'string' && body.sessionId.length > 0
|
||||
? body.sessionId
|
||||
: null;
|
||||
validTokens.set(body.token, sid);
|
||||
}
|
||||
return new Response('ok');
|
||||
}).catch(() => new Response('bad', { status: 400 }));
|
||||
});
|
||||
}
|
||||
|
||||
// /internal/revoke — drop a token (called on WS close or bootstrap reload)
|
||||
if (url.pathname === '/internal/revoke' && req.method === 'POST') {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth !== `Bearer ${INTERNAL_TOKEN}`) {
|
||||
return new Response('forbidden', { status: 403 });
|
||||
}
|
||||
return req.json().then((body: any) => {
|
||||
return internalHandler(req, (body) => {
|
||||
if (typeof body?.token === 'string') validTokens.delete(body.token);
|
||||
return new Response('ok');
|
||||
}).catch(() => new Response('bad', { status: 400 }));
|
||||
});
|
||||
}
|
||||
|
||||
// /internal/restart — dispose the PtySession for a specific sessionId.
|
||||
// Scoped to one caller (not enumerate-all). Server.ts /pty-restart
|
||||
// posts here with the caller's sessionId; we kill ONLY that PTY,
|
||||
// leaving any other live sidebar tabs untouched. Codex T2 of the
|
||||
// eng review caught this gap — pre-spec the route would have
|
||||
// disposed all sessions.
|
||||
if (url.pathname === '/internal/restart' && req.method === 'POST') {
|
||||
return internalHandler(req, (body) => {
|
||||
const sid = typeof body?.sessionId === 'string' ? body.sessionId : null;
|
||||
if (!sid) return { killed: 0 };
|
||||
const session = sessionsById.get(sid);
|
||||
if (!session) return { killed: 0 };
|
||||
// Cancel any pending detach timer before disposal — otherwise it
|
||||
// would fire later against an already-disposed session.
|
||||
if (session.detachTimer) {
|
||||
clearTimeout(session.detachTimer);
|
||||
session.detachTimer = null;
|
||||
}
|
||||
disposeSession(session);
|
||||
sessionsById.delete(sid);
|
||||
return { killed: 1 };
|
||||
});
|
||||
}
|
||||
|
||||
// /internal/healthz — liveness probe used by the v1.44 watchdog.
|
||||
// Returns this agent's pid + gen + active session count without
|
||||
// touching claude binary lookup (which can fail for non-process
|
||||
// reasons and isn't a useful liveness signal). GET — no body to parse,
|
||||
// so it stays on the bare checkInternalAuth gate.
|
||||
if (url.pathname === '/internal/healthz' && req.method === 'GET') {
|
||||
const denied = checkInternalAuth(req);
|
||||
if (denied) return denied;
|
||||
return new Response(JSON.stringify({
|
||||
pid: process.pid,
|
||||
gen: CURRENT_GEN,
|
||||
sessions: validTokens.size,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// /claude-available — bootstrap card hits this when user clicks "I installed it".
|
||||
@@ -305,8 +627,13 @@ function buildServer() {
|
||||
return new Response('unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// v1.44+: surface the token's sessionId binding to the upgraded ws.
|
||||
// open() reads it via ws.data and registers the session in
|
||||
// sessionsById so /internal/restart and (Commit 3) re-attach
|
||||
// lookups can find it.
|
||||
const sessionId = validTokens.get(token) ?? null;
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: { cookie: token },
|
||||
data: { cookie: token, sessionId },
|
||||
// Echo the protocol back so the browser accepts the upgrade.
|
||||
// Required when the client sends Sec-WebSocket-Protocol — the
|
||||
// server MUST select one of the offered protocols, otherwise
|
||||
@@ -320,22 +647,105 @@ function buildServer() {
|
||||
},
|
||||
|
||||
websocket: {
|
||||
/**
|
||||
* Spawn the claude PTY for `session` if it hasn't been spawned yet.
|
||||
* Called from both message paths: the legacy binary-frame trigger
|
||||
* (any keystroke) AND the v1.44 explicit `{type:"start"}` trigger
|
||||
* (forceRestart sends this on every fresh WS to get an eager prompt
|
||||
* without requiring the user to type). Idempotent — a second call
|
||||
* after `spawned: true` is a no-op.
|
||||
*/
|
||||
open(ws) {
|
||||
const sessionId = (ws.data as any)?.sessionId ?? null;
|
||||
const cookie = (ws.data as any)?.cookie || '';
|
||||
|
||||
// Commit 3 re-attach: if this sessionId already has a detached
|
||||
// PtySession in sessionsById, REPLACE its liveWs ref and replay
|
||||
// the ring buffer. The PTY process is unchanged — claude keeps
|
||||
// running through the wifi blip / panel-suspend cycle.
|
||||
if (sessionId) {
|
||||
const existing = sessionsById.get(sessionId);
|
||||
if (existing) {
|
||||
if (existing.detachTimer) {
|
||||
clearTimeout(existing.detachTimer);
|
||||
existing.detachTimer = null;
|
||||
}
|
||||
existing.detached = false;
|
||||
existing.liveWs = ws;
|
||||
existing.cookie = cookie;
|
||||
// Re-bind the WS-keyed map so resize/close/message handlers
|
||||
// can still find this session via the new ws.
|
||||
sessions.set(ws, existing);
|
||||
// Restart keepalive on the new ws.
|
||||
if (existing.pingInterval) clearInterval(existing.pingInterval);
|
||||
existing.pingInterval = setInterval(() => {
|
||||
try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } catch {}
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
// Tell the client to prep its xterm (write RIS) before the
|
||||
// replay binary arrives. Order matters — the binary frame
|
||||
// immediately after this text frame IS the replay.
|
||||
try { ws.send(JSON.stringify({ type: 'reattach-begin', sessionId })); } catch {}
|
||||
try { ws.sendBinary(buildReplayPayload(existing)); } catch {}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const session: PtySession = {
|
||||
proc: null,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cookie,
|
||||
liveWs: ws,
|
||||
sessionId,
|
||||
spawned: false,
|
||||
pingInterval: null,
|
||||
ringBuffer: [],
|
||||
ringBufferBytes: 0,
|
||||
altScreenActive: false,
|
||||
detached: false,
|
||||
detachTimer: null,
|
||||
};
|
||||
session.pingInterval = setInterval(() => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
|
||||
} catch {
|
||||
// ws likely closed mid-tick; close handler clears the interval.
|
||||
}
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
sessions.set(ws, session);
|
||||
// Index by sessionId for /internal/restart + Commit 3 re-attach.
|
||||
if (sessionId) sessionsById.set(sessionId, session);
|
||||
},
|
||||
|
||||
message(ws, raw) {
|
||||
let session = sessions.get(ws);
|
||||
if (!session) {
|
||||
// Fallback for any path where open() didn't fire (shouldn't happen
|
||||
// in Bun.serve but keeps the spawn path safe). No keepalive on
|
||||
// this branch — open() is the supported entry point.
|
||||
session = {
|
||||
proc: null,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cookie: (ws.data as any)?.cookie || '',
|
||||
liveWs: ws,
|
||||
sessionId: (ws.data as any)?.sessionId ?? null,
|
||||
spawned: false,
|
||||
pingInterval: null,
|
||||
ringBuffer: [],
|
||||
ringBufferBytes: 0,
|
||||
altScreenActive: false,
|
||||
detached: false,
|
||||
detachTimer: null,
|
||||
};
|
||||
sessions.set(ws, session);
|
||||
if (session.sessionId) sessionsById.set(session.sessionId, session);
|
||||
}
|
||||
|
||||
// Text frames are control messages: {type: "resize", cols, rows} or
|
||||
// {type: "tabSwitch", tabId, url, title}. Binary frames are raw input
|
||||
// bytes destined for the PTY stdin.
|
||||
// Text frames are control messages: {type: "resize", cols, rows},
|
||||
// {type: "tabSwitch", tabId, url, title}, {type: "tabState", ...},
|
||||
// or v1.44 keepalive frames: {type: "pong", ts}, {type: "keepalive"}.
|
||||
// Binary frames are raw input bytes destined for the PTY stdin.
|
||||
if (typeof raw === 'string') {
|
||||
let msg: any;
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
@@ -355,50 +765,32 @@ function buildServer() {
|
||||
handleTabState(msg);
|
||||
return;
|
||||
}
|
||||
if (msg?.type === 'pong' || msg?.type === 'keepalive' || msg?.type === 'ping') {
|
||||
// Keepalive frames — accepted and silently dropped. The mere
|
||||
// fact that the WS carried this frame is the liveness signal;
|
||||
// there's no application-level state to update at this layer.
|
||||
// `ping` is acknowledged here too in case the client (or a
|
||||
// future agent peer) mirrors our server-side ping shape.
|
||||
return;
|
||||
}
|
||||
if (msg?.type === 'start') {
|
||||
// v1.44 explicit spawn trigger. forceRestart sends this
|
||||
// immediately on every fresh WS so claude boots without the
|
||||
// user having to type a keystroke (pre-v1.44, the lazy-binary
|
||||
// spawn made restart look stuck until the user typed). No-op
|
||||
// if already spawned.
|
||||
maybeSpawnPty(ws, session);
|
||||
return;
|
||||
}
|
||||
// Unknown text frame — ignore.
|
||||
return;
|
||||
}
|
||||
|
||||
// Binary input. Lazy-spawn claude on the first byte.
|
||||
// Binary input. Lazy-spawn claude on the first byte if `start`
|
||||
// wasn't sent first. Both paths land in the same maybeSpawnPty
|
||||
// helper for behavior parity.
|
||||
if (!session.spawned) {
|
||||
session.spawned = true;
|
||||
// UTF-8 boundary detection to prevent splitting multi-byte characters (issue #1272).
|
||||
// Buffer incomplete UTF-8 sequences until the next chunk completes them.
|
||||
let leftover = Buffer.alloc(0);
|
||||
const proc = spawnClaude(session.cols, session.rows, (chunk) => {
|
||||
const combined = Buffer.concat([leftover, Buffer.from(chunk)]);
|
||||
// Find the last index where a UTF-8 codepoint ends. Look back at most 3 bytes.
|
||||
let safeEnd = combined.length;
|
||||
for (let i = combined.length - 1; i >= Math.max(0, combined.length - 3); i--) {
|
||||
const b = combined[i];
|
||||
if ((b & 0x80) === 0) { safeEnd = i + 1; break; } // ASCII
|
||||
if ((b & 0xC0) === 0x80) continue; // continuation byte
|
||||
const expected = (b & 0xE0) === 0xC0 ? 2 : (b & 0xF0) === 0xE0 ? 3 : 4;
|
||||
safeEnd = (combined.length - i >= expected) ? combined.length : i;
|
||||
break;
|
||||
}
|
||||
const flush = combined.slice(0, safeEnd);
|
||||
leftover = combined.slice(safeEnd);
|
||||
if (flush.length) {
|
||||
try { ws.sendBinary(flush); } catch {}
|
||||
}
|
||||
});
|
||||
if (!proc) {
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
code: 'CLAUDE_NOT_FOUND',
|
||||
message: 'claude CLI not on PATH. Install: https://docs.anthropic.com/en/docs/claude-code',
|
||||
}));
|
||||
ws.close(4404, 'claude not found');
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
session.proc = proc;
|
||||
// Watch for child exit so the WS closes cleanly when claude exits.
|
||||
proc.exited?.then?.(() => {
|
||||
try { ws.close(1000, 'pty exited'); } catch {}
|
||||
});
|
||||
if (!maybeSpawnPty(ws, session)) return;
|
||||
}
|
||||
try {
|
||||
// raw is a Uint8Array; Bun.Terminal.write accepts string|Buffer.
|
||||
@@ -409,16 +801,49 @@ function buildServer() {
|
||||
}
|
||||
},
|
||||
|
||||
close(ws) {
|
||||
close(ws, code, _reason) {
|
||||
const session = sessions.get(ws);
|
||||
if (session) {
|
||||
disposeSession(session);
|
||||
if (session.cookie) {
|
||||
// Drop the cookie so it can't be replayed against a new PTY.
|
||||
validTokens.delete(session.cookie);
|
||||
}
|
||||
sessions.delete(ws);
|
||||
if (!session) return;
|
||||
// Always drop the WS-keyed map entry and the per-attach
|
||||
// attachToken — the attach grant was single-use.
|
||||
sessions.delete(ws);
|
||||
if (session.cookie) validTokens.delete(session.cookie);
|
||||
// Keepalive lives with the WS — every attach starts a fresh one.
|
||||
if (session.pingInterval) {
|
||||
clearInterval(session.pingInterval);
|
||||
session.pingInterval = null;
|
||||
}
|
||||
|
||||
// Commit 3 detach state machine. If the close was intentional
|
||||
// (code 4001 = restart, 4404 = no-claude error), dispose
|
||||
// immediately — there's no value in keeping the PTY alive.
|
||||
// Otherwise enter the detach window: claude keeps running, the
|
||||
// ring buffer keeps accumulating, and a re-attach with the same
|
||||
// sessionId within DETACH_WINDOW_MS picks back up. If the timer
|
||||
// fires without a re-attach, the session is disposed normally.
|
||||
//
|
||||
// Sessions without a sessionId (legacy single-shot grants) can't
|
||||
// re-attach by definition — fall through to immediate dispose.
|
||||
const intentional = code === 4001 || code === 4404 || code === 1000;
|
||||
if (intentional || !session.sessionId) {
|
||||
disposeSession(session);
|
||||
if (session.sessionId) sessionsById.delete(session.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark detached and start the disposal timer. The session stays
|
||||
// in sessionsById so the next /ws upgrade with the same
|
||||
// sessionId can find and reattach to it.
|
||||
session.detached = true;
|
||||
session.liveWs = null;
|
||||
session.detachTimer = setTimeout(() => {
|
||||
if (!session.detached) return; // re-attached in the meantime
|
||||
disposeSession(session);
|
||||
if (session.sessionId) sessionsById.delete(session.sessionId);
|
||||
}, DETACH_WINDOW_MS);
|
||||
// setTimeout returns a Bun Timer; unref so the detach window
|
||||
// doesn't keep the process alive past natural shutdown.
|
||||
(session.detachTimer as any)?.unref?.();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -548,14 +973,25 @@ function main() {
|
||||
writeSecureFile(tmp, String(port));
|
||||
fs.renameSync(tmp, PORT_FILE);
|
||||
|
||||
// Write identity-based agent record (pid + per-boot gen). Replaces the
|
||||
// v1.43- `pkill -f terminal-agent\.ts` regex teardown that could kill
|
||||
// sibling gstack sessions. Callers (cli.ts spawn site, server.ts
|
||||
// shutdown, the v1.44 watchdog) now route through killAgentByRecord in
|
||||
// terminal-agent-control.ts.
|
||||
writeAgentRecord(dir, { pid: process.pid, gen: CURRENT_GEN, startedAt: Date.now() });
|
||||
|
||||
// Hand the parent the internal token so it can call /internal/grant.
|
||||
// Parent learns INTERNAL_TOKEN via env (TERMINAL_AGENT_INTERNAL_TOKEN below).
|
||||
// We just print it on stdout for the supervising process to pick up if it's
|
||||
// not already in env. Defense against env races at spawn time.
|
||||
console.log(`[terminal-agent] listening on 127.0.0.1:${port} pid=${process.pid}`);
|
||||
console.log(`[terminal-agent] listening on 127.0.0.1:${port} pid=${process.pid} gen=${CURRENT_GEN}`);
|
||||
|
||||
// Cleanup port file on exit.
|
||||
const cleanup = () => { safeUnlink(PORT_FILE); process.exit(0); };
|
||||
// Cleanup port file + agent record on exit.
|
||||
const cleanup = () => {
|
||||
safeUnlink(PORT_FILE);
|
||||
clearAgentRecord(dir);
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', cleanup);
|
||||
process.on('SIGINT', cleanup);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,14 @@ import { findInstalledBrowsers, importCookies, importCookiesViaCdp, hasV20Cookie
|
||||
import { generatePickerCode } from './cookie-picker-routes';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import { validateOutputPath, validateReadPath } from './path-security';
|
||||
import { guardScreenshotPath } from './screenshot-size-guard';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { SetContentWaitUntil } from './tab-session';
|
||||
import { TEMP_DIR, isPathWithin } from './platform';
|
||||
import { SAFE_DIRECTORIES } from './path-security';
|
||||
import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector';
|
||||
import { withCdpSession } from './cdp-bridge';
|
||||
|
||||
/**
|
||||
* Aggressive page cleanup selectors and heuristics.
|
||||
@@ -1123,6 +1125,10 @@ export async function handleWriteCommand(
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: outputPath, fullPage: !scrollTo });
|
||||
// Guard against Anthropic vision API >2000px brick (#1214). Only
|
||||
// applies to fullPage captures; scrollTo viewport-bound shots are
|
||||
// already capped by the viewport size.
|
||||
if (!scrollTo) await guardScreenshotPath(outputPath);
|
||||
|
||||
// Restore viewport
|
||||
if (viewportWidth && originalViewport) {
|
||||
@@ -1404,9 +1410,10 @@ export async function handleWriteCommand(
|
||||
validateOutputPath(outputPath);
|
||||
|
||||
try {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const { data } = await cdp.send('Page.captureSnapshot', { format: 'mhtml' });
|
||||
await cdp.detach();
|
||||
const data = await withCdpSession(page, async (cdp) => {
|
||||
const result = await cdp.send('Page.captureSnapshot', { format: 'mhtml' });
|
||||
return (result as { data: string }).data;
|
||||
});
|
||||
fs.writeFileSync(outputPath, data);
|
||||
return `Archive saved: ${outputPath} (${Math.round(data.length / 1024)}KB, MHTML)`;
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -29,17 +29,20 @@ describe('shouldEnableChromiumSandbox', () => {
|
||||
const origPlatform = process.platform;
|
||||
const origCI = process.env.CI;
|
||||
const origContainer = process.env.CONTAINER;
|
||||
const origNoSandbox = process.env.GSTACK_CHROMIUM_NO_SANDBOX;
|
||||
const origGetuid = process.getuid;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.CI;
|
||||
delete process.env.CONTAINER;
|
||||
delete process.env.GSTACK_CHROMIUM_NO_SANDBOX;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: origPlatform });
|
||||
if (origCI === undefined) delete process.env.CI; else process.env.CI = origCI;
|
||||
if (origContainer === undefined) delete process.env.CONTAINER; else process.env.CONTAINER = origContainer;
|
||||
if (origNoSandbox === undefined) delete process.env.GSTACK_CHROMIUM_NO_SANDBOX; else process.env.GSTACK_CHROMIUM_NO_SANDBOX = origNoSandbox;
|
||||
process.getuid = origGetuid;
|
||||
});
|
||||
|
||||
@@ -90,6 +93,31 @@ describe('shouldEnableChromiumSandbox', () => {
|
||||
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
||||
expect(shouldEnableChromiumSandbox()).toBe(false);
|
||||
});
|
||||
|
||||
// #1562 — Ubuntu/AppArmor opt-in override
|
||||
it('linux + GSTACK_CHROMIUM_NO_SANDBOX=1 → false (Ubuntu/AppArmor opt-out)', async () => {
|
||||
setPlatform('linux');
|
||||
process.env.GSTACK_CHROMIUM_NO_SANDBOX = '1';
|
||||
process.getuid = (() => 1000) as typeof process.getuid;
|
||||
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
||||
expect(shouldEnableChromiumSandbox()).toBe(false);
|
||||
});
|
||||
|
||||
it('darwin + GSTACK_CHROMIUM_NO_SANDBOX=1 → false (env override wins on any platform)', async () => {
|
||||
setPlatform('darwin');
|
||||
process.env.GSTACK_CHROMIUM_NO_SANDBOX = '1';
|
||||
process.getuid = (() => 501) as typeof process.getuid;
|
||||
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
||||
expect(shouldEnableChromiumSandbox()).toBe(false);
|
||||
});
|
||||
|
||||
it('GSTACK_CHROMIUM_NO_SANDBOX=0 → does NOT trigger override (must be exactly "1")', async () => {
|
||||
setPlatform('linux');
|
||||
process.env.GSTACK_CHROMIUM_NO_SANDBOX = '0';
|
||||
process.getuid = (() => 1000) as typeof process.getuid;
|
||||
const { shouldEnableChromiumSandbox } = await import('../src/browser-manager');
|
||||
expect(shouldEnableChromiumSandbox()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveDisconnectCause ──────────────────────────────────────
|
||||
@@ -163,3 +191,39 @@ describe('resolveDisconnectCause', () => {
|
||||
expect(await resolveDisconnectCause(null)).toBe('crash');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── onDisconnect exit-code propagation (regression test) ──────────
|
||||
//
|
||||
// The contract: BrowserManager.onDisconnect is called with the resolved
|
||||
// exit code (0 for clean Cmd+Q, 2 for crash). server.ts then forwards
|
||||
// that code to activeShutdown(), which exits the process.
|
||||
//
|
||||
// Without this propagation, the headed-mode user-visible Cmd+Q respawn
|
||||
// bug returns: server.ts hardcoded `activeShutdown?.(2)` ignores the
|
||||
// resolved 0 and gbrowser's gbd HealthMonitor treats the clean quit as
|
||||
// a crash, restarting the window.
|
||||
describe('BrowserManager.onDisconnect exit-code propagation', () => {
|
||||
it('signature accepts an optional exitCode argument', async () => {
|
||||
const { BrowserManager } = await import('../src/browser-manager');
|
||||
const bm = new BrowserManager();
|
||||
const calls: Array<number | undefined> = [];
|
||||
bm.onDisconnect = (code?: number) => { calls.push(code); };
|
||||
bm.onDisconnect(0);
|
||||
bm.onDisconnect(2);
|
||||
bm.onDisconnect(undefined);
|
||||
expect(calls).toEqual([0, 2, undefined]);
|
||||
});
|
||||
|
||||
it('server.ts callback forwards exitCode when provided, falls back to 2', async () => {
|
||||
// Mirror the production wiring in browse/src/server.ts so a refactor
|
||||
// that drops the forward (e.g. reverting to `() => activeShutdown?.(2)`)
|
||||
// fails CI before the user-visible bug returns.
|
||||
const shutdownCalls: number[] = [];
|
||||
const activeShutdown = (code: number) => { shutdownCalls.push(code); };
|
||||
const onDisconnect = (code?: number) => activeShutdown(code ?? 2);
|
||||
onDisconnect(0);
|
||||
onDisconnect(2);
|
||||
onDisconnect(undefined);
|
||||
expect(shutdownCalls).toEqual([0, 2, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,17 @@ describe('buildSpawnEnv', () => {
|
||||
process.env.LANG = 'en_US.UTF-8';
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env = origEnv;
|
||||
// process.env = origEnv replaces only the reference; the underlying
|
||||
// env stays mutated and leaks to later test files in the same Bun
|
||||
// process (e.g., breaks Bun.which('bash') in security.test.ts and
|
||||
// bun-spawn in pair-agent-tunnel-eval.test.ts). Delete every current
|
||||
// key then re-assign from the snapshot — restores the actual env.
|
||||
for (const k of Object.keys(process.env)) {
|
||||
if (!(k in origEnv)) delete process.env[k];
|
||||
}
|
||||
for (const [k, v] of Object.entries(origEnv)) {
|
||||
if (v !== undefined) process.env[k] = v;
|
||||
}
|
||||
});
|
||||
|
||||
it('untrusted: drops $HOME and secrets', () => {
|
||||
@@ -293,7 +303,15 @@ describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
|
||||
expect(parsed.gh).toBeNull();
|
||||
expect(parsed.gstack).toBeNull();
|
||||
} finally {
|
||||
process.env = origEnv;
|
||||
// See afterEach comment in `buildSpawnEnv` describe — direct
|
||||
// reassignment of process.env doesn't actually restore the
|
||||
// underlying env in Bun. Delete + re-assign instead.
|
||||
for (const k of Object.keys(process.env)) {
|
||||
if (!(k in origEnv)) delete process.env[k];
|
||||
}
|
||||
for (const [k, v] of Object.entries(origEnv)) {
|
||||
if (v !== undefined) process.env[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -312,7 +330,12 @@ describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(parsed.home).toBe('/Users/test-user');
|
||||
} finally {
|
||||
process.env = origEnv;
|
||||
for (const k of Object.keys(process.env)) {
|
||||
if (!(k in origEnv)) delete process.env[k];
|
||||
}
|
||||
for (const [k, v] of Object.entries(origEnv)) {
|
||||
if (v !== undefined) process.env[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import type { Page } from 'playwright';
|
||||
import {
|
||||
__testInternals,
|
||||
undoModification,
|
||||
} from '../src/cdp-inspector';
|
||||
|
||||
// Regression tests for the modificationHistory cap (D6 / smoking gun #2).
|
||||
// Pre-cap, the module-scoped array grew unbounded across the session. Cap is
|
||||
// 200 entries, oldest evicted on push past the cap. undoModification reports
|
||||
// "evicted at the cap" in the error message so a user who asks for a
|
||||
// no-longer-available index understands what happened (instead of seeing the
|
||||
// pre-cap "No modification at index 500" with no context).
|
||||
|
||||
const { pushModification, MOD_HISTORY_CAP, getRawHistory, getTotalPushed, resetForTest } = __testInternals;
|
||||
|
||||
function fakeMod(id: number) {
|
||||
return {
|
||||
selector: `#node-${id}`,
|
||||
property: 'color',
|
||||
oldValue: 'red',
|
||||
newValue: 'blue',
|
||||
source: 'inline' as const,
|
||||
timestamp: id,
|
||||
method: 'setProperty' as 'setProperty',
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetForTest();
|
||||
});
|
||||
|
||||
describe('modificationHistory cap', () => {
|
||||
test('1. push under cap keeps every entry', () => {
|
||||
for (let i = 0; i < 50; i++) pushModification(fakeMod(i));
|
||||
expect(getRawHistory().length).toBe(50);
|
||||
expect(getTotalPushed()).toBe(50);
|
||||
expect(getRawHistory()[0].timestamp).toBe(0);
|
||||
expect(getRawHistory()[49].timestamp).toBe(49);
|
||||
});
|
||||
|
||||
test('2. push exactly cap keeps every entry', () => {
|
||||
for (let i = 0; i < MOD_HISTORY_CAP; i++) pushModification(fakeMod(i));
|
||||
expect(getRawHistory().length).toBe(MOD_HISTORY_CAP);
|
||||
expect(getTotalPushed()).toBe(MOD_HISTORY_CAP);
|
||||
expect(getRawHistory()[0].timestamp).toBe(0);
|
||||
});
|
||||
|
||||
test('3. push past cap evicts oldest, keeps length at cap', () => {
|
||||
const total = MOD_HISTORY_CAP + 50;
|
||||
for (let i = 0; i < total; i++) pushModification(fakeMod(i));
|
||||
expect(getRawHistory().length).toBe(MOD_HISTORY_CAP);
|
||||
expect(getTotalPushed()).toBe(total);
|
||||
// Oldest 50 dropped — entry that was #0 is gone; new oldest is #50.
|
||||
expect(getRawHistory()[0].timestamp).toBe(50);
|
||||
expect(getRawHistory()[MOD_HISTORY_CAP - 1].timestamp).toBe(total - 1);
|
||||
});
|
||||
|
||||
test('4. resetForTest clears both buffer and totalPushed', () => {
|
||||
for (let i = 0; i < 10; i++) pushModification(fakeMod(i));
|
||||
resetForTest();
|
||||
expect(getRawHistory().length).toBe(0);
|
||||
expect(getTotalPushed()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undoModification eviction-aware error', () => {
|
||||
// Stub Page: undoModification throws before any await when idx is out of
|
||||
// range, so the stub never actually gets called.
|
||||
const stubPage = {} as unknown as Page;
|
||||
|
||||
test('5. out-of-range BEFORE any eviction → no evicted note', async () => {
|
||||
for (let i = 0; i < 5; i++) pushModification(fakeMod(i));
|
||||
await expect(undoModification(stubPage, 99)).rejects.toThrow(
|
||||
'No modification at index 99. History has 5 entries.',
|
||||
);
|
||||
});
|
||||
|
||||
test('6. out-of-range AFTER eviction → message names the evicted count', async () => {
|
||||
const total = MOD_HISTORY_CAP + 73;
|
||||
for (let i = 0; i < total; i++) pushModification(fakeMod(i));
|
||||
// 273 pushed, 200 in buffer, 73 evicted. Ask for idx=400 (above buffer).
|
||||
await expect(undoModification(stubPage, 400)).rejects.toThrow(
|
||||
`No modification at index 400. History has ${MOD_HISTORY_CAP} entries ` +
|
||||
`(most recent ${MOD_HISTORY_CAP} only — 73 earlier entries evicted at the cap).`,
|
||||
);
|
||||
});
|
||||
|
||||
test('7. negative explicit index throws cleanly (no NaN propagation)', async () => {
|
||||
for (let i = 0; i < 10; i++) pushModification(fakeMod(i));
|
||||
await expect(undoModification(stubPage, -1)).rejects.toThrow(
|
||||
'No modification at index -1.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { Page } from 'playwright';
|
||||
import { withCdpSession, getOrCreateCdpSession } from '../src/cdp-bridge';
|
||||
|
||||
// Static-grep tripwire + behavior tests for the CDP session lifecycle
|
||||
// helpers introduced as part of the D11 EXPAND_SCOPE memory-leak fix.
|
||||
//
|
||||
// Direct calls to `page.context().newCDPSession(page)` are the leak class
|
||||
// the helpers exist to close — every direct call needs a matching
|
||||
// `session.detach()` and forgetting it leaves the Chromium-side target
|
||||
// attached until the underlying transport drops. The tripwire fails CI
|
||||
// if any source file calls `newCDPSession(` outside `cdp-bridge.ts`
|
||||
// (the file that owns the helpers).
|
||||
//
|
||||
// Pattern mirrors browse/test/terminal-agent-pid-identity.test.ts and
|
||||
// browse/test/server-sanitize-surrogates.test.ts: read source files
|
||||
// directly, assert an invariant on their contents.
|
||||
|
||||
const SRC_DIR = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src');
|
||||
|
||||
function readAllSourceFiles(): Array<{ file: string; content: string }> {
|
||||
const out: Array<{ file: string; content: string }> = [];
|
||||
for (const entry of fs.readdirSync(SRC_DIR)) {
|
||||
if (!entry.endsWith('.ts')) continue;
|
||||
const full = path.join(SRC_DIR, entry);
|
||||
out.push({ file: entry, content: fs.readFileSync(full, 'utf-8') });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('CDP session cleanup invariant', () => {
|
||||
test('1. no source file calls `newCDPSession(` outside cdp-bridge.ts', () => {
|
||||
const offenders: Array<{ file: string; line: number; text: string }> = [];
|
||||
for (const { file, content } of readAllSourceFiles()) {
|
||||
// The helper file is the ONE allowed home for direct newCDPSession calls.
|
||||
if (file === 'cdp-bridge.ts') continue;
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!/newCDPSession\s*\(/.test(line)) continue;
|
||||
// Skip comment lines — documentation mentions are fine.
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
|
||||
offenders.push({ file, line: i + 1, text: trimmed });
|
||||
}
|
||||
}
|
||||
if (offenders.length > 0) {
|
||||
const formatted = offenders
|
||||
.map((o) => ` ${o.file}:${o.line} ${o.text}`)
|
||||
.join('\n');
|
||||
throw new Error(
|
||||
`Direct newCDPSession(...) calls found outside cdp-bridge.ts. ` +
|
||||
`Route through withCdpSession() (one-shot, finally-detach) or ` +
|
||||
`getOrCreateCdpSession() (cached, close-detach) instead:\n${formatted}`,
|
||||
);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('2. helper file exports the two documented entry points', () => {
|
||||
// Sanity: the tripwire is meaningless if the helpers themselves are gone.
|
||||
expect(typeof withCdpSession).toBe('function');
|
||||
expect(typeof getOrCreateCdpSession).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withCdpSession finally-detach', () => {
|
||||
// Fake Page surface for unit-testing the helper without spinning up a real
|
||||
// browser. The helper only touches page.context().newCDPSession(page) and
|
||||
// the returned session's .detach(), so this surface is enough.
|
||||
function makeFakePage(detachSpy: { called: number; rejected?: Error }) {
|
||||
const session = {
|
||||
detach: async () => {
|
||||
detachSpy.called++;
|
||||
if (detachSpy.rejected) throw detachSpy.rejected;
|
||||
},
|
||||
};
|
||||
return {
|
||||
context: () => ({
|
||||
newCDPSession: async (_p: unknown) => session,
|
||||
}),
|
||||
} as unknown as Page;
|
||||
}
|
||||
|
||||
test('3. detaches on the success path', async () => {
|
||||
const detachSpy = { called: 0 };
|
||||
const page = makeFakePage(detachSpy);
|
||||
const result = await withCdpSession(page, async (session) => {
|
||||
expect(session).toBeDefined();
|
||||
return 42;
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
expect(detachSpy.called).toBe(1);
|
||||
});
|
||||
|
||||
test('4. detaches even when fn throws (the actual leak fix)', async () => {
|
||||
const detachSpy = { called: 0 };
|
||||
const page = makeFakePage(detachSpy);
|
||||
await expect(
|
||||
withCdpSession(page, async () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
expect(detachSpy.called).toBe(1);
|
||||
});
|
||||
|
||||
test('5. swallows detach errors so they do not mask fn errors', async () => {
|
||||
const detachSpy = { called: 0, rejected: new Error('already detached') };
|
||||
const page = makeFakePage(detachSpy);
|
||||
await expect(
|
||||
withCdpSession(page, async () => {
|
||||
throw new Error('original');
|
||||
}),
|
||||
).rejects.toThrow('original');
|
||||
expect(detachSpy.called).toBe(1);
|
||||
});
|
||||
|
||||
test('6. swallows detach errors on the success path too', async () => {
|
||||
const detachSpy = { called: 0, rejected: new Error('target closed') };
|
||||
const page = makeFakePage(detachSpy);
|
||||
const result = await withCdpSession(page, async () => 'ok');
|
||||
expect(result).toBe('ok');
|
||||
expect(detachSpy.called).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrCreateCdpSession close-detach', () => {
|
||||
function makeFakePage() {
|
||||
const closeListeners: Array<() => void> = [];
|
||||
const session = {
|
||||
detach: async () => {
|
||||
session._detachCount++;
|
||||
},
|
||||
_detachCount: 0,
|
||||
};
|
||||
const page = {
|
||||
context: () => ({
|
||||
newCDPSession: async (_p: unknown) => session,
|
||||
}),
|
||||
once: (event: string, fn: () => void) => {
|
||||
if (event === 'close') closeListeners.push(fn);
|
||||
},
|
||||
_fireClose: () => {
|
||||
for (const fn of closeListeners) fn();
|
||||
},
|
||||
};
|
||||
return { page: page as unknown as Page, session, fireClose: page._fireClose };
|
||||
}
|
||||
|
||||
test('7. caches the session across calls', async () => {
|
||||
const { page } = makeFakePage();
|
||||
const cache = new WeakMap<Page, any>();
|
||||
const s1 = await getOrCreateCdpSession(page, cache);
|
||||
const s2 = await getOrCreateCdpSession(page, cache);
|
||||
expect(s1).toBe(s2);
|
||||
});
|
||||
|
||||
test('8. close hook detaches the session AND clears the cache', async () => {
|
||||
const { page, session, fireClose } = makeFakePage();
|
||||
const cache = new WeakMap<Page, any>();
|
||||
await getOrCreateCdpSession(page, cache);
|
||||
expect(cache.get(page)).toBeDefined();
|
||||
fireClose();
|
||||
// Detach runs synchronously up to the await in the close hook; let it settle.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(cache.get(page)).toBeUndefined();
|
||||
expect(session._detachCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Coverage for #1612 — macOS/Linux server must survive sandboxed-shell
|
||||
* harnesses by becoming its own session leader (setsid).
|
||||
*
|
||||
* Pre-#1612, Bun.spawn().unref() removed the child from Bun's event loop
|
||||
* but did NOT call setsid(). When the CLI ran inside Claude Code's
|
||||
* per-command sandbox, Conductor, or CI step runners, the session leader's
|
||||
* exit sent SIGHUP to every PID in the session, killing the bun server.
|
||||
*
|
||||
* The fix routes macOS/Linux spawn through Node's child_process.spawn with
|
||||
* detached:true, which calls setsid() so the server becomes its own session
|
||||
* leader (PPID=1 on Linux, similar reparenting on Darwin).
|
||||
*
|
||||
* The actual setsid syscall is hard to assert in a unit test without a
|
||||
* real spawn — testing here is static: the cli.ts source must use the
|
||||
* Node spawn path on macOS/Linux, with detached:true and .unref(). If a
|
||||
* future refactor reverts to Bun.spawn().unref() on the macOS/Linux branch
|
||||
* the regression returns and these tests fail.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..", "..");
|
||||
const CLI = path.join(ROOT, "browse", "src", "cli.ts");
|
||||
|
||||
function read(): string {
|
||||
return fs.readFileSync(CLI, "utf-8");
|
||||
}
|
||||
|
||||
describe("#1612 macOS/Linux daemonize via Node setsid path", () => {
|
||||
test("cli.ts imports nodeSpawn from child_process (Node spawn alias)", () => {
|
||||
const body = read();
|
||||
// The fix relies on Node's child_process.spawn (which calls setsid on
|
||||
// detached:true), aliased to avoid name collision with Bun.spawn. Match
|
||||
// either `nodeSpawn` or `spawn as nodeSpawn` to be flexible to the
|
||||
// exact import style.
|
||||
expect(body).toMatch(/(spawn as nodeSpawn|nodeSpawn\s*[,}])/);
|
||||
expect(body).toMatch(/from\s+['"]child_process['"]/);
|
||||
});
|
||||
|
||||
test("non-Windows branch uses nodeSpawn(...).unref() with detached:true", () => {
|
||||
const body = read();
|
||||
// Find the non-Windows branch and assert it uses the Node spawn alias
|
||||
// with detached:true. Match the pattern `nodeSpawn(...) ... detached:true`.
|
||||
expect(body).toMatch(/nodeSpawn\([\s\S]{0,500}detached:\s*true/);
|
||||
expect(body).toMatch(/nodeSpawn\([\s\S]{0,500}\.unref\(\)/);
|
||||
});
|
||||
|
||||
test("non-Windows branch comment documents setsid/SIGHUP root cause", () => {
|
||||
const body = read();
|
||||
// The comment block must mention setsid() so a future refactor sees the
|
||||
// why before changing the spawn call.
|
||||
expect(body).toMatch(/setsid/);
|
||||
expect(body).toMatch(/SIGHUP/);
|
||||
});
|
||||
|
||||
test("the spawn call on macOS/Linux is nodeSpawn, not Bun.spawn", () => {
|
||||
const body = read();
|
||||
// Strip line comments before regex matching, so the "Bun.spawn().unref()"
|
||||
// mentions inside the explanatory comment don't trigger false positives.
|
||||
const codeOnly = body
|
||||
.split("\n")
|
||||
.filter((line) => !line.trim().startsWith("//"))
|
||||
.join("\n");
|
||||
// Find the non-Windows branch. The `} else {` block following the
|
||||
// Windows branch. We then require its first ~400 chars contain a
|
||||
// nodeSpawn() call and NOT a Bun.spawn() call (excluding the comment).
|
||||
const nonWindowsStart = codeOnly.indexOf("nodeSpawn('bun'");
|
||||
expect(nonWindowsStart).toBeGreaterThan(-1);
|
||||
const slice = codeOnly.slice(nonWindowsStart, nonWindowsStart + 400);
|
||||
expect(slice).toMatch(/nodeSpawn\(/);
|
||||
expect(slice).not.toMatch(/Bun\.spawn\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 outer supervisor — static-grep invariants.
|
||||
//
|
||||
// Pre-v1.44 `$B connect` was fire-and-forget: spawn server detached, CLI
|
||||
// exits, server runs unsupervised. If the server crashed, the user had to
|
||||
// re-run `$B connect`. The opt-in supervisor (--supervise or
|
||||
// BROWSE_SUPERVISE=1) keeps the CLI attached and respawns the server on
|
||||
// unexpected exit, with the same crash-loop guard shape as the v1.44
|
||||
// terminal-agent watchdog.
|
||||
//
|
||||
// Live respawn tests belong in the e2e tier (real Bun.spawn cycles take
|
||||
// 3-8s each). These tripwires defend the load-bearing invariants:
|
||||
// opt-in by default, signal handlers wired, crash-loop guard, env knobs.
|
||||
|
||||
const CLI_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'cli.ts');
|
||||
|
||||
describe('CLI outer supervisor (v1.44+)', () => {
|
||||
test('1. supervisor is opt-in via --supervise flag or BROWSE_SUPERVISE env', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
expect(src).toContain("commandArgs.includes('--supervise')");
|
||||
expect(src).toContain("process.env.BROWSE_SUPERVISE === '1'");
|
||||
// Default path MUST still exit 0 promptly. The legacy contract is
|
||||
// that every caller of `$B connect` (Claude Code Bash tool, scripts,
|
||||
// CI) gets a prompt return.
|
||||
expect(src).toMatch(/if \(!superviseRequested\) \{\s*process\.exit\(0\);\s*\}/);
|
||||
});
|
||||
|
||||
test('2. SIGINT and SIGTERM trigger clean teardown', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
// Both signals must hit the teardown path or the user's Ctrl-C leaves
|
||||
// an orphaned server (worse than no supervisor).
|
||||
expect(src).toMatch(/process\.on\('SIGINT'.*teardownAndExit/);
|
||||
expect(src).toMatch(/process\.on\('SIGTERM'.*teardownAndExit/);
|
||||
// Teardown must signal the supervised server before exiting itself.
|
||||
expect(src).toContain("safeKill(state.pid, 'SIGTERM')");
|
||||
});
|
||||
|
||||
test('3. crash-loop guard with 5-in-5min rolling window', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
expect(src).toContain('SUPERVISOR_GUARD_WINDOW_MS = 5 * 60_000');
|
||||
expect(src).toContain('SUPERVISOR_GUARD_MAX = 5');
|
||||
// Window pruning: a long-lived daemon with sporadic crashes must NOT
|
||||
// hit the guard (otherwise we punish the user for the supervisor doing
|
||||
// its job).
|
||||
expect(src).toMatch(/respawns\.shift\(\)/);
|
||||
});
|
||||
|
||||
test('4. exponential backoff schedule, env-overridable', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_SUPERVISOR_BACKOFF');
|
||||
// Default schedule must include short waits at first (rapid recovery
|
||||
// from transient crashes) and cap at a sensible long wait.
|
||||
expect(src).toContain('1000,2000,4000,8000,30000');
|
||||
});
|
||||
|
||||
test('5. tick interval is env-overridable for tests', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_SUPERVISOR_TICK_MS');
|
||||
});
|
||||
|
||||
test('6. respawned server gets a fresh terminal-agent too', () => {
|
||||
const src = fs.readFileSync(CLI_TS, 'utf-8');
|
||||
// After server respawn, the terminal-agent state is stale (old PID
|
||||
// record points to a dead agent that exited with its parent). The
|
||||
// supervisor must re-call spawnTerminalAgent or the PTY path stays
|
||||
// broken even though the server is back up.
|
||||
const block = sliceBetween(src, 'Supervisor mode:', '// ─── Headed Disconnect');
|
||||
expect(block).toContain('spawnTerminalAgent({');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { startTestServer } from './test-server';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
import { resolveServerScript } from '../src/cli';
|
||||
import { handleReadCommand as _handleReadCommand } from '../src/read-commands';
|
||||
import { handleReadCommand as _handleReadCommand, parseOutArgs, hasOutArg, resultToString } from '../src/read-commands';
|
||||
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
|
||||
import { handleMetaCommand } from '../src/meta-commands';
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, CircularBuffer } from '../src/buffers';
|
||||
@@ -23,6 +23,65 @@ const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
||||
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
||||
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
||||
|
||||
// ─── Pure arg-parser + result-conversion unit tests (no browser) ───
|
||||
describe('parseOutArgs / hasOutArg', () => {
|
||||
test('--out <path> splits the flag from the positional', () => {
|
||||
expect(parseOutArgs(['expr', '--out', '/tmp/x'])).toEqual({ outPath: '/tmp/x', raw: false, rest: ['expr'] });
|
||||
});
|
||||
|
||||
test('--out=<path> form is equivalent', () => {
|
||||
expect(parseOutArgs(['expr', '--out=/tmp/x'])).toEqual({ outPath: '/tmp/x', raw: false, rest: ['expr'] });
|
||||
});
|
||||
|
||||
test('flag ordering does not matter', () => {
|
||||
expect(parseOutArgs(['--out', '/tmp/x', 'expr'])).toEqual({ outPath: '/tmp/x', raw: false, rest: ['expr'] });
|
||||
});
|
||||
|
||||
test('--raw and --raw=true|false', () => {
|
||||
expect(parseOutArgs(['e', '--out', '/tmp/x', '--raw']).raw).toBe(true);
|
||||
expect(parseOutArgs(['e', '--out', '/tmp/x', '--raw=true']).raw).toBe(true);
|
||||
expect(parseOutArgs(['e', '--out', '/tmp/x', '--raw=false']).raw).toBe(false);
|
||||
});
|
||||
|
||||
test('repeated --out throws', () => {
|
||||
expect(() => parseOutArgs(['e', '--out', '/a', '--out', '/b'])).toThrow(/more than once/);
|
||||
});
|
||||
|
||||
test('--out with a missing value throws', () => {
|
||||
expect(() => parseOutArgs(['e', '--out'])).toThrow(/requires a file path/);
|
||||
expect(() => parseOutArgs(['e', '--out', '--raw'])).toThrow(/requires a file path/);
|
||||
expect(() => parseOutArgs(['e', '--out='])).toThrow(/requires a file path/);
|
||||
});
|
||||
|
||||
test('bad --raw value throws', () => {
|
||||
expect(() => parseOutArgs(['e', '--out', '/a', '--raw=maybe'])).toThrow(/--raw must be true or false/);
|
||||
});
|
||||
|
||||
test('hasOutArg matches --out and --out= exactly, not lookalikes', () => {
|
||||
expect(hasOutArg(['a', '--out', 'b'])).toBe(true);
|
||||
expect(hasOutArg(['a', '--out=b'])).toBe(true);
|
||||
expect(hasOutArg(['a'])).toBe(false);
|
||||
expect(hasOutArg(['a', '--output', 'b'])).toBe(false);
|
||||
expect(hasOutArg(['a', '--outx'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resultToString — byte-for-byte with pre-refactor behavior', () => {
|
||||
test('null becomes "null" (typeof null === object → JSON.stringify)', () => {
|
||||
expect(resultToString(null)).toBe('null');
|
||||
});
|
||||
test('undefined becomes empty string', () => {
|
||||
expect(resultToString(undefined)).toBe('');
|
||||
});
|
||||
test('objects are pretty-printed JSON', () => {
|
||||
expect(resultToString({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2));
|
||||
});
|
||||
test('primitives use String()', () => {
|
||||
expect(resultToString(42)).toBe('42');
|
||||
expect(resultToString(true)).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
let testServer: ReturnType<typeof startTestServer>;
|
||||
let bm: BrowserManager;
|
||||
let baseUrl: string;
|
||||
@@ -225,6 +284,102 @@ describe('Inspection', () => {
|
||||
expect(result).toBe('3');
|
||||
});
|
||||
|
||||
// ─── js/eval --out (render-to-file) ───────────────────────────
|
||||
|
||||
test('js (no --out) returns a multi-MB string without truncation', async () => {
|
||||
// Handler-level guarantee: the result is not sliced/capped before return.
|
||||
// (Full HTTP egress path is exercised elsewhere; this pins the handler.)
|
||||
const result = await handleReadCommand('js', ["'x'.repeat(3 * 1024 * 1024)"], bm);
|
||||
expect(result.length).toBe(3 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('js --out writes the result to disk and returns a short status, not the payload', async () => {
|
||||
const out = `/tmp/browse-out-large-${Date.now()}.txt`;
|
||||
try {
|
||||
const result = await handleReadCommand('js', ["'y'.repeat(2 * 1024 * 1024)", '--out', out], bm);
|
||||
expect(result).toContain('JS result written:');
|
||||
expect(result).toContain(out);
|
||||
expect(result).toContain(`(${2 * 1024 * 1024} bytes)`);
|
||||
expect(result.length).toBeLessThan(200); // status, not the 2MB payload
|
||||
expect(fs.statSync(out).size).toBe(2 * 1024 * 1024);
|
||||
} finally {
|
||||
fs.rmSync(out, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('js --out decodes a base64 PNG data URL to real bytes', async () => {
|
||||
// 1x1 transparent PNG.
|
||||
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
const out = `/tmp/browse-out-png-${Date.now()}.png`;
|
||||
try {
|
||||
const result = await handleReadCommand('js', [`'data:image/png;base64,' + '${b64}'`, '--out', out], bm);
|
||||
const buf = fs.readFileSync(out);
|
||||
// PNG magic bytes: 89 50 4E 47
|
||||
expect([buf[0], buf[1], buf[2], buf[3]]).toEqual([0x89, 0x50, 0x4e, 0x47]);
|
||||
const expectedLen = Buffer.from(b64, 'base64').length;
|
||||
expect(buf.length).toBe(expectedLen);
|
||||
expect(result).toContain(`(${expectedLen} bytes)`);
|
||||
} finally {
|
||||
fs.rmSync(out, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('js --out --raw writes the literal data-URL string (no decode)', async () => {
|
||||
const dataUrl = 'data:text/plain;base64,aGVsbG8=';
|
||||
const out = `/tmp/browse-out-raw-${Date.now()}.txt`;
|
||||
try {
|
||||
await handleReadCommand('js', [`'${dataUrl}'`, '--out', out, '--raw'], bm);
|
||||
expect(fs.readFileSync(out, 'utf-8')).toBe(dataUrl);
|
||||
} finally {
|
||||
fs.rmSync(out, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('js --out throws on a malformed base64 data URL instead of writing corrupt bytes', async () => {
|
||||
const out = `/tmp/browse-out-bad-${Date.now()}.png`;
|
||||
try {
|
||||
await expect(
|
||||
handleReadCommand('js', ["'data:image/png;base64,!!!not-base64!!!'", '--out', out], bm)
|
||||
).rejects.toThrow(/malformed base64/);
|
||||
expect(fs.existsSync(out)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(out, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('js --out rejects a path outside the safe directories', async () => {
|
||||
await expect(
|
||||
handleReadCommand('js', ['1 + 1', '--out', '/etc/browse-should-not-write.txt'], bm)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('js --out creates a missing parent directory', async () => {
|
||||
// validateOutputPath resolves the parent's realpath, so it permits one level
|
||||
// of missing dir under a safe root (/tmp). mkdir then materializes it.
|
||||
const root = `/tmp/browse-out-nested-${Date.now()}`;
|
||||
const out = `${root}/result.txt`;
|
||||
try {
|
||||
await handleReadCommand('js', ["'nested'", '--out', out], bm);
|
||||
expect(fs.readFileSync(out, 'utf-8')).toBe('nested');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('eval --out writes the file result to disk (parity with js)', async () => {
|
||||
const script = `/tmp/browse-eval-out-src-${Date.now()}.js`;
|
||||
const out = `/tmp/browse-eval-out-${Date.now()}.txt`;
|
||||
fs.writeFileSync(script, "'from eval'");
|
||||
try {
|
||||
const result = await handleReadCommand('eval', [script, '--out', out], bm);
|
||||
expect(result).toContain('Eval result written:');
|
||||
expect(fs.readFileSync(out, 'utf-8')).toBe('from eval');
|
||||
} finally {
|
||||
fs.rmSync(script, { force: true });
|
||||
fs.rmSync(out, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('css returns computed property', async () => {
|
||||
const result = await handleReadCommand('css', ['h1', 'color'], bm);
|
||||
// Navy color
|
||||
|
||||
@@ -47,4 +47,15 @@ describe('locateBinary', () => {
|
||||
expect(typeof locateBinary).toBe('function');
|
||||
expect(locateBinary.length).toBe(0);
|
||||
});
|
||||
|
||||
test('source-checkout fallback resolves <git-root>/browse/dist/browse[.exe]', () => {
|
||||
// The windows-setup-e2e.yml workflow builds binaries directly under
|
||||
// browse/dist/ (no .claude/skills/gstack/ install layout). find-browse
|
||||
// must resolve those — otherwise every fresh build that hasn't run
|
||||
// ./setup yet looks broken. Static pin so a future refactor that
|
||||
// drops the source-checkout branch trips this test.
|
||||
const src = require('fs').readFileSync(require('path').join(__dirname, '../src/find-browse.ts'), 'utf-8');
|
||||
expect(src).toContain('Source-checkout fallback');
|
||||
expect(src).toContain("join(root, 'browse', 'dist', 'browse')");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import { __testInternals__ } from '../src/server';
|
||||
|
||||
const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
|
||||
|
||||
@@ -28,6 +29,47 @@ function getFreePort(): Promise<number> {
|
||||
}
|
||||
|
||||
describe('findPort / isPortAvailable', () => {
|
||||
test('explicit BROWSE_PORT diagnostic distinguishes bind denial from occupied port', () => {
|
||||
const blocked = __testInternals__.formatExplicitPortUnavailableError(34567, {
|
||||
available: false,
|
||||
code: 'EPERM',
|
||||
message: 'operation not permitted',
|
||||
}).message;
|
||||
|
||||
expect(blocked).toContain('Cannot bind BROWSE_PORT=34567');
|
||||
expect(blocked).toContain('localhost port binding is blocked');
|
||||
expect(blocked).toContain('not that the port is occupied');
|
||||
|
||||
const occupied = __testInternals__.formatExplicitPortUnavailableError(34567, {
|
||||
available: false,
|
||||
code: 'EADDRINUSE',
|
||||
message: 'address already in use',
|
||||
}).message;
|
||||
|
||||
expect(occupied).toBe('[browse] Port 34567 (from BROWSE_PORT env) is in use');
|
||||
});
|
||||
|
||||
test('random port diagnostic calls out sandbox-style bind denial', () => {
|
||||
const message = __testInternals__.formatRandomPortUnavailableError([
|
||||
{ port: 11001, result: { available: false, code: 'EADDRINUSE', message: 'address already in use' } },
|
||||
{ port: 12002, result: { available: false, code: 'EPERM', message: 'operation not permitted' } },
|
||||
]).message;
|
||||
|
||||
expect(message).toContain('Cannot bind localhost ports after 2 attempts');
|
||||
expect(message).toContain('Last error: 12002 (EPERM: operation not permitted)');
|
||||
expect(message).toContain('not that every sampled port is occupied');
|
||||
expect(message).toContain('set BROWSE_PORT to an approved port');
|
||||
});
|
||||
|
||||
test('random port diagnostic preserves old busy-port meaning when all attempts are occupied', () => {
|
||||
const message = __testInternals__.formatRandomPortUnavailableError([
|
||||
{ port: 11001, result: { available: false, code: 'EADDRINUSE', message: 'address already in use' } },
|
||||
{ port: 12002, result: { available: false, code: 'EADDRINUSE', message: 'address already in use' } },
|
||||
]).message;
|
||||
|
||||
expect(message).toContain('No available port after 5 attempts');
|
||||
expect(message).toContain('every sampled port was already in use');
|
||||
});
|
||||
|
||||
test('isPortAvailable returns true for a free port', async () => {
|
||||
// Use the same isPortAvailable logic from server.ts
|
||||
|
||||
@@ -41,9 +41,16 @@ afterEach(() => {
|
||||
|
||||
describe('gstack-config', () => {
|
||||
// ─── get ──────────────────────────────────────────────────
|
||||
test('get on missing file returns empty, exit 0', () => {
|
||||
test('get on missing file returns the default, exit 0', () => {
|
||||
// auto_upgrade has a default of false; get falls back to the defaults table.
|
||||
const { exitCode, stdout } = run(['get', 'auto_upgrade']);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toBe('false');
|
||||
});
|
||||
|
||||
test('get unknown key on missing file returns empty, exit 0', () => {
|
||||
const { exitCode, stdout } = run(['get', 'some_unknown_key']);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toBe('');
|
||||
});
|
||||
|
||||
@@ -110,10 +117,12 @@ describe('gstack-config', () => {
|
||||
expect(stdout).toContain('update_check: false');
|
||||
});
|
||||
|
||||
test('list on missing file returns empty, exit 0', () => {
|
||||
test('list on missing file shows defaults, exit 0', () => {
|
||||
// list prints the active-values block with defaults for unset keys.
|
||||
const { exitCode, stdout } = run(['list']);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toBe('');
|
||||
expect(stdout).toContain('proactive:');
|
||||
expect(stdout).toContain('(default)');
|
||||
});
|
||||
|
||||
// ─── usage ────────────────────────────────────────────────
|
||||
@@ -151,6 +160,29 @@ describe('gstack-config', () => {
|
||||
expect(content).toContain('skip_eng_review:');
|
||||
});
|
||||
|
||||
// ─── codex_reviews (paid-calls switch: reject-on-set, preserve existing) ──
|
||||
test('codex_reviews defaults to enabled', () => {
|
||||
const { exitCode, stdout } = run(['get', 'codex_reviews']);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toBe('enabled');
|
||||
});
|
||||
|
||||
test('codex_reviews accepts enabled and disabled', () => {
|
||||
expect(run(['set', 'codex_reviews', 'disabled']).exitCode).toBe(0);
|
||||
expect(run(['get', 'codex_reviews']).stdout).toBe('disabled');
|
||||
expect(run(['set', 'codex_reviews', 'enabled']).exitCode).toBe(0);
|
||||
expect(run(['get', 'codex_reviews']).stdout).toBe('enabled');
|
||||
});
|
||||
|
||||
test('codex_reviews rejects an invalid value and preserves the existing one', () => {
|
||||
run(['set', 'codex_reviews', 'disabled']);
|
||||
const { exitCode, stderr } = run(['set', 'codex_reviews', 'disabledd']);
|
||||
expect(exitCode).not.toBe(0); // rejected, not warn-and-default
|
||||
expect(stderr).toContain('not recognized');
|
||||
// existing value must be untouched — a typo never silently flips paid Codex on/off
|
||||
expect(run(['get', 'codex_reviews']).stdout).toBe('disabled');
|
||||
});
|
||||
|
||||
test('header written only once, not duplicated on second set', () => {
|
||||
run(['set', 'foo', 'bar']);
|
||||
run(['set', 'baz', 'qux']);
|
||||
@@ -176,9 +208,9 @@ describe('gstack-config', () => {
|
||||
});
|
||||
|
||||
// ─── routing_declined ──────────────────────────────────────
|
||||
test('routing_declined defaults to empty (not set)', () => {
|
||||
test('routing_declined defaults to false (not set)', () => {
|
||||
const { stdout } = run(['get', 'routing_declined']);
|
||||
expect(stdout).toBe('');
|
||||
expect(stdout).toBe('false');
|
||||
});
|
||||
|
||||
test('routing_declined can be set and read', () => {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { formatBytes, type MemorySnapshot, type MemoryStructureStats } from '../src/memory-snapshot';
|
||||
|
||||
// Unit coverage for the $B memory diagnostic surface — formatter, byte
|
||||
// renderer, and the structures-stats aggregator. The integration path
|
||||
// ($B memory through the BrowserManager → CDP) requires a real headless
|
||||
// Chromium and is covered indirectly by browse-basic in the eval suite.
|
||||
// These tests pin the renderer logic in isolation so format regressions
|
||||
// (rounded GB drift, missing "and N more" tail, snapshot.notes ordering)
|
||||
// surface immediately.
|
||||
|
||||
// ─── formatBytes() ─────────────────────────────────────────────
|
||||
|
||||
describe('formatBytes', () => {
|
||||
test('1. < 1 KB renders as bytes', () => {
|
||||
expect(formatBytes(0)).toBe('0 B');
|
||||
expect(formatBytes(1)).toBe('1 B');
|
||||
expect(formatBytes(1023)).toBe('1023 B');
|
||||
});
|
||||
|
||||
test('2. KB tier (1024 ... 1024^2-1)', () => {
|
||||
expect(formatBytes(1024)).toBe('1.0 KB');
|
||||
expect(formatBytes(1536)).toBe('1.5 KB');
|
||||
expect(formatBytes(1024 * 1024 - 1)).toMatch(/^1024\.0 KB$|^1023\.\d KB$/);
|
||||
});
|
||||
|
||||
test('3. MB tier', () => {
|
||||
expect(formatBytes(1024 * 1024)).toBe('1.0 MB');
|
||||
expect(formatBytes(312 * 1024 * 1024)).toBe('312.0 MB');
|
||||
});
|
||||
|
||||
test('4. GB tier renders with 2 decimals', () => {
|
||||
expect(formatBytes(1024 * 1024 * 1024)).toBe('1.00 GB');
|
||||
expect(formatBytes(1.4 * 1024 * 1024 * 1024)).toMatch(/^1\.40 GB$/);
|
||||
// 160.61 GB — the friend's OOM number from the original screenshot.
|
||||
// Verify the renderer doesn't blow up at the actual leak scale.
|
||||
const big = 160.61 * 1024 * 1024 * 1024;
|
||||
expect(formatBytes(big)).toMatch(/^160\.6\d GB$/);
|
||||
});
|
||||
|
||||
test('5. negative input behavior — coerces to bytes path (best-effort, do not throw)', () => {
|
||||
// Diagnostic should never crash on a weird CDP reading; render
|
||||
// something reasonable.
|
||||
expect(() => formatBytes(-1)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── handleMemoryCommand text + json output ────────────────────
|
||||
|
||||
// Build a minimal MemorySnapshot fixture exercising every render branch.
|
||||
// This is what bm.getMemorySnapshot would return; we stub the BrowserManager
|
||||
// so the test never spins up real Chromium.
|
||||
function makeStructureStats(): MemoryStructureStats {
|
||||
return {
|
||||
modificationHistory: { current: 42, cap: 200, evicted: 0 },
|
||||
activitySubscribers: 1,
|
||||
inspectorSubscribers: 0,
|
||||
consoleBufferLen: 1842,
|
||||
networkBufferLen: 12000,
|
||||
dialogBufferLen: 3,
|
||||
captureBufferBytes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSnapshot(overrides: Partial<MemorySnapshot> = {}): MemorySnapshot {
|
||||
return {
|
||||
bunServer: {
|
||||
rss: 312 * 1024 * 1024,
|
||||
heapUsed: 84 * 1024 * 1024,
|
||||
heapTotal: 120 * 1024 * 1024,
|
||||
external: 21 * 1024 * 1024,
|
||||
},
|
||||
tabs: [],
|
||||
processes: null,
|
||||
structures: makeStructureStats(),
|
||||
capturedAt: 1700000000000,
|
||||
notes: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock BrowserManager surface for handleMemoryCommand. Only
|
||||
// getMemorySnapshot is touched.
|
||||
function makeFakeBm(snapshot: MemorySnapshot) {
|
||||
return {
|
||||
getMemorySnapshot: async (structures: MemoryStructureStats) => ({
|
||||
...snapshot,
|
||||
structures,
|
||||
}),
|
||||
} as unknown as import('../src/browser-manager').BrowserManager;
|
||||
}
|
||||
|
||||
describe('handleMemoryCommand', () => {
|
||||
test('6. --json mode emits parseable JSON with bunServer + structures', async () => {
|
||||
const { handleMemoryCommand } = await import('../src/memory-command');
|
||||
const snapshot = makeSnapshot();
|
||||
const result = await handleMemoryCommand(['--json'], makeFakeBm(snapshot));
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.bunServer.rss).toBe(312 * 1024 * 1024);
|
||||
expect(parsed.structures).toBeDefined();
|
||||
expect(parsed.structures.modificationHistory.cap).toBe(200);
|
||||
});
|
||||
|
||||
test('7. text mode renders Bun server line with RSS + heap', async () => {
|
||||
const { handleMemoryCommand } = await import('../src/memory-command');
|
||||
const result = await handleMemoryCommand([], makeFakeBm(makeSnapshot()));
|
||||
expect(result).toContain('Bun server:');
|
||||
expect(result).toContain('312.0 MB');
|
||||
expect(result).toContain('84.0 MB');
|
||||
});
|
||||
|
||||
test('8. text mode renders "no tabs tracked" when tabs array is empty', async () => {
|
||||
const { handleMemoryCommand } = await import('../src/memory-command');
|
||||
const result = await handleMemoryCommand([], makeFakeBm(makeSnapshot({ tabs: [] })));
|
||||
expect(result).toContain('Renderers:');
|
||||
expect(result).toContain('(no tabs tracked)');
|
||||
});
|
||||
|
||||
test('9. text mode shows top 10 tabs + "...and N more" tail when > 10', async () => {
|
||||
const { handleMemoryCommand } = await import('../src/memory-command');
|
||||
const tabs = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: i,
|
||||
url: `https://example.com/tab${i}`,
|
||||
title: `Tab ${i}`,
|
||||
jsHeapUsed: (15 - i) * 50 * 1024 * 1024, // descending so sort matters
|
||||
jsHeapTotal: (15 - i) * 60 * 1024 * 1024,
|
||||
documents: 1,
|
||||
nodes: 100,
|
||||
listeners: 10,
|
||||
}));
|
||||
const result = await handleMemoryCommand([], makeFakeBm(makeSnapshot({ tabs })));
|
||||
expect(result).toContain('Renderers: 15 tabs');
|
||||
expect(result).toContain('and 5 more');
|
||||
// Sorted by JS heap descending — tab 0 (largest) should appear before tab 9
|
||||
expect(result.indexOf('tab #0 —')).toBeLessThan(result.indexOf('tab #9 —'));
|
||||
});
|
||||
|
||||
test('10. text mode renders Chromium processes grouped by type', async () => {
|
||||
const { handleMemoryCommand } = await import('../src/memory-command');
|
||||
const snapshot = makeSnapshot({
|
||||
processes: [
|
||||
{ id: 1, type: 'browser', cpuTime: 1.5 },
|
||||
{ id: 2, type: 'renderer', cpuTime: 3.2 },
|
||||
{ id: 3, type: 'renderer', cpuTime: 2.1 },
|
||||
{ id: 4, type: 'gpu', cpuTime: 0.5 },
|
||||
],
|
||||
});
|
||||
const result = await handleMemoryCommand([], makeFakeBm(snapshot));
|
||||
expect(result).toContain('Chromium processes: 4 total');
|
||||
expect(result).toContain('renderer=2');
|
||||
expect(result).toContain('browser=1');
|
||||
expect(result).toContain('gpu=1');
|
||||
});
|
||||
|
||||
test('11. text mode renders "unavailable" line when processes is null', async () => {
|
||||
const { handleMemoryCommand } = await import('../src/memory-command');
|
||||
const result = await handleMemoryCommand([], makeFakeBm(makeSnapshot({ processes: null })));
|
||||
expect(result).toContain('Chromium processes: (unavailable — see notes)');
|
||||
});
|
||||
|
||||
test('12. text mode renders modificationHistory with evicted-count when > 0', async () => {
|
||||
// formatSnapshotText is what we're really testing here — exercise it
|
||||
// directly with a known snapshot so the live collectStructureStats
|
||||
// doesn't override the fixture values.
|
||||
const mod = await import('../src/memory-command');
|
||||
// formatSnapshotText is private; reach via re-rendering through
|
||||
// --json mode then visually validating the JSON shape. The text-mode
|
||||
// renderer is exercised by test 13 below with live (zero) values.
|
||||
const stats = makeStructureStats();
|
||||
stats.modificationHistory = { current: 200, cap: 200, evicted: 47 };
|
||||
// Synthesize a "would-render" snapshot to assert the eviction note shape.
|
||||
const renderedExpected =
|
||||
'modificationHistory: 200 / 200 entries (47 evicted since reset)';
|
||||
// Since formatSnapshotText isn't exported, validate the format
|
||||
// contract by re-implementing the line and asserting our expectation
|
||||
// matches the canonical format. This pins the user-visible string
|
||||
// shape — a renderer change to drop the "evicted since reset" suffix
|
||||
// would fail this assertion.
|
||||
const evicted = stats.modificationHistory.evicted;
|
||||
const current = stats.modificationHistory.current;
|
||||
const cap = stats.modificationHistory.cap;
|
||||
const expected =
|
||||
`modificationHistory: ${current} / ${cap} entries` +
|
||||
(evicted > 0 ? ` (${evicted} evicted since reset)` : '');
|
||||
expect(expected).toBe(renderedExpected);
|
||||
void mod;
|
||||
});
|
||||
|
||||
test('13. text mode renders modificationHistory line shape', async () => {
|
||||
const { handleMemoryCommand } = await import('../src/memory-command');
|
||||
const result = await handleMemoryCommand([], makeFakeBm(makeSnapshot()));
|
||||
// collectStructureStats reads live module state; values may be 0 in
|
||||
// the test env. Verify the LINE SHAPE rather than specific numbers.
|
||||
expect(result).toMatch(/modificationHistory:\s+\d+ \/ \d+ entries/);
|
||||
});
|
||||
|
||||
test('14. text mode prints notes section when notes are present', async () => {
|
||||
const { handleMemoryCommand } = await import('../src/memory-command');
|
||||
const snapshot = makeSnapshot({
|
||||
notes: ['Per-Chromium-process RSS not collected — CDP limitation.'],
|
||||
});
|
||||
const result = await handleMemoryCommand([], makeFakeBm(snapshot));
|
||||
expect(result).toContain('Notes:');
|
||||
expect(result).toContain('CDP limitation.');
|
||||
});
|
||||
|
||||
test('15. text mode omits notes section when notes is empty', async () => {
|
||||
const { handleMemoryCommand } = await import('../src/memory-command');
|
||||
const result = await handleMemoryCommand([], makeFakeBm(makeSnapshot({ notes: [] })));
|
||||
expect(result).not.toContain('Notes:');
|
||||
});
|
||||
|
||||
test('16. text mode truncates long tab URLs with ellipsis', async () => {
|
||||
const { handleMemoryCommand } = await import('../src/memory-command');
|
||||
const longUrl = 'https://example.com/' + 'a'.repeat(120);
|
||||
const tabs = [{
|
||||
id: 1,
|
||||
url: longUrl,
|
||||
title: 'long',
|
||||
jsHeapUsed: 1024,
|
||||
jsHeapTotal: 2048,
|
||||
documents: 1,
|
||||
nodes: 10,
|
||||
listeners: 1,
|
||||
}];
|
||||
const result = await handleMemoryCommand([], makeFakeBm(makeSnapshot({ tabs })));
|
||||
expect(result).toContain('...');
|
||||
// The truncated URL appears, the full URL does not
|
||||
expect(result.includes(longUrl)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildMemorySnapshotJson — server-endpoint entry ──────────
|
||||
|
||||
describe('buildMemorySnapshotJson', () => {
|
||||
test('17. returns the snapshot with structures populated', async () => {
|
||||
const { buildMemorySnapshotJson } = await import('../src/memory-command');
|
||||
const snapshot = makeSnapshot();
|
||||
const result = await buildMemorySnapshotJson(makeFakeBm(snapshot));
|
||||
expect(result.bunServer.rss).toBe(snapshot.bunServer.rss);
|
||||
expect(result.structures.modificationHistory.cap).toBe(200);
|
||||
// structures is populated from live module accessors, not from the
|
||||
// fixture. Just assert the shape is right.
|
||||
expect(typeof result.structures.consoleBufferLen).toBe('number');
|
||||
expect(typeof result.structures.networkBufferLen).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
import { networkBuffer } from '../src/buffers';
|
||||
|
||||
// Reproducer for the body-materialization leak fixed in the D10
|
||||
// USE_CDP_EVENT_BATCHED commit. Pre-fix, the wirePageEvents
|
||||
// `requestfinished` listener called `await res.body()` just to read
|
||||
// `.length`, allocating the full response body into a Bun Buffer on
|
||||
// every request — multi-GB/hour of churn on long-lived headed
|
||||
// Chromium with media-heavy pages.
|
||||
//
|
||||
// What this test pins:
|
||||
// - The handler calls Playwright's structured req.sizes() API
|
||||
// (which pulls from Network.loadingFinished without
|
||||
// materializing the body).
|
||||
// - The handler NEVER calls res.body(), even though a fake response
|
||||
// exposes the method.
|
||||
// - networkBuffer entries are still populated with the right size.
|
||||
//
|
||||
// What this test does NOT cover:
|
||||
// - A real Chromium burst measuring peak Bun RSS during concurrent
|
||||
// fetches. That's a periodic-tier test (browse/test/
|
||||
// memory-leak-reproducer-e2e.test.ts, deferred — see TODOS).
|
||||
// - Per-tab JS heap growth on the Chromium side. Outside Bun's
|
||||
// visibility entirely.
|
||||
//
|
||||
// Wall clock target: < 1 second. Gate tier.
|
||||
|
||||
interface CallCounters {
|
||||
sizes: number;
|
||||
body: number;
|
||||
}
|
||||
|
||||
function makeFakeReq(url: string, responseBodySize: number, counters: CallCounters) {
|
||||
return {
|
||||
url: () => url,
|
||||
sizes: async () => {
|
||||
counters.sizes++;
|
||||
return {
|
||||
requestBodySize: 0,
|
||||
requestHeadersSize: 100,
|
||||
responseBodySize,
|
||||
responseHeadersSize: 200,
|
||||
};
|
||||
},
|
||||
method: () => 'GET',
|
||||
response: async () => ({
|
||||
url: () => url,
|
||||
status: () => 200,
|
||||
body: async () => {
|
||||
// If THIS runs, the leak is back. Allocate a real Buffer so a
|
||||
// future reviewer reading the failing assertion sees what
|
||||
// pre-fix code was doing on every request.
|
||||
counters.body++;
|
||||
return Buffer.alloc(responseBodySize);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
interface ListenerMap {
|
||||
[event: string]: Array<(arg: unknown) => void>;
|
||||
}
|
||||
|
||||
function makeFakePage() {
|
||||
const listeners: ListenerMap = {};
|
||||
return {
|
||||
on(event: string, fn: (arg: unknown) => void): void {
|
||||
(listeners[event] ||= []).push(fn);
|
||||
},
|
||||
emit(event: string, arg: unknown): void {
|
||||
for (const fn of listeners[event] || []) fn(arg);
|
||||
},
|
||||
listenerCount(event: string): number {
|
||||
return (listeners[event] || []).length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('memory-leak reproducer: requestfinished does not materialize bodies', () => {
|
||||
test('burst of 200 requestfinished events calls req.sizes() but never res.body()', async () => {
|
||||
const bm = new BrowserManager();
|
||||
const page = makeFakePage();
|
||||
|
||||
// wirePageEvents is private — access via the same indexed pattern the
|
||||
// tab-guardrail test uses to drive private methods.
|
||||
const wirePageEvents = (
|
||||
bm as unknown as { wirePageEvents: (p: unknown) => void }
|
||||
).wirePageEvents.bind(bm);
|
||||
wirePageEvents(page);
|
||||
|
||||
// Seed networkBuffer with 200 request entries via the existing
|
||||
// page.on('request') handler so the requestfinished backward-scan
|
||||
// has something to match against.
|
||||
const startLen = networkBuffer.length;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
page.emit('request', {
|
||||
url: () => `https://example.invalid/asset/${i}`,
|
||||
method: () => 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
// Fire 200 requestfinished events concurrently. Each notional response
|
||||
// is 1 MB — pre-fix this would allocate 200 MB of Buffer. With the fix,
|
||||
// not one byte of body content is allocated.
|
||||
const counters: CallCounters = { sizes: 0, body: 0 };
|
||||
const reqs = Array.from({ length: 200 }, (_, i) =>
|
||||
makeFakeReq(`https://example.invalid/asset/${i}`, 1024 * 1024, counters),
|
||||
);
|
||||
for (const req of reqs) page.emit('requestfinished', req);
|
||||
|
||||
// Drain the async handler chain — wirePageEvents.requestfinished is
|
||||
// async; each emit kicks off a microtask that awaits req.sizes().
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// One more tick in case of cascading microtasks.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// Every event hit req.sizes().
|
||||
expect(counters.sizes).toBeGreaterThanOrEqual(200);
|
||||
// The actual leak fix: res.body() is NEVER called.
|
||||
expect(counters.body).toBe(0);
|
||||
// And the size data still made it into networkBuffer.
|
||||
const populated = Array.from({ length: networkBuffer.length }, (_, i) =>
|
||||
networkBuffer.get(i),
|
||||
)
|
||||
.filter((e) => e && e.url?.startsWith('https://example.invalid/asset/'))
|
||||
.filter((e) => typeof e?.size === 'number' && e.size > 0).length;
|
||||
expect(populated).toBeGreaterThanOrEqual(200);
|
||||
// Sanity: the seed didn't double-count from a previous run.
|
||||
expect(networkBuffer.length).toBeGreaterThan(startLen);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Tests for the /pty-inject-scan endpoint (#1370).
|
||||
*
|
||||
* Verifies the endpoint's invariants without spinning a real browse
|
||||
* server: auth required, tunnel-listener denial, payload cap, JSON
|
||||
* shape, and the local-only routing rule (NOT in TUNNEL_PATHS).
|
||||
*
|
||||
* Full integration with a live sidecar + Chromium is exercised by the
|
||||
* existing browser security suite; this file covers the static + unit
|
||||
* invariants codex's plan review specifically called out.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const SERVER_SRC = readFileSync(
|
||||
join(import.meta.dir, '..', 'src', 'server.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('/pty-inject-scan — server.ts static invariants', () => {
|
||||
test('endpoint is defined as a POST handler', () => {
|
||||
expect(SERVER_SRC).toContain(
|
||||
"url.pathname === '/pty-inject-scan' && req.method === 'POST'",
|
||||
);
|
||||
});
|
||||
|
||||
test('endpoint requires auth (validateAuth gate)', () => {
|
||||
// Find the endpoint block, verify it calls validateAuth before doing
|
||||
// any work.
|
||||
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const blockEnd = SERVER_SRC.indexOf("\n // ─", start);
|
||||
const block = SERVER_SRC.slice(start, blockEnd > start ? blockEnd : start + 5000);
|
||||
expect(block).toContain('validateAuth(req)');
|
||||
expect(block).toContain('401');
|
||||
});
|
||||
|
||||
test('endpoint caps payload at 64KB', () => {
|
||||
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
|
||||
const block = SERVER_SRC.slice(start, start + 5000);
|
||||
expect(block).toContain('64 * 1024');
|
||||
expect(block).toContain('payload-too-large');
|
||||
expect(block).toContain('413');
|
||||
});
|
||||
|
||||
test('endpoint is NOT in the tunnel listener allowlist', () => {
|
||||
const tunnelBlockStart = SERVER_SRC.indexOf('const TUNNEL_PATHS = new Set<string>([');
|
||||
expect(tunnelBlockStart).toBeGreaterThan(-1);
|
||||
const tunnelBlockEnd = SERVER_SRC.indexOf(']);', tunnelBlockStart);
|
||||
const tunnelAllowlist = SERVER_SRC.slice(tunnelBlockStart, tunnelBlockEnd);
|
||||
expect(tunnelAllowlist).not.toContain('/pty-inject-scan');
|
||||
});
|
||||
|
||||
test('response goes through sanitizeReplacer (Unicode egress hardening)', () => {
|
||||
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
|
||||
const block = SERVER_SRC.slice(start, start + 5000);
|
||||
expect(block).toContain('sanitizeReplacer');
|
||||
});
|
||||
|
||||
test('endpoint surfaces l4 availability shape for D7 degrade-to-WARN path', () => {
|
||||
const start = SERVER_SRC.indexOf("'/pty-inject-scan'");
|
||||
const block = SERVER_SRC.slice(start, start + 5000);
|
||||
expect(block).toContain('isSidecarAvailable');
|
||||
expect(block).toContain('available');
|
||||
});
|
||||
|
||||
test('endpoint uses the sidecar client, not direct security-classifier import', () => {
|
||||
// Static check that server.ts imports from security-sidecar-client.ts,
|
||||
// NOT from security-classifier.ts directly (would brick the compiled
|
||||
// binary per CLAUDE.md).
|
||||
expect(SERVER_SRC).toContain("from './security-sidecar-client'");
|
||||
expect(SERVER_SRC).not.toContain("from './security-classifier'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
|
||||
// pty-session-lease registers a sessionId space distinct from the pre-v1.44
|
||||
// attach-token space (browse/src/pty-session-cookie.ts). These tests pin
|
||||
// the validate-first contract that codex outside-voice flagged as critical:
|
||||
// refreshLease MUST NOT resurrect expired leases, otherwise the 30-min TTL
|
||||
// stops bounding leaked-token blast radius.
|
||||
|
||||
import {
|
||||
mintLease,
|
||||
validateLease,
|
||||
refreshLease,
|
||||
revokeLease,
|
||||
leaseCount,
|
||||
__resetLeases,
|
||||
} from '../src/pty-session-lease';
|
||||
|
||||
beforeEach(() => {
|
||||
__resetLeases();
|
||||
});
|
||||
|
||||
describe('pty-session-lease: mint/validate/revoke', () => {
|
||||
test('mintLease returns a fresh non-secret sessionId + future expiresAt', () => {
|
||||
const a = mintLease();
|
||||
const b = mintLease();
|
||||
expect(a.sessionId).toBeTruthy();
|
||||
expect(b.sessionId).toBeTruthy();
|
||||
expect(a.sessionId).not.toBe(b.sessionId);
|
||||
expect(a.expiresAt).toBeGreaterThan(Date.now());
|
||||
// base64url alphabet: characters in [A-Za-z0-9_-].
|
||||
expect(a.sessionId).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(leaseCount()).toBe(2);
|
||||
});
|
||||
|
||||
test('validateLease ok for fresh lease, false for unknown', () => {
|
||||
const { sessionId } = mintLease();
|
||||
const ok = validateLease(sessionId);
|
||||
expect(ok.ok).toBe(true);
|
||||
if (ok.ok) expect(ok.expiresAt).toBeGreaterThan(Date.now());
|
||||
expect(validateLease('not-a-real-session-id').ok).toBe(false);
|
||||
expect(validateLease(null).ok).toBe(false);
|
||||
expect(validateLease(undefined).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('revokeLease removes the lease; subsequent validate returns false', () => {
|
||||
const { sessionId } = mintLease();
|
||||
expect(validateLease(sessionId).ok).toBe(true);
|
||||
revokeLease(sessionId);
|
||||
expect(validateLease(sessionId).ok).toBe(false);
|
||||
expect(leaseCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('revokeLease tolerates unknown sessionId without throwing', () => {
|
||||
expect(() => revokeLease('phantom')).not.toThrow();
|
||||
expect(() => revokeLease(null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pty-session-lease: refresh contract (validate-first)', () => {
|
||||
test('refreshLease extends expiresAt for a valid lease', () => {
|
||||
const { sessionId, expiresAt: initial } = mintLease();
|
||||
// Sleep micro-tick — Date.now() is ms-grain so a synchronous extend
|
||||
// may not move the integer. Use a tight async wait instead.
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
const r = refreshLease(sessionId);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.expiresAt).toBeGreaterThan(initial);
|
||||
resolve();
|
||||
}, 5);
|
||||
});
|
||||
});
|
||||
|
||||
test('refreshLease rejects unknown sessionId (validate-first invariant)', () => {
|
||||
const r = refreshLease('never-minted');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('refreshLease never resurrects an expired lease', async () => {
|
||||
// Force TTL down to 5ms for this assertion by minting + waiting past expiry.
|
||||
// Lease internals use Date.now() so the easiest way to expire one is
|
||||
// to artificially backdate via revoke+remint cycle. Simpler: mint, then
|
||||
// wait for the registry's own expiry check to trip.
|
||||
//
|
||||
// We can't backdate without breaking encapsulation, so this test exercises
|
||||
// the negative-validate path: minted lease, then prove that refresh after
|
||||
// explicit revoke still returns ok:false (same as expired-and-pruned).
|
||||
const { sessionId } = mintLease();
|
||||
revokeLease(sessionId);
|
||||
const r = refreshLease(sessionId);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('refreshLease tolerates null / undefined sessionId', () => {
|
||||
expect(refreshLease(null).ok).toBe(false);
|
||||
expect(refreshLease(undefined).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Regression test for PR #1169 bug #7 — `pdf --from-file` ran JSON.parse on
|
||||
* user-supplied file contents with no try/catch. A malformed payload crashed
|
||||
* the pdf handler with a raw SyntaxError. Codex flagged that JSON.parse
|
||||
* accepts primitives too (numbers, strings, null) and Array.isArray must be
|
||||
* checked separately, so the fix added an explicit object-shape gate.
|
||||
*
|
||||
* Test surface: parsePdfFromFile, exported for tests at meta-commands.ts:139.
|
||||
* All fixtures land in process.cwd() (SAFE_DIRECTORIES allows TEMP_DIR or cwd;
|
||||
* cwd is universally safe on every platform our CI runs on).
|
||||
*/
|
||||
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { parsePdfFromFile } from "../src/meta-commands";
|
||||
|
||||
const FIXTURE_DIR = fs.mkdtempSync(path.join(process.cwd(), "pr1169-pdf-"));
|
||||
|
||||
beforeAll(() => {
|
||||
// mkdtempSync already created the dir
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeFixture(name: string, body: string): string {
|
||||
const p = path.join(FIXTURE_DIR, name);
|
||||
fs.writeFileSync(p, body);
|
||||
return p;
|
||||
}
|
||||
|
||||
describe("parsePdfFromFile — invalid JSON regression (PR #1169 bug #7)", () => {
|
||||
test("invalid JSON: throws with file path AND parser detail", () => {
|
||||
const p = writeFixture("invalid.json", "{ not-json");
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/not valid JSON/);
|
||||
expect(() => parsePdfFromFile(p)).toThrow(p);
|
||||
});
|
||||
|
||||
test("empty file: throws JSON-parse style error", () => {
|
||||
const p = writeFixture("empty.json", "");
|
||||
// Empty string is invalid JSON per ECMA-404.
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/not valid JSON/);
|
||||
});
|
||||
|
||||
test("top-level array: throws 'must be a JSON object' with type", () => {
|
||||
const p = writeFixture("array.json", JSON.stringify(["a", "b"]));
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/must be a JSON object/);
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/array/);
|
||||
});
|
||||
|
||||
test("top-level number: throws with 'number' type label", () => {
|
||||
const p = writeFixture("number.json", "42");
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/must be a JSON object/);
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/number/);
|
||||
});
|
||||
|
||||
test("top-level string: throws with 'string' type label", () => {
|
||||
const p = writeFixture("string.json", JSON.stringify("hello"));
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/must be a JSON object/);
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/string/);
|
||||
});
|
||||
|
||||
test("top-level null: throws with 'object' type label (JS null typeof === object)", () => {
|
||||
const p = writeFixture("null.json", "null");
|
||||
// null passes typeof === 'object' but the fix's `=== null` branch catches it.
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/must be a JSON object/);
|
||||
});
|
||||
|
||||
test("top-level boolean: throws with 'boolean' type label", () => {
|
||||
const p = writeFixture("bool.json", "true");
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/must be a JSON object/);
|
||||
expect(() => parsePdfFromFile(p)).toThrow(/boolean/);
|
||||
});
|
||||
|
||||
test("valid object: parses successfully (happy-path regression)", () => {
|
||||
const p = writeFixture("valid.json", JSON.stringify({ format: "A4", pageNumbers: true }));
|
||||
const result = parsePdfFromFile(p);
|
||||
expect(result.format).toBe("A4");
|
||||
expect(result.pageNumbers).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { buildRestartEnv } from "../src/cli";
|
||||
|
||||
// #1781: an auto-restart triggered by a plain command (no --headed flag) must
|
||||
// NOT silently downgrade a headed session to headless. buildRestartEnv reapplies
|
||||
// headed/proxy/configHash from this invocation OR the persisted server state.
|
||||
describe("buildRestartEnv (#1781 headed persistence)", () => {
|
||||
const headedState = { pid: 1, port: 9, token: "t", startedAt: "", serverPath: "", mode: "headed" as const };
|
||||
const launchedState = { pid: 1, port: 9, token: "t", startedAt: "", serverPath: "", mode: "launched" as const };
|
||||
|
||||
test("headed flag on this invocation → BROWSE_HEADED=1", () => {
|
||||
expect(buildRestartEnv({ headed: true } as any, null).BROWSE_HEADED).toBe("1");
|
||||
});
|
||||
|
||||
test("plain command + persisted headed state → still BROWSE_HEADED=1 (the regression)", () => {
|
||||
const env = buildRestartEnv({} as any, headedState as any);
|
||||
expect(env.BROWSE_HEADED).toBe("1");
|
||||
});
|
||||
|
||||
test("plain command + headless state → no BROWSE_HEADED (no spurious headed)", () => {
|
||||
const env = buildRestartEnv({} as any, launchedState as any);
|
||||
expect(env.BROWSE_HEADED).toBeUndefined();
|
||||
});
|
||||
|
||||
test("nothing set → empty env", () => {
|
||||
expect(buildRestartEnv(null, null)).toEqual({});
|
||||
});
|
||||
|
||||
test("proxy + configHash reapplied from flags", () => {
|
||||
const env = buildRestartEnv({ proxyUrl: "socks5://x", configHash: "abc" } as any, null);
|
||||
expect(env.BROWSE_PROXY_URL).toBe("socks5://x");
|
||||
expect(env.BROWSE_CONFIG_HASH).toBe("abc");
|
||||
});
|
||||
|
||||
test("configHash falls back to persisted state", () => {
|
||||
const env = buildRestartEnv({} as any, { ...launchedState, configHash: "fromstate" } as any);
|
||||
expect(env.BROWSE_CONFIG_HASH).toBe("fromstate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Unit tests for the screenshot size guard (#1214).
|
||||
*
|
||||
* Verifies that images exceeding 2000px on the longest dimension get
|
||||
* downscaled to fit the Anthropic vision API cap, while images already
|
||||
* inside the cap pass through untouched.
|
||||
*
|
||||
* Integration with the three callsites (snapshot.ts, meta-commands.ts,
|
||||
* write-commands.ts) is exercised by the existing browse E2E suite — we
|
||||
* don't need to spin up Chromium just to verify the helper. The static
|
||||
* invariant test below pins that all three callsites import the guard.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
SCREENSHOT_MAX_DIMENSION_PX,
|
||||
guardScreenshotBuffer,
|
||||
guardScreenshotPath,
|
||||
} from '../src/screenshot-size-guard';
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'screenshot-guard-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function makePng(width: number, height: number): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: { r: 200, g: 50, b: 50 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
describe('guardScreenshotBuffer', () => {
|
||||
test('passes through images already within the cap', async () => {
|
||||
const input = await makePng(1500, 1800);
|
||||
const { buffer, result } = await guardScreenshotBuffer(input);
|
||||
expect(result.resized).toBe(false);
|
||||
expect(result.width).toBe(1500);
|
||||
expect(result.height).toBe(1800);
|
||||
expect(buffer).toBe(input); // identity — no re-encode
|
||||
});
|
||||
|
||||
test('downscales a 5000px-tall image to fit the cap', async () => {
|
||||
const input = await makePng(1200, 5000);
|
||||
const { buffer, result } = await guardScreenshotBuffer(input);
|
||||
expect(result.resized).toBe(true);
|
||||
expect(result.originalHeight).toBe(5000);
|
||||
expect(Math.max(result.width, result.height)).toBeLessThanOrEqual(
|
||||
SCREENSHOT_MAX_DIMENSION_PX,
|
||||
);
|
||||
// Aspect ratio preserved.
|
||||
expect(result.height / result.width).toBeCloseTo(5000 / 1200, 1);
|
||||
// Buffer is a different (smaller) PNG.
|
||||
expect(buffer.length).toBeLessThan(input.length);
|
||||
});
|
||||
|
||||
test('downscales a 6000px-wide image', async () => {
|
||||
const input = await makePng(6000, 1200);
|
||||
const { buffer, result } = await guardScreenshotBuffer(input);
|
||||
expect(result.resized).toBe(true);
|
||||
expect(result.originalWidth).toBe(6000);
|
||||
expect(Math.max(result.width, result.height)).toBeLessThanOrEqual(
|
||||
SCREENSHOT_MAX_DIMENSION_PX,
|
||||
);
|
||||
expect(buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('treats exactly-2000px images as in-bounds (no resize)', async () => {
|
||||
const input = await makePng(2000, 1000);
|
||||
const { result } = await guardScreenshotBuffer(input);
|
||||
expect(result.resized).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardScreenshotPath', () => {
|
||||
test('rewrites the file in place when downscale is needed', async () => {
|
||||
const filePath = join(tmp, 'tall.png');
|
||||
writeFileSync(filePath, await makePng(1200, 5000));
|
||||
const result = await guardScreenshotPath(filePath);
|
||||
expect(result.resized).toBe(true);
|
||||
const written = readFileSync(filePath);
|
||||
const meta = await sharp(written).metadata();
|
||||
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(
|
||||
SCREENSHOT_MAX_DIMENSION_PX,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves the file untouched when already within cap', async () => {
|
||||
const filePath = join(tmp, 'short.png');
|
||||
const original = await makePng(800, 600);
|
||||
writeFileSync(filePath, original);
|
||||
const result = await guardScreenshotPath(filePath);
|
||||
expect(result.resized).toBe(false);
|
||||
const written = readFileSync(filePath);
|
||||
expect(written.equals(original)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('static invariant: all three full-page callsites import the guard', () => {
|
||||
test('snapshot.ts, meta-commands.ts, and write-commands.ts wire the size guard', () => {
|
||||
const browseSrc = join(import.meta.dir, '..', 'src');
|
||||
const paths = ['snapshot.ts', 'meta-commands.ts', 'write-commands.ts'];
|
||||
for (const rel of paths) {
|
||||
const content = readFileSync(join(browseSrc, rel), 'utf-8');
|
||||
expect(content).toContain('screenshot-size-guard');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Regression test for PR #1169 bug #6 — downloadFile opened a WriteStream to
|
||||
* `<dest>.tmp.<pid>` but never closed it on error paths. If the reader or
|
||||
* writer threw mid-download, the FD leaked and the half-written tmp could
|
||||
* be promoted by a retry's renameSync.
|
||||
*
|
||||
* The fix wraps the read loop in try/catch and runs `writer.destroy()` +
|
||||
* `fs.unlinkSync(tmp)` before rethrowing.
|
||||
*
|
||||
* Per codex's pushback, this test must exercise BOTH the reader-throws path
|
||||
* and the non-2xx-response path, and it must NOT assume the specific tmp
|
||||
* filename — only that no `<dest>.tmp.*` sibling remains.
|
||||
*/
|
||||
import { describe, expect, test, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { downloadFile } from "../src/security-classifier";
|
||||
|
||||
function tmpSiblings(destDir: string, destBase: string): string[] {
|
||||
if (!fs.existsSync(destDir)) return [];
|
||||
return fs.readdirSync(destDir).filter((f) =>
|
||||
f.startsWith(destBase + ".tmp.")
|
||||
);
|
||||
}
|
||||
|
||||
let FIXTURE_DIR = "";
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
beforeAll(() => {
|
||||
FIXTURE_DIR = fs.mkdtempSync(path.join(process.cwd(), "pr1169-dl-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (FIXTURE_DIR) {
|
||||
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("downloadFile error-path cleanup (PR #1169 bug #6)", () => {
|
||||
test("reader rejects mid-stream: throws, no dest, no tmp sibling left", async () => {
|
||||
const dest = path.join(FIXTURE_DIR, "reader-fail-model.bin");
|
||||
const destDir = path.dirname(dest);
|
||||
const destBase = path.basename(dest);
|
||||
|
||||
// Build a ReadableStream that emits one chunk then errors on second pull.
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3, 4]));
|
||||
},
|
||||
pull(controller) {
|
||||
// Second pull triggers the failure path the fix protects against.
|
||||
controller.error(new Error("simulated mid-stream read failure"));
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error — overwrite global fetch for the test
|
||||
globalThis.fetch = async () =>
|
||||
new Response(body, { status: 200, statusText: "OK" });
|
||||
|
||||
await expect(downloadFile("https://example.com/model.bin", dest)).rejects.toThrow(
|
||||
/simulated mid-stream read failure/
|
||||
);
|
||||
|
||||
expect(fs.existsSync(dest)).toBe(false);
|
||||
expect(tmpSiblings(destDir, destBase)).toEqual([]);
|
||||
});
|
||||
|
||||
test("non-2xx response: throws with status, no tmp file created", async () => {
|
||||
const dest = path.join(FIXTURE_DIR, "http500-model.bin");
|
||||
const destDir = path.dirname(dest);
|
||||
const destBase = path.basename(dest);
|
||||
|
||||
// @ts-expect-error — overwrite global fetch for the test
|
||||
globalThis.fetch = async () =>
|
||||
new Response("server boom", { status: 500, statusText: "Server Error" });
|
||||
|
||||
await expect(downloadFile("https://example.com/model.bin", dest)).rejects.toThrow(
|
||||
/Failed to fetch.*500/
|
||||
);
|
||||
|
||||
expect(fs.existsSync(dest)).toBe(false);
|
||||
expect(tmpSiblings(destDir, destBase)).toEqual([]);
|
||||
});
|
||||
|
||||
test("missing body: throws, no tmp file created", async () => {
|
||||
const dest = path.join(FIXTURE_DIR, "nobody-model.bin");
|
||||
const destDir = path.dirname(dest);
|
||||
const destBase = path.basename(dest);
|
||||
|
||||
// Response with null body (some upstreams send this on edge errors).
|
||||
// @ts-expect-error — overwrite global fetch for the test
|
||||
globalThis.fetch = async () =>
|
||||
new Response(null, { status: 200, statusText: "OK" });
|
||||
|
||||
await expect(downloadFile("https://example.com/model.bin", dest)).rejects.toThrow(
|
||||
/Failed to fetch/
|
||||
);
|
||||
|
||||
expect(fs.existsSync(dest)).toBe(false);
|
||||
expect(tmpSiblings(destDir, destBase)).toEqual([]);
|
||||
});
|
||||
|
||||
test("happy path: 2xx body completes, dest exists, no tmp sibling remains", async () => {
|
||||
const dest = path.join(FIXTURE_DIR, "ok-model.bin");
|
||||
const destDir = path.dirname(dest);
|
||||
const destBase = path.basename(dest);
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([9, 9, 9, 9]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error — overwrite global fetch for the test
|
||||
globalThis.fetch = async () =>
|
||||
new Response(body, { status: 200, statusText: "OK" });
|
||||
|
||||
await downloadFile("https://example.com/model.bin", dest);
|
||||
|
||||
expect(fs.existsSync(dest)).toBe(true);
|
||||
expect(tmpSiblings(destDir, destBase)).toEqual([]);
|
||||
const written = fs.readFileSync(dest);
|
||||
expect(Array.from(written)).toEqual([9, 9, 9, 9]);
|
||||
|
||||
fs.unlinkSync(dest);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Unit tests for browse/src/security-sidecar-client.ts.
|
||||
*
|
||||
* Tests the IPC client's behavior against a fake sidecar (a tiny Node
|
||||
* script we spawn) — verifies request/response id correlation, timeout,
|
||||
* payload cap, malformed-response handling, and circuit-breaker tripping.
|
||||
*
|
||||
* Does NOT exercise the real classifier — that lives behind the model
|
||||
* download and is covered by the existing security-classifier tests + the
|
||||
* E2E browser security suite.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "sidecar-client-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const mod = await import("../src/security-sidecar-client");
|
||||
mod.resetSidecarForTests();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("security-sidecar-client — payload cap", () => {
|
||||
test("rejects requests over 64KB without spawning", async () => {
|
||||
const { scanWithSidecar } = await import("../src/security-sidecar-client");
|
||||
const huge = "a".repeat(65 * 1024);
|
||||
await expect(scanWithSidecar(huge)).rejects.toThrow(/payload-too-large/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security-sidecar-client — availability probe", () => {
|
||||
test("isSidecarAvailable returns a shape regardless of platform", async () => {
|
||||
const { isSidecarAvailable } = await import("../src/security-sidecar-client");
|
||||
const result = isSidecarAvailable();
|
||||
expect(typeof result.available).toBe("boolean");
|
||||
if (!result.available) {
|
||||
// When unavailable, reason must explain why
|
||||
expect(typeof result.reason).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("security-sidecar-client — circuit breaker after repeated failures", () => {
|
||||
test("trips after RESPAWN_LIMIT failures and stays unavailable", async () => {
|
||||
// We can simulate the breaker tripping by repeatedly calling against an
|
||||
// invalid sidecar entry. The cleanest way without faking spawn() is to
|
||||
// exercise the payload-too-large path which doesn't trip the breaker
|
||||
// (it short-circuits before spawn), so this is an indirect proof:
|
||||
// verify the timeout path can be exercised by an oversized small text
|
||||
// and that retries don't crash.
|
||||
const { scanWithSidecar } = await import("../src/security-sidecar-client");
|
||||
const oversized = "x".repeat(70 * 1024);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await expect(scanWithSidecar(oversized)).rejects.toThrow(/payload-too-large/);
|
||||
}
|
||||
// Sentinel — if the loop above silently passed, fail fast.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -63,13 +63,13 @@ describe('Server auth security', () => {
|
||||
|
||||
// Test 4: /activity/history requires auth via validateAuth
|
||||
test('/activity/history requires authentication', () => {
|
||||
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Sidebar endpoints');
|
||||
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Batch endpoint');
|
||||
expect(historyBlock).toContain('validateAuth');
|
||||
});
|
||||
|
||||
// Test 5: /activity/history has no wildcard CORS header
|
||||
test('/activity/history has no wildcard CORS header', () => {
|
||||
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Sidebar endpoints');
|
||||
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Batch endpoint');
|
||||
expect(historyBlock).not.toContain("'*'");
|
||||
});
|
||||
|
||||
@@ -314,7 +314,7 @@ describe('Server auth security', () => {
|
||||
// Regression: connect command crashed with "domains is not defined" because
|
||||
// a stray `domains,` variable was in the status fetch body (cli.ts:852).
|
||||
test('connect command status fetch body has no undefined variable references', () => {
|
||||
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Sidebar agent started');
|
||||
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started');
|
||||
// The status fetch should use a clean JSON body
|
||||
expect(connectBlock).toContain("command: 'status'");
|
||||
// Must NOT contain a bare `domains` reference in the fetch body
|
||||
@@ -335,10 +335,15 @@ describe('Server auth security', () => {
|
||||
// The connect subprocess env must override BROWSE_PARENT_PID
|
||||
expect(pairBlock).toContain("BROWSE_PARENT_PID");
|
||||
expect(pairBlock).toContain("'0'");
|
||||
// The connect command must propagate BROWSE_PARENT_PID=0 to serverEnv
|
||||
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Sidebar agent started');
|
||||
expect(connectBlock).toContain("BROWSE_PARENT_PID");
|
||||
expect(connectBlock).toContain("serverEnv.BROWSE_PARENT_PID");
|
||||
// The connect command must propagate BROWSE_PARENT_PID=0 via the
|
||||
// serverEnv object literal passed to startServer. The literal text
|
||||
// `serverEnv.BROWSE_PARENT_PID` is NOT in source — the value is
|
||||
// assigned via object-literal syntax (`BROWSE_PARENT_PID: '0'`)
|
||||
// inside the `const serverEnv: Record<string, string> = { ... }`
|
||||
// declaration. Assert both pieces appear in the connect block.
|
||||
const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started');
|
||||
expect(connectBlock).toContain("const serverEnv");
|
||||
expect(connectBlock).toContain("BROWSE_PARENT_PID: '0'");
|
||||
});
|
||||
|
||||
// Regression: newtab returned 403 for scoped tokens because the tab ownership
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, test, expect, beforeEach, beforeAll, afterAll } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import {
|
||||
buildFetchHandler,
|
||||
__resetShuttingDown,
|
||||
type ServerConfig,
|
||||
} from '../src/server';
|
||||
import { __resetRegistry } from '../src/token-registry';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
import { resolveConfig } from '../src/config';
|
||||
|
||||
// Tests for the v1.41+ ownsTerminalAgent flag.
|
||||
//
|
||||
// Embedders (gbrowser phoenix overlay) that run their own PTY server and write
|
||||
// terminal-port / terminal-internal-token / terminal-agent-pid themselves were
|
||||
// getting those files clobbered by gstack's shutdown(). The flag (default true)
|
||||
// gates four side effects (v1.44+):
|
||||
// 1. identity-based kill of the PID in <stateDir>/terminal-agent-pid
|
||||
// 2. unlink terminal-port
|
||||
// 3. unlink terminal-internal-token
|
||||
// 4. unlink terminal-agent-pid
|
||||
// False = embedder owns them, gstack stays hands-off.
|
||||
//
|
||||
// Pre-v1.44 used `pkill -f terminal-agent\.ts` which matched sibling gstack
|
||||
// sessions on the same host — see browse/src/terminal-agent-control.ts header.
|
||||
//
|
||||
// CRITICAL: each test stubs process.exit (so shutdown's exit doesn't kill
|
||||
// the test runner). The PID in the test agent-record is a guaranteed-dead
|
||||
// PID (1 = init / launchd — exists but cannot be killed by an unprivileged
|
||||
// process, so safeKill returns ESRCH-equivalent without affecting anything).
|
||||
// Use isProcessAlive's false branch by also testing with a PID that does
|
||||
// not exist (negative PID rejected by the OS).
|
||||
|
||||
const stateDir = resolveConfig().stateDir;
|
||||
const PORT_FILE = path.join(stateDir, 'terminal-port');
|
||||
const TOKEN_FILE = path.join(stateDir, 'terminal-internal-token');
|
||||
const AGENT_RECORD_FILE = path.join(stateDir, 'terminal-agent-pid');
|
||||
const SENTINEL_PORT = 'sentinel-port-65432';
|
||||
const SENTINEL_TOKEN = 'sentinel-token-abcdef1234567890';
|
||||
// PID 2^31-1 is the Linux PID_MAX_LIMIT; macOS uses 99998. Either way, no
|
||||
// real process will ever hold this PID on a developer machine. isProcessAlive
|
||||
// returns false → killAgentByRecord no-ops without sending any signal.
|
||||
const SENTINEL_DEAD_PID = 2147483646;
|
||||
|
||||
function makeMinimalConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
|
||||
const token = 'embedder-test-' + crypto.randomBytes(16).toString('hex');
|
||||
return {
|
||||
authToken: token,
|
||||
browsePort: 34568,
|
||||
idleTimeoutMs: 1_800_000,
|
||||
config: resolveConfig(),
|
||||
browserManager: new BrowserManager(),
|
||||
startTime: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function writeSentinels(): void {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(PORT_FILE, SENTINEL_PORT);
|
||||
fs.writeFileSync(TOKEN_FILE, SENTINEL_TOKEN);
|
||||
fs.writeFileSync(
|
||||
AGENT_RECORD_FILE,
|
||||
JSON.stringify({ pid: SENTINEL_DEAD_PID, gen: 'sentinel-gen', startedAt: Date.now() }),
|
||||
);
|
||||
}
|
||||
|
||||
function readIfExists(p: string): string | null {
|
||||
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stubs process.exit so shutdown()'s process.exit(0) throws an __exit:N
|
||||
* marker the test can swallow instead of killing the runner. Also stubs
|
||||
* process.kill so an accidental kill (regression in killAgentByRecord
|
||||
* that bypassed isProcessAlive) cannot reach a real PID on the developer
|
||||
* machine. Returns the captured kill calls so tests can assert kill
|
||||
* scope.
|
||||
*/
|
||||
async function withStubs(
|
||||
cb: (killCalls: Array<[number, NodeJS.Signals | number]>) => Promise<void>
|
||||
): Promise<Array<[number, NodeJS.Signals | number]>> {
|
||||
const origExit = process.exit;
|
||||
const origKill = process.kill;
|
||||
const killCalls: Array<[number, NodeJS.Signals | number]> = [];
|
||||
(process as any).exit = ((code: number) => {
|
||||
throw new Error(`__exit:${code}`);
|
||||
}) as any;
|
||||
(process as any).kill = ((pid: number, signal: NodeJS.Signals | number) => {
|
||||
killCalls.push([pid, signal ?? 'SIGTERM']);
|
||||
// signal 0 is a liveness probe — keep the existing 'process is dead'
|
||||
// semantics so isProcessAlive(SENTINEL_DEAD_PID) returns false.
|
||||
if (signal === 0) {
|
||||
const err: any = new Error('No such process');
|
||||
err.code = 'ESRCH';
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}) as any;
|
||||
try {
|
||||
await cb(killCalls);
|
||||
} finally {
|
||||
(process as any).exit = origExit;
|
||||
(process as any).kill = origKill;
|
||||
}
|
||||
return killCalls;
|
||||
}
|
||||
|
||||
async function runShutdown(handle: { shutdown: (code?: number) => Promise<void> }): Promise<void> {
|
||||
try {
|
||||
await handle.shutdown(0);
|
||||
} catch (err: any) {
|
||||
if (typeof err?.message !== 'string' || !err.message.startsWith('__exit:')) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out the signal=0 liveness probes; only count actual termination signals.
|
||||
function terminationCalls(
|
||||
calls: Array<[number, NodeJS.Signals | number]>,
|
||||
): Array<[number, NodeJS.Signals | number]> {
|
||||
return calls.filter(([, sig]) => sig !== 0);
|
||||
}
|
||||
|
||||
describe('buildFetchHandler ownsTerminalAgent gate', () => {
|
||||
// shutdown() reads `path.dirname(config.stateFile)` from module-level config
|
||||
// (composition gap — see TODOS T9). So unlinks target the real state dir,
|
||||
// not a per-test temp dir. If a real gstack daemon is running on this host,
|
||||
// its terminal-port + terminal-internal-token + terminal-agent-pid live
|
||||
// where this test writes. Save + restore real-daemon file contents around
|
||||
// the whole suite so the test never clobbers a developer's running session.
|
||||
let realPortBackup: string | null = null;
|
||||
let realTokenBackup: string | null = null;
|
||||
let realAgentRecordBackup: string | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
realPortBackup = readIfExists(PORT_FILE);
|
||||
realTokenBackup = readIfExists(TOKEN_FILE);
|
||||
realAgentRecordBackup = readIfExists(AGENT_RECORD_FILE);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (realPortBackup !== null) {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(PORT_FILE, realPortBackup);
|
||||
} else {
|
||||
try { fs.unlinkSync(PORT_FILE); } catch {}
|
||||
}
|
||||
if (realTokenBackup !== null) {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(TOKEN_FILE, realTokenBackup);
|
||||
} else {
|
||||
try { fs.unlinkSync(TOKEN_FILE); } catch {}
|
||||
}
|
||||
if (realAgentRecordBackup !== null) {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(AGENT_RECORD_FILE, realAgentRecordBackup);
|
||||
} else {
|
||||
try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
__resetRegistry();
|
||||
__resetShuttingDown();
|
||||
// Clean any leftover sentinels from a prior failed run so the "preserved"
|
||||
// assertion can't pass spuriously off a stale file.
|
||||
try { fs.unlinkSync(PORT_FILE); } catch {}
|
||||
try { fs.unlinkSync(TOKEN_FILE); } catch {}
|
||||
try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {}
|
||||
});
|
||||
|
||||
test('1. ownsTerminalAgent:false preserves all three files and sends no signal', async () => {
|
||||
writeSentinels();
|
||||
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: false }));
|
||||
const calls = await withStubs(async () => {
|
||||
await runShutdown(handle);
|
||||
});
|
||||
expect(readIfExists(PORT_FILE)).toBe(SENTINEL_PORT);
|
||||
expect(readIfExists(TOKEN_FILE)).toBe(SENTINEL_TOKEN);
|
||||
expect(readIfExists(AGENT_RECORD_FILE)).not.toBeNull();
|
||||
expect(terminationCalls(calls).length).toBe(0);
|
||||
});
|
||||
|
||||
test('2. ownsTerminalAgent:true deletes all three files; identity-based kill probes the recorded PID', async () => {
|
||||
writeSentinels();
|
||||
const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: true }));
|
||||
const calls = await withStubs(async () => {
|
||||
await runShutdown(handle);
|
||||
});
|
||||
expect(readIfExists(PORT_FILE)).toBeNull();
|
||||
expect(readIfExists(TOKEN_FILE)).toBeNull();
|
||||
expect(readIfExists(AGENT_RECORD_FILE)).toBeNull();
|
||||
// isProcessAlive sends signal 0; PID is the sentinel-dead PID, so the
|
||||
// probe returns false and no SIGTERM is sent.
|
||||
const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0);
|
||||
expect(probes.length).toBeGreaterThan(0);
|
||||
expect(terminationCalls(calls).length).toBe(0);
|
||||
});
|
||||
|
||||
test('3. ownsTerminalAgent unset defaults to true (deletes all three; probes recorded PID)', async () => {
|
||||
writeSentinels();
|
||||
// Note: no ownsTerminalAgent in the overrides — uses the `?? true` default.
|
||||
const handle = buildFetchHandler(makeMinimalConfig());
|
||||
const calls = await withStubs(async () => {
|
||||
await runShutdown(handle);
|
||||
});
|
||||
expect(readIfExists(PORT_FILE)).toBeNull();
|
||||
expect(readIfExists(TOKEN_FILE)).toBeNull();
|
||||
expect(readIfExists(AGENT_RECORD_FILE)).toBeNull();
|
||||
const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0);
|
||||
expect(probes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('4. CLI start() call site passes ownsTerminalAgent: true literally (static grep)', () => {
|
||||
// Resolves browse/src/server.ts relative to this test file so the test
|
||||
// works regardless of cwd. import.meta.url is the test file's URL.
|
||||
const serverTsPath = path.resolve(
|
||||
new URL(import.meta.url).pathname,
|
||||
'..',
|
||||
'..',
|
||||
'src',
|
||||
'server.ts',
|
||||
);
|
||||
const source = fs.readFileSync(serverTsPath, 'utf-8');
|
||||
// Match the call site inside start()'s buildFetchHandler({...}) literal.
|
||||
// The pattern looks for the trailing comma and trailing context so the
|
||||
// match cannot be satisfied by the JSDoc reference earlier in the file.
|
||||
expect(source).toMatch(/ownsTerminalAgent:\s*true,\s*\/\/\s*CLI spawns terminal-agent\.ts/);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
||||
import {
|
||||
resolveConfigFromEnv,
|
||||
buildFetchHandler,
|
||||
__testInternals__,
|
||||
type ServerConfig,
|
||||
type ServerHandle,
|
||||
type Surface,
|
||||
@@ -11,6 +12,8 @@ import { __resetRegistry, initRegistry } from '../src/token-registry';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
import { resolveConfig } from '../src/config';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* Tests for the factory-export API surface added so gbrowser (phoenix) can
|
||||
@@ -381,3 +384,141 @@ describe('buildFetchHandler factory contract', () => {
|
||||
expect(() => initRegistry('second-token-pad-to-16-chars')).toThrow(/already initialized/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Idle timer + onDisconnect dual-instance fix (v1.42.3.0) ──────────
|
||||
//
|
||||
// Before this fix, module-level handlers (idleCheckTick, parent watchdog,
|
||||
// SIGTERM, onDisconnect default wire) all read the module-level
|
||||
// BrowserManager directly. For embedders (gbrowser) that pass their own
|
||||
// BrowserManager into buildFetchHandler, the module-level instance never
|
||||
// has launchHeaded() called on it — so connectionMode stays 'launched'
|
||||
// forever and headed mode never short-circuits idle-shutdown. Result:
|
||||
// 30-min auto-shutdown of overlay sessions.
|
||||
//
|
||||
// Fix: introduce `let activeBrowserManager` indirection (symmetric with
|
||||
// the existing `let activeShutdown` pattern). buildFetchHandler retargets
|
||||
// it at cfg.browserManager AND chains cfg.browserManager.onDisconnect to
|
||||
// activeShutdown (without clobbering any caller-provided handler).
|
||||
|
||||
function makeMockBrowserManager(mode: 'launched' | 'headed') {
|
||||
return {
|
||||
getConnectionMode: () => mode,
|
||||
isWatching: () => false,
|
||||
stopWatch: () => {},
|
||||
close: async () => {},
|
||||
onDisconnect: null as ((code?: number) => void | Promise<void>) | null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('idle timer + onDisconnect dual-instance fix', () => {
|
||||
beforeEach(() => {
|
||||
__resetRegistry();
|
||||
// Reset module state every test. Bun memoizes the server.ts module
|
||||
// import for the whole test process, so `lastActivity`, `tunnelActive`,
|
||||
// `activeShutdown`, `activeBrowserManager`, and `isShuttingDown` leak
|
||||
// between tests. We reset what we touch here; the rest is fresh
|
||||
// because each test calls buildFetchHandler with a new mock instance.
|
||||
__testInternals__.setTunnelActive(false);
|
||||
__testInternals__.setLastActivity(Date.now());
|
||||
__testInternals__.resetShutdownState();
|
||||
});
|
||||
|
||||
test('CRITICAL — REGRESSION: headed embedder does not auto-shutdown at idle', () => {
|
||||
const exitMock = mock((_code?: number) => { throw new Error('process.exit called'); });
|
||||
const originalExit = process.exit;
|
||||
(process as any).exit = exitMock;
|
||||
try {
|
||||
const mockBM = makeMockBrowserManager('headed');
|
||||
buildFetchHandler(makeMinimalConfig({ browserManager: mockBM as any }));
|
||||
// Drive lastActivity past the idle threshold via the test seam instead
|
||||
// of mutating Date.now — the leaked module-level setInterval would
|
||||
// see fake-time and could fire shutdown if the timing aligned.
|
||||
__testInternals__.setLastActivity(Date.now() - (31 * 60 * 1000));
|
||||
__testInternals__.idleCheckTick();
|
||||
expect(exitMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
(process as any).exit = originalExit;
|
||||
}
|
||||
});
|
||||
|
||||
test('headless still auto-shuts down at idle (paired defensive)', async () => {
|
||||
// Non-throwing mock: idleCheckTick fires shutdown as a fire-and-forget
|
||||
// async call. Throwing from process.exit becomes an unhandled rejection
|
||||
// that the test runner catches. Recording the call is enough.
|
||||
const exitMock = mock((_code?: number) => {});
|
||||
const originalExit = process.exit;
|
||||
(process as any).exit = exitMock;
|
||||
try {
|
||||
const mockBM = makeMockBrowserManager('launched');
|
||||
buildFetchHandler(makeMinimalConfig({ browserManager: mockBM as any }));
|
||||
__testInternals__.setLastActivity(Date.now() - (31 * 60 * 1000));
|
||||
__testInternals__.idleCheckTick();
|
||||
// Drain microtasks: shutdown awaits flushBuffers + cfgBrowserManager.close
|
||||
// before reaching process.exit.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>(r => setImmediate(r));
|
||||
await new Promise<void>(r => setImmediate(r));
|
||||
expect(exitMock).toHaveBeenCalled();
|
||||
} finally {
|
||||
(process as any).exit = originalExit;
|
||||
}
|
||||
});
|
||||
|
||||
test('buildFetchHandler chains cfgBrowserManager.onDisconnect, preserving caller-set handler', async () => {
|
||||
const mockBM = makeMockBrowserManager('headed');
|
||||
const callerCb = mock(async (_code?: number) => {});
|
||||
mockBM.onDisconnect = callerCb;
|
||||
buildFetchHandler(makeMinimalConfig({ browserManager: mockBM as any }));
|
||||
// gstack should have wrapped the caller-installed handler instead of
|
||||
// clobbering it (Codex finding: BrowserManager.onDisconnect is a public
|
||||
// field; gbrowser may set it before calling buildFetchHandler).
|
||||
expect(typeof mockBM.onDisconnect).toBe('function');
|
||||
expect(mockBM.onDisconnect).not.toBe(callerCb);
|
||||
// Verify the chain: invoking the wrapped handler runs the caller
|
||||
// callback AND reaches activeShutdown (which calls process.exit at the
|
||||
// very end of its async path). Stubbing process.exit to throw aborts
|
||||
// the chain before isShuttingDown can leak into later tests.
|
||||
const exitMock = mock((_code?: number) => { throw new Error('process.exit called'); });
|
||||
const originalExit = process.exit;
|
||||
(process as any).exit = exitMock;
|
||||
try {
|
||||
await expect((mockBM.onDisconnect as any)(0)).rejects.toThrow('process.exit called');
|
||||
expect(callerCb).toHaveBeenCalledWith(0);
|
||||
expect(exitMock).toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
(process as any).exit = originalExit;
|
||||
}
|
||||
});
|
||||
|
||||
test('tunnelActive blocks idle-shutdown even in headless mode', () => {
|
||||
const exitMock = mock((_code?: number) => { throw new Error('process.exit called'); });
|
||||
const originalExit = process.exit;
|
||||
(process as any).exit = exitMock;
|
||||
try {
|
||||
const mockBM = makeMockBrowserManager('launched');
|
||||
buildFetchHandler(makeMinimalConfig({ browserManager: mockBM as any }));
|
||||
__testInternals__.setTunnelActive(true);
|
||||
__testInternals__.setLastActivity(Date.now() - (31 * 60 * 1000));
|
||||
__testInternals__.idleCheckTick();
|
||||
expect(exitMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
(process as any).exit = originalExit;
|
||||
}
|
||||
});
|
||||
|
||||
test('lifecycle handlers (idleCheckTick + parent watchdog + SIGTERM) read activeBrowserManager, not module-level browserManager', () => {
|
||||
// Static guard against a future refactor reintroducing a stale read.
|
||||
// The 3 lifecycle sites this plan fixed all call getConnectionMode via
|
||||
// the indirection. Other module-level browserManager reads inside
|
||||
// handleCommandInternalImpl (informational mode reporting in response
|
||||
// payloads) are out of scope and intentionally untouched.
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'server.ts'), 'utf-8');
|
||||
const factoryStart = src.indexOf('export function buildFetchHandler');
|
||||
expect(factoryStart).toBeGreaterThan(0);
|
||||
const moduleLevel = src.slice(0, factoryStart);
|
||||
const activeCount = (moduleLevel.match(/activeBrowserManager\.getConnectionMode\(\)/g) || []).length;
|
||||
// Edit 2 (idleCheckTick), Edit 3 (parent watchdog), Edit 6 (SIGTERM).
|
||||
expect(activeCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Server-side route shape for the v1.44 lease + restart + dispose +
|
||||
// lease-refresh wiring. Live route exercises require the terminal-agent
|
||||
// loopback to be live (e2e-tier); these static-grep tripwires pin the
|
||||
// load-bearing protocol invariants.
|
||||
|
||||
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
|
||||
|
||||
describe('server: PTY lease routes (v1.44+ Commit 2)', () => {
|
||||
test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/pty-session' &&", "url.pathname === '/pty-session/reattach'");
|
||||
expect(block).toContain('mintLease()');
|
||||
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
|
||||
expect(block).toContain('sessionId: lease.sessionId');
|
||||
expect(block).toContain('attachToken: minted.token');
|
||||
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
|
||||
// Backward compat: legacy ptySessionToken alias preserved for one release.
|
||||
expect(block).toContain('ptySessionToken: minted.token');
|
||||
});
|
||||
|
||||
test('2. /pty-session/reattach validates lease + mints fresh attachToken', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/pty-session/reattach'", "url.pathname === '/pty-restart'");
|
||||
// Validate-first: rejects unknown/expired sessionId with 410 Gone so
|
||||
// the client knows to fall back to a fresh /pty-session.
|
||||
expect(block).toContain('validateLease(sessionId)');
|
||||
expect(block).toContain('status: 410');
|
||||
// Mint fresh token bound to SAME sessionId.
|
||||
expect(block).toContain('grantPtyToken(minted.token, sessionId!)');
|
||||
});
|
||||
|
||||
test('3. /pty-restart is one transaction — dispose + revoke + fresh mint', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/pty-restart'", "url.pathname === '/pty-dispose'");
|
||||
// Disposes old session (best-effort — missing sessionId is non-fatal).
|
||||
expect(block).toContain('restartPtySession(oldSessionId)');
|
||||
expect(block).toContain('revokeLease(oldSessionId)');
|
||||
// Then mints fresh sessionId + lease + attachToken in the same handler.
|
||||
expect(block).toContain('mintLease()');
|
||||
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
|
||||
// Returns the same 4-tuple shape so the client doesn't need a
|
||||
// separate /pty-session round-trip.
|
||||
expect(block).toContain('attachToken: minted.token');
|
||||
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
|
||||
});
|
||||
|
||||
test('4. /pty-dispose accepts body-token (sendBeacon-compatible)', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/pty-dispose'", "url.pathname === '/internal/lease-refresh'");
|
||||
// sendBeacon can't set custom headers, so the route MUST accept the
|
||||
// auth token in the request body. Otherwise pagehide cleanup fails
|
||||
// silently every time the user closes the browser.
|
||||
expect(block).toContain('body?.authToken');
|
||||
expect(block).toContain('authedByBody');
|
||||
// Both auth paths must validate against authToken — never just trust
|
||||
// a body-supplied token without the equality check.
|
||||
expect(block).toContain('authTokenFromBody === authToken');
|
||||
});
|
||||
|
||||
test('5. /internal/lease-refresh resets the daemon idle timer (T6)', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/lease-refresh'", '─── /pty-inject-scan');
|
||||
expect(block).toContain('refreshLease(sessionId)');
|
||||
expect(block).toContain('resetIdleTimer()');
|
||||
// Refresh failure (unknown / expired) MUST 410, not 200, so the
|
||||
// agent knows to close the WS and force a clean re-auth.
|
||||
expect(block).toContain('status: 410');
|
||||
});
|
||||
|
||||
test('6. grantPtyToken loopback carries sessionId binding', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
expect(src).toMatch(/grantPtyToken\(token: string, sessionId\?: string\)/);
|
||||
expect(src).toContain('sessionId ? { token, sessionId } : { token }');
|
||||
});
|
||||
|
||||
test('7. restartPtySession helper exists and POSTs the agent /internal/restart', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
expect(src).toMatch(/async function restartPtySession\(sessionId: string\)/);
|
||||
expect(src).toContain('/internal/restart');
|
||||
expect(src).toContain('JSON.stringify({ sessionId })');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -113,17 +113,45 @@ describe('sanitizeLoneSurrogates — wiring invariants', () => {
|
||||
expect(SERVER_SRC).toContain('result: sanitizeLoneSurrogates(cr.result)');
|
||||
});
|
||||
|
||||
test('SSE activity feed sanitizes outbound frames via sanitizeReplacer', () => {
|
||||
// Replacer must run DURING stringify; post-stringify regex is ineffective
|
||||
// because JSON.stringify converts \uD800 → "\\ud800" before our regex sees it.
|
||||
expect(SERVER_SRC).toContain('JSON.stringify(entry, sanitizeReplacer)');
|
||||
test('SSE activity feed routes outbound frames through createSseEndpoint', () => {
|
||||
// v1.51 refactor: /activity/stream no longer inlines its own
|
||||
// ReadableStream/sanitizer wiring; it routes through createSseEndpoint
|
||||
// which applies sanitizeReplacer to every JSON.stringify. The grep
|
||||
// pins both halves of the contract: the endpoint uses the helper,
|
||||
// and the helper does the sanitization.
|
||||
const activityBlock = SERVER_SRC.match(
|
||||
/if \(url\.pathname === '\/activity\/stream'\)[\s\S]*?createSseEndpoint\(/,
|
||||
);
|
||||
expect(activityBlock).not.toBeNull();
|
||||
});
|
||||
|
||||
test('SSE inspector stream sanitizes outbound frames via sanitizeReplacer', () => {
|
||||
expect(SERVER_SRC).toContain('JSON.stringify(event, sanitizeReplacer)');
|
||||
test('SSE inspector stream routes outbound frames through createSseEndpoint', () => {
|
||||
// Same v1.51 refactor invariant for /inspector/events.
|
||||
const inspectorBlock = SERVER_SRC.match(
|
||||
/if \(url\.pathname === '\/inspector\/events'[\s\S]*?createSseEndpoint\(/,
|
||||
);
|
||||
expect(inspectorBlock).not.toBeNull();
|
||||
});
|
||||
|
||||
test('sanitizeReplacer is a function defined in server.ts', () => {
|
||||
test('createSseEndpoint applies sanitizeReplacer to every JSON.stringify', () => {
|
||||
// The helper is the single source of truth for SSE sanitization now.
|
||||
// If a future refactor moves stringify off the replacer (e.g. someone
|
||||
// adds a fast-path encode), this test fails and the surrogate-escape
|
||||
// class regresses across every SSE endpoint at once.
|
||||
const helperPath = path.resolve(import.meta.dir, '..', 'src', 'sse-helpers.ts');
|
||||
const helperSrc = fs.readFileSync(helperPath, 'utf-8');
|
||||
expect(helperSrc).toContain('JSON.stringify(');
|
||||
expect(helperSrc).toContain('sanitizeReplacer');
|
||||
// The sanitizer itself uses stripLoneSurrogates (the shared utility in
|
||||
// sanitize.ts) — not a private copy. Re-confirms the helper is wired
|
||||
// to the canonical sanitizer, not a drift'd duplicate.
|
||||
expect(helperSrc).toContain("import { stripLoneSurrogates } from './sanitize'");
|
||||
});
|
||||
|
||||
test('sanitizeReplacer is a function defined in server.ts (for non-SSE egress)', () => {
|
||||
// server.ts keeps its own sanitizeReplacer for the non-SSE JSON egress
|
||||
// paths (handleCommandInternal etc.). The SSE path uses sse-helpers.ts's
|
||||
// own sanitizeReplacer; both must exist independently.
|
||||
expect(SERVER_SRC).toContain('function sanitizeReplacer(');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1589,19 +1589,17 @@ describe('tool calls collapse into reasoning disclosure', () => {
|
||||
});
|
||||
|
||||
// ─── Idle timeout disabled in headed mode (server.ts) ───────────
|
||||
//
|
||||
// The original 'idle check skips in headed mode' string-grep test was deleted
|
||||
// in v1.42.3.0 — it would have passed even with the dual-instance bug present
|
||||
// because it only grepped for "=== 'headed'" + 'return' in the same window.
|
||||
// Behavioral coverage lives in browse/test/server-factory.test.ts under the
|
||||
// 'idle timer + onDisconnect dual-instance fix' describe block, which
|
||||
// exercises the headed/headless/tunnel branches of idleCheckTick directly.
|
||||
|
||||
describe('idle timeout behavior (server.ts)', () => {
|
||||
const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
|
||||
|
||||
test('idle check skips in headed mode', () => {
|
||||
const idleCheck = serverSrc.slice(
|
||||
serverSrc.indexOf('idleCheckInterval'),
|
||||
serverSrc.indexOf('idleCheckInterval') + 300,
|
||||
);
|
||||
expect(idleCheck).toContain("=== 'headed'");
|
||||
expect(idleCheck).toContain('return');
|
||||
});
|
||||
|
||||
test('sidebar-command resets idle timer', () => {
|
||||
const sidebarCmd = serverSrc.slice(
|
||||
serverSrc.indexOf("url.pathname === '/sidebar-command'"),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 patient autoConnect — static-grep invariants for the polling loop.
|
||||
//
|
||||
// Pre-v1.44 the sidebar gave up at 15s with "Browse server not ready.
|
||||
// Reload sidebar to retry." Cold-start the browse server takes ~3-8s on a
|
||||
// healthy laptop, longer on Conductor workspaces / slow CI, so the user
|
||||
// frequently saw the failure message even when nothing was wrong. The
|
||||
// fix: poll forever with ascending status messages and only abort on
|
||||
// explicit unrecoverable signals (401 auth invalid).
|
||||
|
||||
const CLIENT_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'extension',
|
||||
'sidepanel-terminal.js',
|
||||
);
|
||||
|
||||
describe('sidepanel tryAutoConnect patience (v1.44+)', () => {
|
||||
test('1. no 15s give-up message', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
// The v0.x give-up string must NOT reappear — it's the message users
|
||||
// saw on every cold start and the whole point of v1.44 was to delete it.
|
||||
expect(src).not.toContain('Browse server not ready. Reload sidebar to retry.');
|
||||
});
|
||||
|
||||
test('2. ascending status messages at 15s / 60s / 5min', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
expect(src).toContain('Waiting for browse server...');
|
||||
expect(src).toContain('Still waiting');
|
||||
expect(src).toContain('still not responding after 5 min');
|
||||
});
|
||||
|
||||
test('3. sticky abort flag prevents loop spam on 401', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
expect(src).toContain('autoConnectAborted');
|
||||
// The mint failure branch must short-circuit on 401 specifically.
|
||||
expect(src).toMatch(/minted\.error.*startsWith\('401'\)/);
|
||||
// tryAutoConnect tick must respect the flag.
|
||||
expect(src).toMatch(/if \(autoConnectAborted\) return/);
|
||||
});
|
||||
|
||||
test('4. forceRestart re-arms the loop by clearing the abort flag', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
// forceRestart is the user's "try again" escape hatch — must reset
|
||||
// the sticky flag or 401-once means stuck-forever.
|
||||
const block = sliceBetween(src, 'function forceRestart', 'function repaintIfLive');
|
||||
expect(block).toContain('autoConnectAborted = false');
|
||||
});
|
||||
|
||||
test('5. poll interval is 2s, not the legacy 200ms tight loop', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
// 200ms ticks burned CPU and made the give-up window land too fast.
|
||||
// 2s is the v1.44 cadence — verify the tight-loop literal is gone.
|
||||
expect(src).toContain('setTimeout(tick, 2000)');
|
||||
expect(src).not.toContain('setTimeout(tick, 200)');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 Commit 3 — client-side re-attach loop.
|
||||
//
|
||||
// On unexpected WS close (anything other than clean 1000 / 4001 / 4404),
|
||||
// the sidebar now silently posts /pty-session/reattach with backoff,
|
||||
// opens a new WS with the fresh attachToken, writes RIS to xterm when
|
||||
// the agent sends {type:"reattach-begin"}, then treats the next binary
|
||||
// frame as the scrollback replay payload. Static-grep tripwires defend
|
||||
// the load-bearing protocol invariants; live re-attach exercises belong
|
||||
// in the e2e tier.
|
||||
|
||||
const TERMINAL_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
|
||||
);
|
||||
|
||||
describe('sidepanel re-attach loop (v1.44+ Commit 3)', () => {
|
||||
test('1. STATE.RECONNECTING exists for the in-flight re-attach window', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toContain("RECONNECTING: 'reconnecting'");
|
||||
});
|
||||
|
||||
test('2. backoff schedule matches the eng-review plan (1s/2s/4s/8s, 60s window)', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toContain('REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000]');
|
||||
expect(src).toContain('REATTACH_WINDOW_MS = 60_000');
|
||||
});
|
||||
|
||||
test('3. startReattachLoop posts /pty-session/reattach with sessionId', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toMatch(/function startReattachLoop\(prevSessionId\)/);
|
||||
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
|
||||
expect(block).toContain('/pty-session/reattach');
|
||||
expect(block).toContain('sessionId: prevSessionId');
|
||||
});
|
||||
|
||||
test('4. 410 Gone from re-attach short-circuits to ENDED (no retry loop)', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
|
||||
// 410 = lease window expired. Retrying wouldn't help; fall through
|
||||
// so the user clicks Restart for a fresh session.
|
||||
expect(block).toContain('resp.status === 410');
|
||||
expect(block).toContain('setState(STATE.ENDED)');
|
||||
});
|
||||
|
||||
test('5. 401 from re-attach sticky-aborts auto-connect', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
|
||||
expect(block).toContain('resp.status === 401');
|
||||
expect(block).toContain('autoConnectAborted = true');
|
||||
});
|
||||
|
||||
test('6. openReattachWebSocket handles {type:"reattach-begin"} → RIS to xterm', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
const block = sliceBetween(src, 'function openReattachWebSocket', 'async function checkClaudeAvailable');
|
||||
expect(block).toContain("msg.type === 'reattach-begin'");
|
||||
// RIS (\x1bc) is the full-reset escape that clears xterm cleanly
|
||||
// before the replay binary arrives.
|
||||
expect(block).toContain("term.write('\\x1bc')");
|
||||
expect(block).toContain('nextBinaryIsReplay = true');
|
||||
});
|
||||
|
||||
test('7. live connect()/forceRestart() close handlers trigger re-attach on transient close', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
// Both the connect() and forceRestart() close handlers must route
|
||||
// through startReattachLoop for non-clean codes. Count = 3
|
||||
// (open-reattach close handler + connect close + forceRestart close).
|
||||
const occurrences = (src.match(/startReattachLoop\(currentSessionId\)/g) || []).length;
|
||||
expect(occurrences).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('8. clean codes (1000 / 4001 / 4404) bypass the re-attach loop', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
// The branch guard MUST exclude these codes from re-attach. 1000 =
|
||||
// PTY exited (claude quit), 4001 = intentional restart, 4404 = no
|
||||
// claude on PATH. Re-attaching in those cases would be wasted work
|
||||
// (or actively wrong — a force-restart that re-attaches to its own
|
||||
// pre-restart session is the bug we're avoiding).
|
||||
expect(src).toContain('code === 1000');
|
||||
expect(src).toContain('code === 4001');
|
||||
expect(src).toContain('code === 4404');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 Commit 2C — client-side restart + dispose wiring.
|
||||
//
|
||||
// Pre-v1.44 forceRestart only closed the client WS and disposed xterm;
|
||||
// the old PTY died asynchronously via the agent's WS close handler.
|
||||
// Race window between kill and mint, two claude instances briefly,
|
||||
// no prompt visible until the user typed.
|
||||
//
|
||||
// Now forceRestart POSTs /pty-restart (one transaction: dispose + mint),
|
||||
// opens the new WS with the fresh attachToken from the response, and
|
||||
// sends {type:"start"} for the eager spawn. pagehide handler in
|
||||
// sidepanel.js sendBeacon /pty-dispose so browser quit / panel close
|
||||
// doesn't leak a 60s-zombie claude.
|
||||
|
||||
const TERMINAL_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
|
||||
);
|
||||
const SIDEPANEL_JS = path.resolve(
|
||||
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel.js',
|
||||
);
|
||||
|
||||
describe('sidepanel-terminal: forceRestart via /pty-restart (v1.44+)', () => {
|
||||
test('1. mintSession callers read the 4-tuple (sessionId + attachToken)', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
// The new shape lands in `minted.sessionId` and `minted.attachToken`.
|
||||
expect(src).toContain('const { terminalPort, sessionId } = minted');
|
||||
expect(src).toContain('minted.attachToken || minted.ptySessionToken');
|
||||
// Backward-compat fallback to ptySessionToken kept so a partially-
|
||||
// updated extension still works against a fresh server.
|
||||
});
|
||||
|
||||
test('2. eager spawn via {type:"start"} on ws.open', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
// Replaces the legacy `ws.send(TextEncoder().encode("\\n"))` newline
|
||||
// hack that nudged the lazy-binary-spawn.
|
||||
expect(src).toMatch(/ws\.send\(JSON\.stringify\(\{\s*type:\s*'start'\s*\}\)\)/);
|
||||
expect(src).not.toContain("TextEncoder().encode('\\n')");
|
||||
});
|
||||
|
||||
test('3. forceRestart sends 4001 close code (intentional restart)', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toMatch(/ws\.close\(4001/);
|
||||
});
|
||||
|
||||
test('4. forceRestart POSTs /pty-restart with current sessionId', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toContain('/pty-restart');
|
||||
expect(src).toContain('priorSessionId ? { sessionId: priorSessionId } : {}');
|
||||
});
|
||||
|
||||
test('5. forceRestart 401 triggers sticky abort (no spam loop)', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
// Same defense pattern as connect() — 401 must flip the sticky flag
|
||||
// or every 2s the user sees a fresh "Auth invalid" message.
|
||||
const block = sliceBetween(src, 'async function forceRestart', 'function repaintIfLive');
|
||||
expect(block).toContain('resp.status === 401');
|
||||
expect(block).toContain('autoConnectAborted = true');
|
||||
});
|
||||
|
||||
test('6. currentSessionId is exposed on window for sidepanel.js pagehide', () => {
|
||||
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
|
||||
expect(src).toContain('window.gstackPtySession = currentSessionId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidepanel: pagehide → sendBeacon /pty-dispose (v1.44+)', () => {
|
||||
test('7. pagehide handler fires sendBeacon to /pty-dispose', () => {
|
||||
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
|
||||
expect(src).toMatch(/window\.addEventListener\('pagehide'/);
|
||||
expect(src).toContain('navigator.sendBeacon');
|
||||
expect(src).toContain('/pty-dispose');
|
||||
});
|
||||
|
||||
test('8. pagehide payload carries sessionId + authToken in body (sendBeacon-compat)', () => {
|
||||
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
|
||||
// sendBeacon can't set custom headers — server route accepts body-auth.
|
||||
// Both fields must be in the payload or the server rejects.
|
||||
expect(src).toMatch(/JSON\.stringify\(\{\s*sessionId,\s*authToken\s*\}\)/);
|
||||
expect(src).toContain('window.gstackPtySession');
|
||||
expect(src).toContain('window.gstackAuthToken');
|
||||
});
|
||||
|
||||
test('9. pagehide handler is best-effort (try/catch swallows failures)', () => {
|
||||
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
|
||||
// The 60s detach window catches any sendBeacon that fails, so the
|
||||
// handler MUST not throw — uncaught throws can interfere with the
|
||||
// browser's unload sequence. Slice between pagehide and end-of-file
|
||||
// (it's the last addEventListener in sidepanel.js by design).
|
||||
const i = src.indexOf("addEventListener('pagehide'");
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const block = src.slice(i);
|
||||
expect(block).toMatch(/try \{/);
|
||||
expect(block).toMatch(/} catch /);
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { createSseEndpoint } from '../src/sse-helpers';
|
||||
|
||||
// Unit tests for the SSE cleanup contract introduced by D6 EXTRACT_HELPER.
|
||||
//
|
||||
// The pre-helper bug: /activity/stream and /inspector/events ran cleanup
|
||||
// only on the `req.signal.abort` edge. If the underlying TCP died without
|
||||
// firing abort (Chromium MV3 service-worker suspend, intermediate proxy
|
||||
// half-close), the subscriber closure stayed in the Set capturing the
|
||||
// ReadableStreamDefaultController and any payloads queued behind it.
|
||||
//
|
||||
// These tests pin the three cleanup edges:
|
||||
// 1. abort signal → cleanup
|
||||
// 2. enqueue throws (consumer gone) → cleanup
|
||||
// 3. heartbeat enqueue throws → cleanup
|
||||
// And the idempotency invariant: cleanup running twice is a no-op.
|
||||
|
||||
function makeRequest(): { req: Request; abort: () => void } {
|
||||
const controller = new AbortController();
|
||||
// Minimal Request — we only use req.signal here. URL is irrelevant.
|
||||
const req = new Request('http://localhost/test', { signal: controller.signal });
|
||||
return { req, abort: () => controller.abort() };
|
||||
}
|
||||
|
||||
/** Pull SSE bytes from a Response stream, return decoded text. */
|
||||
async function readAll(res: Response, ms: number): Promise<string> {
|
||||
if (!res.body) return '';
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let out = '';
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const { value, done } = await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<{ value: undefined; done: true }>((resolve) =>
|
||||
setTimeout(() => resolve({ value: undefined, done: true }), deadline - Date.now()),
|
||||
),
|
||||
]);
|
||||
if (done) break;
|
||||
if (value) out += decoder.decode(value, { stream: true });
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
try { reader.cancel().catch(() => {}); } catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('createSseEndpoint cleanup contract', () => {
|
||||
test('1. abort signal triggers unsubscribe', async () => {
|
||||
let unsubscribed = 0;
|
||||
const { req, abort } = makeRequest();
|
||||
const res = createSseEndpoint(req, {
|
||||
subscribe: () => () => {
|
||||
unsubscribed++;
|
||||
},
|
||||
liveEventName: 'test',
|
||||
heartbeatMs: 60_000, // long enough that we don't see heartbeats in this test
|
||||
});
|
||||
// Start the stream by reading once, then abort.
|
||||
const reader = res.body!.getReader();
|
||||
// Yield to let start() run.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
abort();
|
||||
// Let the abort listener fire.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(unsubscribed).toBe(1);
|
||||
reader.cancel().catch(() => {});
|
||||
});
|
||||
|
||||
test('2. enqueue throw triggers unsubscribe + heartbeat clear', async () => {
|
||||
let unsubscribed = 0;
|
||||
let notify: ((entry: { msg: string }) => void) | null = null;
|
||||
const { req } = makeRequest();
|
||||
const res = createSseEndpoint<{ msg: string }>(req, {
|
||||
subscribe: (n) => {
|
||||
notify = n;
|
||||
return () => {
|
||||
unsubscribed++;
|
||||
};
|
||||
},
|
||||
liveEventName: 'test',
|
||||
heartbeatMs: 60_000,
|
||||
});
|
||||
// Cancel the reader so subsequent enqueues throw.
|
||||
const reader = res.body!.getReader();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(notify).not.toBeNull();
|
||||
await reader.cancel(); // closes the consumer side
|
||||
// Now fire a live event — enqueue should throw → cleanup → unsubscribe.
|
||||
notify!({ msg: 'will fail to enqueue' });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(unsubscribed).toBe(1);
|
||||
});
|
||||
|
||||
test('3. cleanup is idempotent (abort then enqueue-fail)', async () => {
|
||||
let unsubscribed = 0;
|
||||
let notify: ((entry: { msg: string }) => void) | null = null;
|
||||
const { req, abort } = makeRequest();
|
||||
const res = createSseEndpoint<{ msg: string }>(req, {
|
||||
subscribe: (n) => {
|
||||
notify = n;
|
||||
return () => {
|
||||
unsubscribed++;
|
||||
};
|
||||
},
|
||||
liveEventName: 'test',
|
||||
heartbeatMs: 60_000,
|
||||
});
|
||||
const reader = res.body!.getReader();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
abort();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// Second cleanup edge — should be a no-op.
|
||||
notify!({ msg: 'no-op' });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(unsubscribed).toBe(1);
|
||||
reader.cancel().catch(() => {});
|
||||
});
|
||||
|
||||
test('4. initialReplay events reach the client before live events', async () => {
|
||||
let notify: ((entry: { msg: string }) => void) | null = null;
|
||||
const { req } = makeRequest();
|
||||
const res = createSseEndpoint<{ msg: string }>(req, {
|
||||
initialReplay: (send) => {
|
||||
send('replay', { msg: 'first' });
|
||||
},
|
||||
subscribe: (n) => {
|
||||
notify = n;
|
||||
return () => {};
|
||||
},
|
||||
liveEventName: 'live',
|
||||
heartbeatMs: 60_000,
|
||||
});
|
||||
// Trigger one live event soon after stream starts.
|
||||
setTimeout(() => notify?.({ msg: 'second' }), 5);
|
||||
const text = await readAll(res, 50);
|
||||
expect(text).toContain('event: replay');
|
||||
expect(text).toContain('"msg":"first"');
|
||||
expect(text).toContain('event: live');
|
||||
expect(text).toContain('"msg":"second"');
|
||||
// Replay must come before live.
|
||||
expect(text.indexOf('"first"')).toBeLessThan(text.indexOf('"second"'));
|
||||
});
|
||||
|
||||
test('5. initialReplay throw triggers cleanup without subscribing', async () => {
|
||||
let subscribed = 0;
|
||||
const { req } = makeRequest();
|
||||
const res = createSseEndpoint(req, {
|
||||
initialReplay: () => {
|
||||
throw new Error('replay boom');
|
||||
},
|
||||
subscribe: () => {
|
||||
subscribed++;
|
||||
return () => {};
|
||||
},
|
||||
liveEventName: 'test',
|
||||
heartbeatMs: 60_000,
|
||||
});
|
||||
// Drain — stream should close cleanly.
|
||||
const text = await readAll(res, 30);
|
||||
expect(text).toBe(''); // no events
|
||||
expect(subscribed).toBe(0); // never reached subscribe()
|
||||
});
|
||||
|
||||
test('6. lone surrogates in payload string are sanitized', async () => {
|
||||
let notify: ((entry: { msg: string }) => void) | null = null;
|
||||
const { req } = makeRequest();
|
||||
const res = createSseEndpoint<{ msg: string }>(req, {
|
||||
subscribe: (n) => {
|
||||
notify = n;
|
||||
return () => {};
|
||||
},
|
||||
liveEventName: 'test',
|
||||
heartbeatMs: 60_000,
|
||||
});
|
||||
setTimeout(() => {
|
||||
// Lone high surrogate (no matching low). JSON.stringify would emit
|
||||
// \uD800 escape that breaks Claude API. Helper must strip it.
|
||||
notify?.({ msg: 'hello \uD800 world' });
|
||||
}, 5);
|
||||
const text = await readAll(res, 50);
|
||||
expect(text).toContain('event: test');
|
||||
// JSON.stringify emits U+FFFD as the literal character, not as escape.
|
||||
expect(text).toContain('�');
|
||||
// The raw lone-surrogate escape MUST NOT survive — that's the failure
|
||||
// mode that breaks the Claude API with HTTP 400.
|
||||
expect(text.toLowerCase()).not.toContain('\\ud800');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tests for the opt-in extended stealth mode (#1112 rebased into the
|
||||
* v1.41 wave).
|
||||
*
|
||||
* Pins:
|
||||
* 1. Default mode applies the always-on Layer C stealth script (and NOT
|
||||
* the extended script) — the consistency-first default.
|
||||
* 2. GSTACK_STEALTH=extended adds EXTENDED_STEALTH_SCRIPT on top of Layer C.
|
||||
* 3. EXTENDED_STEALTH_SCRIPT contains the six detection-vector patches.
|
||||
* 4. Apply order: Layer C first, extended second (so the extended
|
||||
* delete-from-prototype path layers on top of Layer C's getter without
|
||||
* silently overriding it if delete fails).
|
||||
*
|
||||
* Live SannySoft pass-rate verification is a periodic-tier E2E test
|
||||
* (gated behind external network + Chromium); this file pins the
|
||||
* static + applyStealth semantics that run on every commit.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
EXTENDED_STEALTH_SCRIPT,
|
||||
isExtendedStealthEnabled,
|
||||
applyStealth,
|
||||
} from '../src/stealth';
|
||||
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.GSTACK_STEALTH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) delete process.env.GSTACK_STEALTH;
|
||||
else process.env.GSTACK_STEALTH = originalEnv;
|
||||
});
|
||||
|
||||
describe('extended stealth — opt-in mode flag', () => {
|
||||
test('default mode is OFF (consistency-first contract)', () => {
|
||||
delete process.env.GSTACK_STEALTH;
|
||||
expect(isExtendedStealthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('GSTACK_STEALTH=extended enables extended mode', () => {
|
||||
process.env.GSTACK_STEALTH = 'extended';
|
||||
expect(isExtendedStealthEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('GSTACK_STEALTH=1 also enables (env-style boolean)', () => {
|
||||
process.env.GSTACK_STEALTH = '1';
|
||||
expect(isExtendedStealthEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('GSTACK_STEALTH=anything-else does NOT enable', () => {
|
||||
process.env.GSTACK_STEALTH = 'verbose';
|
||||
expect(isExtendedStealthEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EXTENDED_STEALTH_SCRIPT — six detection-vector patches', () => {
|
||||
test('1. deletes navigator.webdriver from prototype', () => {
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toMatch(/delete.*Object\.getPrototypeOf\(navigator\)\.webdriver/);
|
||||
});
|
||||
|
||||
test('2. spoofs WebGL renderer to Apple M1 Pro', () => {
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain('Apple M1 Pro');
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain('UNMASKED_VENDOR_WEBGL');
|
||||
});
|
||||
|
||||
test('3. installs PluginArray-prototype-passing navigator.plugins', () => {
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain('PluginArray');
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain('MimeType');
|
||||
});
|
||||
|
||||
test('4. populates window.chrome with app, runtime, loadTimes, csi', () => {
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain('chrome.app');
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain('chrome.runtime');
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain('chrome.loadTimes');
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain('chrome.csi');
|
||||
});
|
||||
|
||||
test('5. backfills navigator.mediaDevices when missing', () => {
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain('mediaDevices');
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain('enumerateDevices');
|
||||
});
|
||||
|
||||
test('6. clears CDP cdc_* property names from window', () => {
|
||||
expect(EXTENDED_STEALTH_SCRIPT).toContain("startsWith('cdc_')");
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyStealth — script wiring', () => {
|
||||
test('default mode applies ONLY the Layer C script (not extended)', async () => {
|
||||
delete process.env.GSTACK_STEALTH;
|
||||
const calls: string[] = [];
|
||||
const fakeCtx = {
|
||||
addInitScript: async (opts: { content: string }) => {
|
||||
calls.push(opts.content);
|
||||
},
|
||||
} as unknown as Parameters<typeof applyStealth>[0];
|
||||
await applyStealth(fakeCtx);
|
||||
expect(calls).toHaveLength(1);
|
||||
// Layer C signatures: toString-proxy native-code lie + webdriver mask.
|
||||
expect(calls[0]).toContain('[native code]');
|
||||
expect(calls[0]).toContain('webdriver');
|
||||
expect(calls[0]).not.toBe(EXTENDED_STEALTH_SCRIPT);
|
||||
});
|
||||
|
||||
test('extended mode applies BOTH scripts in order (Layer C first, extended second)', async () => {
|
||||
process.env.GSTACK_STEALTH = 'extended';
|
||||
const calls: string[] = [];
|
||||
const fakeCtx = {
|
||||
addInitScript: async (opts: { content: string }) => {
|
||||
calls.push(opts.content);
|
||||
},
|
||||
} as unknown as Parameters<typeof applyStealth>[0];
|
||||
await applyStealth(fakeCtx);
|
||||
expect(calls).toHaveLength(2);
|
||||
// Layer C first (its native-code lie), extended second.
|
||||
expect(calls[0]).toContain('[native code]');
|
||||
expect(calls[1]).toBe(EXTENDED_STEALTH_SCRIPT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { BrowserManager } from '../src/browser-manager';
|
||||
import { subscribe } from '../src/activity';
|
||||
|
||||
// Tests for the tab-count guardrail. Each threshold fires exactly once per
|
||||
// upward crossing and re-arms when the count drops back below. The toast
|
||||
// UX lives in the sidebar; this exercises the server-side audit-trail
|
||||
// invariant that an activity entry is emitted at each crossing.
|
||||
|
||||
interface CapturedEntry {
|
||||
type: string;
|
||||
command?: string;
|
||||
error?: string;
|
||||
tabs?: number;
|
||||
}
|
||||
|
||||
function captureGuardrailEntries(): { entries: CapturedEntry[]; unsubscribe: () => void } {
|
||||
const entries: CapturedEntry[] = [];
|
||||
const unsubscribe = subscribe((entry) => {
|
||||
if (entry.command === 'tab-guardrail') {
|
||||
entries.push({
|
||||
type: entry.type,
|
||||
command: entry.command,
|
||||
error: entry.error,
|
||||
tabs: entry.tabs,
|
||||
});
|
||||
}
|
||||
});
|
||||
return { entries, unsubscribe };
|
||||
}
|
||||
|
||||
/** Drive the guardrail by writing directly into the manager's pages map. */
|
||||
async function setTabCount(bm: BrowserManager, n: number): Promise<void> {
|
||||
// Reach into private state via index access — test-only manipulation that
|
||||
// avoids spinning up a real Chromium just to verify the threshold math.
|
||||
const inner = bm as unknown as {
|
||||
pages: Map<number, unknown>;
|
||||
checkTabGuardrails: () => void;
|
||||
recheckTabGuardrailsOnClose: () => void;
|
||||
};
|
||||
inner.pages.clear();
|
||||
for (let i = 0; i < n; i++) inner.pages.set(i, { fakeTab: true });
|
||||
// Drive whichever direction matches the count change.
|
||||
inner.checkTabGuardrails();
|
||||
inner.recheckTabGuardrailsOnClose();
|
||||
// emitActivity dispatches subscribers via queueMicrotask, so let the
|
||||
// microtask queue drain before the test assertion runs.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
describe('tab-count guardrail', () => {
|
||||
let bm: BrowserManager;
|
||||
let capture: ReturnType<typeof captureGuardrailEntries>;
|
||||
|
||||
beforeEach(() => {
|
||||
bm = new BrowserManager();
|
||||
capture = captureGuardrailEntries();
|
||||
});
|
||||
|
||||
test('1. no entry fires under the soft threshold', async () => {
|
||||
await setTabCount(bm, 10);
|
||||
await setTabCount(bm, 49);
|
||||
expect(capture.entries).toEqual([]);
|
||||
capture.unsubscribe();
|
||||
});
|
||||
|
||||
test('2. soft threshold (50) fires exactly once on upward crossing', async () => {
|
||||
await setTabCount(bm, 49);
|
||||
await setTabCount(bm, 50);
|
||||
await setTabCount(bm, 51);
|
||||
await setTabCount(bm, 60);
|
||||
expect(capture.entries.length).toBe(1);
|
||||
expect(capture.entries[0].tabs).toBe(50);
|
||||
expect(capture.entries[0].error).toContain('crossed 50');
|
||||
capture.unsubscribe();
|
||||
});
|
||||
|
||||
test('3. hard threshold (200) fires exactly once on upward crossing', async () => {
|
||||
await setTabCount(bm, 199);
|
||||
await setTabCount(bm, 200);
|
||||
await setTabCount(bm, 201);
|
||||
await setTabCount(bm, 220);
|
||||
// 0 → 199 fired the soft threshold; 199 → 200 fires the hard one once.
|
||||
const hardEntries = capture.entries.filter((e) => e.error?.includes('crossed 200'));
|
||||
expect(hardEntries.length).toBe(1);
|
||||
expect(hardEntries[0].tabs).toBe(200);
|
||||
capture.unsubscribe();
|
||||
});
|
||||
|
||||
test('4. both thresholds fire in order when count jumps from 0 → 250', async () => {
|
||||
await setTabCount(bm, 250);
|
||||
expect(capture.entries.length).toBe(2);
|
||||
expect(capture.entries[0].error).toContain('crossed 50');
|
||||
expect(capture.entries[1].error).toContain('crossed 200');
|
||||
capture.unsubscribe();
|
||||
});
|
||||
|
||||
test('5. soft threshold re-arms when tab count drops below it', async () => {
|
||||
await setTabCount(bm, 60);
|
||||
expect(capture.entries.length).toBe(1);
|
||||
await setTabCount(bm, 30);
|
||||
await setTabCount(bm, 55);
|
||||
expect(capture.entries.length).toBe(2);
|
||||
expect(capture.entries[1].error).toContain('crossed 50');
|
||||
capture.unsubscribe();
|
||||
});
|
||||
|
||||
test('6. hard threshold re-arms when tab count drops below it', async () => {
|
||||
await setTabCount(bm, 210);
|
||||
const beforeReArm = capture.entries.filter((e) => e.error?.includes('crossed 200')).length;
|
||||
expect(beforeReArm).toBe(1);
|
||||
await setTabCount(bm, 150);
|
||||
await setTabCount(bm, 220);
|
||||
const afterReArm = capture.entries.filter((e) => e.error?.includes('crossed 200')).length;
|
||||
expect(afterReArm).toBe(2);
|
||||
capture.unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 Commit 3 — detach state machine + ring buffer + re-attach replay.
|
||||
//
|
||||
// The state machine is what turns a single network blip from "fall through
|
||||
// to ENDED state, click Restart" into "silent re-attach with scrollback
|
||||
// intact, keep typing." Live WS cycles + buffer-overflow exercises belong
|
||||
// in the e2e tier; these static-grep tripwires defend the load-bearing
|
||||
// protocol + correctness properties.
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent detach + re-attach (v1.44+ Commit 3)', () => {
|
||||
test('1. PtySession carries ring buffer + alt-screen + detach state', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const i = src.indexOf('interface PtySession {');
|
||||
const j = src.indexOf('\n}', i);
|
||||
const block = src.slice(i, j);
|
||||
expect(block).toContain('liveWs: any | null');
|
||||
expect(block).toContain('ringBuffer: Buffer[]');
|
||||
expect(block).toContain('ringBufferBytes: number');
|
||||
expect(block).toContain('altScreenActive: boolean');
|
||||
expect(block).toContain('detached: boolean');
|
||||
expect(block).toContain('detachTimer:');
|
||||
});
|
||||
|
||||
test('2. RING_BUFFER_MAX_BYTES default is 1 MB, env-overridable', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_PTY_RING_BUFFER_BYTES');
|
||||
expect(src).toContain('1024 * 1024');
|
||||
});
|
||||
|
||||
test('3. DETACH_WINDOW_MS default is 60s, env-overridable', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_PTY_DETACH_WINDOW_MS');
|
||||
expect(src).toContain("'60000'");
|
||||
});
|
||||
|
||||
test('4. appendToRingBuffer evicts oldest frames past the cap', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/function appendToRingBuffer\(/);
|
||||
// Eviction loop: must keep at least one frame even at extreme caps
|
||||
// (otherwise a single oversized frame would empty the buffer).
|
||||
expect(src).toMatch(/session\.ringBufferBytes > RING_BUFFER_MAX_BYTES/);
|
||||
expect(src).toContain('session.ringBuffer.length > 1');
|
||||
expect(src).toContain('session.ringBuffer.shift()');
|
||||
});
|
||||
|
||||
test('5. alt-screen tracking watches for CSI ?1049h / CSI ?1049l', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Canonical xterm enter/exit alt-screen sequences. Must update
|
||||
// session.altScreenActive so the replay prelude knows.
|
||||
expect(src).toContain('\\x1b[?1049h');
|
||||
expect(src).toContain('\\x1b[?1049l');
|
||||
expect(src).toContain('session.altScreenActive');
|
||||
});
|
||||
|
||||
test('6. buildReplayPayload prefixes soft-reset (+ alt-screen if active)', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/function buildReplayPayload\(/);
|
||||
// DECSTR soft reset — re-defaults character attributes after the
|
||||
// client's RIS clears the xterm buffer.
|
||||
expect(src).toContain('\\x1b[!p');
|
||||
// Conditionally re-enter alt-screen if claude was in a tool-call
|
||||
// (alt-screen mode) at detach.
|
||||
expect(src).toContain('session.altScreenActive');
|
||||
});
|
||||
|
||||
test('7. WS open() re-attaches when sessionId already lives in sessionsById', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, 'open(ws) {', 'message(ws, raw) {');
|
||||
expect(block).toContain('sessionsById.get(sessionId)');
|
||||
expect(block).toContain('existing.liveWs = ws');
|
||||
expect(block).toContain('clearTimeout(existing.detachTimer)');
|
||||
// Tells the client to write RIS before treating the next binary
|
||||
// frame as replay.
|
||||
expect(block).toContain("type: 'reattach-begin'");
|
||||
expect(block).toContain('sendBinary(buildReplayPayload(existing))');
|
||||
});
|
||||
|
||||
test('8. WS close starts detach timer for non-intentional close codes', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const i = src.indexOf('close(ws');
|
||||
const j = src.indexOf('function handleTabState', i);
|
||||
const block = src.slice(i, j);
|
||||
// 4001 = intentional restart (Commit 2), 4404 = no-claude, 1000 = clean
|
||||
// exit. Any other code (1006 abnormal, 1001 going-away, etc.) gets the
|
||||
// 60s detach grace.
|
||||
expect(block).toContain('code === 4001');
|
||||
expect(block).toContain('code === 4404');
|
||||
expect(block).toContain('code === 1000');
|
||||
expect(block).toContain('session.detached = true');
|
||||
expect(block).toContain('session.detachTimer = setTimeout');
|
||||
expect(block).toContain('DETACH_WINDOW_MS');
|
||||
// Detach timer must unref so the bun process can exit cleanly.
|
||||
expect(block).toContain('detachTimer as any)?.unref?.()');
|
||||
});
|
||||
|
||||
test('9. /internal/restart cancels detach timer before disposal', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
|
||||
// Without the cancellation, a later detach-timer fire would dispose a
|
||||
// session that's already been disposed by the explicit restart path.
|
||||
expect(block).toContain('clearTimeout(session.detachTimer)');
|
||||
});
|
||||
|
||||
test('10. PTY on-data writes through session.liveWs (not the original ws closure)', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Critical for re-attach correctness: the PTY's on-data callback
|
||||
// closes over `session`, not the original `ws`, so after re-attach
|
||||
// it routes to the new liveWs automatically.
|
||||
expect(src).toContain('session.liveWs.sendBinary');
|
||||
// Always append to the ring buffer regardless of attach state — so
|
||||
// a detached session still captures output for the next re-attach.
|
||||
expect(src).toContain('appendToRingBuffer(session, flush)');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Static-grep tripwire for the v1.44 internalHandler refactor.
|
||||
//
|
||||
// /internal/grant and /internal/revoke were copies of the same dance:
|
||||
// bearer-auth → x-browse-gen check → req.json().then(...).catch(...).
|
||||
// internalHandler<T>(req, fn) collapses that into a single helper call.
|
||||
// This test fails CI if the helper goes away or the existing routes
|
||||
// regress to inline auth + JSON parse boilerplate. Wiring tests
|
||||
// (token grant/revoke behavior) already live in
|
||||
// browse/test/terminal-agent-integration.test.ts.
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent internalHandler refactor (v1.44+)', () => {
|
||||
test('1. internalHandler<T> exists with the documented signature', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/async function internalHandler<T>\s*\(/);
|
||||
// Body must include the auth gate, body parse, and result coercion.
|
||||
expect(src).toContain('checkInternalAuth(req)');
|
||||
expect(src).toContain('await req.json()');
|
||||
expect(src).toContain('instanceof Response');
|
||||
});
|
||||
|
||||
test('2. /internal/grant routes through internalHandler', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Match the route handler block.
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/grant'", "url.pathname === '/internal/revoke'");
|
||||
expect(block).toContain('internalHandler(req');
|
||||
// Must NOT have the old inline pattern (would be a regression).
|
||||
expect(block).not.toContain('req.headers.get(\'authorization\')');
|
||||
expect(block).not.toContain('req.json().then(');
|
||||
});
|
||||
|
||||
test('3. /internal/revoke routes through internalHandler', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/revoke'", "url.pathname === '/internal/healthz'");
|
||||
expect(block).toContain('internalHandler(req');
|
||||
expect(block).not.toContain('req.json().then(');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 WS keepalive — static-grep invariants for the protocol contract.
|
||||
//
|
||||
// terminal-agent.ts and sidepanel-terminal.js cooperate on a 25s ping/pong +
|
||||
// keepalive cycle so long-idle PTY connections survive NAT idle timeouts and
|
||||
// Chromium's MV3 panel suspension heuristics. The wiring is invisible to
|
||||
// integration tests (you'd have to wait 25s to observe a ping) but trivially
|
||||
// regressed by a refactor. These tests fail CI if either side stops sending
|
||||
// or stops accepting the protocol frames.
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
const CLIENT_JS = path.resolve(new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js');
|
||||
|
||||
describe('terminal-agent WS keepalive (v1.44+)', () => {
|
||||
test('1. agent has a KEEPALIVE_INTERVAL_MS env knob, default 25000', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_PTY_KEEPALIVE_INTERVAL_MS');
|
||||
expect(src).toMatch(/KEEPALIVE_INTERVAL_MS\s*=\s*parseInt\(/);
|
||||
// Default constant present so the env knob has a fallback.
|
||||
expect(src).toContain("'25000'");
|
||||
});
|
||||
|
||||
test('2. WS open handler starts a ping interval on the session', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// The open(ws) handler in the websocket: { ... } block must call
|
||||
// setInterval to drive the ping cadence and store the handle.
|
||||
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
|
||||
expect(wsBlock).toMatch(/open\s*\(\s*ws\s*\)/);
|
||||
expect(wsBlock).toContain('setInterval');
|
||||
expect(wsBlock).toContain("type: 'ping'");
|
||||
expect(wsBlock).toContain('pingInterval');
|
||||
});
|
||||
|
||||
test('3. WS close handler clears the ping interval', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
|
||||
// close(ws, code?, reason?) MUST clearInterval the pingInterval —
|
||||
// otherwise we leak timers across reconnects and the ping handler
|
||||
// captures a dead ws ref. Signature widened in Commit 3 to include
|
||||
// the close code for the detach state machine, hence the loose match.
|
||||
expect(wsBlock).toMatch(/close\s*\(\s*ws/);
|
||||
expect(wsBlock).toContain('clearInterval(session.pingInterval)');
|
||||
});
|
||||
|
||||
test('4. message handler accepts pong / keepalive frames silently', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// The text-frame router must recognize the keepalive vocabulary —
|
||||
// if a future refactor strips this branch, unknown-text-frame
|
||||
// suppression would still drop them but we lose intent.
|
||||
expect(src).toMatch(/msg\?\.type === 'pong'/);
|
||||
expect(src).toMatch(/msg\?\.type === 'keepalive'/);
|
||||
});
|
||||
|
||||
test('5. client sends keepalive every 25s on ws.open', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
expect(src).toContain('keepaliveInterval');
|
||||
expect(src).toMatch(/setInterval\(/);
|
||||
expect(src).toContain("type: 'keepalive'");
|
||||
expect(src).toContain('KEEPALIVE_INTERVAL_MS = 25000');
|
||||
});
|
||||
|
||||
test('6. client replies pong to server ping', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
// The ws.message handler must short-circuit on msg.type === 'ping'
|
||||
// and reply with {type: 'pong', ts: msg.ts}.
|
||||
expect(src).toMatch(/msg\.type === 'ping'/);
|
||||
expect(src).toMatch(/type: 'pong'/);
|
||||
});
|
||||
|
||||
test('7. client clears keepalive in close + teardown + forceRestart', () => {
|
||||
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
||||
// Three teardown paths exist; all three must drop the interval to
|
||||
// avoid leaking timers across reconnect attempts.
|
||||
const occurrences = (src.match(/clearInterval\(keepaliveInterval\)/g) || []).length;
|
||||
expect(occurrences).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
readAgentRecord,
|
||||
writeAgentRecord,
|
||||
clearAgentRecord,
|
||||
killAgentByRecord,
|
||||
agentRecordPath,
|
||||
type AgentRecord,
|
||||
} from '../src/terminal-agent-control';
|
||||
|
||||
// REGRESSION TEST for the v1.44 PID-identity migration.
|
||||
//
|
||||
// Pre-v1.44, both `cli.ts` and `server.ts` killed the terminal-agent with
|
||||
// `spawnSync('pkill', ['-f', 'terminal-agent\\.ts'])`. That command matches
|
||||
// by argv regex — any process whose command line contains the string
|
||||
// `terminal-agent.ts` got SIGTERM'd. In practice this killed:
|
||||
//
|
||||
// * sibling gstack sessions on the same host
|
||||
// * editor processes (vim, code, less) that had the file open
|
||||
// * any second gstack run on the host
|
||||
//
|
||||
// The v1.44 migration replaces both kill sites with identity-based PID kill
|
||||
// against the record written at `<stateDir>/terminal-agent-pid` by the
|
||||
// agent's own boot path. This test is the static-grep tripwire that prevents
|
||||
// reintroducing the regex teardown anywhere in the source tree.
|
||||
//
|
||||
// Pattern mirrors browse/test/server-embedder-terminal-port.test.ts (Test 4)
|
||||
// and browse/test/server-sanitize-surrogates.test.ts: read source files
|
||||
// directly, assert an invariant on their contents.
|
||||
|
||||
const SRC_DIR = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src');
|
||||
|
||||
function readAllSourceFiles(): Array<{ file: string; content: string }> {
|
||||
const out: Array<{ file: string; content: string }> = [];
|
||||
for (const entry of fs.readdirSync(SRC_DIR)) {
|
||||
if (!entry.endsWith('.ts')) continue;
|
||||
const full = path.join(SRC_DIR, entry);
|
||||
out.push({ file: entry, content: fs.readFileSync(full, 'utf-8') });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('terminal-agent PID identity (v1.44+)', () => {
|
||||
test('1. no source file calls `pkill -f terminal-agent`', () => {
|
||||
// The regex matches both `pkill -f terminal-agent\.ts` (escaped form
|
||||
// used in spawnSync args) and `pkill -f terminal-agent.ts` (literal),
|
||||
// since the dot is the only difference and both are footguns.
|
||||
const offenders: string[] = [];
|
||||
for (const { file, content } of readAllSourceFiles()) {
|
||||
// Walk line by line so we can skip comments that mention the historical
|
||||
// pattern (acceptable as documentation, not as code).
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!/pkill/.test(line)) continue;
|
||||
if (!/terminal-agent/.test(line)) continue;
|
||||
// Skip comment lines — historical mentions in JSDoc are fine.
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
|
||||
offenders.push(`${file}:${i + 1}: ${trimmed}`);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('2. neither cli.ts nor server.ts calls spawnSync with pkill', () => {
|
||||
// Tighter check — even if someone routes through a different code path,
|
||||
// any spawnSync('pkill', ...) anywhere in src/ is the smell.
|
||||
const offenders: string[] = [];
|
||||
for (const { file, content } of readAllSourceFiles()) {
|
||||
if (/spawnSync\s*\(\s*['"]pkill['"]/.test(content)) {
|
||||
offenders.push(file);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('3. readAgentRecord round-trips writeAgentRecord', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-pid-id-'));
|
||||
try {
|
||||
const record: AgentRecord = {
|
||||
pid: 12345,
|
||||
gen: 'test-gen-abcdef',
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
writeAgentRecord(tmpDir, record);
|
||||
const read = readAgentRecord(tmpDir);
|
||||
expect(read).toEqual(record);
|
||||
expect(fs.existsSync(agentRecordPath(tmpDir))).toBe(true);
|
||||
|
||||
clearAgentRecord(tmpDir);
|
||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
||||
expect(fs.existsSync(agentRecordPath(tmpDir))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('4. readAgentRecord returns null on missing or malformed file', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-pid-id-'));
|
||||
try {
|
||||
// Missing.
|
||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
||||
|
||||
// Malformed: wrong type for pid.
|
||||
fs.writeFileSync(agentRecordPath(tmpDir), JSON.stringify({ pid: 'not-a-number', gen: 'x', startedAt: 0 }));
|
||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
||||
|
||||
// Malformed: not JSON.
|
||||
fs.writeFileSync(agentRecordPath(tmpDir), 'definitely not json');
|
||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
||||
|
||||
// Missing field.
|
||||
fs.writeFileSync(agentRecordPath(tmpDir), JSON.stringify({ pid: 1, gen: 'x' }));
|
||||
expect(readAgentRecord(tmpDir)).toBeNull();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('5. killAgentByRecord returns false for a dead PID and never throws', () => {
|
||||
// PID 2147483646 is below Linux PID_MAX_LIMIT but way above macOS's
|
||||
// typical max — no real process will ever hold it. isProcessAlive
|
||||
// returns false; killAgentByRecord no-ops.
|
||||
const record: AgentRecord = {
|
||||
pid: 2147483646,
|
||||
gen: 'sentinel',
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
const result = killAgentByRecord(record, 'SIGTERM');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('6. killAgentByRecord skips the kill when isProcessAlive is false', () => {
|
||||
// Guard via process.kill stub: confirm killAgentByRecord does NOT call
|
||||
// process.kill with a non-zero signal when the PID is dead. This is the
|
||||
// belt-and-suspenders defense against PID-reuse: even if isProcessAlive
|
||||
// changes implementation, killAgentByRecord must validate liveness first.
|
||||
const origKill = process.kill;
|
||||
const kills: Array<[number, NodeJS.Signals | number]> = [];
|
||||
(process as any).kill = ((pid: number, sig: NodeJS.Signals | number) => {
|
||||
kills.push([pid, sig ?? 'SIGTERM']);
|
||||
if (sig === 0) {
|
||||
const err: any = new Error('ESRCH');
|
||||
err.code = 'ESRCH';
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}) as any;
|
||||
try {
|
||||
const record: AgentRecord = { pid: 9999999, gen: 'x', startedAt: Date.now() };
|
||||
killAgentByRecord(record, 'SIGTERM');
|
||||
const terminations = kills.filter(([, s]) => s !== 0);
|
||||
expect(terminations).toEqual([]);
|
||||
} finally {
|
||||
(process as any).kill = origKill;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
appendToRingBuffer,
|
||||
buildReplayPayload,
|
||||
type PtySession,
|
||||
} from '../src/terminal-agent';
|
||||
|
||||
// Runtime exercises for the v1.44 Commit 3 ring buffer + replay prelude.
|
||||
// Companion to browse/test/terminal-agent-detach-reattach.test.ts which
|
||||
// covers the structural invariants; this file calls the helpers directly
|
||||
// to prove behavioral correctness without spinning up a real Bun.serve
|
||||
// listener.
|
||||
|
||||
function fresh(): PtySession {
|
||||
return {
|
||||
proc: null,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cookie: 'test-cookie',
|
||||
liveWs: null,
|
||||
sessionId: 'test-session',
|
||||
spawned: false,
|
||||
pingInterval: null,
|
||||
ringBuffer: [],
|
||||
ringBufferBytes: 0,
|
||||
altScreenActive: false,
|
||||
detached: false,
|
||||
detachTimer: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('appendToRingBuffer runtime', () => {
|
||||
test('appends frames in order and tracks byte count', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('hello '));
|
||||
appendToRingBuffer(s, Buffer.from('world'));
|
||||
expect(s.ringBuffer).toHaveLength(2);
|
||||
expect(s.ringBufferBytes).toBe(11);
|
||||
expect(Buffer.concat(s.ringBuffer).toString()).toBe('hello world');
|
||||
});
|
||||
|
||||
test('evicts oldest frames when cap exceeded', () => {
|
||||
// Default cap is 1 MB. Override via env wouldn't help inside this
|
||||
// running process (constant was read at module load), so use frames
|
||||
// big enough to exceed it deterministically.
|
||||
const s = fresh();
|
||||
const big = Buffer.alloc(400_000, 0x41); // 400 KB of 'A'
|
||||
appendToRingBuffer(s, big);
|
||||
appendToRingBuffer(s, big);
|
||||
appendToRingBuffer(s, big); // total 1.2 MB — exceeds default cap
|
||||
// Eviction must drop frames until under cap; first 400 KB chunk goes.
|
||||
expect(s.ringBuffer.length).toBeLessThan(3);
|
||||
expect(s.ringBufferBytes).toBeLessThanOrEqual(1024 * 1024);
|
||||
});
|
||||
|
||||
test('keeps at least one frame even when a single frame exceeds the cap', () => {
|
||||
const s = fresh();
|
||||
// 2 MB single frame — bigger than the 1 MB cap. The eviction loop
|
||||
// guards on `ringBuffer.length > 1`, so the single oversized frame
|
||||
// stays. Without that guard, the buffer would empty itself, defeating
|
||||
// the whole point of replay on re-attach.
|
||||
const huge = Buffer.alloc(2 * 1024 * 1024, 0x42);
|
||||
appendToRingBuffer(s, huge);
|
||||
expect(s.ringBuffer.length).toBe(1);
|
||||
expect(s.ringBufferBytes).toBe(huge.length);
|
||||
});
|
||||
|
||||
test('tracks alt-screen enter (CSI ?1049h)', () => {
|
||||
const s = fresh();
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
appendToRingBuffer(s, Buffer.from('plain text'));
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h'));
|
||||
expect(s.altScreenActive).toBe(true);
|
||||
});
|
||||
|
||||
test('tracks alt-screen exit (CSI ?1049l)', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h'));
|
||||
expect(s.altScreenActive).toBe(true);
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049l'));
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
});
|
||||
|
||||
test('trailing state wins when enter + exit appear in one frame', () => {
|
||||
const s = fresh();
|
||||
// Tool call opened alt-screen then closed it inside one render — net
|
||||
// state is back to main screen. lastIndexOf comparison handles this.
|
||||
appendToRingBuffer(s, Buffer.from('start\x1b[?1049hmiddle\x1b[?1049lend'));
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
|
||||
const s2 = fresh();
|
||||
// Reverse order: exited then re-entered — net state alt-screen.
|
||||
appendToRingBuffer(s2, Buffer.from('\x1b[?1049l\x1b[?1049h'));
|
||||
expect(s2.altScreenActive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReplayPayload runtime', () => {
|
||||
test('prepends DECSTR soft reset before ring buffer contents', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('prompt> '));
|
||||
const payload = buildReplayPayload(s).toString('latin1');
|
||||
expect(payload.startsWith('\x1b[!p')).toBe(true);
|
||||
expect(payload.endsWith('prompt> ')).toBe(true);
|
||||
});
|
||||
|
||||
test('re-enters alt-screen when session was in alt-screen at detach', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h tool output '));
|
||||
const payload = buildReplayPayload(s).toString('latin1');
|
||||
// Order: soft reset, alt-screen re-enter, ring buffer.
|
||||
expect(payload.indexOf('\x1b[!p')).toBeLessThan(payload.indexOf('\x1b[?1049h'));
|
||||
expect(payload.indexOf('\x1b[?1049h')).toBeLessThan(payload.indexOf('tool output'));
|
||||
});
|
||||
|
||||
test('omits alt-screen re-enter when session was on main screen', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('regular prompt'));
|
||||
const payload = buildReplayPayload(s).toString('latin1');
|
||||
// Soft reset is present, but alt-screen enter is NOT. Both substrings
|
||||
// are otherwise identical 8 bytes apart in the alphabet, so equal-
|
||||
// substring checks need to be strict.
|
||||
expect(payload).toContain('\x1b[!p');
|
||||
expect(payload).not.toContain('\x1b[?1049h');
|
||||
});
|
||||
|
||||
test('replay buffer length = soft-reset + (optional alt-screen) + ring bytes', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('abc'));
|
||||
appendToRingBuffer(s, Buffer.from('def'));
|
||||
const payload = buildReplayPayload(s);
|
||||
// 4 bytes (DECSTR) + 6 bytes (abc/def) = 10 bytes. No alt-screen.
|
||||
expect(payload.length).toBe(4 + 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lease lifecycle interplay (via pty-session-lease)', () => {
|
||||
// Cross-module behavior: lease + ring buffer are both per-session.
|
||||
// This catches the case where a refactor accidentally couples them.
|
||||
test('lease registry is independent of ring buffer state', async () => {
|
||||
const { mintLease, validateLease, __resetLeases } = await import('../src/pty-session-lease');
|
||||
__resetLeases();
|
||||
const a = mintLease();
|
||||
const b = mintLease();
|
||||
expect(a.sessionId).not.toBe(b.sessionId);
|
||||
const va = validateLease(a.sessionId);
|
||||
const vb = validateLease(b.sessionId);
|
||||
expect(va.ok && vb.ok).toBe(true);
|
||||
if (va.ok && vb.ok) {
|
||||
expect(va.expiresAt).toBe(vb.expiresAt);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 Commit 2 — terminal-agent sessionId routing + eager spawn.
|
||||
//
|
||||
// Live spawn tests would require a real claude binary on PATH and a Bun.serve
|
||||
// listener; both are e2e-tier. These static-grep tripwires defend the load-
|
||||
// bearing protocol changes:
|
||||
// - validTokens carries the sessionId binding (Map, not Set)
|
||||
// - sessionsById index exists for /internal/restart + (Commit 3) re-attach
|
||||
// - /internal/restart is scoped to one sessionId (codex T2 fix)
|
||||
// - {type:"start"} triggers spawn for eager UX after forceRestart
|
||||
// - maybeSpawnPty helper is the single entry point for both spawn paths
|
||||
|
||||
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
||||
|
||||
describe('terminal-agent session routing (v1.44+ Commit 2)', () => {
|
||||
test('1. validTokens is a Map binding token → sessionId', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Pre-Commit 2 was `Set<string>`; the Map carries the sessionId
|
||||
// binding that /internal/restart and (Commit 3) re-attach depend on.
|
||||
expect(src).toMatch(/const validTokens = new Map<string, string \| null>\(\)/);
|
||||
expect(src).not.toMatch(/const validTokens = new Set</);
|
||||
});
|
||||
|
||||
test('2. sessionsById reverse index exists', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/const sessionsById = new Map<string, PtySession>\(\)/);
|
||||
// Populated in open() — required so /internal/restart can find the session.
|
||||
expect(src).toMatch(/if \(sessionId\) sessionsById\.set\(sessionId, session\)/);
|
||||
});
|
||||
|
||||
test('3. /internal/grant binds an optional sessionId to the token', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/grant'", "url.pathname === '/internal/revoke'");
|
||||
expect(block).toContain('validTokens.set(body.token, sid)');
|
||||
expect(block).toContain('body?.sessionId');
|
||||
});
|
||||
|
||||
test('4. /internal/restart is scoped to one sessionId, not dispose-all', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
|
||||
expect(block).toContain('sessionsById.get(sid)');
|
||||
expect(block).toContain('disposeSession(session)');
|
||||
expect(block).toContain('sessionsById.delete(sid)');
|
||||
// Negative: must NOT enumerate all live sessions and dispose them
|
||||
// (codex T2 caught this — pre-spec the route killed every PTY on the
|
||||
// agent, breaking multi-sidebar / pair-agent setups).
|
||||
expect(block).not.toMatch(/for\s*\(\s*const\s+\[?ws/);
|
||||
});
|
||||
|
||||
test('5. WS upgrade surfaces sessionId on ws.data', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toContain('validTokens.get(token) ?? null');
|
||||
expect(src).toMatch(/data:\s*\{\s*cookie:\s*token,\s*sessionId\s*\}/);
|
||||
});
|
||||
|
||||
test('6. eager spawn via {type:"start"} text frame', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
expect(src).toMatch(/msg\?\.type === 'start'/);
|
||||
// Both spawn paths route through the same helper for parity.
|
||||
expect(src).toContain('function maybeSpawnPty(');
|
||||
expect(src).toMatch(/maybeSpawnPty\(ws, session\)/);
|
||||
});
|
||||
|
||||
test('7. close() drops sessionsById entry alongside ws cleanup', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Commit 3 widened the close signature to `close(ws, code, _reason)`
|
||||
// for the detach state machine. Match either shape so test is stable
|
||||
// across the rest of the long-lived-sidebar PR.
|
||||
const i = src.indexOf('close(ws');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const j = src.indexOf('function handleTabState', i);
|
||||
const block = src.slice(i, j);
|
||||
expect(block).toContain('sessionsById.delete(session.sessionId)');
|
||||
});
|
||||
|
||||
test('8. PtySession interface carries the sessionId field', () => {
|
||||
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
||||
// Whole interface — close paren is sufficient.
|
||||
const i = src.indexOf('interface PtySession {');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const j = src.indexOf('\n}', i);
|
||||
const block = src.slice(i, j);
|
||||
expect(block).toContain('sessionId: string | null');
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// v1.44 terminal-agent watchdog — static-grep invariants.
|
||||
//
|
||||
// The watchdog respawns terminal-agent when its PID dies. Live process-tree
|
||||
// tests would require spawning, killing, and observing across two real Bun
|
||||
// processes — slow and flaky in the free tier. These tripwires defend the
|
||||
// load-bearing properties: identity-based liveness check (not name match),
|
||||
// crash-loop guard, gated on ownsTerminalAgent, and cleared on shutdown.
|
||||
|
||||
const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts');
|
||||
const CONTROL_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent-control.ts');
|
||||
|
||||
describe('terminal-agent watchdog (v1.44+)', () => {
|
||||
test('1. spawnTerminalAgent helper exists with PID return type', () => {
|
||||
const src = fs.readFileSync(CONTROL_TS, 'utf-8');
|
||||
expect(src).toMatch(/export function spawnTerminalAgent\(/);
|
||||
// Must clean up prior PID before spawning (no zombies).
|
||||
expect(src).toContain('readAgentRecord(stateDir)');
|
||||
expect(src).toContain('killAgentByRecord(prior');
|
||||
expect(src).toContain('clearAgentRecord(stateDir)');
|
||||
});
|
||||
|
||||
test('2. watchdog is gated on ownsTerminalAgent', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
// Match the comment + the guard. The guard MUST be a positive check;
|
||||
// an inverted check would respawn for embedders and trample their PTY.
|
||||
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
||||
expect(block).toMatch(/if \(ownsTerminalAgent\)/);
|
||||
expect(block).toContain('agentWatchdogInterval = setInterval');
|
||||
});
|
||||
|
||||
test('3. watchdog uses PID liveness, not process name probe', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
||||
// The whole point of the v1.44 watchdog over v1.43- pkill teardown:
|
||||
// identity-based liveness. Slow-but-alive agents must NOT trigger
|
||||
// respawn (split-brain defense).
|
||||
expect(block).toContain('readAgentRecord(stateDir)');
|
||||
expect(block).toContain('isProcessAlive(record.pid)');
|
||||
// Negative: no executable name-based process lookup. Allow the strings
|
||||
// to appear in prose comments (the watchdog doc explains what it
|
||||
// replaces), reject only actual invocations.
|
||||
expect(block).not.toMatch(/spawnSync\s*\(\s*['"]pkill/);
|
||||
expect(block).not.toMatch(/Bun\.spawn\s*\(\s*\[\s*['"]pgrep/);
|
||||
});
|
||||
|
||||
test('4. crash-loop guard with rolling window', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
const block = sliceBetween(src, '─── Terminal-Agent Watchdog', 'Factory-scoped validateAuth');
|
||||
expect(block).toContain('RESPAWN_GUARD_WINDOW_MS = 60_000');
|
||||
expect(block).toContain('RESPAWN_GUARD_MAX = 3');
|
||||
expect(block).toContain('respawnHistory');
|
||||
expect(block).toContain('agentRespawnGuardTripped');
|
||||
// Window pruning: old entries must be evicted before counting toward
|
||||
// the limit. Otherwise a daemon up for a week with one crash a day
|
||||
// would eventually trip the guard.
|
||||
expect(block).toMatch(/respawnHistory\.shift\(\)/);
|
||||
});
|
||||
|
||||
test('5. watchdog interval is cleared on shutdown', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
expect(src).toContain('if (agentWatchdogInterval) clearInterval(agentWatchdogInterval)');
|
||||
});
|
||||
|
||||
test('6. tick interval is env-overridable for tests', () => {
|
||||
const src = fs.readFileSync(SERVER_TS, 'utf-8');
|
||||
expect(src).toContain('GSTACK_AGENT_WATCHDOG_TICK_MS');
|
||||
});
|
||||
|
||||
test('7. CLI cold-start path uses the same spawnTerminalAgent helper', () => {
|
||||
const cli = fs.readFileSync(
|
||||
path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'cli.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
// Otherwise the CLI and watchdog could drift on spawn env/cwd, and
|
||||
// teardown invariants tested against one would silently miss the other.
|
||||
expect(cli).toContain('spawnTerminalAgent({');
|
||||
expect(cli).toContain("from './terminal-agent-control'");
|
||||
});
|
||||
});
|
||||
|
||||
function sliceBetween(source: string, start: string, end: string): string {
|
||||
const i = source.indexOf(start);
|
||||
if (i === -1) throw new Error(`marker not found: ${start}`);
|
||||
const j = source.indexOf(end, i + start.length);
|
||||
if (j === -1) throw new Error(`end marker not found: ${end}`);
|
||||
return source.slice(i, j);
|
||||
}
|
||||
@@ -95,3 +95,35 @@ describe('canDispatchOverTunnel — alias canonicalization', () => {
|
||||
expect(canDispatchOverTunnel('closetab')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canDispatchOverTunnel — --out writes are never tunnel-dispatchable', () => {
|
||||
// `--out` turns an otherwise-readable command into a local-disk WRITE. The
|
||||
// tunnel surface never grants disk-write to remote paired agents, so any
|
||||
// --out invocation must be 403'd even when the bare command is allowlisted.
|
||||
test('bare eval dispatches, but eval --out does not', () => {
|
||||
expect(canDispatchOverTunnel('eval', ['/tmp/x.js'])).toBe(true);
|
||||
expect(canDispatchOverTunnel('eval', ['/tmp/x.js', '--out', '/tmp/o.png'])).toBe(false);
|
||||
});
|
||||
|
||||
test('--out= form is rejected too (no parser-shape bypass)', () => {
|
||||
expect(canDispatchOverTunnel('eval', ['/tmp/x.js', '--out=/tmp/o.png'])).toBe(false);
|
||||
});
|
||||
|
||||
test('--out anywhere in args is caught regardless of ordering', () => {
|
||||
expect(canDispatchOverTunnel('eval', ['--out', '/tmp/o.png', '/tmp/x.js'])).toBe(false);
|
||||
});
|
||||
|
||||
test('args without --out still dispatch', () => {
|
||||
expect(canDispatchOverTunnel('goto', ['https://example.com'])).toBe(true);
|
||||
expect(canDispatchOverTunnel('eval', ['/tmp/x.js'])).toBe(true);
|
||||
});
|
||||
|
||||
test('omitting args preserves the old command-only behavior', () => {
|
||||
expect(canDispatchOverTunnel('eval')).toBe(true);
|
||||
});
|
||||
|
||||
test('a lookalike flag (--output) is NOT treated as --out', () => {
|
||||
// hasOutArg matches '--out' exactly or '--out='; '--output' must not trip it.
|
||||
expect(canDispatchOverTunnel('eval', ['/tmp/x.js', '--output', '/tmp/o'])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user