Merge origin/main into garrytan/browserharness

Resolves 52 conflicts from the merge:

VERSION + CHANGELOG + package.json: kept v1.16.0.0 (next slot above
main's v1.15.0.0). CHANGELOG entry for v1.16.0.0 (browser-skills) sits
above v1.15.0.0 (slim preamble + plan-mode E2E harness) and the rest
of main's history.

TODOS.md: kept browser-skills phases (P1 Phase 2, P2 Phase 3, P2
Phase 4) AND main's new entries (Sidebar Terminal v1.1, Structural
STOP-Ask forcing function P1).

README.md: took main's GBrain section (newer /setup-gbrain story).

browse/src/server.ts: took main's chat-queue refactor (sidebar agent
ripped in favor of interactive PTY) and re-applied browser-skills'
LOCAL_LISTEN_PORT module-level state + daemonPort plumbing through
MetaCommandOpts.

scripts/resolvers/preamble.ts: took main's reorder of AskUserQuestion
Format ahead of model overlay (v1.6.4.0 fix).

scripts/resolvers/preamble/generate-brain-sync-block.ts: took main's
slimmer version (slim preamble v1.15.0.0).

bin/gstack-brain-{init,sync}, bin/gstack-config, test/brain-sync.test.ts:
took main's mature versions (gbrain-sync shipped via #1151).

test/skill-validation.test.ts: took main's known-large-fixtures form +
removed sidebar-agent #584 assertions (file was deleted in main); kept
my Bundled browser-skills frontmatter contract block.

SKILL.md files (37 of them) + golden fixtures: took main's, then ran
`bun run gen:skill-docs --host all` to re-add the new $B skill +
domain-skill + cdp commands to the generated docs.

All 805 tests pass across browser-skills + skill-validation + gen-skill-docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-26 14:24:50 -07:00
167 changed files with 23453 additions and 20217 deletions

View File

@@ -50,19 +50,15 @@ _TEL_START=$(date +%s)
_SESSION_ID="$$-$(date +%s)"
echo "TELEMETRY: ${_TEL:-off}"
echo "TEL_PROMPTED: $_TEL_PROMPTED"
# Writing style verbosity (V1: default = ELI10, terse = tighter V0 prose.
# Read on every skill run so terse mode takes effect without a restart.)
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
# Question tuning (see /plan-tune). Observational only in V1.
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
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
fi
# zsh-compatible: use find instead of glob to avoid NOMATCH error
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
if [ -f "$_PF" ]; then
if [ "$_TEL" != "off" ] && [ -x "~/.claude/skills/gstack/bin/gstack-telemetry-log" ]; then
@@ -72,7 +68,6 @@ for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null
fi
break
done
# Learnings count
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
_LEARN_FILE="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/learnings.jsonl"
if [ -f "$_LEARN_FILE" ]; then
@@ -84,9 +79,7 @@ if [ -f "$_LEARN_FILE" ]; then
else
echo "LEARNINGS: 0"
fi
# Session timeline: record skill start (local-only, never sent anywhere)
~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"browse","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null &
# Check if CLAUDE.md has routing rules
_HAS_ROUTING="no"
if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then
_HAS_ROUTING="yes"
@@ -94,7 +87,6 @@ fi
_ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false")
echo "HAS_ROUTING: $_HAS_ROUTING"
echo "ROUTING_DECLINED: $_ROUTING_DECLINED"
# Vendoring deprecation: detect if CWD has a vendored gstack copy
_VENDORED="no"
if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then
if [ -f ".claude/skills/gstack/VERSION" ] || [ -d ".claude/skills/gstack/.git" ]; then
@@ -103,66 +95,38 @@ if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then
fi
echo "VENDORED_GSTACK: $_VENDORED"
echo "MODEL_OVERLAY: claude"
# Checkpoint mode (explicit = no auto-commit, continuous = WIP commits as you go)
_CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode 2>/dev/null || echo "explicit")
_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"
# Detect spawned session (OpenClaw or other orchestrator)
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
```
If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills AND do not
auto-invoke skills based on conversation context. Only run skills the user explicitly
types (e.g., /qa, /ship). If you would have auto-invoked a skill, instead briefly say:
"I think /skillname might help here — want me to run it?" and wait for confirmation.
The user opted out of proactive behavior.
## Plan Mode Safe Operations
If `SKILL_PREFIX` is `"true"`, the user has namespaced skill names. When suggesting
or invoking other gstack skills, use the `/gstack-` prefix (e.g., `/gstack-qa` instead
of `/qa`, `/gstack-ship` instead of `/ship`). Disk paths are unaffected — always use
`~/.claude/skills/gstack/[skill-name]/SKILL.md` for reading skill files.
In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`codex review`, writes to `~/.gstack/`, writes to the plan file, and `open` for generated artifacts.
## 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 satisfies plan mode's end-of-turn requirement. 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?"
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined).
If output shows `JUST_UPGRADED <from> <to>` AND `SPAWNED_SESSION` is NOT set: tell
the user "Running gstack v{to} (just updated!)" and then check for new features to
surface. For each per-feature marker below, if the marker file is missing AND the
feature is plausibly useful for this user, use AskUserQuestion to let them try it.
Fire once per feature per user, NOT once per upgrade.
If output shows `JUST_UPGRADED <from> <to>`: print "Running gstack v{to} (just updated!)". If `SPAWNED_SESSION` is true, skip feature discovery.
**In spawned sessions (`SPAWNED_SESSION` = "true"): SKIP feature discovery entirely.**
Just print "Running gstack v{to}" and continue. Orchestrators do not want interactive
prompts from sub-sessions.
Feature discovery, max one prompt per session:
- Missing `~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint`: AskUserQuestion for Continuous checkpoint auto-commits. If accepted, run `~/.claude/skills/gstack/bin/gstack-config set checkpoint_mode continuous`. Always touch marker.
- Missing `~/.claude/skills/gstack/.feature-prompted-model-overlay`: inform "Model overlays are active. MODEL_OVERLAY shows the patch." Always touch marker.
**Feature discovery markers and prompts** (one at a time, max one per session):
After upgrade prompts, continue workflow.
1. `~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint`
Prompt: "Continuous checkpoint auto-commits your work as you go with `WIP:` prefix
so you never lose progress to a crash. Local-only by default — doesn't push
anywhere unless you turn that on. Want to try it?"
Options: A) Enable continuous mode, B) Show me first (print the section from
the preamble Continuous Checkpoint Mode), C) Skip.
If A: run `~/.claude/skills/gstack/bin/gstack-config set checkpoint_mode continuous`.
Always: `touch ~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint`
If `WRITING_STYLE_PENDING` is `yes`: ask once about writing style:
2. `~/.claude/skills/gstack/.feature-prompted-model-overlay`
Inform only (no prompt): "Model overlays are active. `MODEL_OVERLAY: {model}`
shown in the preamble output tells you which behavioral patch is applied.
Override with `--model` when regenerating skills (e.g., `bun run gen:skill-docs
--model gpt-5.4`). Default is claude."
Always: `touch ~/.claude/skills/gstack/.feature-prompted-model-overlay`
After handling JUST_UPGRADED (prompts done or skipped), continue with the skill
workflow.
If `WRITING_STYLE_PENDING` is `yes`: You're on the first skill run after upgrading
to gstack v1. Ask the user once about the new default writing style. Use AskUserQuestion:
> v1 prompts = simpler. Technical terms get a one-sentence gloss on first use,
> questions are framed in outcome terms, sentences are shorter.
>
> Keep the new default, or prefer the older tighter prose?
> v1 prompts are simpler: first-use jargon glosses, outcome-framed questions, shorter prose. Keep default or restore terse?
Options:
- A) Keep the new default (recommended — good writing helps everyone)
@@ -177,27 +141,20 @@ rm -f ~/.gstack/.writing-style-prompt-pending
touch ~/.gstack/.writing-style-prompted
```
This only happens once. If `WRITING_STYLE_PENDING` is `no`, skip this entirely.
Skip if `WRITING_STYLE_PENDING` is `no`.
If `LAKE_INTRO` is `no`: Before continuing, introduce the Completeness Principle.
Tell the user: "gstack follows the **Boil the Lake** principle — always do the complete
thing when AI makes the marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean"
Then offer to open the essay in their default browser:
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:
```bash
open https://garryslist.org/posts/boil-the-ocean
touch ~/.gstack/.completeness-intro-seen
```
Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once.
Only run `open` if yes. Always run `touch`.
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled,
ask the user about telemetry. Use AskUserQuestion:
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
> Help gstack get better! Community mode shares usage data (which skills you use, how long
> they take, crash info) with a stable device ID so we can track trends and fix bugs faster.
> No code, file paths, or repo names are ever sent.
> Change anytime with `gstack-config set telemetry off`.
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code, file paths, or repo names.
Options:
- A) Help gstack get better! (recommended)
@@ -205,10 +162,9 @@ Options:
If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry community`
If B: ask a follow-up AskUserQuestion:
If B: ask follow-up:
> How about anonymous mode? We just learn that *someone* used gstack — no unique ID,
> no way to connect sessions. Just a counter that helps us know if anyone's out there.
> Anonymous mode sends only aggregate usage, no unique ID.
Options:
- A) Sure, anonymous is fine
@@ -222,14 +178,11 @@ Always run:
touch ~/.gstack/.telemetry-prompted
```
This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely.
Skip if `TEL_PROMPTED` is `yes`.
If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: After telemetry is handled,
ask the user about proactive behavior. Use AskUserQuestion:
If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: ask once:
> gstack can proactively figure out when you might need a skill while you work —
> like suggesting /qa when you say "does this work?" or /investigate when you hit
> a bug. We recommend keeping this on — it speeds up every part of your workflow.
> Let gstack proactively suggest skills, like /qa for "does this work?" or /investigate for bugs?
Options:
- A) Keep it on (recommended)
@@ -243,7 +196,7 @@ Always run:
touch ~/.gstack/.proactive-prompted
```
This only happens once. If `PROACTIVE_PROMPTED` is `yes`, skip this entirely.
Skip if `PROACTIVE_PROMPTED` is `yes`.
If `HAS_ROUTING` is `no` AND `ROUTING_DECLINED` is `false` AND `PROACTIVE_PROMPTED` is `yes`:
Check if a CLAUDE.md file exists in the project root. If it does not exist, create it.
@@ -251,8 +204,6 @@ Check if a CLAUDE.md file exists in the project root. If it does not exist, crea
Use AskUserQuestion:
> gstack works best when your project's CLAUDE.md includes skill routing rules.
> This tells Claude to use specialized workflows (like /ship, /investigate, /qa)
> instead of answering directly. It's a one-time addition, about 15 lines.
Options:
- A) Add routing rules to CLAUDE.md (recommended)
@@ -264,63 +215,33 @@ If A: Append this section to the end of CLAUDE.md:
## Skill routing
When the user's request matches an available skill, invoke it via the Skill tool. The
skill has multi-step workflows, checklists, and quality gates that produce better
results than an ad-hoc answer. When in doubt, invoke the skill. A false positive is
cheaper than a false negative.
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
Key routing rules:
- Product ideas, "is this worth building", brainstorming → invoke /office-hours
- Strategy, scope, "think bigger", "what should we build" → invoke /plan-ceo-review
- Architecture, "does this design make sense" → invoke /plan-eng-review
- Design system, brand, "how should this look" → invoke /design-consultation
- Design review of a plan → invoke /plan-design-review
- Developer experience of a plan → invoke /plan-devex-review
- "Review everything", full review pipeline → invoke /autoplan
- Bugs, errors, "why is this broken", "wtf", "this doesn't work" → invoke /investigate
- Test the site, find bugs, "does this work" → invoke /qa (or /qa-only for report only)
- Code review, check the diff, "look at my changes" → invoke /review
- Visual polish, design audit, "this looks off" → invoke /design-review
- Developer experience audit, try onboarding → invoke /devex-review
- Ship, deploy, create a PR, "send it" → invoke /ship
- Merge + deploy + verify → invoke /land-and-deploy
- Configure deployment → invoke /setup-deploy
- Post-deploy monitoring → invoke /canary
- Update docs after shipping → invoke /document-release
- Weekly retro, "how'd we do" → invoke /retro
- Second opinion, codex review → invoke /codex
- Safety mode, careful mode, lock it down → invoke /careful or /guard
- Restrict edits to a directory → invoke /freeze or /unfreeze
- Upgrade gstack → invoke /gstack-upgrade
- Save progress, "save my work" → invoke /context-save
- Resume, restore, "where was I" → invoke /context-restore
- Security audit, OWASP, "is this secure" → invoke /cso
- Make a PDF, document, publication → invoke /make-pdf
- Launch real browser for QA → invoke /open-gstack-browser
- Import cookies for authenticated testing → invoke /setup-browser-cookies
- Performance regression, page speed, benchmarks → invoke /benchmark
- Review what gstack has learned → invoke /learn
- Tune question sensitivity → invoke /plan-tune
- Code quality dashboard → invoke /health
- Product ideas/brainstorming → invoke /office-hours
- Strategy/scope → invoke /plan-ceo-review
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
- Ship/deploy/PR → invoke /ship or /land-and-deploy
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
```
Then commit the change: `git add CLAUDE.md && git commit -m "chore: add gstack skill routing rules to CLAUDE.md"`
If B: run `~/.claude/skills/gstack/bin/gstack-config set routing_declined true`
Say "No problem. You can add routing rules later by running `gstack-config set routing_declined false` and re-running any skill."
If B: run `~/.claude/skills/gstack/bin/gstack-config set routing_declined true` and say they can re-enable with `gstack-config set routing_declined false`.
This only happens once per project. If `HAS_ROUTING` is `yes` or `ROUTING_DECLINED` is `true`, skip this entirely.
This only happens once per project. Skip if `HAS_ROUTING` is `yes` or `ROUTING_DECLINED` is `true`.
If `VENDORED_GSTACK` is `yes`: This project has a vendored copy of gstack at
`.claude/skills/gstack/`. Vendoring is deprecated. We will not keep vendored copies
up to date, so this project's gstack will fall behind.
Use AskUserQuestion (one-time per project, check for `~/.gstack/.vendoring-warned-$SLUG` marker):
If `VENDORED_GSTACK` is `yes`, warn once via AskUserQuestion unless `~/.gstack/.vendoring-warned-$SLUG` exists:
> This project has gstack vendored in `.claude/skills/gstack/`. Vendoring is deprecated.
> We won't keep this copy up to date, so you'll fall behind on new features and fixes.
>
> Want to migrate to team mode? It takes about 30 seconds.
> Migrate to team mode?
Options:
- A) Yes, migrate to team mode now
@@ -341,7 +262,7 @@ eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || tru
touch ~/.gstack/.vendoring-warned-${SLUG:-unknown}
```
This only happens once per project. If the marker file exists, skip entirely.
If marker exists, skip.
If `SPAWNED_SESSION` is `"true"`, you are running inside a session spawned by an
AI orchestrator (e.g., OpenClaw). In spawned sessions:
@@ -353,10 +274,6 @@ AI orchestrator (e.g., OpenClaw). In spawned sessions:
## GBrain Sync (skill start)
```bash
# gbrain-sync: drain pending writes, pull once per day. Silent no-op when
# the feature isn't initialized or gbrain_sync_mode is "off". See
# docs/gbrain-sync.md.
_GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
_BRAIN_REMOTE_FILE="$HOME/.gstack-brain-remote.txt"
_BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync"
@@ -364,7 +281,6 @@ _BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config"
_BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get gbrain_sync_mode 2>/dev/null || echo off)
# New-machine hint: URL file present, local .git missing, sync not yet enabled.
if [ -f "$_BRAIN_REMOTE_FILE" ] && [ ! -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" = "off" ]; then
_BRAIN_NEW_URL=$(head -1 "$_BRAIN_REMOTE_FILE" 2>/dev/null | tr -d '[:space:]')
if [ -n "$_BRAIN_NEW_URL" ]; then
@@ -373,9 +289,7 @@ if [ -f "$_BRAIN_REMOTE_FILE" ] && [ ! -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_S
fi
fi
# Active-sync path.
if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
# Once-per-day pull.
_BRAIN_LAST_PULL_FILE="$_GSTACK_HOME/.brain-last-pull"
_BRAIN_NOW=$(date +%s)
_BRAIN_DO_PULL=1
@@ -388,11 +302,9 @@ if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
( cd "$_GSTACK_HOME" && git fetch origin >/dev/null 2>&1 && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" >/dev/null 2>&1 ) || true
echo "$_BRAIN_NOW" > "$_BRAIN_LAST_PULL_FILE"
fi
# Drain pending queue, push.
"$_BRAIN_SYNC_BIN" --once 2>/dev/null || true
fi
# Status line — always emitted, easy to grep.
if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
_BRAIN_QUEUE_DEPTH=0
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
@@ -406,24 +318,16 @@ fi
**Privacy stop-gate (fires ONCE per machine).**
Privacy stop-gate: if output shows `BRAIN_SYNC: off`, `gbrain_sync_mode_prompted` is `false`, and gbrain is on PATH or `gbrain doctor --fast --json` works, ask once:
If the bash output shows `BRAIN_SYNC: off` AND the config value
`gbrain_sync_mode_prompted` is `false` AND gbrain is detected on this host
(either `gbrain doctor --fast --json` succeeds or the `gbrain` binary is in PATH),
fire a one-time privacy gate via AskUserQuestion:
> gstack can publish your session memory (learnings, plans, designs, retros) to a
> private GitHub repo that GBrain indexes across your machines. Higher tiers
> include behavioral data (session timelines, developer profile). How much do you
> want to sync?
> gstack can publish your session memory to a private GitHub repo that GBrain indexes across machines. How much should sync?
Options:
- A) Everything allowlisted (recommended — maximum cross-machine memory)
- B) Only artifacts (plans, designs, retros, learnings) — skip timelines and profile
- C) Decline keep everything local
- A) Everything allowlisted (recommended)
- B) Only artifacts
- C) Decline, keep everything local
After the user answers, run (substituting the chosen value):
After answer:
```bash
# Chosen mode: full | artifacts-only | off
@@ -431,17 +335,9 @@ After the user answers, run (substituting the chosen value):
"$_BRAIN_CONFIG_BIN" set gbrain_sync_mode_prompted true
```
If A or B was chosen AND `~/.gstack/.git` doesn't exist, ask a follow-up:
"Set up the GBrain sync repo now? (runs `gstack-brain-init`)"
- A) Yes, run it now
- B) Show me the command, I'll run it myself
If A/B and `~/.gstack/.git` is missing, ask whether to run `gstack-brain-init`. Do not block the skill.
Do not block the skill. Emit the question, continue the skill workflow. The
next skill run picks up wherever this left off.
**At skill END (before the telemetry block),** run these bash commands to
catch artifact writes (design docs, plans, retros) that skipped the writer
shims, plus drain any still-pending queue entries:
At skill END before telemetry:
```bash
"~/.claude/skills/gstack/bin/gstack-brain-sync" --discover-new 2>/dev/null || true
@@ -469,66 +365,38 @@ equivalents (cat, sed, find, grep). The dedicated tools are cheaper and clearer.
## Voice
**Tone:** direct, concrete, sharp, never corporate, never academic. Sound like a builder, not a consultant. Name the file, the function, the command. No filler, no throat-clearing.
Direct, concrete, builder-to-builder. Name the file, function, command, and user-visible impact. No filler.
**Writing rules:** No em dashes (use commas, periods, "..."). No AI vocabulary (delve, crucial, robust, comprehensive, nuanced, etc.). Short paragraphs. End with what to do.
No em dashes. No AI vocabulary: delve, crucial, robust, comprehensive, nuanced, multifaceted. Never corporate or academic. Short paragraphs. End with what to do.
The user always has context you don't. Cross-model agreement is a recommendation, not a decision — the user decides.
The user has context you do not. Cross-model agreement is a recommendation, not a decision. The user decides.
## Completion Status Protocol
When completing a skill workflow, report status using one of:
- **DONE** — All steps completed successfully. Evidence provided for each claim.
- **DONE_WITH_CONCERNS** — Completed, but with issues the user should know about. List each concern.
- **BLOCKED** — Cannot proceed. State what is blocking and what was tried.
- **NEEDS_CONTEXT** — Missing information required to continue. State exactly what you need.
- **DONE** — completed with evidence.
- **DONE_WITH_CONCERNS** — completed, but list concerns.
- **BLOCKED** — cannot proceed; state blocker and what was tried.
- **NEEDS_CONTEXT** — missing info; state exactly what is needed.
### Escalation
It is always OK to stop and say "this is too hard for me" or "I'm not confident in this result."
Bad work is worse than no work. You will not be penalized for escalating.
- If you have attempted a task 3 times without success, STOP and escalate.
- If you are uncertain about a security-sensitive change, STOP and escalate.
- If the scope of work exceeds what you can verify, STOP and escalate.
Escalation format:
```
STATUS: BLOCKED | NEEDS_CONTEXT
REASON: [1-2 sentences]
ATTEMPTED: [what you tried]
RECOMMENDATION: [what the user should do next]
```
Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope you cannot verify. Format: `STATUS`, `REASON`, `ATTEMPTED`, `RECOMMENDATION`.
## Operational Self-Improvement
Before completing, reflect on this session:
- Did any commands fail unexpectedly?
- Did you take a wrong approach and have to backtrack?
- Did you discover a project-specific quirk (build order, env vars, timing, auth)?
- Did something take longer than expected because of a missing flag or config?
If yes, log an operational learning for future sessions:
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
```bash
~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
```
Replace SKILL_NAME with the current skill name. Only log genuine operational discoveries.
Don't log obvious things or one-time transient errors (network blips, rate limits).
A good test: would knowing this save 5+ minutes in a future session? If yes, log it.
Do not log obvious facts or one-time transient errors.
## Telemetry (run last)
After the skill workflow completes (success, error, or abort), log the telemetry event.
Determine the skill name from the `name:` field in this file's YAML frontmatter.
Determine the outcome from the workflow result (success if completed normally, error
if it failed, abort if the user interrupted).
After workflow completion, log telemetry. Use skill `name:` from frontmatter. OUTCOME is success/error/abort/unknown.
**PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes telemetry to
`~/.gstack/analytics/` (user config directory, not project files). The skill
preamble already writes to the same directory — this is the same pattern.
Skipping this command loses session duration and outcome data.
`~/.gstack/analytics/`, matching preamble analytics writes.
Run this bash:
@@ -550,34 +418,11 @@ if [ "$_TEL" != "off" ] && [ -x ~/.claude/skills/gstack/bin/gstack-telemetry-log
fi
```
Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with
success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used.
If you cannot determine the outcome, use "unknown". The local JSONL always logs. The
remote binary only runs if telemetry is not off and the binary exists.
## Plan Mode Safe Operations
In plan mode, these are always allowed (they inform the plan, don't modify source):
`$B` (browse), `$D` (design), `codex exec`/`codex review`, writes to `~/.gstack/`,
writes to the plan file, `open` for generated artifacts.
## Skill Invocation During Plan Mode
If the user invokes a skill in plan mode, that skill takes precedence over generic plan mode behavior. Treat it as executable instructions, not reference. Follow step
by step. AskUserQuestion calls satisfy plan mode's end-of-turn requirement. At a STOP
point, stop immediately. Do not continue the workflow past a STOP point and do not call ExitPlanMode there. Commands marked "PLAN
MODE EXCEPTION — ALWAYS RUN" execute. Other writes need to be already permitted
above or explicitly exception-marked. Call ExitPlanMode only after the skill
workflow completes — only then call ExitPlanMode (or if the user tells you to cancel the skill or leave plan mode).
Replace `SKILL_NAME`, `OUTCOME`, and `USED_BROWSE` before running.
## Plan Status Footer
In plan mode, before ExitPlanMode: if the plan file lacks a `## GSTACK REVIEW REPORT`
section, run `~/.claude/skills/gstack/bin/gstack-review-read` and append a report.
With JSONL entries (before `---CONFIG---`), format the standard runs/status/findings
table. With `NO_REVIEWS` or empty, append a 5-row placeholder table (CEO/Codex/Eng/
Design/DX Review) with all zeros and verdict "NO REVIEWS YET — run `/autoplan`".
If a richer review report already exists, skip — review skills wrote it.
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).
@@ -962,6 +807,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
| `closetab [id]` | Close tab |
| `newtab [url] [--json]` | Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf). |
| `tab <id>` | Switch to tab |
| `tab-each <command> [args...]` | Run a command on every open tab. Returns JSON with per-tab results. |
| `tabs` | List open tabs |
### Server

View File

@@ -853,7 +853,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
// Delete stale state file
safeUnlinkQuiet(config.stateFile);
console.log('Launching headed Chromium with extension + sidebar agent...');
console.log('Launching headed Chromium with extension + terminal agent...');
try {
// Start server in headed mode with extension auto-loaded
// Use a well-known port so the Chrome extension auto-connects
@@ -882,56 +882,41 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
const status = await resp.text();
console.log(`Connected to real Chrome\n${status}`);
// Auto-start sidebar agent
// __dirname is inside $bunfs in compiled binaries — resolve from execPath instead
let agentScript = path.resolve(__dirname, 'sidebar-agent.ts');
if (!fs.existsSync(agentScript)) {
agentScript = path.resolve(path.dirname(process.execPath), '..', 'src', 'sidebar-agent.ts');
// 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');
}
try {
if (!fs.existsSync(agentScript)) {
throw new Error(`sidebar-agent.ts not found at ${agentScript}`);
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})`);
}
// Clear old agent queue
const agentQueue = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
try {
fs.mkdirSync(path.dirname(agentQueue), { recursive: true, mode: 0o700 });
fs.writeFileSync(agentQueue, '', { mode: 0o600 });
} catch (err: any) {
if (err?.code !== 'EACCES') throw err;
}
// Resolve browse binary path the same way — execPath-relative
let browseBin = path.resolve(__dirname, '..', 'dist', 'browse');
if (!fs.existsSync(browseBin)) {
browseBin = process.execPath; // the compiled binary itself
}
// Kill any existing sidebar-agent processes before starting a new one.
// Old agents have stale auth tokens and will silently fail to relay events,
// causing the server to mark the agent as "hung".
try {
const { spawnSync } = require('child_process');
spawnSync('pkill', ['-f', 'sidebar-agent\\.ts'], { stdio: 'ignore', timeout: 3000 });
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
}
const agentProc = Bun.spawn(['bun', 'run', agentScript], {
cwd: config.projectDir,
env: {
...process.env,
BROWSE_BIN: browseBin,
BROWSE_STATE_FILE: config.stateFile,
BROWSE_SERVER_PORT: String(newState.port),
},
stdio: ['ignore', 'ignore', 'ignore'],
});
agentProc.unref();
console.log(`[browse] Sidebar agent started (PID: ${agentProc.pid})`);
} catch (err: any) {
console.error(`[browse] Sidebar agent failed to start: ${err.message}`);
console.error(`[browse] Run manually: bun run ${agentScript}`);
// Non-fatal: chat still works without the terminal agent.
console.error(`[browse] Terminal agent failed to start: ${err.message}`);
}
} catch (err: any) {
console.error(`[browse] Connect failed: ${err.message}`);

View File

@@ -30,7 +30,7 @@ export const WRITE_COMMANDS = new Set([
]);
export const META_COMMANDS = new Set([
'tabs', 'tab', 'newtab', 'closetab',
'tabs', 'tab', 'tab-each', 'newtab', 'closetab',
'status', 'stop', 'restart',
'screenshot', 'pdf', 'responsive',
'chain', 'diff',
@@ -147,6 +147,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
'tab': { category: 'Tabs', description: 'Switch to tab', usage: 'tab <id>' },
'newtab': { category: 'Tabs', description: 'Open new tab. With --json, returns {"tabId":N,"url":...} for programmatic use (make-pdf).', usage: 'newtab [url] [--json]' },
'closetab':{ category: 'Tabs', description: 'Close tab', usage: 'closetab [id]' },
'tab-each':{ category: 'Tabs', description: 'Run a command on every open tab. Returns JSON with per-tab results.', usage: 'tab-each <command> [args...]' },
// Server
'status': { category: 'Server', description: 'Health check' },
'stop': { category: 'Server', description: 'Shutdown server' },

View File

@@ -289,6 +289,108 @@ export async function handleMetaCommand(
return `Closed tab${id ? ` ${id}` : ''}`;
}
case 'tab-each': {
// Fan out a single command across every open tab. Returns a JSON
// object: { results: [{tabId, url, title, status, output}], total }.
// Restores the originally active tab when done so the user's view
// doesn't shift under them.
//
// Usage: $B tab-each <command> [args...]
// $B tab-each snapshot -i → snapshot every tab
// $B tab-each text → grab clean text from every tab
// $B tab-each goto https://x.y → load the same URL in every tab
if (args.length === 0) {
throw new Error(
'Usage: browse tab-each <command> [args...]\n' +
'Example: browse tab-each snapshot -i'
);
}
const innerRaw = args[0];
const innerName = canonicalizeCommand(innerRaw);
const innerArgs = args.slice(1);
// Scope check the inner command before fanning out, so a single
// permission failure aborts the whole batch instead of partially
// mutating tabs.
if (tokenInfo && tokenInfo.clientId !== 'root' && !checkScope(tokenInfo, innerName)) {
throw new Error(
`tab-each rejected: subcommand "${innerRaw}" not allowed by your token scope (${tokenInfo.scopes.join(', ')}).`
);
}
const tabs = await bm.getTabListWithTitles();
const originalActive = tabs.find(t => t.active)?.id ?? bm.getActiveTabId();
const executeCmd = opts?.executeCommand;
const results: Array<{
tabId: number;
url: string;
title: string;
status: number;
output: string;
}> = [];
try {
for (const tab of tabs) {
// Skip chrome:// internal pages — they aren't useful targets and
// many commands fail outright on them.
if (tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) {
results.push({
tabId: tab.id,
url: tab.url,
title: tab.title || '',
status: 0,
output: 'skipped: internal page',
});
continue;
}
// Switch to the tab. Don't pull focus away — we're a background
// operation; the user shouldn't see the OS window jump.
bm.switchTab(tab.id, { bringToFront: false });
let status = 0;
let output = '';
if (executeCmd) {
const r = await executeCmd(
{ command: innerName, args: innerArgs, tabId: tab.id },
tokenInfo,
);
status = r.status;
output = r.result;
if (status !== 200) {
try { output = JSON.parse(output).error || output; } catch (err: any) { if (!(err instanceof SyntaxError)) throw err; }
}
} else {
// Fallback path (CLI / test harness without a server context).
// We don't recurse through read/write/meta directly here because
// tab-each is only meaningful with the live server; surface a
// clear error.
status = 500;
output = 'tab-each requires the browse server (no executeCommand context)';
}
results.push({
tabId: tab.id,
url: tab.url,
title: tab.title || '',
status,
output,
});
}
} finally {
// Restore the original active tab so the user's view is unchanged.
try { bm.switchTab(originalActive, { bringToFront: false }); } catch {}
}
return JSON.stringify({
command: innerName,
args: innerArgs,
total: results.length,
results,
}, null, 2);
}
// ─── Server Control ────────────────────────────────
case 'status': {
const page = bm.getPage();

View File

@@ -0,0 +1,122 @@
/**
* Session cookie registry for the Terminal sidebar tab's PTY WebSocket.
*
* Why this exists: WebSocket clients in browsers cannot send Authorization
* headers on the upgrade request. The terminal-agent's /ws upgrade therefore
* authenticates via cookie. We never put the PTY token in /health (codex
* outside-voice finding #2: /health already leaks AUTH_TOKEN to any
* localhost caller in headed mode; reusing that path for shell access would
* widen an existing bug). Instead, the extension does an authenticated
* POST /pty-session with the bootstrap AUTH_TOKEN; the server mints a
* short-lived cookie scoped to this terminal session and pushes it to the
* agent via loopback. The browser then carries the cookie automatically on
* the WS upgrade.
*
* Design mirrors `sse-session-cookie.ts` deliberately. Same TTL, same
* scoped-token-must-not-be-valid-as-root invariant, same opportunistic
* pruning. Two registries instead of one because the cookie names are
* different (`gstack_sse` vs `gstack_pty`) and the token spaces must not
* overlap — an SSE-read cookie must never grant PTY access, and vice versa.
*/
import * as crypto from 'crypto';
interface Session {
createdAt: number;
expiresAt: number;
}
const TTL_MS = 30 * 60 * 1000; // 30 minutes — matches SSE cookie
const MAX_SESSIONS = 10_000;
const sessions = new Map<string, Session>();
export const PTY_COOKIE_NAME = 'gstack_pty';
/** Mint a fresh PTY session token. */
export function mintPtySessionToken(): { token: string; expiresAt: number } {
const token = crypto.randomBytes(32).toString('base64url');
const now = Date.now();
const expiresAt = now + TTL_MS;
sessions.set(token, { createdAt: now, expiresAt });
pruneExpired(now);
return { token, expiresAt };
}
/**
* Validate a token. Returns true only if the token exists AND is not expired.
* Lazily removes expired entries; opportunistically prunes a few more on
* every call so the registry stays bounded under reconnect pressure.
*/
export function validatePtySessionToken(token: string | null | undefined): boolean {
if (!token) return false;
const s = sessions.get(token);
if (!s) {
pruneExpired(Date.now());
return false;
}
if (Date.now() > s.expiresAt) {
sessions.delete(token);
pruneExpired(Date.now());
return false;
}
return true;
}
/**
* Drop a session token (called on WS close so a leaked cookie can't be
* replayed against a new PTY).
*/
export function revokePtySessionToken(token: string | null | undefined): void {
if (!token) return;
sessions.delete(token);
}
/** Parse the PTY session token from a Cookie header. */
export function extractPtyCookie(req: Request): string | null {
const cookieHeader = req.headers.get('cookie');
if (!cookieHeader) return null;
for (const part of cookieHeader.split(';')) {
const [name, ...valueParts] = part.trim().split('=');
if (name === PTY_COOKIE_NAME) {
return valueParts.join('=') || null;
}
}
return null;
}
/**
* Build the Set-Cookie header value for the PTY session cookie.
* - HttpOnly: not readable from JS (mitigates XSS exfiltration).
* - SameSite=Strict: not sent on cross-site requests (mitigates CSWSH).
* - Path=/: scope to whole origin so /ws and /pty-session both see it.
* - Max-Age matches the TTL.
*
* Secure is intentionally omitted: the daemon binds to 127.0.0.1 over plain
* HTTP; setting Secure would prevent the browser from ever sending it back.
*/
export function buildPtySetCookie(token: string): string {
const maxAge = Math.floor(TTL_MS / 1000);
return `${PTY_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
}
/** Clear the PTY session cookie. */
export function buildPtyClearCookie(): string {
return `${PTY_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
}
function pruneExpired(now: number): void {
let checked = 0;
for (const [token, session] of sessions) {
if (checked++ >= 20) break;
if (session.expiresAt <= now) sessions.delete(token);
}
while (sessions.size > MAX_SESSIONS) {
const first = sessions.keys().next().value;
if (!first) break;
sessions.delete(first);
}
}
// Test-only reset.
export function __resetPtySessions(): void {
sessions.clear();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,947 +0,0 @@
/**
* Sidebar Agent — polls agent-queue from server, spawns claude -p for each
* message, streams live events back to the server via /sidebar-agent/event.
*
* This runs as a NON-COMPILED bun process because compiled bun binaries
* cannot posix_spawn external executables. The server writes to the queue
* file, this process reads it and spawns claude.
*
* Usage: BROWSE_BIN=/path/to/browse bun run browse/src/sidebar-agent.ts
*/
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { safeUnlink } from './error-handling';
import {
checkCanaryInStructure, logAttempt, hashPayload, extractDomain,
combineVerdict, writeSessionState, readSessionState, THRESHOLDS,
readDecision, clearDecision, excerptForReview,
type LayerSignal,
} from './security';
import {
loadTestsavant, scanPageContent, checkTranscript,
shouldRunTranscriptCheck, getClassifierStatus,
loadDeberta, scanPageContentDeberta,
type ToolCallInput,
} from './security-classifier';
const QUEUE = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
const KILL_FILE = path.join(path.dirname(QUEUE), 'sidebar-agent-kill');
const SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '34567', 10);
const SERVER_URL = `http://127.0.0.1:${SERVER_PORT}`;
const POLL_MS = 200; // 200ms poll — keeps time-to-first-token low
const B = process.env.BROWSE_BIN || path.resolve(__dirname, '../../.claude/skills/gstack/browse/dist/browse');
const CANCEL_DIR = path.join(process.env.HOME || '/tmp', '.gstack');
function cancelFileForTab(tabId: number): string {
return path.join(CANCEL_DIR, `sidebar-agent-cancel-${tabId}`);
}
interface QueueEntry {
prompt: string;
args?: string[];
stateFile?: string;
cwd?: string;
tabId?: number | null;
message?: string | null;
pageUrl?: string | null;
sessionId?: string | null;
ts?: string;
canary?: string; // session-scoped token; leak = prompt injection evidence
}
function isValidQueueEntry(e: unknown): e is QueueEntry {
if (typeof e !== 'object' || e === null) return false;
const obj = e as Record<string, unknown>;
if (typeof obj.prompt !== 'string' || obj.prompt.length === 0) return false;
if (obj.args !== undefined && (!Array.isArray(obj.args) || !obj.args.every(a => typeof a === 'string'))) return false;
if (obj.stateFile !== undefined) {
if (typeof obj.stateFile !== 'string') return false;
if (obj.stateFile.includes('..')) return false;
}
if (obj.cwd !== undefined) {
if (typeof obj.cwd !== 'string') return false;
if (obj.cwd.includes('..')) return false;
}
if (obj.tabId !== undefined && obj.tabId !== null && typeof obj.tabId !== 'number') return false;
if (obj.message !== undefined && obj.message !== null && typeof obj.message !== 'string') return false;
if (obj.pageUrl !== undefined && obj.pageUrl !== null && typeof obj.pageUrl !== 'string') return false;
if (obj.sessionId !== undefined && obj.sessionId !== null && typeof obj.sessionId !== 'string') return false;
if (obj.canary !== undefined && typeof obj.canary !== 'string') return false;
return true;
}
let lastLine = 0;
let authToken: string | null = null;
// Per-tab processing — each tab can run its own agent concurrently
const processingTabs = new Set<number>();
// Active claude subprocesses — keyed by tabId for targeted kill
const activeProcs = new Map<number, ReturnType<typeof spawn>>();
let activeProc: ReturnType<typeof spawn> | null = null;
// Kill-file timestamp last seen — avoids double-kill on same write
let lastKillTs = 0;
// ─── File drop relay ──────────────────────────────────────────
function getGitRoot(): string | null {
try {
const { execSync } = require('child_process');
return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
} catch (err: any) {
console.debug('[sidebar-agent] Not in a git repo:', err.message);
return null;
}
}
function writeToInbox(message: string, pageUrl?: string, sessionId?: string): void {
const gitRoot = getGitRoot();
if (!gitRoot) {
console.error('[sidebar-agent] Cannot write to inbox — not in a git repo');
return;
}
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
const now = new Date();
const timestamp = now.toISOString().replace(/:/g, '-');
const filename = `${timestamp}-observation.json`;
const tmpFile = path.join(inboxDir, `.${filename}.tmp`);
const finalFile = path.join(inboxDir, filename);
const inboxMessage = {
type: 'observation',
timestamp: now.toISOString(),
page: { url: pageUrl || 'unknown', title: '' },
userMessage: message,
sidebarSessionId: sessionId || 'unknown',
};
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2), { mode: 0o600 });
fs.renameSync(tmpFile, finalFile);
console.log(`[sidebar-agent] Wrote inbox message: ${filename}`);
}
// ─── Auth ────────────────────────────────────────────────────────
async function refreshToken(): Promise<string | null> {
// Read token from state file (same-user, mode 0o600) instead of /health
try {
const stateFile = process.env.BROWSE_STATE_FILE ||
path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
authToken = data.token || null;
return authToken;
} catch (err: any) {
console.error('[sidebar-agent] Failed to refresh auth token:', err.message);
return null;
}
}
// ─── Event relay to server ──────────────────────────────────────
async function sendEvent(event: Record<string, any>, tabId?: number): Promise<void> {
if (!authToken) await refreshToken();
if (!authToken) return;
try {
await fetch(`${SERVER_URL}/sidebar-agent/event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ ...event, tabId: tabId ?? null }),
});
} catch (err) {
console.error('[sidebar-agent] Failed to send event:', err);
}
}
// ─── Claude subprocess ──────────────────────────────────────────
function shorten(str: string): string {
return str
.replace(new RegExp(B.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '$B')
.replace(/\/Users\/[^/]+/g, '~')
.replace(/\/conductor\/workspaces\/[^/]+\/[^/]+/g, '')
.replace(/\.claude\/skills\/gstack\//g, '')
.replace(/browse\/dist\/browse/g, '$B');
}
function describeToolCall(tool: string, input: any): string {
if (!input) return '';
// For Bash commands, generate a plain-English description
if (tool === 'Bash' && input.command) {
const cmd = input.command;
// Browse binary commands — the most common case
const browseMatch = cmd.match(/\$B\s+(\w+)|browse[^\s]*\s+(\w+)/);
if (browseMatch) {
const browseCmd = browseMatch[1] || browseMatch[2];
const args = cmd.split(/\s+/).slice(2).join(' ');
switch (browseCmd) {
case 'goto': return `Opening ${args.replace(/['"]/g, '')}`;
case 'snapshot': return args.includes('-i') ? 'Scanning for interactive elements' : args.includes('-D') ? 'Checking what changed' : 'Taking a snapshot of the page';
case 'screenshot': return `Saving screenshot${args ? ` to ${shorten(args)}` : ''}`;
case 'click': return `Clicking ${args}`;
case 'fill': { const parts = args.split(/\s+/); return `Typing "${parts.slice(1).join(' ')}" into ${parts[0]}`; }
case 'text': return 'Reading page text';
case 'html': return args ? `Reading HTML of ${args}` : 'Reading full page HTML';
case 'links': return 'Finding all links on the page';
case 'forms': return 'Looking for forms';
case 'console': return 'Checking browser console for errors';
case 'network': return 'Checking network requests';
case 'url': return 'Checking current URL';
case 'back': return 'Going back';
case 'forward': return 'Going forward';
case 'reload': return 'Reloading the page';
case 'scroll': return args ? `Scrolling to ${args}` : 'Scrolling down';
case 'wait': return `Waiting for ${args}`;
case 'inspect': return args ? `Inspecting CSS of ${args}` : 'Getting CSS for last picked element';
case 'style': return `Changing CSS: ${args}`;
case 'cleanup': return 'Removing page clutter (ads, popups, banners)';
case 'prettyscreenshot': return 'Taking a clean screenshot';
case 'css': return `Checking CSS property: ${args}`;
case 'is': return `Checking if element is ${args}`;
case 'diff': return `Comparing ${args}`;
case 'responsive': return 'Taking screenshots at mobile, tablet, and desktop sizes';
case 'status': return 'Checking browser status';
case 'tabs': return 'Listing open tabs';
case 'focus': return 'Bringing browser to front';
case 'select': return `Selecting option in ${args}`;
case 'hover': return `Hovering over ${args}`;
case 'viewport': return `Setting viewport to ${args}`;
case 'upload': return `Uploading file to ${args.split(/\s+/)[0]}`;
default: return `Running browse ${browseCmd} ${args}`.trim();
}
}
// Non-browse bash commands
if (cmd.includes('git ')) return `Running: ${shorten(cmd)}`;
let short = shorten(cmd);
return short.length > 100 ? short.slice(0, 100) + '…' : short;
}
if (tool === 'Read' && input.file_path) {
// Skip Claude's internal tool-result file reads — they're plumbing, not user-facing
if (input.file_path.includes('/tool-results/') || input.file_path.includes('/.claude/projects/')) return '';
return `Reading ${shorten(input.file_path)}`;
}
if (tool === 'Edit' && input.file_path) return `Editing ${shorten(input.file_path)}`;
if (tool === 'Write' && input.file_path) return `Writing ${shorten(input.file_path)}`;
if (tool === 'Grep' && input.pattern) return `Searching for "${input.pattern}"`;
if (tool === 'Glob' && input.pattern) return `Finding files matching ${input.pattern}`;
try { return shorten(JSON.stringify(input)).slice(0, 80); } catch { return ''; }
}
// Keep the old name as an alias for backward compat
function summarizeToolInput(tool: string, input: any): string {
return describeToolCall(tool, input);
}
/**
* Scan a Claude stream event for the session canary. Returns the channel where
* it leaked, or null if clean. Covers every outbound channel: text blocks,
* text deltas, tool_use arguments (including nested URL/path/command strings),
* and result payloads.
*/
function detectCanaryLeak(event: any, canary: string, buf?: DeltaBuffer): string | null {
if (!canary) return null;
if (event.type === 'assistant' && event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'text' && typeof block.text === 'string' && block.text.includes(canary)) {
return 'assistant_text';
}
if (block.type === 'tool_use' && checkCanaryInStructure(block.input, canary)) {
return `tool_use:${block.name}`;
}
}
}
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
if (checkCanaryInStructure(event.content_block.input, canary)) {
return `tool_use:${event.content_block.name}`;
}
}
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
if (typeof event.delta.text === 'string') {
// Rolling buffer: an attacker can ask Claude to emit the canary split
// across two deltas (e.g., "CANARY-" then "ABCDEF"). A per-delta
// substring check misses this. Concatenate the previous tail with
// this chunk and search, then trim the tail to last canary.length-1
// chars for the next event.
const combined = buf ? buf.text_delta + event.delta.text : event.delta.text;
if (combined.includes(canary)) return 'text_delta';
if (buf) buf.text_delta = combined.slice(-(canary.length - 1));
}
}
if (event.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') {
if (typeof event.delta.partial_json === 'string') {
const combined = buf ? buf.input_json_delta + event.delta.partial_json : event.delta.partial_json;
if (combined.includes(canary)) return 'tool_input_delta';
if (buf) buf.input_json_delta = combined.slice(-(canary.length - 1));
}
}
if (event.type === 'content_block_stop' && buf) {
// Block boundary — reset the rolling buffer so a canary straddling
// two independent tool_use blocks isn't inferred.
buf.text_delta = '';
buf.input_json_delta = '';
}
if (event.type === 'result' && typeof event.result === 'string' && event.result.includes(canary)) {
return 'result';
}
return null;
}
/** Rolling-window tails for delta canary detection. See detectCanaryLeak. */
interface DeltaBuffer {
text_delta: string;
input_json_delta: string;
}
interface CanaryContext {
canary: string;
pageUrl: string;
onLeak: (channel: string) => void;
deltaBuf: DeltaBuffer;
}
interface ToolResultScanContext {
scan: (toolName: string, text: string) => Promise<void>;
}
/**
* Per-tab map of tool_use_id → tool name. Lets the tool_result handler
* know what tool produced the content (Read, Grep, Glob, Bash $B ...) so
* we can tag attack logs with the ingress source.
*/
const toolUseRegistry = new Map<string, { toolName: string; toolInput: unknown }>();
/**
* Extract plain-text content from a tool_result block. The Claude stream
* encodes it as either a string or an array of content blocks (text, image).
* We care about text — images can't carry prompt injection at this layer.
*/
function extractToolResultText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
const parts: string[] = [];
for (const block of content) {
if (block && typeof block === 'object') {
const b = block as Record<string, unknown>;
if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
}
}
return parts.join('\n');
}
/**
* Tools whose outputs should be ML-scanned. Bash/$B outputs already get
* scanned via the page-content flow. Read/Glob/Grep outputs have been
* uncovered — Codex review flagged this gap. Adding coverage here closes it.
*/
const SCANNED_TOOLS = new Set(['Read', 'Grep', 'Glob', 'Bash', 'WebFetch']);
async function handleStreamEvent(event: any, tabId?: number, canaryCtx?: CanaryContext, toolResultScanCtx?: ToolResultScanContext): Promise<void> {
// Canary check runs BEFORE any outbound send — we never want to relay
// a leaked token to the sidepanel UI.
if (canaryCtx) {
const channel = detectCanaryLeak(event, canaryCtx.canary, canaryCtx.deltaBuf);
if (channel) {
canaryCtx.onLeak(channel);
return; // drop the event — never relay content that leaked the canary
}
}
if (event.type === 'system' && event.session_id) {
// Relay claude session ID for --resume support
await sendEvent({ type: 'system', claudeSessionId: event.session_id }, tabId);
}
if (event.type === 'assistant' && event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'tool_use') {
// Register the tool_use so we can correlate tool_results back to
// the originating tool when they arrive in the next user-role message.
if (block.id) toolUseRegistry.set(block.id, { toolName: block.name, toolInput: block.input });
await sendEvent({ type: 'tool_use', tool: block.name, input: summarizeToolInput(block.name, block.input) }, tabId);
} else if (block.type === 'text' && block.text) {
await sendEvent({ type: 'text', text: block.text }, tabId);
}
}
}
// Tool results come back in user-role messages. Content can be a string
// or an array of typed content blocks.
if (event.type === 'user' && event.message?.content) {
for (const block of event.message.content) {
if (block && typeof block === 'object' && block.type === 'tool_result') {
const meta = block.tool_use_id ? toolUseRegistry.get(block.tool_use_id) : null;
const toolName = meta?.toolName ?? 'Unknown';
const text = extractToolResultText(block.content);
// Scan this tool output with the ML classifier if the tool is in
// the SCANNED_TOOLS set and the content is non-trivial.
if (SCANNED_TOOLS.has(toolName) && text.length >= 32 && toolResultScanCtx) {
// Fire-and-forget — never block the stream handler. If BLOCK
// fires, onToolResultBlock handles kill + emit.
toolResultScanCtx.scan(toolName, text).catch(() => {});
}
if (block.tool_use_id) toolUseRegistry.delete(block.tool_use_id);
}
}
}
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
if (event.content_block.id) {
toolUseRegistry.set(event.content_block.id, {
toolName: event.content_block.name,
toolInput: event.content_block.input,
});
}
await sendEvent({ type: 'tool_use', tool: event.content_block.name, input: summarizeToolInput(event.content_block.name, event.content_block.input) }, tabId);
}
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
await sendEvent({ type: 'text_delta', text: event.delta.text }, tabId);
}
// Relay tool results so the sidebar can show what happened
if (event.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') {
// Tool input streaming — skip, we already announced the tool
}
if (event.type === 'result') {
await sendEvent({ type: 'result', text: event.result || '' }, tabId);
}
// Tool result events — summarize and relay
if (event.type === 'tool_result' || (event.type === 'assistant' && event.message?.content)) {
// Tool results come in the next assistant turn — handled above
}
}
/**
* Fire the prompt-injection-detected event to the server. This terminates
* the session from the sidepanel's perspective and renders the canary leak
* banner. Also logs locally (salted hash + domain only) and fires telemetry
* if configured.
*/
async function onCanaryLeaked(params: {
tabId: number;
channel: string;
canary: string;
pageUrl: string;
}): Promise<void> {
const { tabId, channel, canary, pageUrl } = params;
const domain = extractDomain(pageUrl);
console.warn(`[sidebar-agent] CANARY LEAK detected on ${channel} for tab ${tabId} (domain=${domain || 'unknown'})`);
// Local log — salted hash + domain only, never the payload
logAttempt({
ts: new Date().toISOString(),
urlDomain: domain,
payloadHash: hashPayload(canary), // hash the canary, not the payload (which might be leaked content)
confidence: 1.0,
layer: 'canary',
verdict: 'block',
});
// Broadcast to sidepanel so it can render the approved banner
await sendEvent({
type: 'security_event',
verdict: 'block',
reason: 'canary_leaked',
layer: 'canary',
channel,
domain,
}, tabId);
// Also emit agent_error so the sidepanel's existing error surface
// reflects that the session terminated. Keeps old clients working.
await sendEvent({
type: 'agent_error',
error: `Session terminated — prompt injection detected${domain ? ` from ${domain}` : ''}`,
}, tabId);
}
/**
* Pre-spawn ML scan of the user message. If the classifier fires at BLOCK,
* we log the attempt, emit a security_event to the sidepanel, and DO NOT
* spawn claude. Returns true if the scan blocked the session.
*
* Fail-open: any classifier error or degraded state returns false (safe) so
* the sidebar keeps working. The architectural controls (XML framing +
* command allowlist, live in server.ts:554-577) still defend.
*/
async function preSpawnSecurityCheck(entry: QueueEntry): Promise<boolean> {
const { message, canary, pageUrl, tabId } = entry;
if (!message || message.length === 0) return false;
const tid = tabId ?? 0;
// L4: scan the user message for direct injection patterns (TestSavantAI)
// L4c: also scan with DeBERTa-v3 when ensemble is enabled (opt-in)
const [contentSignal, debertaSignal] = await Promise.all([
scanPageContent(message),
scanPageContentDeberta(message),
]);
const signals: LayerSignal[] = [contentSignal, debertaSignal];
// L4b: only bother with Haiku if another layer already lit up at >= LOG_ONLY.
// Saves ~70% of Haiku calls per plan §E1 "gating optimization".
if (shouldRunTranscriptCheck(signals)) {
const transcriptSignal = await checkTranscript({
user_message: message,
tool_calls: [], // no tool calls yet at session start
});
signals.push(transcriptSignal);
}
const result = combineVerdict(signals);
if (result.verdict !== 'block') return false;
// BLOCK verdict. Log + emit + refuse to spawn.
const domain = extractDomain(pageUrl ?? '');
const leaderSignal = signals.reduce((a, b) => (a.confidence > b.confidence ? a : b));
logAttempt({
ts: new Date().toISOString(),
urlDomain: domain,
payloadHash: hashPayload(message),
confidence: result.confidence,
layer: leaderSignal.layer,
verdict: 'block',
});
console.warn(`[sidebar-agent] Pre-spawn BLOCK (${result.reason}) for tab ${tid}, confidence=${result.confidence.toFixed(3)}`);
await sendEvent({
type: 'security_event',
verdict: 'block',
reason: result.reason ?? 'ml_classifier',
layer: leaderSignal.layer,
confidence: result.confidence,
domain,
}, tid);
await sendEvent({
type: 'agent_error',
error: `Session blocked — prompt injection detected${domain ? ` from ${domain}` : ' in your message'}`,
}, tid);
return true;
}
async function askClaude(queueEntry: QueueEntry): Promise<void> {
const { prompt, args, stateFile, cwd, tabId, canary, pageUrl } = queueEntry;
const tid = tabId ?? 0;
processingTabs.add(tid);
await sendEvent({ type: 'agent_start' }, tid);
// Pre-spawn ML scan: if the user message trips the ensemble, refuse to
// spawn claude. Fail-open on classifier errors.
if (await preSpawnSecurityCheck(queueEntry)) {
processingTabs.delete(tid);
return;
}
return new Promise((resolve) => {
// Canary context is set after proc is spawned (needs proc reference for kill).
let canaryCtx: CanaryContext | undefined;
let canaryTriggered = false;
// Use args from queue entry (server sets --model, --allowedTools, prompt framing).
// Fall back to defaults only if queue entry has no args (backward compat).
// Write doesn't expand attack surface beyond what Bash already provides.
// The security boundary is the localhost-only message path, not the tool allowlist.
let claudeArgs = args || ['-p', prompt, '--output-format', 'stream-json', '--verbose',
'--allowedTools', 'Bash,Read,Glob,Grep,Write'];
// Validate cwd exists — queue may reference a stale worktree
let effectiveCwd = cwd || process.cwd();
try { fs.accessSync(effectiveCwd); } catch (err: any) {
console.warn('[sidebar-agent] Worktree path inaccessible, falling back to cwd:', effectiveCwd, err.message);
effectiveCwd = process.cwd();
}
// Clear any stale cancel signal for this tab before starting
const cancelFile = cancelFileForTab(tid);
safeUnlink(cancelFile);
const proc = spawn('claude', claudeArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: effectiveCwd,
env: {
...process.env,
BROWSE_STATE_FILE: stateFile || '',
// Connect to the existing headed browse server, never start a new one.
// BROWSE_PORT tells the CLI which port to check.
// BROWSE_NO_AUTOSTART prevents spawning an invisible headless browser
// if the headed server is down — fail fast with a clear error instead.
BROWSE_PORT: process.env.BROWSE_PORT || '34567',
BROWSE_NO_AUTOSTART: '1',
// Pin this agent to its tab — prevents cross-tab interference
// when multiple agents run simultaneously
BROWSE_TAB: String(tid),
},
});
// Track active procs so kill-file polling can terminate them
activeProcs.set(tid, proc);
activeProc = proc;
proc.stdin.end();
// Now that proc exists, set up the canary-leak handler. It fires at most
// once; on fire we kill the subprocess, emit security_event + agent_error,
// and let the normal close handler resolve the promise.
if (canary) {
canaryCtx = {
canary,
pageUrl: pageUrl ?? '',
deltaBuf: { text_delta: '', input_json_delta: '' },
onLeak: (channel: string) => {
if (canaryTriggered) return;
canaryTriggered = true;
onCanaryLeaked({ tabId: tid, channel, canary, pageUrl: pageUrl ?? '' });
try { proc.kill('SIGTERM'); } catch (err: any) { if (err?.code !== 'ESRCH') throw err; }
setTimeout(() => {
try { proc.kill('SIGKILL'); } catch (err: any) { if (err?.code !== 'ESRCH') throw err; }
}, 2000);
},
};
}
// Tool-result ML scan context. Addresses the Codex review gap: Read,
// Grep, Glob, and WebFetch outputs enter Claude's context without
// passing through the Bash $B pipeline that content-security.ts
// already wraps. Scan them here.
let toolResultBlockFired = false;
const toolResultScanCtx: ToolResultScanContext = {
scan: async (toolName: string, text: string) => {
if (toolResultBlockFired) return;
// Parallel L4 + L4c ensemble scan (DeBERTa no-op when disabled).
// We run L4/L4c AND Haiku in parallel on tool outputs regardless of
// L4's score, because BrowseSafe-Bench shows L4 (TestSavantAI) has
// low recall on browser-agent-specific attacks (~15% at v1). Gating
// Haiku on L4 meant our best signal almost never ran. The cost is
// ~$0.002 + ~300ms per tool output, bounded by the Haiku timeout
// and offset by Haiku actually seeing the real attack context.
//
// Haiku only runs when the Claude CLI is available (checkHaikuAvailable
// caches the probe). In environments without it, the call returns a
// degraded signal and the verdict falls back to L4 alone.
const [contentSignal, debertaSignal, transcriptSignal] = await Promise.all([
scanPageContent(text),
scanPageContentDeberta(text),
checkTranscript({
user_message: queueEntry.message ?? '',
tool_calls: [{ tool_name: toolName, tool_input: {} }],
tool_output: text,
}),
]);
const signals: LayerSignal[] = [contentSignal, debertaSignal, transcriptSignal];
const result = combineVerdict(signals, { toolOutput: true });
if (result.verdict !== 'block') return;
toolResultBlockFired = true;
const domain = extractDomain(pageUrl ?? '');
const payloadHash = hashPayload(text.slice(0, 4096));
// Log pending — if the user overrides, we'll update via a separate
// log line. The attempts.jsonl is append-only so both entries survive.
logAttempt({
ts: new Date().toISOString(),
urlDomain: domain,
payloadHash,
confidence: result.confidence,
layer: 'testsavant_content',
verdict: 'block',
});
console.warn(`[sidebar-agent] Tool-result BLOCK on ${toolName} for tab ${tid} (confidence=${result.confidence.toFixed(3)}) — awaiting user decision`);
// Surface a REVIEWABLE block event. Sidepanel renders the suspected
// text + layer scores + [Allow and continue] / [Block session] buttons.
// The user has 60s to decide; default is BLOCK (safe fallback).
const layerScores = signals
.filter((s) => s.confidence > 0)
.map((s) => ({ layer: s.layer, confidence: s.confidence }));
await sendEvent({
type: 'security_event',
verdict: 'block',
reason: 'tool_result_ml',
layer: 'testsavant_content',
confidence: result.confidence,
domain,
tool: toolName,
reviewable: true,
suspected_text: excerptForReview(text),
signals: layerScores,
}, tid);
// Poll for the user's decision. Default to BLOCK on timeout.
const REVIEW_TIMEOUT_MS = 60_000;
const POLL_MS = 500;
clearDecision(tid); // clear any stale decision from a prior session
const deadline = Date.now() + REVIEW_TIMEOUT_MS;
let decision: 'allow' | 'block' = 'block';
let decisionReason = 'timeout';
while (Date.now() < deadline) {
const rec = readDecision(tid);
if (rec?.decision === 'allow' || rec?.decision === 'block') {
decision = rec.decision;
decisionReason = rec.reason ?? 'user';
break;
}
await new Promise((r) => setTimeout(r, POLL_MS));
}
clearDecision(tid);
if (decision === 'allow') {
// User overrode. Log the override so the audit trail captures it.
// toolResultBlockFired stays true so we don't re-prompt within the
// same message — one override per BLOCK event.
logAttempt({
ts: new Date().toISOString(),
urlDomain: domain,
payloadHash,
confidence: result.confidence,
layer: 'testsavant_content',
verdict: 'user_overrode',
});
await sendEvent({
type: 'security_event',
verdict: 'user_overrode',
reason: 'tool_result_ml',
layer: 'testsavant_content',
confidence: result.confidence,
domain,
tool: toolName,
}, tid);
console.warn(`[sidebar-agent] Tab ${tid}: user overrode BLOCK — session continues`);
// Let the block stay consumed; reset the flag so subsequent tool
// results get scanned fresh.
toolResultBlockFired = false;
return;
}
// User chose BLOCK (or timed out). Kill the session as before.
await sendEvent({
type: 'agent_error',
error: `Session terminated — prompt injection detected in ${toolName} output${decisionReason === 'timeout' ? ' (review timeout)' : ''}`,
}, tid);
try { proc.kill('SIGTERM'); } catch (err: any) { if (err?.code !== 'ESRCH') throw err; }
setTimeout(() => {
try { proc.kill('SIGKILL'); } catch (err: any) { if (err?.code !== 'ESRCH') throw err; }
}, 2000);
},
};
// Poll for per-tab cancel signal from server's killAgent()
const cancelCheck = setInterval(() => {
try {
if (fs.existsSync(cancelFile)) {
console.log(`[sidebar-agent] Cancel signal received for tab ${tid} — killing claude subprocess`);
try { proc.kill('SIGTERM'); } catch (err: any) { if (err?.code !== 'ESRCH') throw err; }
setTimeout(() => { try { proc.kill('SIGKILL'); } catch (err: any) { if (err?.code !== 'ESRCH') throw err; } }, 3000);
fs.unlinkSync(cancelFile);
clearInterval(cancelCheck);
}
} catch (err: any) { if (err?.code !== 'ENOENT') throw err; }
}, 500);
let buffer = '';
proc.stdout.on('data', (data: Buffer) => {
buffer += data.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try { handleStreamEvent(JSON.parse(line), tid, canaryCtx, toolResultScanCtx); } catch (err: any) {
console.error(`[sidebar-agent] Tab ${tid}: Failed to parse stream line:`, line.slice(0, 100), err.message);
}
}
});
let stderrBuffer = '';
proc.stderr.on('data', (data: Buffer) => {
stderrBuffer += data.toString();
});
proc.on('close', (code) => {
clearInterval(cancelCheck);
activeProc = null;
activeProcs.delete(tid);
if (buffer.trim()) {
try { handleStreamEvent(JSON.parse(buffer), tid, canaryCtx, toolResultScanCtx); } catch (err: any) {
console.error(`[sidebar-agent] Tab ${tid}: Failed to parse final buffer:`, buffer.slice(0, 100), err.message);
}
}
const doneEvent: Record<string, any> = { type: 'agent_done' };
if (code !== 0 && stderrBuffer.trim()) {
doneEvent.stderr = stderrBuffer.trim().slice(-500);
}
sendEvent(doneEvent, tid).then(() => {
processingTabs.delete(tid);
resolve();
});
});
proc.on('error', (err) => {
clearInterval(cancelCheck);
activeProc = null;
const errorMsg = stderrBuffer.trim()
? `${err.message}\nstderr: ${stderrBuffer.trim().slice(-500)}`
: err.message;
sendEvent({ type: 'agent_error', error: errorMsg }, tid).then(() => {
processingTabs.delete(tid);
resolve();
});
});
// Timeout (default 300s / 5 min — multi-page tasks need time)
const timeoutMs = parseInt(process.env.SIDEBAR_AGENT_TIMEOUT || '300000', 10);
setTimeout(() => {
try { proc.kill('SIGTERM'); } catch (killErr: any) {
console.warn(`[sidebar-agent] Tab ${tid}: Failed to kill timed-out process:`, killErr.message);
}
setTimeout(() => { try { proc.kill('SIGKILL'); } catch (err: any) { if (err?.code !== 'ESRCH') throw err; } }, 3000);
const timeoutMsg = stderrBuffer.trim()
? `Timed out after ${timeoutMs / 1000}s\nstderr: ${stderrBuffer.trim().slice(-500)}`
: `Timed out after ${timeoutMs / 1000}s`;
sendEvent({ type: 'agent_error', error: timeoutMsg }, tid).then(() => {
processingTabs.delete(tid);
resolve();
});
}, timeoutMs);
});
}
// ─── Poll loop ───────────────────────────────────────────────────
function countLines(): number {
try {
return fs.readFileSync(QUEUE, 'utf-8').split('\n').filter(Boolean).length;
} catch (err: any) {
console.error('[sidebar-agent] Failed to read queue file:', err.message);
return 0;
}
}
function readLine(n: number): string | null {
try {
const lines = fs.readFileSync(QUEUE, 'utf-8').split('\n').filter(Boolean);
return lines[n - 1] || null;
} catch (err: any) {
console.error(`[sidebar-agent] Failed to read queue line ${n}:`, err.message);
return null;
}
}
async function poll() {
const current = countLines();
if (current <= lastLine) return;
while (lastLine < current) {
lastLine++;
const line = readLine(lastLine);
if (!line) continue;
let parsed: unknown;
try { parsed = JSON.parse(line); } catch (err: any) {
console.warn(`[sidebar-agent] Skipping malformed queue entry at line ${lastLine}:`, line.slice(0, 80), err.message);
continue;
}
if (!isValidQueueEntry(parsed)) {
console.warn(`[sidebar-agent] Skipping invalid queue entry at line ${lastLine}: failed schema validation`);
continue;
}
const entry = parsed;
const tid = entry.tabId ?? 0;
// Skip if this tab already has an agent running — server queues per-tab
if (processingTabs.has(tid)) continue;
console.log(`[sidebar-agent] Processing tab ${tid}: "${entry.message}"`);
// Write to inbox so workspace agent can pick it up
writeToInbox(entry.message || entry.prompt, entry.pageUrl, entry.sessionId);
// Fire and forget — each tab's agent runs concurrently
askClaude(entry).catch((err) => {
console.error(`[sidebar-agent] Error on tab ${tid}:`, err);
sendEvent({ type: 'agent_error', error: String(err) }, tid);
});
}
}
// ─── Main ────────────────────────────────────────────────────────
function pollKillFile(): void {
try {
const stat = fs.statSync(KILL_FILE);
const mtime = stat.mtimeMs;
if (mtime > lastKillTs) {
lastKillTs = mtime;
if (activeProcs.size > 0) {
console.log(`[sidebar-agent] Kill signal received — terminating ${activeProcs.size} active agent(s)`);
for (const [tid, proc] of activeProcs) {
try { proc.kill('SIGTERM'); } catch (err: any) { if (err?.code !== 'ESRCH') throw err; }
setTimeout(() => { try { proc.kill('SIGKILL'); } catch (err: any) { if (err?.code !== 'ESRCH') throw err; } }, 2000);
processingTabs.delete(tid);
}
activeProcs.clear();
}
}
} catch {
// Kill file doesn't exist yet — normal state
}
}
async function main() {
const dir = path.dirname(QUEUE);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
if (!fs.existsSync(QUEUE)) fs.writeFileSync(QUEUE, '', { mode: 0o600 });
try { fs.chmodSync(QUEUE, 0o600); } catch (err: any) { if (err?.code !== 'ENOENT') throw err; }
lastLine = countLines();
await refreshToken();
console.log(`[sidebar-agent] Started. Watching ${QUEUE} from line ${lastLine}`);
console.log(`[sidebar-agent] Server: ${SERVER_URL}`);
console.log(`[sidebar-agent] Browse binary: ${B}`);
// If GSTACK_SECURITY_ENSEMBLE=deberta is set, also warm the DeBERTa-v3
// ensemble classifier. Fire-and-forget alongside TestSavantAI — they
// warm in parallel. No-op when the env var is unset.
loadDeberta((msg) => console.log(`[security-classifier] ${msg}`))
.catch((err) => console.warn('[sidebar-agent] DeBERTa warmup failed:', err?.message));
// Warm up the ML classifier in the background. First call triggers a 112MB
// download (~30s on average broadband). Non-blocking — the sidebar stays
// functional on cold start; classifier just reports 'off' until warmed.
//
// On warmup completion (success or failure), write the classifier status to
// ~/.gstack/security/session-state.json so server.ts's /health endpoint can
// report it to the sidepanel for shield icon rendering.
loadTestsavant((msg) => console.log(`[security-classifier] ${msg}`))
.then(() => {
const s = getClassifierStatus();
console.log(`[sidebar-agent] Classifier warmup complete: ${JSON.stringify(s)}`);
const existing = readSessionState();
writeSessionState({
sessionId: existing?.sessionId ?? String(process.pid),
canary: existing?.canary ?? '',
warnedDomains: existing?.warnedDomains ?? [],
classifierStatus: s,
lastUpdated: new Date().toISOString(),
});
})
.catch((err) => console.warn('[sidebar-agent] Classifier warmup failed (degraded mode):', err?.message));
setInterval(poll, POLL_MS);
setInterval(pollKillFile, POLL_MS);
}
main().catch(console.error);

View File

@@ -0,0 +1,556 @@
/**
* Terminal Agent — PTY-backed Claude Code terminal for the gstack browser
* sidebar. Translates the phoenix gbrowser PTY (cmd/gbd/terminal.go) into
* Bun, with a few changes informed by codex's outside-voice review:
*
* - Lives in a separate non-compiled bun process from sidebar-agent.ts so
* a bug in WS framing or PTY cleanup can't take down the chat path.
* - Binds 127.0.0.1 only — never on the dual-listener tunnel surface.
* - Origin validation on the WS upgrade is REQUIRED (not defense-in-depth)
* because a localhost shell WS is a real cross-site WebSocket-hijacking
* target.
* - Cookie-based auth via /internal/grant from the parent server, not a
* token in /health.
* - Lazy spawn: claude PTY is not spawned until the WS receives its first
* data frame. Sidebar opens that never type don't burn a claude session.
* - PTY dies with WS close (one PTY per WS). v1.1 may add session
* survival; for v1 we match phoenix's lifecycle.
*
* The PTY uses Bun's `terminal:` spawn option (verified at impl time on
* Bun 1.3.10): pass cols/rows + a data callback; write input via
* `proc.terminal.write(buf)`; resize via `proc.terminal.resize(cols, rows)`.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { safeUnlink } from './error-handling';
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
// In-memory cookie token registry. Parent posts /internal/grant after
// /pty-session; we validate WS cookies against this set.
const validTokens = new Set<string>();
// 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
// the listener loop.
process.on('uncaughtException', (err) => {
console.error('[terminal-agent] uncaughtException:', err);
});
process.on('unhandledRejection', (reason) => {
console.error('[terminal-agent] unhandledRejection:', reason);
});
interface PtySession {
proc: any | null; // Bun.Subprocess once spawned
cols: number;
rows: number;
cookie: string;
spawned: boolean;
}
const sessions = new WeakMap<any, PtySession>(); // ws -> session
/** Find claude on PATH. */
function findClaude(): string | null {
// Test-only override. Lets the integration tests spawn /bin/bash instead
// of requiring claude to be installed on every CI runner. NEVER read in
// production (sidebar UI). Documented in browse/test/terminal-agent-integration.test.ts.
const override = process.env.BROWSE_TERMINAL_BINARY;
if (override && fs.existsSync(override)) return override;
// Bun.which is sync and respects PATH. Falls back to a small list of
// common install locations if PATH is stripped (e.g., launched from
// Conductor with a minimal env).
const which = (Bun as any).which?.('claude');
if (which) return which;
const candidates = [
'/opt/homebrew/bin/claude',
'/usr/local/bin/claude',
`${process.env.HOME}/.local/bin/claude`,
`${process.env.HOME}/.bun/bin/claude`,
`${process.env.HOME}/.npm-global/bin/claude`,
];
for (const c of candidates) {
try { fs.accessSync(c, fs.constants.X_OK); return c; } catch {}
}
return null;
}
/** Probe + persist claude availability for the bootstrap card. */
function writeClaudeAvailable(): void {
const stateDir = path.dirname(STATE_FILE);
try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {}
const found = findClaude();
const status = {
available: !!found,
path: found || undefined,
install_url: 'https://docs.anthropic.com/en/docs/claude-code',
checked_at: new Date().toISOString(),
};
const target = path.join(stateDir, 'claude-available.json');
const tmp = path.join(stateDir, `.tmp-claude-${process.pid}`);
try {
fs.writeFileSync(tmp, JSON.stringify(status, null, 2), { mode: 0o600 });
fs.renameSync(tmp, target);
} catch {
safeUnlink(tmp);
}
}
/**
* System-prompt hint passed to claude via --append-system-prompt. Tells
* claude what tab-awareness affordances exist in this session so it
* doesn't have to discover them by trial. The user can override anything
* here just by saying so — system prompt is a soft hint, not a contract.
*
* Two paths claude has:
* 1. Read live state from <stateDir>/tabs.json + active-tab.json
* (updated continuously by the gstack browser extension).
* 2. Run $B tab, $B tabs, $B tab-each <command> to act on tabs. The
* tab-each helper fans a single command across every open tab and
* returns per-tab results as JSON.
*/
function buildTabAwarenessHint(stateDir: string): string {
const tabsFile = path.join(stateDir, 'tabs.json');
const activeFile = path.join(stateDir, 'active-tab.json');
return [
'You are running inside the gstack browser sidebar with live access to the user\'s browser tabs.',
'',
'Tab state files (kept fresh automatically by the extension):',
` ${tabsFile} — all open tabs (id, url, title, active, pinned)`,
` ${activeFile} — the currently active tab`,
'Read these any time the user asks about "tabs", "the current page", or anything multi-tab. Do NOT shell out to $B tabs just to learn what\'s open — read the file.',
'',
'Tab manipulation commands (via $B):',
' $B tab <id> — switch to a tab',
' $B newtab [url] — open a new tab',
' $B closetab [id] — close a tab (current if no id)',
' $B tab-each <command> — fan out a command across every tab; returns JSON results',
'',
'When the user asks for multi-tab work, prefer $B tab-each. Examples:',
' $B tab-each snapshot -i — grab a snapshot from every tab',
' $B tab-each text — pull clean text from every tab',
' $B tab-each title — list every tab\'s title',
'',
'You\'re in a real terminal with a real PTY — slash commands, /resume, ANSI colors all work as in a normal claude session.',
].join('\n');
}
/** Spawn claude in a PTY. Returns null if claude not on PATH. */
function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void) {
const claudePath = findClaude();
if (!claudePath) return null;
// Match phoenix env so claude knows which browse server to talk to and
// doesn't try to autostart its own. BROWSE_HEADED=1 keeps the existing
// headed-mode browser; BROWSE_NO_AUTOSTART prevents claude's gstack
// tooling from racing to spawn another server.
const env: Record<string, string> = {
...process.env as any,
BROWSE_PORT: String(BROWSE_SERVER_PORT),
BROWSE_STATE_FILE: STATE_FILE,
BROWSE_NO_AUTOSTART: '1',
BROWSE_HEADED: '1',
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
};
// --append-system-prompt is the right injection surface (per `claude --help`):
// it gets appended to the model's system prompt, so claude treats this as
// contextual guidance, not a user message. Don't use a leading PTY write
// for this — that would show up as if the user typed the hint, polluting
// the visible transcript.
const stateDir = path.dirname(STATE_FILE);
const tabHint = buildTabAwarenessHint(stateDir);
const proc = (Bun as any).spawn([claudePath, '--append-system-prompt', tabHint], {
terminal: {
rows,
cols,
data(_terminal: any, chunk: Buffer) { onData(chunk); },
},
env,
});
return proc;
}
/** Cleanup a PTY session: SIGINT, then SIGKILL after 3s. */
function disposeSession(session: PtySession): void {
try { session.proc?.terminal?.close?.(); } catch {}
if (session.proc?.pid) {
try { session.proc.kill?.('SIGINT'); } catch {}
setTimeout(() => {
try {
if (session.proc && !session.proc.killed) session.proc.kill?.('SIGKILL');
} catch {}
}, 3000);
}
session.proc = null;
session.spawned = false;
}
/**
* Build the HTTP server. Two routes:
* POST /internal/grant — parent server pushes a fresh cookie token
* GET /ws — extension upgrades to WebSocket (PTY transport)
*
* Everything else returns 404. The listener binds 127.0.0.1 only.
*/
function buildServer() {
return Bun.serve({
hostname: '127.0.0.1',
port: 0,
idleTimeout: 0, // PTY connections are long-lived; default idleTimeout would kill them
fetch(req, server) {
const url = new URL(req.url);
// /internal/grant — loopback-only handshake from parent server.
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) => {
if (typeof body?.token === 'string' && body.token.length > 16) {
validTokens.add(body.token);
}
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) => {
if (typeof body?.token === 'string') validTokens.delete(body.token);
return new Response('ok');
}).catch(() => new Response('bad', { status: 400 }));
}
// /claude-available — bootstrap card hits this when user clicks "I installed it".
if (url.pathname === '/claude-available' && req.method === 'GET') {
writeClaudeAvailable();
const found = findClaude();
return new Response(JSON.stringify({ available: !!found, path: found }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// /ws — WebSocket upgrade. CRITICAL gates:
// (1) Origin must be chrome-extension://<id>. Cross-site WS hijacking
// defense — required, not optional.
// (2) Token must be in validTokens. We accept the token via two
// transports for compatibility:
// - Sec-WebSocket-Protocol (preferred for browsers — the only
// auth header settable from the browser WebSocket API)
// - Cookie gstack_pty (works for non-browser callers and
// same-port browser callers; doesn't survive the cross-port
// jump from server.ts:34567 to the agent's random port
// when SameSite=Strict is set)
// Either path works; both verify against the same in-memory
// validTokens Set, populated by the parent server's
// authenticated /pty-session → /internal/grant chain.
if (url.pathname === '/ws') {
const origin = req.headers.get('origin') || '';
const isExtensionOrigin = origin.startsWith('chrome-extension://');
if (!isExtensionOrigin) {
return new Response('forbidden origin', { status: 403 });
}
if (EXTENSION_ID && origin !== `chrome-extension://${EXTENSION_ID}`) {
return new Response('forbidden origin', { status: 403 });
}
// Try Sec-WebSocket-Protocol first. Format: a single token, possibly
// with a `gstack-pty.` prefix (which we strip). Browsers send a
// comma-separated list when multiple were requested; we pick the
// first that matches a known token.
const protoHeader = req.headers.get('sec-websocket-protocol') || '';
let token: string | null = null;
let acceptedProtocol: string | null = null;
for (const raw of protoHeader.split(',').map(s => s.trim()).filter(Boolean)) {
const candidate = raw.startsWith('gstack-pty.') ? raw.slice('gstack-pty.'.length) : raw;
if (validTokens.has(candidate)) {
token = candidate;
acceptedProtocol = raw;
break;
}
}
// Fallback: Cookie gstack_pty (legacy / non-browser callers).
if (!token) {
const cookieHeader = req.headers.get('cookie') || '';
for (const part of cookieHeader.split(';')) {
const [name, ...rest] = part.trim().split('=');
if (name === 'gstack_pty') {
const candidate = rest.join('=') || null;
if (candidate && validTokens.has(candidate)) {
token = candidate;
}
break;
}
}
}
if (!token) {
return new Response('unauthorized', { status: 401 });
}
const upgraded = server.upgrade(req, {
data: { cookie: token },
// 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
// the browser closes the connection immediately.
...(acceptedProtocol ? { headers: { 'Sec-WebSocket-Protocol': acceptedProtocol } } : {}),
});
return upgraded ? undefined : new Response('upgrade failed', { status: 500 });
}
return new Response('not found', { status: 404 });
},
websocket: {
message(ws, raw) {
let session = sessions.get(ws);
if (!session) {
session = {
proc: null,
cols: 80,
rows: 24,
cookie: (ws.data as any)?.cookie || '',
spawned: false,
};
sessions.set(ws, 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.
if (typeof raw === 'string') {
let msg: any;
try { msg = JSON.parse(raw); } catch { return; }
if (msg?.type === 'resize') {
const cols = Math.max(2, Math.floor(Number(msg.cols) || 80));
const rows = Math.max(2, Math.floor(Number(msg.rows) || 24));
session.cols = cols;
session.rows = rows;
try { session.proc?.terminal?.resize?.(cols, rows); } catch {}
return;
}
if (msg?.type === 'tabSwitch') {
handleTabSwitch(msg);
return;
}
if (msg?.type === 'tabState') {
handleTabState(msg);
return;
}
// Unknown text frame — ignore.
return;
}
// Binary input. Lazy-spawn claude on the first byte.
if (!session.spawned) {
session.spawned = true;
const proc = spawnClaude(session.cols, session.rows, (chunk) => {
try { ws.sendBinary(chunk); } 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 {}
});
}
try {
// raw is a Uint8Array; Bun.Terminal.write accepts string|Buffer.
// Convert to Buffer for safety.
session.proc?.terminal?.write?.(Buffer.from(raw as Uint8Array));
} catch (err) {
console.error('[terminal-agent] terminal.write failed:', err);
}
},
close(ws) {
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);
}
},
},
});
}
/**
* Tab-switch helper: write the active tab to a state file (claude reads it)
* and notify the parent server so its activeTabId stays synced. Skips
* chrome:// and chrome-extension:// internal pages.
*/
/**
* Live tab snapshot. Writes <stateDir>/tabs.json (full list) and updates
* <stateDir>/active-tab.json (current active). claude can read these any
* time without invoking $B tabs — saves a round-trip when the model just
* needs to check the landscape before deciding what to do.
*/
function handleTabState(msg: {
active?: { tabId?: number; url?: string; title?: string } | null;
tabs?: Array<{ tabId?: number; url?: string; title?: string; active?: boolean; windowId?: number; pinned?: boolean; audible?: boolean }>;
reason?: string;
}): void {
const stateDir = path.dirname(STATE_FILE);
try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {}
// tabs.json — full list
if (Array.isArray(msg.tabs)) {
const payload = {
updatedAt: new Date().toISOString(),
reason: msg.reason || 'unknown',
tabs: msg.tabs.map(t => ({
tabId: t.tabId ?? null,
url: t.url || '',
title: t.title || '',
active: !!t.active,
windowId: t.windowId ?? null,
pinned: !!t.pinned,
audible: !!t.audible,
})),
};
const target = path.join(stateDir, 'tabs.json');
const tmp = path.join(stateDir, `.tmp-tabs-${process.pid}`);
try {
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
fs.renameSync(tmp, target);
} catch {
safeUnlink(tmp);
}
}
// active-tab.json — single active tab. Skip chrome-internal pages so
// claude doesn't see chrome:// or chrome-extension:// URLs as
// "current target."
const active = msg.active;
if (active && active.url && !active.url.startsWith('chrome://') && !active.url.startsWith('chrome-extension://')) {
const ctxFile = path.join(stateDir, 'active-tab.json');
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
try {
fs.writeFileSync(tmp, JSON.stringify({
tabId: active.tabId ?? null,
url: active.url,
title: active.title ?? '',
}), { mode: 0o600 });
fs.renameSync(tmp, ctxFile);
} catch {
safeUnlink(tmp);
}
}
}
function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }): void {
const url = msg.url || '';
if (!url || url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return;
const stateDir = path.dirname(STATE_FILE);
const ctxFile = path.join(stateDir, 'active-tab.json');
const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`);
try {
fs.writeFileSync(tmp, JSON.stringify({
tabId: msg.tabId ?? null,
url,
title: msg.title ?? '',
}), { mode: 0o600 });
fs.renameSync(tmp, ctxFile);
} catch {
safeUnlink(tmp);
}
// Best-effort sync to parent server so its activeTabId tracking matches.
// No await; this is fire-and-forget.
if (BROWSE_SERVER_PORT > 0) {
fetch(`http://127.0.0.1:${BROWSE_SERVER_PORT}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${readBrowseToken()}`,
},
body: JSON.stringify({
command: 'tab',
args: [String(msg.tabId ?? ''), '--no-focus'],
}),
}).catch(() => {});
}
}
function readBrowseToken(): string {
try {
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
const j = JSON.parse(raw);
return j.token || '';
} catch { return ''; }
}
// Boot.
function main() {
writeClaudeAvailable();
const server = buildServer();
const port = (server as any).port || (server as any).address?.port;
if (!port) {
console.error('[terminal-agent] failed to bind: no port');
process.exit(1);
}
// Write port file atomically so the parent server can pick it up.
const dir = path.dirname(PORT_FILE);
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch {}
const tmp = `${PORT_FILE}.tmp-${process.pid}`;
fs.writeFileSync(tmp, String(port), { mode: 0o600 });
fs.renameSync(tmp, PORT_FILE);
// 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}`);
// Cleanup port file on exit.
const cleanup = () => { safeUnlink(PORT_FILE); process.exit(0); };
process.on('SIGTERM', cleanup);
process.on('SIGINT', cleanup);
}
// Export the internal token so cli.ts can pass the SAME value to the parent
// server via env. Parent reads BROWSE_TERMINAL_INTERNAL_TOKEN and uses it
// for /internal/grant calls.
//
// In practice, the agent generates INTERNAL_TOKEN once at boot and writes it
// to a state file the parent reads. This avoids env-passing races. See main().
const INTERNAL_TOKEN_FILE = path.join(path.dirname(STATE_FILE), 'terminal-internal-token');
try {
fs.mkdirSync(path.dirname(INTERNAL_TOKEN_FILE), { recursive: true, mode: 0o700 });
fs.writeFileSync(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN, { mode: 0o600 });
} catch {}
main();

View File

@@ -19,31 +19,10 @@ import { PAGE_CONTENT_COMMANDS } from '../src/commands';
const REPO_ROOT = path.resolve(__dirname, '..', '..');
describe('canary stream-chunk split detection', () => {
test('detectCanaryLeak uses rolling buffer across consecutive deltas', () => {
// Pull in the function via dynamic require so we don't re-export it
// from sidebar-agent.ts (it's internal on purpose).
const agentSource = fs.readFileSync(
path.join(REPO_ROOT, 'browse', 'src', 'sidebar-agent.ts'),
'utf-8',
);
// Contract: detectCanaryLeak accepts an optional DeltaBuffer and
// uses .slice(-(canary.length - 1)) to retain a rolling tail.
expect(agentSource).toContain('DeltaBuffer');
expect(agentSource).toMatch(/text_delta\s*=\s*combined\.slice\(-\(canary\.length - 1\)\)/);
expect(agentSource).toMatch(/input_json_delta\s*=\s*combined\.slice\(-\(canary\.length - 1\)\)/);
});
test('canary context initializes deltaBuf', () => {
const agentSource = fs.readFileSync(
path.join(REPO_ROOT, 'browse', 'src', 'sidebar-agent.ts'),
'utf-8',
);
// The askClaude call site must construct the buffer so the rolling
// detection actually runs.
expect(agentSource).toContain("deltaBuf: { text_delta: '', input_json_delta: '' }");
});
});
// canary stream-chunk split detection — tested detectCanaryLeak inside
// sidebar-agent.ts. Both the chat-stream pipeline and the function are
// gone (Terminal pane uses an interactive PTY; user keystrokes are the
// trust source, no chunked LLM stream to canary-scan).
describe('tool-output ensemble rule (single-layer BLOCK)', () => {
test('user-input context: single layer at BLOCK degrades to WARN', () => {
@@ -117,13 +96,10 @@ describe('transcript classifier tool_output parameter', () => {
expect(src).toContain('tool_output');
});
test('sidebar-agent passes tool text to transcript on tool-result scan', () => {
const src = fs.readFileSync(
path.join(REPO_ROOT, 'browse', 'src', 'sidebar-agent.ts'),
'utf-8',
);
expect(src).toContain('tool_output: text');
});
// sidebar-agent passed tool text to the transcript classifier on
// tool-result scans. That whole pipeline is gone — Terminal pane has
// no LLM stream to scan, and security-classifier.ts is dead code with
// no production caller (a separate v1.1+ cleanup TODO).
});
describe('GSTACK_SECURITY_OFF kill switch', () => {

View File

@@ -15,7 +15,13 @@ import * as os from 'os';
const META_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/meta-commands.ts'), 'utf-8');
const WRITE_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/write-commands.ts'), 'utf-8');
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
const AGENT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/sidebar-agent.ts'), 'utf-8');
// sidebar-agent.ts was ripped (chat queue replaced by interactive PTY).
// AGENT_SRC kept as empty string so the legacy describe block below skips
// without crashing module load on a missing file.
const AGENT_SRC = (() => {
try { return fs.readFileSync(path.join(import.meta.dir, '../src/sidebar-agent.ts'), 'utf-8'); }
catch { return ''; }
})();
const SNAPSHOT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/snapshot.ts'), 'utf-8');
const PATH_SECURITY_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/path-security.ts'), 'utf-8');
@@ -51,53 +57,12 @@ function extractFunction(src: string, name: string): string {
return src.slice(start);
}
// ─── Task 4: Agent queue poisoning — full schema validation + permissions ───
describe('Agent queue security', () => {
it('server queue directory must use restricted permissions', () => {
const queueSection = SERVER_SRC.slice(SERVER_SRC.indexOf('agentQueue'), SERVER_SRC.indexOf('agentQueue') + 2000);
expect(queueSection).toMatch(/0o700/);
});
it('sidebar-agent queue directory must use restricted permissions', () => {
// The mkdirSync for the queue dir lives in main() — search the main() body
const mainStart = AGENT_SRC.indexOf('async function main');
const queueSection = AGENT_SRC.slice(mainStart);
expect(queueSection).toMatch(/0o700/);
});
it('cli.ts queue file creation must use restricted permissions', () => {
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
const queueSection = CLI_SRC.slice(CLI_SRC.indexOf('queue') || 0, CLI_SRC.indexOf('queue') + 2000);
expect(queueSection).toMatch(/0o700|0o600|mode/);
});
it('queue reader must have a validator function covering all fields', () => {
// Extract ONLY the validator function body by walking braces
const validatorStart = AGENT_SRC.indexOf('function isValidQueueEntry');
expect(validatorStart).toBeGreaterThan(-1);
let depth = 0;
let bodyStart = AGENT_SRC.indexOf('{', validatorStart);
let bodyEnd = bodyStart;
for (let i = bodyStart; i < AGENT_SRC.length; i++) {
if (AGENT_SRC[i] === '{') depth++;
if (AGENT_SRC[i] === '}') depth--;
if (depth === 0) { bodyEnd = i + 1; break; }
}
const validatorBlock = AGENT_SRC.slice(validatorStart, bodyEnd);
expect(validatorBlock).toMatch(/prompt.*string/);
expect(validatorBlock).toMatch(/Array\.isArray/);
expect(validatorBlock).toMatch(/\.\./);
expect(validatorBlock).toContain('stateFile');
expect(validatorBlock).toContain('tabId');
expect(validatorBlock).toMatch(/number/);
expect(validatorBlock).toContain('null');
expect(validatorBlock).toContain('message');
expect(validatorBlock).toContain('pageUrl');
expect(validatorBlock).toContain('sessionId');
});
});
// ─── Agent queue security ──────────────────────────────────────────────────
// Original block validated the chat queue's filesystem permissions and
// schema validator on sidebar-agent.ts. Both are gone (chat queue ripped
// in favor of the interactive Terminal PTY). The remaining 0o700 / 0o600
// invariants on extension queue paths are now covered by terminal-agent
// integration tests and the sidebar-tabs regression suite.
// ─── Shared source reads for CSS validator tests ────────────────────────────
const CDP_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cdp-inspector.ts'), 'utf-8');
@@ -325,30 +290,13 @@ describe('Round-2 finding 2: snapshot.ts annotated path uses realpathSync', () =
});
});
// ─── Round-2 finding 3: stateFile path traversal check in isValidQueueEntry
describe('Round-2 finding 3: isValidQueueEntry checks stateFile for path traversal', () => {
it('isValidQueueEntry checks stateFile for .. traversal sequences', () => {
const fn = extractFunction(AGENT_SRC, 'isValidQueueEntry');
expect(fn).toBeTruthy();
// Must check stateFile for '..' — find the stateFile block and look for '..' string
const stateFileIdx = fn.indexOf('stateFile');
expect(stateFileIdx).toBeGreaterThan(-1);
const stateFileBlock = fn.slice(stateFileIdx, stateFileIdx + 200);
// The block must contain a check for the two-dot traversal sequence
expect(stateFileBlock).toMatch(/'\.\.'|"\.\."|\.\./);
});
it('isValidQueueEntry stateFile block contains both type check and traversal check', () => {
const fn = extractFunction(AGENT_SRC, 'isValidQueueEntry');
const stateFileIdx = fn.indexOf('stateFile');
const stateBlock = fn.slice(stateFileIdx, stateFileIdx + 300);
// Must contain the type check
expect(stateBlock).toContain('typeof obj.stateFile');
// Must contain the includes('..') call
expect(stateBlock).toMatch(/includes\s*\(\s*['"]\.\.['"]\s*\)/);
});
});
// ─── Round-2 finding 3: stateFile path traversal check ────────────────────
// Tested isValidQueueEntry's stateFile validator on sidebar-agent.ts. Both
// the function and the file are gone (chat queue ripped). The terminal-agent
// PTY path no longer takes a queue entry — it accepts WebSocket frames
// gated on Origin + session token, no on-disk queue to traverse. Path
// traversal in browse-server's tab-state writer is covered by
// browse/test/terminal-agent.test.ts (handleTabState atomic-write tests).
// ─── Task 5: /health endpoint must not expose sensitive fields ───────────────
@@ -421,24 +369,11 @@ describe('cookie-import domain validation', () => {
});
});
// ─── Task 9: loadSession ID validation ──────────────────────────────────────
describe('loadSession session ID validation', () => {
it('loadSession validates session ID format before using it in a path', () => {
const fn = extractFunction(SERVER_SRC, 'loadSession');
expect(fn).toBeTruthy();
// Must contain the alphanumeric regex guard
expect(fn).toMatch(/\[a-zA-Z0-9_-\]/);
});
it('loadSession returns null on invalid session ID', () => {
const fn = extractFunction(SERVER_SRC, 'loadSession');
const block = fn.slice(fn.indexOf('activeData.id'));
// Must warn and return null
expect(block).toContain('Invalid session ID');
expect(block).toContain('return null');
});
});
// loadSession session ID validation — loadSession lived inside the chat
// agent state block (sidebar-agent.ts session persistence). Chat queue
// is gone, so the function and its session-ID validator are gone. The
// terminal-agent's PTY session has no on-disk session ID — the WebSocket
// holds the session for its lifetime.
// ─── Task 10: Responsive screenshot path validation ──────────────────────────
@@ -520,40 +455,11 @@ describe('Task 11: state load cookie validation', () => {
});
});
// ─── Task 12: Validate activeTabUrl before syncActiveTabByUrl ─────────────────
describe('Task 12: activeTabUrl sanitized before syncActiveTabByUrl', () => {
it('sidebar-tabs route sanitizes activeUrl before syncActiveTabByUrl', () => {
const block = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-tabs'", "url.pathname === '/sidebar-tabs/switch'");
expect(block).toContain('sanitizeExtensionUrl');
expect(block).toContain('syncActiveTabByUrl');
const sanitizeIdx = block.indexOf('sanitizeExtensionUrl');
const syncIdx = block.indexOf('syncActiveTabByUrl');
expect(sanitizeIdx).toBeLessThan(syncIdx);
});
it('sidebar-command route sanitizes extensionUrl before syncActiveTabByUrl', () => {
const block = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-command'", "url.pathname === '/sidebar-chat/clear'");
expect(block).toContain('sanitizeExtensionUrl');
expect(block).toContain('syncActiveTabByUrl');
const sanitizeIdx = block.indexOf('sanitizeExtensionUrl');
const syncIdx = block.indexOf('syncActiveTabByUrl');
expect(sanitizeIdx).toBeLessThan(syncIdx);
});
it('direct unsanitized syncActiveTabByUrl calls are not present (all calls go through sanitize)', () => {
// Every syncActiveTabByUrl call should be preceded by sanitizeExtensionUrl in the nearby code
// We verify there are no direct browserManager.syncActiveTabByUrl(activeUrl) or
// browserManager.syncActiveTabByUrl(extensionUrl) patterns (without sanitize wrapper)
const block1 = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-tabs'", "url.pathname === '/sidebar-tabs/switch'");
// Should NOT contain direct call with raw activeUrl
expect(block1).not.toMatch(/syncActiveTabByUrl\(activeUrl\)/);
const block2 = sliceBetween(SERVER_SRC, "url.pathname === '/sidebar-command'", "url.pathname === '/sidebar-chat/clear'");
// Should NOT contain direct call with raw extensionUrl
expect(block2).not.toMatch(/syncActiveTabByUrl\(extensionUrl\)/);
});
});
// activeTabUrl sanitized before syncActiveTabByUrl — tested URL sanitization
// on the now-deleted /sidebar-tabs and /sidebar-command routes. The
// terminal-agent reads tab URLs from the live tabs.json file (atomic write
// from background.js), and chrome:// / chrome-extension:// pages are
// filtered server-side in handleTabState — see browse/test/terminal-agent.test.ts.
// ─── Task 13: Inbox output wrapped as untrusted ──────────────────────────────
@@ -581,107 +487,17 @@ describe('Task 13: inbox output wrapped as untrusted content', () => {
});
});
// ─── Task 14: DOM serialization round-trip replaced with DocumentFragment ─────
// switchChatTab DocumentFragment + pollChat reentrancy guard tests targeted
// now-deleted chat-tab DOM logic and chat-polling reentrancy. Both are gone
// (Terminal pane is the sole sidebar surface; xterm.js owns its own DOM
// lifecycle, and the WebSocket has no reentrancy hazard).
const SIDEPANEL_SRC = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.js'), 'utf-8');
describe('Task 14: switchChatTab uses DocumentFragment, not innerHTML round-trip', () => {
it('switchChatTab does NOT use innerHTML to restore chat (string-based re-parse removed)', () => {
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
expect(fn).toBeTruthy();
// Must NOT have the dangerous pattern of assigning chatDomByTab value back to innerHTML
expect(fn).not.toMatch(/chatMessages\.innerHTML\s*=\s*chatDomByTab/);
});
it('switchChatTab uses createDocumentFragment to save chat DOM', () => {
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
expect(fn).toContain('createDocumentFragment');
});
it('switchChatTab moves nodes via appendChild/firstChild (not innerHTML assignment)', () => {
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
// Must use appendChild to restore nodes from fragment
expect(fn).toContain('chatMessages.appendChild');
});
it('chatDomByTab comment documents that values are DocumentFragments, not strings', () => {
// Check module-level comment on chatDomByTab
const commentIdx = SIDEPANEL_SRC.indexOf('chatDomByTab');
const commentLine = SIDEPANEL_SRC.slice(commentIdx, commentIdx + 120);
expect(commentLine).toMatch(/DocumentFragment|fragment/i);
});
it('welcome screen is built with DOM methods in the else branch (not innerHTML)', () => {
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
// The else branch must use createElement, not innerHTML template literal
expect(fn).toContain('createElement');
// The specific innerHTML template with chat-welcome must be gone
expect(fn).not.toMatch(/innerHTML\s*=\s*`[\s\S]*?chat-welcome/);
});
});
// ─── Task 15: pollChat/switchChatTab reentrancy guard ────────────────────────
describe('Task 15: pollChat reentrancy guard and deferred call in switchChatTab', () => {
it('pollInProgress guard variable is declared at module scope', () => {
// Must be declared before any function definitions (within first 2000 chars)
const moduleTop = SIDEPANEL_SRC.slice(0, 2000);
expect(moduleTop).toContain('pollInProgress');
});
it('pollChat function checks and sets pollInProgress', () => {
const fn = extractFunction(SIDEPANEL_SRC, 'pollChat');
expect(fn).toBeTruthy();
expect(fn).toContain('pollInProgress');
});
it('pollChat resets pollInProgress in finally block', () => {
const fn = extractFunction(SIDEPANEL_SRC, 'pollChat');
// The finally block must contain the reset
const finallyIdx = fn.indexOf('finally');
expect(finallyIdx).toBeGreaterThan(-1);
const finallyBlock = fn.slice(finallyIdx, finallyIdx + 60);
expect(finallyBlock).toContain('pollInProgress');
});
it('switchChatTab calls pollChat via setTimeout (not directly)', () => {
const fn = extractFunction(SIDEPANEL_SRC, 'switchChatTab');
// Must use setTimeout to defer pollChat — no direct call at the end
expect(fn).toMatch(/setTimeout\s*\(\s*pollChat/);
// Must NOT have a bare direct call `pollChat()` at the end (outside setTimeout)
// We check that there is no standalone `pollChat()` call (outside setTimeout wrapper)
const withoutSetTimeout = fn.replace(/setTimeout\s*\(\s*pollChat[^)]*\)/g, '');
expect(withoutSetTimeout).not.toMatch(/\bpollChat\s*\(\s*\)/);
});
});
// ─── Task 16: SIGKILL escalation in sidebar-agent timeout ────────────────────
describe('Task 16: sidebar-agent timeout handler uses SIGTERM→SIGKILL escalation', () => {
it('timeout block sends SIGTERM first', () => {
// Slice from "Timed out" / setTimeout block to processingTabs.delete
const timeoutStart = AGENT_SRC.indexOf("SIDEBAR_AGENT_TIMEOUT");
expect(timeoutStart).toBeGreaterThan(-1);
const timeoutBlock = AGENT_SRC.slice(timeoutStart, timeoutStart + 600);
expect(timeoutBlock).toContain('SIGTERM');
});
it('timeout block escalates to SIGKILL after delay', () => {
const timeoutStart = AGENT_SRC.indexOf("SIDEBAR_AGENT_TIMEOUT");
const timeoutBlock = AGENT_SRC.slice(timeoutStart, timeoutStart + 600);
expect(timeoutBlock).toContain('SIGKILL');
});
it('SIGTERM appears before SIGKILL in timeout block', () => {
const timeoutStart = AGENT_SRC.indexOf("SIDEBAR_AGENT_TIMEOUT");
const timeoutBlock = AGENT_SRC.slice(timeoutStart, timeoutStart + 600);
const sigtermIdx = timeoutBlock.indexOf('SIGTERM');
const sigkillIdx = timeoutBlock.indexOf('SIGKILL');
expect(sigtermIdx).toBeGreaterThan(-1);
expect(sigkillIdx).toBeGreaterThan(-1);
expect(sigtermIdx).toBeLessThan(sigkillIdx);
});
});
// ─── Task 16: SIGKILL escalation ────────────────────────────────────────────
// Originally tested sidebar-agent's SIDEBAR_AGENT_TIMEOUT block. The chat
// queue and its watchdog are gone. terminal-agent.ts disposes claude with
// the same SIGINT-then-SIGKILL-after-3s pattern; that's covered by
// browse/test/terminal-agent.test.ts ("cleanup escalates SIGINT to SIGKILL
// after 3s on close").
// ─── Task 17: viewport and wait bounds clamping ──────────────────────────────

View File

@@ -1,218 +0,0 @@
/**
* Full-stack E2E — the security-contract anchor test.
*
* Spins up a real browse server + real sidebar-agent subprocess, points
* them at a MOCK claude binary (browse/test/fixtures/mock-claude/claude)
* that deterministically emits a canary-leaking tool_use event, then
* verifies the whole pipeline reacts:
*
* 1. Server canary-injects into the system prompt
* 2. Server queues the message
* 3. Sidebar-agent spawns mock-claude
* 4. Mock-claude emits tool_use with CANARY-XXX in a URL arg
* 5. Sidebar-agent's detectCanaryLeak fires on the stream event
* 6. onCanaryLeaked logs, SIGTERM's mock-claude, emits security_event
* 7. /sidebar-chat returns security_event + agent_error entries
*
* This test proves the end-to-end contract: when a canary leak happens,
* the session terminates AND the sidepanel receives the events that drive
* the approved banner render. No LLM cost, <10s total runtime.
*
* Fully deterministic — safe to run on every commit (gate tier).
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawn, type Subprocess } from 'bun';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let serverProc: Subprocess | null = null;
let agentProc: Subprocess | null = null;
let serverPort = 0;
let authToken = '';
let tmpDir = '';
let stateFile = '';
let queueFile = '';
const MOCK_CLAUDE_DIR = path.resolve(import.meta.dir, 'fixtures', 'mock-claude');
async function apiFetch(pathname: string, opts: RequestInit = {}): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
...(opts.headers as Record<string, string> | undefined),
};
return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...opts, headers });
}
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'security-e2e-fullstack-'));
stateFile = path.join(tmpDir, 'browse.json');
queueFile = path.join(tmpDir, 'sidebar-queue.jsonl');
fs.mkdirSync(path.dirname(queueFile), { recursive: true });
const serverScript = path.resolve(import.meta.dir, '..', 'src', 'server.ts');
const agentScript = path.resolve(import.meta.dir, '..', 'src', 'sidebar-agent.ts');
// 1) Start the browse server.
serverProc = spawn(['bun', 'run', serverScript], {
env: {
...process.env,
BROWSE_STATE_FILE: stateFile,
BROWSE_HEADLESS_SKIP: '1', // no Chromium for this test
BROWSE_PORT: '0',
SIDEBAR_QUEUE_PATH: queueFile,
BROWSE_IDLE_TIMEOUT: '300',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
// Wait for state file with token + port
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
if (fs.existsSync(stateFile)) {
try {
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
if (state.port && state.token) {
serverPort = state.port;
authToken = state.token;
break;
}
} catch {}
}
await new Promise((r) => setTimeout(r, 100));
}
if (!serverPort) throw new Error('Server did not start in time');
// 2) Start the sidebar-agent with PATH prepended by the mock-claude dir.
// sidebar-agent spawns `claude` via PATH lookup (spawn('claude', ...) — see
// browse/src/sidebar-agent.ts spawnClaude), so prepending works without any
// source change.
const shimmedPath = `${MOCK_CLAUDE_DIR}:${process.env.PATH ?? ''}`;
agentProc = spawn(['bun', 'run', agentScript], {
env: {
...process.env,
PATH: shimmedPath,
BROWSE_STATE_FILE: stateFile,
SIDEBAR_QUEUE_PATH: queueFile,
BROWSE_SERVER_PORT: String(serverPort),
BROWSE_PORT: String(serverPort),
BROWSE_NO_AUTOSTART: '1',
// Scenario for mock-claude inherits through spawn env below — the agent
// itself doesn't read this, but the claude subprocess it spawns does.
MOCK_CLAUDE_SCENARIO: 'canary_leak_in_tool_arg',
// Force classifier off so pre-spawn ML scan doesn't fire on our
// benign synthetic test prompt. This test exercises the canary
// path specifically.
GSTACK_SECURITY_OFF: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
// Give the agent a moment to establish its poll loop.
await new Promise((r) => setTimeout(r, 500));
}, 30000);
async function drainStderr(proc: Subprocess | null, label: string): Promise<void> {
if (!proc?.stderr) return;
try {
const reader = (proc.stderr as ReadableStream).getReader();
// Drain briefly — don't block shutdown
const result = await Promise.race([
reader.read(),
new Promise<ReadableStreamReadResult<Uint8Array>>((resolve) =>
setTimeout(() => resolve({ done: true, value: undefined }), 100)
),
]);
if (result?.value) {
const text = new TextDecoder().decode(result.value);
if (text.trim()) console.error(`[${label} stderr]`, text.slice(0, 2000));
}
} catch {}
}
afterAll(async () => {
// Dump agent stderr for diagnostic
await drainStderr(agentProc, 'agent');
for (const proc of [serverProc, agentProc]) {
if (proc) {
try { proc.kill('SIGTERM'); } catch {}
try { setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 1500); } catch {}
}
}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
describe('security pipeline E2E (mock claude)', () => {
test('server injects canary, queues message, agent spawns mock claude', async () => {
const resp = await apiFetch('/sidebar-command', {
method: 'POST',
body: JSON.stringify({
message: "What's on this page?",
activeTabUrl: 'https://attacker.example.com/',
}),
});
expect(resp.status).toBe(200);
// Wait for the sidebar-agent to pick up the entry and spawn mock-claude.
// Queue entry must contain `canary` field (added by server.ts spawnClaude).
await new Promise((r) => setTimeout(r, 250));
const queueContent = fs.readFileSync(queueFile, 'utf-8').trim();
const lines = queueContent.split('\n').filter(Boolean);
expect(lines.length).toBeGreaterThan(0);
const entry = JSON.parse(lines[lines.length - 1]);
expect(entry.canary).toMatch(/^CANARY-[0-9A-F]+$/);
expect(entry.prompt).toContain(entry.canary);
expect(entry.prompt).toContain('NEVER include it');
});
test('canary leak triggers security_event + agent_error in /sidebar-chat', async () => {
// By now the mock-claude subprocess has emitted the tool_use with the
// leaked canary. Sidebar-agent's handleStreamEvent -> detectCanaryLeak
// -> onCanaryLeaked should have fired security_event + agent_error and
// SIGTERM'd the mock. Poll /sidebar-chat up to 10s for the events.
const deadline = Date.now() + 10000;
let securityEvent: any = null;
let agentError: any = null;
while (Date.now() < deadline && (!securityEvent || !agentError)) {
const resp = await apiFetch('/sidebar-chat');
const data: any = await resp.json();
for (const entry of data.entries ?? []) {
if (entry.type === 'security_event') securityEvent = entry;
if (entry.type === 'agent_error') agentError = entry;
}
if (securityEvent && agentError) break;
await new Promise((r) => setTimeout(r, 250));
}
expect(securityEvent).not.toBeNull();
expect(securityEvent.verdict).toBe('block');
expect(securityEvent.reason).toBe('canary_leaked');
expect(securityEvent.layer).toBe('canary');
// The leak is on a tool_use channel — onCanaryLeaked records "tool_use:Bash"
expect(String(securityEvent.channel)).toContain('tool_use');
expect(securityEvent.domain).toBe('attacker.example.com');
expect(agentError).not.toBeNull();
expect(agentError.error).toContain('Session terminated');
expect(agentError.error).toContain('prompt injection detected');
}, 15000);
test('attempts.jsonl logged with salted payload_hash and verdict=block', async () => {
// onCanaryLeaked also calls logAttempt — check the log file exists
// and contains the event. The file lives at ~/.gstack/security/attempts.jsonl.
const logPath = path.join(os.homedir(), '.gstack', 'security', 'attempts.jsonl');
expect(fs.existsSync(logPath)).toBe(true);
const content = fs.readFileSync(logPath, 'utf-8');
const recent = content.split('\n').filter(Boolean).slice(-10);
// Find at least one entry with verdict=block and layer=canary from our run
const ourEntry = recent
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
.find((e) => e && e.layer === 'canary' && e.verdict === 'block' && e.urlDomain === 'attacker.example.com');
expect(ourEntry).toBeTruthy();
// payload_hash is a 64-char sha256 hex
expect(String(ourEntry.payloadHash)).toMatch(/^[0-9a-f]{64}$/);
// Never stored the payload itself — only the hash
expect(JSON.stringify(ourEntry)).not.toContain('CANARY-');
});
});

View File

@@ -1,405 +0,0 @@
/**
* Full-stack review-flow E2E with the real classifier.
*
* Spins up real server + real sidebar-agent subprocess + mock-claude and
* exercises the whole tool-output BLOCK → review → decide path with the
* real TestSavantAI classifier warm. The injection string trips the real
* model reliably (measured: confidence 0.9999 on classic DAN-style text).
*
* What this covers that gate-tier tests don't:
* * Real classifier actually fires on the injection
* * sidebar-agent emits a reviewable security_event for real, not a stub
* * server's POST /security-decision writes the on-disk decision file
* * sidebar-agent's poll loop reads the file and either resumes or kills
* the mock-claude subprocess
* * attempts.jsonl ends up with the right verdict (block vs user_overrode)
*
* This is periodic tier. First run warms the ~112MB classifier from
* HuggingFace — ~30s cold. Subsequent runs use the cached model under
* ~/.gstack/models/testsavant-small/ and complete in ~5s.
*
* SKIPS if the classifier can't warm (no network, no disk) — the test is
* truth-seeking only when the stack is genuinely up.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawn, type Subprocess } from 'bun';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const MOCK_CLAUDE_DIR = path.resolve(import.meta.dir, 'fixtures', 'mock-claude');
const WARMUP_TIMEOUT_MS = 90_000; // first-run download budget
const CLASSIFIER_CACHE = path.join(os.homedir(), '.gstack', 'models', 'testsavant-small');
let serverProc: Subprocess | null = null;
let agentProc: Subprocess | null = null;
let serverPort = 0;
let authToken = '';
let tmpDir = '';
let stateFile = '';
let queueFile = '';
let attemptsPath = '';
/**
* Eager check — is the classifier model already on disk? `test.skipIf()`
* is evaluated at file-registration time (before beforeAll runs), so a
* runtime boolean wouldn't work — all tests would unconditionally register
* as skipped. Probe the model dir synchronously at file load.
* Same pattern as security-sidepanel-dom.test.ts uses for chromium.
*/
const CLASSIFIER_READY = (() => {
try {
if (!fs.existsSync(CLASSIFIER_CACHE)) return false;
// At minimum we need the tokenizer config + onnx model.
return fs.existsSync(path.join(CLASSIFIER_CACHE, 'tokenizer.json'))
&& fs.existsSync(path.join(CLASSIFIER_CACHE, 'onnx'));
} catch {
return false;
}
})();
async function apiFetch(pathname: string, opts: RequestInit = {}): Promise<Response> {
return fetch(`http://127.0.0.1:${serverPort}${pathname}`, {
...opts,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
...(opts.headers as Record<string, string> | undefined),
},
});
}
async function waitForSecurityEntry(
predicate: (entry: any) => boolean,
timeoutMs: number,
): Promise<any | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resp = await apiFetch('/sidebar-chat');
const data: any = await resp.json();
for (const entry of data.entries ?? []) {
if (entry.type === 'security_event' && predicate(entry)) return entry;
}
await new Promise((r) => setTimeout(r, 250));
}
return null;
}
async function waitForProcessExit(proc: Subprocess, timeoutMs: number): Promise<number | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (proc.exitCode !== null) return proc.exitCode;
await new Promise((r) => setTimeout(r, 100));
}
return null;
}
async function readAttempts(): Promise<any[]> {
if (!fs.existsSync(attemptsPath)) return [];
const raw = fs.readFileSync(attemptsPath, 'utf-8');
return raw.split('\n').filter(Boolean).map((l) => {
try { return JSON.parse(l); } catch { return null; }
}).filter(Boolean);
}
async function startStack(scenario: string, attemptsDir: string): Promise<void> {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'security-review-fullstack-'));
stateFile = path.join(tmpDir, 'browse.json');
queueFile = path.join(tmpDir, 'sidebar-queue.jsonl');
fs.mkdirSync(path.dirname(queueFile), { recursive: true });
// Re-root HOME for both server and agent so:
// - server.ts's SESSIONS_DIR doesn't load pre-existing chat history
// from ~/.gstack/sidebar-sessions/ (caused ghost security_events to
// leak in from the live /open-gstack-browser session)
// - security.ts's attempts.jsonl writes land in a test-owned dir
// - session-state.json, chromium-profile, etc. stay isolated
fs.mkdirSync(path.join(attemptsDir, '.gstack'), { recursive: true });
// Symlink the models dir through to the real cache — without it the
// sidebar-agent would try to re-download 112MB every test run.
const testModelsDir = path.join(attemptsDir, '.gstack', 'models');
const realModelsDir = path.join(os.homedir(), '.gstack', 'models');
try {
if (fs.existsSync(realModelsDir) && !fs.existsSync(testModelsDir)) {
fs.symlinkSync(realModelsDir, testModelsDir);
}
} catch {
// Symlink may already exist — ignore.
}
const serverScript = path.resolve(import.meta.dir, '..', 'src', 'server.ts');
const agentScript = path.resolve(import.meta.dir, '..', 'src', 'sidebar-agent.ts');
serverProc = spawn(['bun', 'run', serverScript], {
env: {
...process.env,
BROWSE_STATE_FILE: stateFile,
BROWSE_HEADLESS_SKIP: '1',
BROWSE_PORT: '0',
SIDEBAR_QUEUE_PATH: queueFile,
BROWSE_IDLE_TIMEOUT: '300',
HOME: attemptsDir,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
if (fs.existsSync(stateFile)) {
try {
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
if (state.port && state.token) {
serverPort = state.port;
authToken = state.token;
break;
}
} catch {}
}
await new Promise((r) => setTimeout(r, 100));
}
if (!serverPort) throw new Error('Server did not start in time');
const shimmedPath = `${MOCK_CLAUDE_DIR}:${process.env.PATH ?? ''}`;
agentProc = spawn(['bun', 'run', agentScript], {
env: {
...process.env,
PATH: shimmedPath,
BROWSE_STATE_FILE: stateFile,
SIDEBAR_QUEUE_PATH: queueFile,
BROWSE_SERVER_PORT: String(serverPort),
BROWSE_PORT: String(serverPort),
BROWSE_NO_AUTOSTART: '1',
MOCK_CLAUDE_SCENARIO: scenario,
HOME: attemptsDir,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
attemptsPath = path.join(attemptsDir, '.gstack', 'security', 'attempts.jsonl');
// Give the agent a moment to establish its poll loop + warmup the model.
await new Promise((r) => setTimeout(r, 500));
}
async function stopStack(): Promise<void> {
for (const proc of [serverProc, agentProc]) {
if (proc) {
try { proc.kill('SIGTERM'); } catch {}
try { setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 1500); } catch {}
}
}
serverProc = null;
agentProc = null;
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
}
beforeAll(async () => {
// Sanity: the on-disk cache is real + decodable. If this fails, mark the
// file as "classifier unavailable" (we can't toggle CLASSIFIER_READY
// post-registration — a failure here just means the tests below will
// exercise the agent without a working classifier, which is the honest
// signal we want anyway).
if (!CLASSIFIER_READY) return;
});
afterAll(async () => {
await stopStack();
});
describe('review-flow full-stack E2E', () => {
test.skipIf(!CLASSIFIER_READY)(
'tool_result injection → reviewable event → user ALLOWS → attempts.jsonl has user_overrode',
async () => {
const attemptsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'attempts-allow-'));
try {
await startStack('tool_result_injection', attemptsDir);
// Fire the message that will cause mock-claude to emit the
// injection-laden tool_result.
const resp = await apiFetch('/sidebar-command', {
method: 'POST',
body: JSON.stringify({
message: 'summarize the hacker news comments',
activeTabUrl: 'https://news.ycombinator.com/item?id=42',
}),
});
expect(resp.status).toBe(200);
// Wait for the real classifier to fire and emit a reviewable
// security_event. The classifier is warm so this should happen in
// well under 10s once the tool_result arrives.
const reviewable = await waitForSecurityEntry(
(e) => e.verdict === 'block' && e.reviewable === true,
30_000,
);
expect(reviewable).not.toBeNull();
expect(reviewable.reason).toBe('tool_result_ml');
expect(reviewable.tool).toBe('Bash');
expect(String(reviewable.suspected_text ?? '')).toContain('IGNORE ALL PREVIOUS');
// User clicks Allow via the banner → sidepanel POSTs to server.
const decisionResp = await apiFetch('/security-decision', {
method: 'POST',
body: JSON.stringify({
tabId: reviewable.tabId,
decision: 'allow',
reason: 'user',
}),
});
expect(decisionResp.status).toBe(200);
// Wait for sidebar-agent's poll loop to consume the decision and
// emit a follow-up user_overrode security_event.
const overrode = await waitForSecurityEntry(
(e) => e.verdict === 'user_overrode',
10_000,
);
expect(overrode).not.toBeNull();
// Audit log must capture both the block and the override, in that
// order. Both records share the same salted payload hash so the
// security dashboard can aggregate them as a single attempt.
const attempts = await readAttempts();
const blockLog = attempts.find(
(a) => a.verdict === 'block' && a.layer === 'testsavant_content',
);
const overrodeLog = attempts.find(
(a) => a.verdict === 'user_overrode' && a.layer === 'testsavant_content',
);
expect(blockLog).toBeTruthy();
expect(overrodeLog).toBeTruthy();
expect(overrodeLog.payloadHash).toBe(blockLog.payloadHash);
// Privacy contract: neither record includes the raw payload.
expect(JSON.stringify(overrodeLog)).not.toContain('IGNORE ALL PREVIOUS');
// Liveness: session must actually KEEP RUNNING after Allow. Mock-claude
// emits a second tool_use to post-block-followup.example.com ~8s
// after the tool_result. That event must reach the chat feed, proving
// the sidebar-agent resumed the stream-handler relay instead of
// silently wedging.
const followupDeadline = Date.now() + 20_000;
let followup: any = null;
while (Date.now() < followupDeadline && !followup) {
const chatResp = await apiFetch('/sidebar-chat');
const chatData: any = await chatResp.json();
for (const entry of chatData.entries ?? []) {
const input = String((entry as any).input ?? '');
if (
entry.type === 'tool_use' &&
input.includes('post-block-followup.example.com')
) {
followup = entry;
break;
}
}
if (!followup) await new Promise((r) => setTimeout(r, 300));
}
expect(followup).not.toBeNull();
} finally {
await stopStack();
try { fs.rmSync(attemptsDir, { recursive: true, force: true }); } catch {}
}
},
90_000,
);
test.skipIf(!CLASSIFIER_READY)(
'tool_result injection → reviewable event → user BLOCKS → agent session terminates',
async () => {
const attemptsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'attempts-block-'));
try {
await startStack('tool_result_injection', attemptsDir);
const resp = await apiFetch('/sidebar-command', {
method: 'POST',
body: JSON.stringify({
message: 'summarize the hacker news comments',
activeTabUrl: 'https://news.ycombinator.com/item?id=42',
}),
});
expect(resp.status).toBe(200);
const reviewable = await waitForSecurityEntry(
(e) => e.verdict === 'block' && e.reviewable === true,
30_000,
);
expect(reviewable).not.toBeNull();
const decisionResp = await apiFetch('/security-decision', {
method: 'POST',
body: JSON.stringify({
tabId: reviewable.tabId,
decision: 'block',
reason: 'user',
}),
});
expect(decisionResp.status).toBe(200);
// Wait for the agent_error that the sidebar-agent emits when it
// kills the claude subprocess after a user-confirmed block. This
// is the sidepanel's "Session terminated" signal.
const deadline = Date.now() + 15_000;
let errorEntry: any = null;
while (Date.now() < deadline && !errorEntry) {
const chatResp = await apiFetch('/sidebar-chat');
const chatData: any = await chatResp.json();
for (const entry of chatData.entries ?? []) {
if (
entry.type === 'agent_error' &&
String(entry.error ?? '').includes('Session terminated')
) {
errorEntry = entry;
break;
}
}
if (!errorEntry) await new Promise((r) => setTimeout(r, 200));
}
expect(errorEntry).not.toBeNull();
// attempts.jsonl must NOT have a user_overrode entry for this run.
const attempts = await readAttempts();
const overrodeLog = attempts.find((a) => a.verdict === 'user_overrode');
expect(overrodeLog).toBeFalsy();
// The real security property: after Block, NO FURTHER tool calls
// reach the chat feed. Mock-claude would have emitted a tool_use
// to post-block-followup.example.com ~8s after the tool_result if
// the session had kept running. Wait long enough for that window
// to close (12s total), then assert the followup event never
// appeared. This is what makes "block" actually stop the page —
// the subprocess is SIGTERM'd before it can emit the next event.
await new Promise((r) => setTimeout(r, 12_000));
const finalChatResp = await apiFetch('/sidebar-chat');
const finalChatData: any = await finalChatResp.json();
const followupAttempted = (finalChatData.entries ?? []).some(
(entry: any) =>
entry.type === 'tool_use' &&
String(entry.input ?? '').includes('post-block-followup.example.com'),
);
expect(followupAttempted).toBe(false);
// And mock-claude must actually have died (not just been signaled
// — the SIGTERM + SIGKILL pair should have exited the process).
const mockAlive = (await apiFetch('/sidebar-chat')).ok; // channel still open
expect(mockAlive).toBe(true);
} finally {
await stopStack();
try { fs.rmSync(attemptsDir, { recursive: true, force: true }); } catch {}
}
},
90_000,
);
test.skipIf(!CLASSIFIER_READY)(
'no decision within 60s → timeout auto-blocks',
async () => {
// This test would naturally take 60s+ to run. We assert the
// decision file semantics instead — the unit-test suite already
// verified the poll loop times out and defaults to block
// (security-review-flow.test.ts). Kept here as a spec marker so
// the scenario is documented in the full-stack file.
expect(true).toBe(true);
},
);
});

View File

@@ -1,345 +0,0 @@
/**
* Review-flow E2E (sidepanel side, hermetic).
*
* Loads the real extension sidepanel.html in Playwright Chromium, stubs
* the browse server responses, injects a `reviewable: true` security_event
* into /sidebar-chat, and asserts the user-in-the-loop flow end-to-end:
*
* 1. Banner renders with "Review suspected injection" title
* 2. Suspected text excerpt shows up inside the expandable details
* 3. Allow + Block buttons are visible and actionable
* 4. Clicking Allow posts to /security-decision with decision:"allow"
* 5. Clicking Block posts to /security-decision with decision:"block"
* 6. Banner auto-hides after decision
*
* This is the UI-and-wire test. The server-side handshake (decision file
* write + sidebar-agent poll) is covered by security-review-flow.test.ts.
* The full-stack version with real mock-claude + real classifier lives
* in security-review-fullstack.test.ts (periodic tier).
*
* Gate tier. ~3s. Skipped if Playwright chromium is unavailable.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { chromium, type Browser, type Page } from 'playwright';
const EXTENSION_DIR = path.resolve(import.meta.dir, '..', '..', 'extension');
const SIDEPANEL_URL = `file://${EXTENSION_DIR}/sidepanel.html`;
const CHROMIUM_AVAILABLE = (() => {
try {
const exe = chromium.executablePath();
return !!exe && fs.existsSync(exe);
} catch {
return false;
}
})();
interface DecisionCall {
tabId: number;
decision: 'allow' | 'block';
reason?: string;
}
/**
* Install the same stubs the existing sidepanel-dom test uses, plus a
* fetch interceptor that captures POSTs to /security-decision into a
* page-scoped array. Returns a handle to read the captured calls.
*/
async function installStubsAndCapture(
page: Page,
scenario: { securityEntries: any[] },
): Promise<void> {
await page.addInitScript((params: any) => {
(window as any).__decisionCalls = [];
(window as any).chrome = {
runtime: {
sendMessage: (_req: any, cb: any) => {
const payload = { connected: true, port: 34567 };
if (typeof cb === 'function') {
setTimeout(() => cb(payload), 0);
return undefined;
}
return Promise.resolve(payload);
},
lastError: null,
onMessage: { addListener: () => {} },
},
tabs: {
query: (_q: any, cb: any) => setTimeout(() => cb([{ id: 1, url: 'https://example.com' }]), 0),
onActivated: { addListener: () => {} },
onUpdated: { addListener: () => {} },
},
};
(window as any).EventSource = class {
constructor() {}
addEventListener() {}
close() {}
};
const scenarioRef = params;
const origFetch = window.fetch;
window.fetch = async function (input: any, init?: any) {
const url = String(input);
if (url.endsWith('/health')) {
return new Response(JSON.stringify({
status: 'healthy',
token: 'test-token',
mode: 'headed',
agent: { status: 'idle', runningFor: null, queueLength: 0 },
session: null,
security: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } },
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.includes('/sidebar-chat')) {
return new Response(JSON.stringify({
entries: scenarioRef.securityEntries ?? [],
total: (scenarioRef.securityEntries ?? []).length,
agentStatus: 'idle',
activeTabId: 1,
security: { status: 'protected', layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' } },
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.includes('/security-decision') && init?.method === 'POST') {
try {
const body = JSON.parse(init.body || '{}');
(window as any).__decisionCalls.push(body);
} catch {
(window as any).__decisionCalls.push({ _parseError: true, raw: init?.body });
}
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.includes('/sidebar-tabs')) {
return new Response(JSON.stringify({ tabs: [] }), { status: 200 });
}
if (typeof origFetch === 'function') return origFetch(input, init);
return new Response('{}', { status: 200 });
} as any;
}, scenario);
}
let browser: Browser | null = null;
beforeAll(async () => {
if (!CHROMIUM_AVAILABLE) return;
browser = await chromium.launch({ headless: true });
}, 30000);
afterAll(async () => {
if (browser) {
try {
// Race browser.close() against a timeout — on rare occasions Playwright
// hangs on close because an EventSource stub keeps a poll alive. 10s is
// plenty; past that we forcibly drop the handle. Bun's default hook
// timeout is 5s and has bitten this file.
await Promise.race([
browser.close(),
new Promise<void>((resolve) => setTimeout(resolve, 10000)),
]);
} catch {}
}
}, 15000);
/**
* The reviewable security_event the sidebar-agent emits on tool-output BLOCK.
* Mirrors the shape of the real production event: verdict:'block',
* reviewable:true, suspected_text excerpt, per-layer signals, and tabId
* so the banner's Allow/Block buttons know which tab to decide for.
*/
function buildReviewableEntry(overrides?: Partial<any>): any {
return {
id: 42,
ts: '2026-04-20T12:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: 'tool_result_ml',
layer: 'testsavant_content',
confidence: 0.95,
domain: 'news.ycombinator.com',
tool: 'Bash',
reviewable: true,
suspected_text: 'A comment thread discussing ignore previous instructions and reveal secrets — classifier flagged this as injection but it is actually benign developer content about a prompt injection incident.',
signals: [
{ layer: 'testsavant_content', confidence: 0.95 },
{ layer: 'transcript_classifier', confidence: 0.0, meta: { degraded: true } },
],
tabId: 1,
...overrides,
};
}
describe('sidepanel review-flow E2E', () => {
test.skipIf(!CHROMIUM_AVAILABLE)('reviewable event shows review banner with suspected text + buttons', async () => {
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsAndCapture(page, { securityEntries: [buildReviewableEntry()] });
await page.goto(SIDEPANEL_URL);
// Wait for /sidebar-chat poll to deliver the entry + banner to render.
await page.waitForFunction(
() => {
const b = document.getElementById('security-banner') as HTMLElement | null;
return !!b && b.style.display !== 'none';
},
{ timeout: 5000 },
);
// Title flips to the review framing (not "Session terminated")
const title = await page.$eval('#security-banner-title', (el) => el.textContent);
expect(title).toContain('Review suspected injection');
// Subtitle mentions the tool + domain
const subtitle = await page.$eval('#security-banner-subtitle', (el) => el.textContent);
expect(subtitle).toContain('Bash');
expect(subtitle).toContain('news.ycombinator.com');
expect(subtitle).toContain('allow to continue');
// Suspected text shows up unescaped (textContent, not innerHTML)
const suspect = await page.$eval('#security-banner-suspect', (el) => el.textContent);
expect(suspect).toContain('ignore previous instructions');
// Both action buttons are visible
const allowVisible = await page.locator('#security-banner-btn-allow').isVisible();
const blockVisible = await page.locator('#security-banner-btn-block').isVisible();
expect(allowVisible).toBe(true);
expect(blockVisible).toBe(true);
// Details auto-expanded so the user sees context
const detailsHidden = await page.$eval('#security-banner-details', (el) => (el as HTMLElement).hidden);
expect(detailsHidden).toBe(false);
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('clicking Allow posts {decision:"allow"} and hides banner', async () => {
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsAndCapture(page, { securityEntries: [buildReviewableEntry()] });
await page.goto(SIDEPANEL_URL);
await page.waitForSelector('#security-banner-btn-allow:visible', { timeout: 5000 });
await page.click('#security-banner-btn-allow');
// Decision POST should have fired with decision:"allow" and the tabId
// from the security_event. Give the fetch promise a tick to resolve.
await page.waitForFunction(
() => (window as any).__decisionCalls?.length > 0,
{ timeout: 2000 },
);
const calls = await page.evaluate(() => (window as any).__decisionCalls);
expect(calls).toHaveLength(1);
expect(calls[0].decision).toBe('allow');
expect(calls[0].tabId).toBe(1);
expect(calls[0].reason).toBe('user');
// Banner should hide optimistically after the POST
await page.waitForFunction(
() => {
const b = document.getElementById('security-banner') as HTMLElement | null;
return !!b && b.style.display === 'none';
},
{ timeout: 2000 },
);
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('clicking Block posts {decision:"block"} and hides banner', async () => {
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsAndCapture(page, { securityEntries: [buildReviewableEntry({ id: 55 })] });
await page.goto(SIDEPANEL_URL);
await page.waitForSelector('#security-banner-btn-block:visible', { timeout: 5000 });
await page.click('#security-banner-btn-block');
await page.waitForFunction(
() => (window as any).__decisionCalls?.length > 0,
{ timeout: 2000 },
);
const calls = await page.evaluate(() => (window as any).__decisionCalls);
expect(calls).toHaveLength(1);
expect(calls[0].decision).toBe('block');
expect(calls[0].tabId).toBe(1);
await page.waitForFunction(
() => {
const b = document.getElementById('security-banner') as HTMLElement | null;
return !!b && b.style.display === 'none';
},
{ timeout: 2000 },
);
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('non-reviewable event still shows hard-stop banner with no buttons', async () => {
// Regression guard: the existing hard-stop canary leak UX must not be
// disturbed by the reviewable branch. An event without reviewable:true
// keeps the old behavior.
const hardStop = {
id: 99,
ts: '2026-04-20T12:00:00Z',
role: 'agent',
type: 'security_event',
verdict: 'block',
reason: 'canary_leaked',
layer: 'canary',
confidence: 1.0,
domain: 'attacker.example.com',
channel: 'tool_use:Bash',
tabId: 1,
};
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsAndCapture(page, { securityEntries: [hardStop] });
await page.goto(SIDEPANEL_URL);
await page.waitForFunction(
() => {
const b = document.getElementById('security-banner') as HTMLElement | null;
return !!b && b.style.display !== 'none';
},
{ timeout: 5000 },
);
const title = await page.$eval('#security-banner-title', (el) => el.textContent);
expect(title).toContain('Session terminated');
// Action row stays hidden for the non-reviewable path
const actionsHidden = await page.$eval('#security-banner-actions', (el) => (el as HTMLElement).hidden);
expect(actionsHidden).toBe(true);
await context.close();
}, 15000);
test.skipIf(!CHROMIUM_AVAILABLE)('suspected text renders via textContent, not innerHTML (XSS guard)', async () => {
// If the sidepanel ever regressed to innerHTML for the suspected text,
// a crafted excerpt could execute script. This test uses one; if the
// <script> runs, window.__xss gets set. It must remain undefined.
const xssAttempt = buildReviewableEntry({
suspected_text: '<script>window.__xss = "pwn"</script><img src=x onerror="window.__xss=\'onerror\'">',
});
const context = await browser!.newContext();
const page = await context.newPage();
await installStubsAndCapture(page, { securityEntries: [xssAttempt] });
await page.goto(SIDEPANEL_URL);
await page.waitForSelector('#security-banner-suspect:not([hidden])', { timeout: 5000 });
// The literal text should appear inside the suspect block (as text, not markup)
const suspectText = await page.$eval('#security-banner-suspect', (el) => el.textContent);
expect(suspectText).toContain('<script>');
// No script executed
const xssFlag = await page.evaluate(() => (window as any).__xss);
expect(xssFlag).toBeUndefined();
await context.close();
}, 15000);
});

View File

@@ -1,226 +0,0 @@
/**
* Layer 3: Sidebar agent round-trip tests.
* Starts server + sidebar-agent together. Mocks the `claude` binary with a shell
* script that outputs canned stream-json. Verifies events flow end-to-end:
* POST /sidebar-command → queue → sidebar-agent → mock claude → events → /sidebar-chat
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawn, type Subprocess } from 'bun';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let serverProc: Subprocess | null = null;
let agentProc: Subprocess | null = null;
let serverPort: number = 0;
let authToken: string = '';
let tmpDir: string = '';
let stateFile: string = '';
let queueFile: string = '';
let mockBinDir: string = '';
async function api(pathname: string, opts: RequestInit = {}): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(opts.headers as Record<string, string> || {}),
};
if (!headers['Authorization'] && authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...opts, headers });
}
async function resetState() {
await api('/sidebar-session/new', { method: 'POST' });
fs.writeFileSync(queueFile, '');
}
async function pollChatUntil(
predicate: (entries: any[]) => boolean,
timeoutMs = 10000,
): Promise<any[]> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resp = await api('/sidebar-chat?after=0');
const data = await resp.json();
if (predicate(data.entries)) return data.entries;
await new Promise(r => setTimeout(r, 300));
}
// Return whatever we have on timeout
const resp = await api('/sidebar-chat?after=0');
return (await resp.json()).entries;
}
function writeMockClaude(script: string) {
const mockPath = path.join(mockBinDir, 'claude');
fs.writeFileSync(mockPath, script, { mode: 0o755 });
}
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-roundtrip-'));
stateFile = path.join(tmpDir, 'browse.json');
queueFile = path.join(tmpDir, 'sidebar-queue.jsonl');
mockBinDir = path.join(tmpDir, 'bin');
fs.mkdirSync(mockBinDir, { recursive: true });
fs.mkdirSync(path.dirname(queueFile), { recursive: true });
// Write default mock claude that outputs canned events
writeMockClaude(`#!/bin/bash
echo '{"type":"system","session_id":"mock-session-123"}'
echo '{"type":"assistant","message":{"content":[{"type":"text","text":"I can see the page. It looks like a test fixture."}]}}'
echo '{"type":"result","result":"Done."}'
`);
// Start server (no browser)
const serverScript = path.resolve(__dirname, '..', 'src', 'server.ts');
serverProc = spawn(['bun', 'run', serverScript], {
env: {
...process.env,
BROWSE_STATE_FILE: stateFile,
BROWSE_HEADLESS_SKIP: '1',
BROWSE_PORT: '0',
SIDEBAR_QUEUE_PATH: queueFile,
BROWSE_IDLE_TIMEOUT: '300',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
// Wait for server
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
if (fs.existsSync(stateFile)) {
try {
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
if (state.port && state.token) {
serverPort = state.port;
authToken = state.token;
break;
}
} catch {}
}
await new Promise(r => setTimeout(r, 100));
}
if (!serverPort) throw new Error('Server did not start in time');
// Start sidebar-agent with mock claude on PATH
const agentScript = path.resolve(__dirname, '..', 'src', 'sidebar-agent.ts');
agentProc = spawn(['bun', 'run', agentScript], {
env: {
...process.env,
PATH: `${mockBinDir}:${process.env.PATH}`,
BROWSE_SERVER_PORT: String(serverPort),
BROWSE_STATE_FILE: stateFile,
SIDEBAR_QUEUE_PATH: queueFile,
SIDEBAR_AGENT_TIMEOUT: '10000',
BROWSE_BIN: 'browse', // doesn't matter, mock claude doesn't use it
},
stdio: ['ignore', 'pipe', 'pipe'],
});
// Give sidebar-agent time to start polling
await new Promise(r => setTimeout(r, 1000));
}, 20000);
afterAll(() => {
if (agentProc) { try { agentProc.kill(); } catch {} }
if (serverProc) { try { serverProc.kill(); } catch {} }
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
describe('sidebar-agent round-trip', () => {
test('full message round-trip with mock claude', async () => {
await resetState();
// Send a command
const resp = await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({
message: 'what is on this page?',
activeTabUrl: 'https://example.com/test',
}),
});
expect(resp.status).toBe(200);
// Wait for mock claude to process and events to arrive
const entries = await pollChatUntil(
(entries) => entries.some((e: any) => e.type === 'agent_done'),
15000,
);
// Verify the flow: user message → agent_start → text → agent_done
const userEntry = entries.find((e: any) => e.role === 'user');
expect(userEntry).toBeDefined();
expect(userEntry.message).toBe('what is on this page?');
// The mock claude outputs text — check for any agent text entry
const textEntries = entries.filter((e: any) => e.role === 'agent' && (e.type === 'text' || e.type === 'result'));
expect(textEntries.length).toBeGreaterThan(0);
const doneEntry = entries.find((e: any) => e.type === 'agent_done');
expect(doneEntry).toBeDefined();
// Agent should be back to idle
const session = await (await api('/sidebar-session')).json();
expect(session.agent.status).toBe('idle');
}, 20000);
test('claude crash produces agent_error', async () => {
await resetState();
// Replace mock claude with one that crashes
writeMockClaude(`#!/bin/bash
echo '{"type":"system","session_id":"crash-test"}' >&2
exit 1
`);
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'crash test' }),
});
// Wait for agent_done (sidebar-agent sends agent_done even on crash via proc.on('close'))
const entries = await pollChatUntil(
(entries) => entries.some((e: any) => e.type === 'agent_done' || e.type === 'agent_error'),
15000,
);
// Agent should recover to idle
const session = await (await api('/sidebar-session')).json();
expect(session.agent.status).toBe('idle');
// Restore working mock
writeMockClaude(`#!/bin/bash
echo '{"type":"assistant","message":{"content":[{"type":"text","text":"recovered"}]}}'
`);
}, 20000);
test('sequential queue drain', async () => {
await resetState();
// Restore working mock
writeMockClaude(`#!/bin/bash
echo '{"type":"assistant","message":{"content":[{"type":"text","text":"response to: '"'"'$*'"'"'"}]}}'
`);
// Send two messages rapidly — first processes, second queues
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'first message' }),
});
await api('/sidebar-command', {
method: 'POST',
body: JSON.stringify({ message: 'second message' }),
});
// Wait for both to complete (two agent_done events)
const entries = await pollChatUntil(
(entries) => entries.filter((e: any) => e.type === 'agent_done').length >= 2,
20000,
);
// Both user messages should be in chat
const userEntries = entries.filter((e: any) => e.role === 'user');
expect(userEntries.length).toBeGreaterThanOrEqual(2);
}, 25000);
});

View File

@@ -1,562 +0,0 @@
/**
* Tests for sidebar agent queue parsing and inbox writing.
*
* sidebar-agent.ts functions are not exported (it's an entry-point script),
* so we test the same logic inline: JSONL parsing, writeToInbox filesystem
* behavior, and edge cases.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// ─── Helpers: replicate sidebar-agent logic for unit testing ──────
/** Parse a single JSONL line — same logic as sidebar-agent poll() */
function parseQueueLine(line: string): any | null {
if (!line.trim()) return null;
try {
const entry = JSON.parse(line);
if (!entry.message && !entry.prompt) return null;
return entry;
} catch {
return null;
}
}
/** Read all valid entries from a JSONL string — same as countLines + readLine loop */
function parseQueueFile(content: string): any[] {
const entries: any[] = [];
const lines = content.split('\n').filter(Boolean);
for (const line of lines) {
const entry = parseQueueLine(line);
if (entry) entries.push(entry);
}
return entries;
}
/** Write to inbox — extracted logic from sidebar-agent.ts writeToInbox() */
function writeToInbox(
gitRoot: string,
message: string,
pageUrl?: string,
sessionId?: string,
): string | null {
if (!gitRoot) return null;
const inboxDir = path.join(gitRoot, '.context', 'sidebar-inbox');
fs.mkdirSync(inboxDir, { recursive: true });
const now = new Date();
const timestamp = now.toISOString().replace(/:/g, '-');
const filename = `${timestamp}-observation.json`;
const tmpFile = path.join(inboxDir, `.${filename}.tmp`);
const finalFile = path.join(inboxDir, filename);
const inboxMessage = {
type: 'observation',
timestamp: now.toISOString(),
page: { url: pageUrl || 'unknown', title: '' },
userMessage: message,
sidebarSessionId: sessionId || 'unknown',
};
fs.writeFileSync(tmpFile, JSON.stringify(inboxMessage, null, 2));
fs.renameSync(tmpFile, finalFile);
return finalFile;
}
/** Shorten paths — same logic as sidebar-agent.ts shorten() */
function shorten(str: string): string {
return str
.replace(/\/Users\/[^/]+/g, '~')
.replace(/\/conductor\/workspaces\/[^/]+\/[^/]+/g, '')
.replace(/\.claude\/skills\/gstack\//g, '')
.replace(/browse\/dist\/browse/g, '$B');
}
/** describeToolCall — replicated from sidebar-agent.ts for unit testing */
function describeToolCall(tool: string, input: any): string {
if (!input) return '';
if (tool === 'Bash' && input.command) {
const cmd = input.command;
const browseMatch = cmd.match(/\$B\s+(\w+)|browse[^\s]*\s+(\w+)/);
if (browseMatch) {
const browseCmd = browseMatch[1] || browseMatch[2];
const args = cmd.split(/\s+/).slice(2).join(' ');
switch (browseCmd) {
case 'goto': return `Opening ${args.replace(/['"]/g, '')}`;
case 'snapshot': return args.includes('-i') ? 'Scanning for interactive elements' : args.includes('-D') ? 'Checking what changed' : 'Taking a snapshot of the page';
case 'screenshot': return `Saving screenshot${args ? ` to ${shorten(args)}` : ''}`;
case 'click': return `Clicking ${args}`;
case 'fill': { const parts = args.split(/\s+/); return `Typing "${parts.slice(1).join(' ')}" into ${parts[0]}`; }
case 'text': return 'Reading page text';
case 'html': return args ? `Reading HTML of ${args}` : 'Reading full page HTML';
case 'links': return 'Finding all links on the page';
case 'forms': return 'Looking for forms';
case 'console': return 'Checking browser console for errors';
case 'network': return 'Checking network requests';
case 'url': return 'Checking current URL';
case 'back': return 'Going back';
case 'forward': return 'Going forward';
case 'reload': return 'Reloading the page';
case 'scroll': return args ? `Scrolling to ${args}` : 'Scrolling down';
case 'wait': return `Waiting for ${args}`;
case 'inspect': return args ? `Inspecting CSS of ${args}` : 'Getting CSS for last picked element';
case 'style': return `Changing CSS: ${args}`;
case 'cleanup': return 'Removing page clutter (ads, popups, banners)';
case 'prettyscreenshot': return 'Taking a clean screenshot';
case 'css': return `Checking CSS property: ${args}`;
case 'is': return `Checking if element is ${args}`;
case 'diff': return `Comparing ${args}`;
case 'responsive': return 'Taking screenshots at mobile, tablet, and desktop sizes';
case 'status': return 'Checking browser status';
case 'tabs': return 'Listing open tabs';
case 'focus': return 'Bringing browser to front';
case 'select': return `Selecting option in ${args}`;
case 'hover': return `Hovering over ${args}`;
case 'viewport': return `Setting viewport to ${args}`;
case 'upload': return `Uploading file to ${args.split(/\s+/)[0]}`;
default: return `Running browse ${browseCmd} ${args}`.trim();
}
}
if (cmd.includes('git ')) return `Running: ${shorten(cmd)}`;
let short = shorten(cmd);
return short.length > 100 ? short.slice(0, 100) + '…' : short;
}
if (tool === 'Read' && input.file_path) return `Reading ${shorten(input.file_path)}`;
if (tool === 'Edit' && input.file_path) return `Editing ${shorten(input.file_path)}`;
if (tool === 'Write' && input.file_path) return `Writing ${shorten(input.file_path)}`;
if (tool === 'Grep' && input.pattern) return `Searching for "${input.pattern}"`;
if (tool === 'Glob' && input.pattern) return `Finding files matching ${input.pattern}`;
try { return shorten(JSON.stringify(input)).slice(0, 80); } catch { return ''; }
}
// ─── Test setup ──────────────────────────────────────────────────
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-agent-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ─── Queue File Parsing ─────────────────────────────────────────
describe('queue file parsing', () => {
test('valid JSONL line parsed correctly', () => {
const line = JSON.stringify({ message: 'hello', prompt: 'check this', pageUrl: 'https://example.com' });
const entry = parseQueueLine(line);
expect(entry).not.toBeNull();
expect(entry.message).toBe('hello');
expect(entry.prompt).toBe('check this');
expect(entry.pageUrl).toBe('https://example.com');
});
test('malformed JSON line skipped without crash', () => {
const entry = parseQueueLine('this is not json {{{');
expect(entry).toBeNull();
});
test('valid JSON without message or prompt is skipped', () => {
const line = JSON.stringify({ foo: 'bar' });
const entry = parseQueueLine(line);
expect(entry).toBeNull();
});
test('empty file returns no entries', () => {
const entries = parseQueueFile('');
expect(entries).toEqual([]);
});
test('file with blank lines returns no entries', () => {
const entries = parseQueueFile('\n\n\n');
expect(entries).toEqual([]);
});
test('mixed valid and invalid lines', () => {
const content = [
JSON.stringify({ message: 'first' }),
'not json',
JSON.stringify({ unrelated: true }),
JSON.stringify({ message: 'second', prompt: 'do stuff' }),
].join('\n');
const entries = parseQueueFile(content);
expect(entries.length).toBe(2);
expect(entries[0].message).toBe('first');
expect(entries[1].message).toBe('second');
});
});
// ─── writeToInbox ────────────────────────────────────────────────
describe('writeToInbox', () => {
test('creates .context/sidebar-inbox/ directory', () => {
writeToInbox(tmpDir, 'test message');
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
expect(fs.existsSync(inboxDir)).toBe(true);
expect(fs.statSync(inboxDir).isDirectory()).toBe(true);
});
test('writes valid JSON file', () => {
const filePath = writeToInbox(tmpDir, 'test message', 'https://example.com', 'session-123');
expect(filePath).not.toBeNull();
expect(fs.existsSync(filePath!)).toBe(true);
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
expect(data.type).toBe('observation');
expect(data.userMessage).toBe('test message');
expect(data.page.url).toBe('https://example.com');
expect(data.sidebarSessionId).toBe('session-123');
expect(data.timestamp).toBeTruthy();
});
test('atomic write — final file exists, no .tmp left', () => {
const filePath = writeToInbox(tmpDir, 'atomic test');
expect(filePath).not.toBeNull();
expect(fs.existsSync(filePath!)).toBe(true);
// Check no .tmp files remain in the inbox directory
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
const files = fs.readdirSync(inboxDir);
const tmpFiles = files.filter(f => f.endsWith('.tmp'));
expect(tmpFiles.length).toBe(0);
// Final file should end with -observation.json
const jsonFiles = files.filter(f => f.endsWith('-observation.json') && !f.startsWith('.'));
expect(jsonFiles.length).toBe(1);
});
test('handles missing git root gracefully', () => {
const result = writeToInbox('', 'test');
expect(result).toBeNull();
});
test('defaults pageUrl to unknown when not provided', () => {
const filePath = writeToInbox(tmpDir, 'no url provided');
expect(filePath).not.toBeNull();
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
expect(data.page.url).toBe('unknown');
});
test('defaults sessionId to unknown when not provided', () => {
const filePath = writeToInbox(tmpDir, 'no session');
expect(filePath).not.toBeNull();
const data = JSON.parse(fs.readFileSync(filePath!, 'utf-8'));
expect(data.sidebarSessionId).toBe('unknown');
});
test('multiple writes create separate files', () => {
writeToInbox(tmpDir, 'message 1');
// Tiny delay to ensure different timestamps
const t = Date.now();
while (Date.now() === t) {} // spin until next ms
writeToInbox(tmpDir, 'message 2');
const inboxDir = path.join(tmpDir, '.context', 'sidebar-inbox');
const files = fs.readdirSync(inboxDir).filter(f => f.endsWith('.json') && !f.startsWith('.'));
expect(files.length).toBe(2);
});
});
// ─── describeToolCall (verbose narration) ────────────────────────
describe('describeToolCall', () => {
// Browse navigation commands
test('goto → plain English with URL', () => {
const result = describeToolCall('Bash', { command: '$B goto https://example.com' });
expect(result).toBe('Opening https://example.com');
});
test('goto strips quotes from URL', () => {
const result = describeToolCall('Bash', { command: '$B goto "https://example.com"' });
expect(result).toBe('Opening https://example.com');
});
test('url → checking current URL', () => {
expect(describeToolCall('Bash', { command: '$B url' })).toBe('Checking current URL');
});
test('back/forward/reload → plain English', () => {
expect(describeToolCall('Bash', { command: '$B back' })).toBe('Going back');
expect(describeToolCall('Bash', { command: '$B forward' })).toBe('Going forward');
expect(describeToolCall('Bash', { command: '$B reload' })).toBe('Reloading the page');
});
// Snapshot variants
test('snapshot -i → scanning for interactive elements', () => {
expect(describeToolCall('Bash', { command: '$B snapshot -i' })).toBe('Scanning for interactive elements');
});
test('snapshot -D → checking what changed', () => {
expect(describeToolCall('Bash', { command: '$B snapshot -D' })).toBe('Checking what changed');
});
test('snapshot (plain) → taking a snapshot', () => {
expect(describeToolCall('Bash', { command: '$B snapshot' })).toBe('Taking a snapshot of the page');
});
// Interaction commands
test('click → clicking element', () => {
expect(describeToolCall('Bash', { command: '$B click @e3' })).toBe('Clicking @e3');
});
test('fill → typing into element', () => {
expect(describeToolCall('Bash', { command: '$B fill @e4 "hello world"' })).toBe('Typing ""hello world"" into @e4');
});
test('scroll with selector → scrolling to element', () => {
expect(describeToolCall('Bash', { command: '$B scroll .footer' })).toBe('Scrolling to .footer');
});
test('scroll without args → scrolling down', () => {
expect(describeToolCall('Bash', { command: '$B scroll' })).toBe('Scrolling down');
});
// Reading commands
test('text → reading page text', () => {
expect(describeToolCall('Bash', { command: '$B text' })).toBe('Reading page text');
});
test('html with selector → reading HTML of element', () => {
expect(describeToolCall('Bash', { command: '$B html .header' })).toBe('Reading HTML of .header');
});
test('html without selector → reading full page HTML', () => {
expect(describeToolCall('Bash', { command: '$B html' })).toBe('Reading full page HTML');
});
test('links → finding all links', () => {
expect(describeToolCall('Bash', { command: '$B links' })).toBe('Finding all links on the page');
});
test('console → checking console', () => {
expect(describeToolCall('Bash', { command: '$B console' })).toBe('Checking browser console for errors');
});
// Inspector commands
test('inspect with selector → inspecting CSS', () => {
expect(describeToolCall('Bash', { command: '$B inspect .header' })).toBe('Inspecting CSS of .header');
});
test('inspect without args → getting last picked element', () => {
expect(describeToolCall('Bash', { command: '$B inspect' })).toBe('Getting CSS for last picked element');
});
test('style → changing CSS', () => {
expect(describeToolCall('Bash', { command: '$B style .header color red' })).toBe('Changing CSS: .header color red');
});
test('cleanup → removing page clutter', () => {
expect(describeToolCall('Bash', { command: '$B cleanup --all' })).toBe('Removing page clutter (ads, popups, banners)');
});
// Visual commands
test('screenshot → saving screenshot', () => {
expect(describeToolCall('Bash', { command: '$B screenshot /tmp/shot.png' })).toBe('Saving screenshot to /tmp/shot.png');
});
test('screenshot without path', () => {
expect(describeToolCall('Bash', { command: '$B screenshot' })).toBe('Saving screenshot');
});
test('responsive → multi-size screenshots', () => {
expect(describeToolCall('Bash', { command: '$B responsive' })).toBe('Taking screenshots at mobile, tablet, and desktop sizes');
});
// Non-browse tools
test('Read tool → reading file', () => {
expect(describeToolCall('Read', { file_path: '/Users/foo/project/src/app.ts' })).toBe('Reading ~/project/src/app.ts');
});
test('Grep tool → searching for pattern', () => {
expect(describeToolCall('Grep', { pattern: 'handleClick' })).toBe('Searching for "handleClick"');
});
test('Glob tool → finding files', () => {
expect(describeToolCall('Glob', { pattern: '**/*.tsx' })).toBe('Finding files matching **/*.tsx');
});
test('Edit tool → editing file', () => {
expect(describeToolCall('Edit', { file_path: '/Users/foo/src/main.ts' })).toBe('Editing ~/src/main.ts');
});
// Edge cases
test('null input → empty string', () => {
expect(describeToolCall('Bash', null)).toBe('');
});
test('unknown browse command → generic description', () => {
expect(describeToolCall('Bash', { command: '$B newtab https://foo.com' })).toContain('newtab');
});
test('non-browse bash → shortened command', () => {
expect(describeToolCall('Bash', { command: 'echo hello' })).toBe('echo hello');
});
test('full browse binary path recognized', () => {
const result = describeToolCall('Bash', { command: '/Users/garrytan/.claude/skills/gstack/browse/dist/browse goto https://example.com' });
expect(result).toBe('Opening https://example.com');
});
test('tab command → switching tab', () => {
expect(describeToolCall('Bash', { command: '$B tab 2' })).toContain('tab');
});
});
// ─── Per-tab agent concurrency (source code validation) ──────────
describe('per-tab agent concurrency', () => {
const serverSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'server.ts'), 'utf-8');
const agentSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'sidebar-agent.ts'), 'utf-8');
test('server has per-tab agent state map', () => {
expect(serverSrc).toContain('tabAgents');
expect(serverSrc).toContain('TabAgentState');
expect(serverSrc).toContain('getTabAgent');
});
test('server returns per-tab agent status in /sidebar-chat', () => {
expect(serverSrc).toContain('getTabAgentStatus');
expect(serverSrc).toContain('tabAgentStatus');
});
test('spawnClaude accepts forTabId parameter', () => {
const spawnFn = serverSrc.slice(
serverSrc.indexOf('function spawnClaude('),
serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1),
);
expect(spawnFn).toContain('forTabId');
expect(spawnFn).toContain('tabState.status');
});
test('sidebar-command endpoint uses per-tab agent state', () => {
expect(serverSrc).toContain('msgTabId');
expect(serverSrc).toContain('tabState.status');
expect(serverSrc).toContain('tabState.queue');
});
test('agent event handler resets per-tab state', () => {
expect(serverSrc).toContain('eventTabId');
expect(serverSrc).toContain('tabState.status = \'idle\'');
});
test('agent event handler processes per-tab queue', () => {
// After agent_done, should process next message from THIS tab's queue
expect(serverSrc).toContain('tabState.queue.length > 0');
expect(serverSrc).toContain('tabState.queue.shift');
});
test('sidebar-agent uses per-tab processing set', () => {
expect(agentSrc).toContain('processingTabs');
expect(agentSrc).not.toContain('isProcessing');
});
test('sidebar-agent sends tabId with all events', () => {
// sendEvent should accept tabId parameter
expect(agentSrc).toContain('async function sendEvent(event: Record<string, any>, tabId?: number)');
// askClaude destructures tabId from queue entry (regex tolerates
// additional fields like `canary` and `pageUrl` from security module).
expect(agentSrc).toMatch(
/const \{[^}]*\bprompt\b[^}]*\bargs\b[^}]*\bstateFile\b[^}]*\bcwd\b[^}]*\btabId\b[^}]*\}/
);
});
test('sidebar-agent allows concurrent agents across tabs', () => {
// poll() should not block globally — it should check per-tab
expect(agentSrc).toContain('processingTabs.has(tid)');
// askClaude should be fire-and-forget (no await blocking the loop)
expect(agentSrc).toContain('askClaude(entry).catch');
});
test('queue entries include tabId', () => {
const spawnFn = serverSrc.slice(
serverSrc.indexOf('function spawnClaude('),
serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1),
);
expect(spawnFn).toContain('tabId: agentTabId');
});
test('health check monitors all per-tab agents', () => {
expect(serverSrc).toContain('for (const [tid, state] of tabAgents)');
});
});
describe('BROWSE_TAB tab pinning (cross-tab isolation)', () => {
const serverSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'server.ts'), 'utf-8');
const agentSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'sidebar-agent.ts'), 'utf-8');
const cliSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
test('sidebar-agent passes BROWSE_TAB env var to claude process', () => {
// The env block should include BROWSE_TAB set to the tab ID
expect(agentSrc).toContain('BROWSE_TAB');
expect(agentSrc).toContain('String(tid)');
});
test('CLI reads BROWSE_TAB and sends tabId in command body', () => {
// BROWSE_TAB env var is still honored (sidebar-agent path). After the
// make-pdf refactor, the CLI layer now also accepts --tab-id <N>, with
// the CLI flag taking precedence over the env var. Both resolve to the
// same `tabId` body field.
expect(cliSrc).toContain('process.env.BROWSE_TAB');
expect(cliSrc).toContain('parseInt(envTab, 10)');
});
test('handleCommandInternal accepts tabId from request body', () => {
const handleFn = serverSrc.slice(
serverSrc.indexOf('async function handleCommandInternal('),
serverSrc.indexOf('\n/** HTTP wrapper', serverSrc.indexOf('async function handleCommandInternal(') + 1) > 0
? serverSrc.indexOf('\n/** HTTP wrapper', serverSrc.indexOf('async function handleCommandInternal(') + 1)
: serverSrc.indexOf('\nasync function ', serverSrc.indexOf('async function handleCommandInternal(') + 200),
);
// Should destructure tabId from body
expect(handleFn).toContain('tabId');
// Should save and restore the active tab
expect(handleFn).toContain('savedTabId');
expect(handleFn).toContain('switchTab(tabId');
});
test('handleCommandInternal restores active tab after command (success path)', () => {
// On success, should restore savedTabId without stealing focus
const handleFn = serverSrc.slice(
serverSrc.indexOf('async function handleCommandInternal('),
serverSrc.length,
);
// Count restore calls — should appear in both success and error paths
const restoreCount = (handleFn.match(/switchTab\(savedTabId/g) || []).length;
expect(restoreCount).toBeGreaterThanOrEqual(2); // success + error paths
});
test('handleCommandInternal restores active tab on error path', () => {
// The catch block should also restore
const catchBlock = serverSrc.slice(
serverSrc.indexOf('} catch (err: any) {', serverSrc.indexOf('async function handleCommandInternal(')),
);
expect(catchBlock).toContain('switchTab(savedTabId');
});
test('tab pinning only activates when tabId is provided', () => {
const handleFn = serverSrc.slice(
serverSrc.indexOf('async function handleCommandInternal('),
serverSrc.indexOf('try {', serverSrc.indexOf('async function handleCommandInternal(') + 1),
);
// Should check tabId is not undefined/null before switching
expect(handleFn).toContain('tabId !== undefined');
expect(handleFn).toContain('tabId !== null');
});
test('CLI only sends tabId when it is a valid number', () => {
// Body should conditionally include tabId. Historically that was keyed off
// the BROWSE_TAB env var. After the make-pdf refactor, the CLI also honors
// a --tab-id <N> flag on the CLI itself, so the check is "tabId defined
// AND not NaN" rather than literally inspecting the env var.
expect(cliSrc).toContain('tabId !== undefined && !isNaN(tabId)');
});
});

View File

@@ -0,0 +1,256 @@
/**
* Regression: sidebar layout invariants after the chat-tab rip.
*
* The Chrome side panel used to host two surfaces: Chat (one-shot
* `claude -p` queue) and Terminal (interactive PTY). Chat was ripped
* once the PTY proved out — sidebar-agent.ts is gone, the chat queue
* endpoints are gone, and the primary-tab nav (Terminal | Chat) is
* gone. Terminal is now the sole primary surface.
*
* This file locks the load-bearing invariants of that layout so a
* future refactor can't silently re-introduce the old surface or break
* the new one.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const HTML = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.html'), 'utf-8');
const JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.js'), 'utf-8');
const TERM_JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel-terminal.js'), 'utf-8');
const MANIFEST = JSON.parse(fs.readFileSync(path.join(import.meta.dir, '../../extension/manifest.json'), 'utf-8'));
describe('sidebar: chat tab + nav are removed, Terminal is sole primary surface', () => {
test('No primary-tab nav element exists', () => {
expect(HTML).not.toContain('class="primary-tabs"');
expect(HTML).not.toContain('data-pane="chat"');
expect(HTML).not.toContain('data-pane="terminal"');
});
test('No <main id="tab-chat"> pane', () => {
expect(HTML).not.toMatch(/<main[^>]*id="tab-chat"/);
expect(HTML).not.toContain('id="chat-messages"');
expect(HTML).not.toContain('id="chat-loading"');
expect(HTML).not.toContain('id="chat-welcome"');
});
test('No chat input / send button / experimental banner', () => {
expect(HTML).not.toContain('class="command-bar"');
expect(HTML).not.toContain('id="command-input"');
expect(HTML).not.toContain('id="send-btn"');
expect(HTML).not.toContain('id="stop-agent-btn"');
expect(HTML).not.toContain('id="experimental-banner"');
});
test('No clear-chat button in footer', () => {
expect(HTML).not.toContain('id="clear-chat"');
});
test('Terminal pane is .active by default and has the toolbar', () => {
expect(HTML).toMatch(/<main[^>]*id="tab-terminal"[^>]*class="tab-content active"/);
expect(HTML).toContain('id="terminal-toolbar"');
expect(HTML).toContain('id="terminal-restart-now"');
});
test('Quick-actions buttons (Cleanup / Screenshot / Cookies) survive in the terminal toolbar', () => {
// Garry explicitly wanted these kept after the chat rip — they drive
// browser actions, not chat.
expect(HTML).toContain('id="chat-cleanup-btn"');
expect(HTML).toContain('id="chat-screenshot-btn"');
expect(HTML).toContain('id="chat-cookies-btn"');
// They live inside the terminal toolbar now (siblings of the Restart
// button), not as a separate strip below all panes.
const toolbarStart = HTML.indexOf('id="terminal-toolbar"');
const toolbarEnd = HTML.indexOf('</div>', toolbarStart);
const toolbarBlock = HTML.slice(toolbarStart, toolbarEnd + 6);
expect(toolbarBlock).toContain('id="chat-cleanup-btn"');
expect(toolbarBlock).toContain('id="chat-screenshot-btn"');
expect(toolbarBlock).toContain('id="chat-cookies-btn"');
});
});
describe('sidepanel.js: chat helpers ripped, terminal-injection helper survives', () => {
test('No primary-tab click handler', () => {
expect(JS).not.toContain("querySelectorAll('.primary-tab')");
expect(JS).not.toContain('activePrimaryPaneId');
});
test('No chat polling, sendMessage, sendChat, stopAgent, or pollTabs', () => {
expect(JS).not.toContain('chatPollInterval');
expect(JS).not.toContain('function sendMessage');
expect(JS).not.toContain('function pollChat');
expect(JS).not.toContain('function pollTabs');
expect(JS).not.toContain('function switchChatTab');
expect(JS).not.toContain('function stopAgent');
expect(JS).not.toContain('function applyChatEnabled');
expect(JS).not.toContain('function showSecurityBanner');
});
test('Cleanup runs through the live PTY (no /sidebar-command POST)', () => {
// The new Cleanup handler injects the prompt straight into claude's
// PTY via gstackInjectToTerminal. The dead code path was a POST to
// /sidebar-command which kicked off a fresh claude -p subprocess.
const cleanup = JS.slice(JS.indexOf('async function runCleanup'));
expect(cleanup).toContain('window.gstackInjectToTerminal');
expect(cleanup).not.toContain('/sidebar-command');
expect(cleanup).not.toContain('addChatEntry');
});
test('Inspector "Send to Code" routes through the live PTY', () => {
const sendBtn = JS.slice(JS.indexOf('inspectorSendBtn.addEventListener'));
expect(sendBtn).toContain('window.gstackInjectToTerminal');
expect(sendBtn).not.toContain("type: 'sidebar-command'");
});
test('updateConnection no longer kicks off chat / tab polling', () => {
const update = JS.slice(JS.indexOf('function updateConnection'), JS.indexOf('function updateConnection') + 1500);
expect(update).not.toContain('chatPollInterval');
expect(update).not.toContain('tabPollInterval');
expect(update).not.toContain('pollChat');
expect(update).not.toContain('pollTabs');
// BUT must still expose the bootstrap globals for sidepanel-terminal.js.
expect(update).toContain('window.gstackServerPort');
expect(update).toContain('window.gstackAuthToken');
});
});
describe('sidepanel-terminal.js: eager auto-connect + injection API', () => {
test('Exposes window.gstackInjectToTerminal for cross-pane use', () => {
expect(TERM_JS).toContain('window.gstackInjectToTerminal');
// Returns false when no live session, true when bytes go out.
const inject = TERM_JS.slice(TERM_JS.indexOf('window.gstackInjectToTerminal'));
expect(inject).toContain('return false');
expect(inject).toContain('return true');
expect(inject).toContain('ws.readyState !== WebSocket.OPEN');
});
test('Auto-connects on init (no keypress required)', () => {
expect(TERM_JS).not.toContain('function onAnyKey');
expect(TERM_JS).not.toContain("addEventListener('keydown'");
expect(TERM_JS).toContain('function tryAutoConnect');
});
test('Repaint hook fires when Terminal pane becomes visible', () => {
// The chat-tab rip removed gstack:primary-tab-changed; we use a
// MutationObserver on #tab-terminal's class attr instead. The
// observer must call repaintIfLive when the .active class returns.
expect(TERM_JS).toContain('MutationObserver');
expect(TERM_JS).toContain("attributeFilter: ['class']");
expect(TERM_JS).toContain('repaintIfLive');
const repaint = TERM_JS.slice(TERM_JS.indexOf('function repaintIfLive'));
expect(repaint).toContain('fitAddon && fitAddon.fit()');
expect(repaint).toContain('term.refresh');
expect(repaint).toContain("type: 'resize'");
});
test('No auto-reconnect on close (Restart is user-initiated)', () => {
const closeOnly = TERM_JS.slice(
TERM_JS.indexOf("ws.addEventListener('close'"),
TERM_JS.indexOf("ws.addEventListener('error'"),
);
expect(closeOnly).not.toContain('setTimeout');
expect(closeOnly).not.toContain('tryAutoConnect');
expect(closeOnly).not.toContain('connect()');
});
test('forceRestart helper closes ws, disposes xterm, returns to IDLE', () => {
expect(TERM_JS).toContain('function forceRestart');
const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart'));
expect(fn).toContain('ws && ws.close()');
expect(fn).toContain('term.dispose()');
expect(fn).toContain('STATE.IDLE');
expect(fn).toContain('tryAutoConnect()');
});
test('Both restart buttons (mid-session and ENDED) call forceRestart', () => {
expect(TERM_JS).toContain("els.restart?.addEventListener('click', forceRestart)");
expect(TERM_JS).toContain("els.restartNow?.addEventListener('click', forceRestart)");
});
});
describe('server.ts: chat / sidebar-agent endpoints are gone', () => {
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
test('No /sidebar-command, /sidebar-chat, /sidebar-agent/* routes', () => {
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-command['"]/);
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-chat['"]/);
expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(['"]\/sidebar-agent\//);
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-agent\/event['"]/);
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-tabs['"]/);
expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-session['"]/);
});
test('No chat-related state declarations or helpers', () => {
// Allow the symbol names inside the rip-marker comments — but no
// `let`, `const`, `function`, or `interface` declarations of them.
expect(SERVER_SRC).not.toMatch(/^let agentProcess/m);
expect(SERVER_SRC).not.toMatch(/^let agentStatus/m);
expect(SERVER_SRC).not.toMatch(/^let messageQueue/m);
expect(SERVER_SRC).not.toMatch(/^let sidebarSession/m);
expect(SERVER_SRC).not.toMatch(/^const tabAgents/m);
expect(SERVER_SRC).not.toMatch(/^function pickSidebarModel/m);
expect(SERVER_SRC).not.toMatch(/^function processAgentEvent/m);
expect(SERVER_SRC).not.toMatch(/^function killAgent/m);
expect(SERVER_SRC).not.toMatch(/^function addChatEntry/m);
expect(SERVER_SRC).not.toMatch(/^interface ChatEntry/m);
expect(SERVER_SRC).not.toMatch(/^interface SidebarSession/m);
});
test('/health no longer surfaces agentStatus or messageQueue length', () => {
const health = SERVER_SRC.slice(SERVER_SRC.indexOf("url.pathname === '/health'"));
const slice = health.slice(0, 2000);
expect(slice).not.toContain('agentStatus');
expect(slice).not.toContain('messageQueue');
expect(slice).not.toContain('agentStartTime');
// chatEnabled is hardcoded false now (older clients still see the field).
expect(slice).toMatch(/chatEnabled:\s*false/);
// terminalPort survives.
expect(slice).toContain('terminalPort');
});
});
describe('cli.ts: sidebar-agent is no longer spawned', () => {
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
test('No Bun.spawn of sidebar-agent.ts', () => {
expect(CLI_SRC).not.toMatch(/Bun\.spawn\(\s*\['bun',\s*'run',\s*\w*[Aa]gent[Ss]cript\][\s\S]{0,300}sidebar-agent/);
// The variable name `agentScript` was for sidebar-agent. After the
// rip there's only termAgentScript. Allow comments to mention the
// history but not active spawn calls.
expect(CLI_SRC).not.toMatch(/^\s*let agentScript = path\.resolve/m);
});
test('Terminal-agent spawn survives', () => {
expect(CLI_SRC).toContain('terminal-agent.ts');
expect(CLI_SRC).toMatch(/Bun\.spawn\(\['bun',\s*'run',\s*termAgentScript\]/);
});
});
describe('files: sidebar-agent.ts and its tests are deleted', () => {
test('browse/src/sidebar-agent.ts is gone', () => {
expect(fs.existsSync(path.join(import.meta.dir, '../src/sidebar-agent.ts'))).toBe(false);
});
test('sidebar-agent test files are gone', () => {
expect(fs.existsSync(path.join(import.meta.dir, 'sidebar-agent.test.ts'))).toBe(false);
expect(fs.existsSync(path.join(import.meta.dir, 'sidebar-agent-roundtrip.test.ts'))).toBe(false);
});
});
describe('manifest: ws permission + xterm-safe CSP', () => {
test('host_permissions covers ws localhost', () => {
expect(MANIFEST.host_permissions).toContain('ws://127.0.0.1:*/');
});
test('host_permissions still covers http localhost', () => {
expect(MANIFEST.host_permissions).toContain('http://127.0.0.1:*/');
});
test('manifest does NOT add unsafe-eval to extension_pages CSP', () => {
const csp = MANIFEST.content_security_policy;
if (csp && csp.extension_pages) {
expect(csp.extension_pages).not.toContain('unsafe-eval');
}
});
});

View File

@@ -0,0 +1,196 @@
/**
* tab-each — fan-out command for the live Terminal pane.
*
* Source-level guards: command is registered, has a description + usage,
* scope-check the inner command, restore the original active tab in a
* finally block (so a mid-batch exception doesn't leave the user looking
* at a tab they didn't choose).
*
* Behavioral logic test: drive handleMetaCommand directly with a mock
* BrowserManager + executeCommand callback. Verify the iteration order,
* the JSON shape, the tab restore, and the chrome:// skip.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { handleMetaCommand } from '../src/meta-commands';
import { META_COMMANDS, COMMAND_DESCRIPTIONS } from '../src/commands';
const META_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/meta-commands.ts'), 'utf-8');
describe('tab-each: registration', () => {
test('command is in META_COMMANDS', () => {
expect(META_COMMANDS.has('tab-each')).toBe(true);
});
test('has a description and usage entry', () => {
expect(COMMAND_DESCRIPTIONS['tab-each']).toBeDefined();
expect(COMMAND_DESCRIPTIONS['tab-each'].usage).toContain('tab-each');
expect(COMMAND_DESCRIPTIONS['tab-each'].category).toBe('Tabs');
});
});
describe('tab-each: source-level guards', () => {
test('scope-checks the inner command before fanning out', () => {
const block = META_SRC.slice(META_SRC.indexOf("case 'tab-each':"));
expect(block).toContain('checkScope(tokenInfo, innerName)');
// The scope check must run BEFORE the for-loop. If it ran inside the
// loop, a permission failure on the second tab would leave the first
// tab already mutated.
const checkIdx = block.indexOf('checkScope(tokenInfo, innerName)');
const loopIdx = block.indexOf('for (const tab of tabs)');
expect(checkIdx).toBeLessThan(loopIdx);
});
test('restores the original active tab in a finally block', () => {
const block = META_SRC.slice(META_SRC.indexOf("case 'tab-each':"), META_SRC.indexOf("case 'tab-each':") + 4000);
expect(block).toContain('finally');
expect(block).toContain('originalActive');
expect(block).toContain('switchTab(originalActive');
});
test('uses bringToFront: false so the OS window does NOT jump', () => {
const block = META_SRC.slice(META_SRC.indexOf("case 'tab-each':"), META_SRC.indexOf("case 'tab-each':") + 4000);
// tab-each is a background operation — pulling focus would steal the
// user's foreground app every time claude fans out, which is
// unacceptable.
expect(block).toContain('bringToFront: false');
});
test('skips chrome:// and chrome-extension:// internal pages', () => {
const block = META_SRC.slice(META_SRC.indexOf("case 'tab-each':"), META_SRC.indexOf("case 'tab-each':") + 4000);
expect(block).toContain("startsWith('chrome://')");
expect(block).toContain("startsWith('chrome-extension://')");
});
});
describe('tab-each: behavior', () => {
function mockBm(tabs: Array<{ id: number; url: string; title: string; active: boolean }>) {
let activeId = tabs.find(t => t.active)?.id ?? tabs[0]?.id ?? 0;
const switched: number[] = [];
return {
__switched: switched,
__activeId: () => activeId,
getActiveSession: () => ({}),
getActiveTabId: () => activeId,
getTabListWithTitles: async () => tabs.map(t => ({ ...t })),
switchTab: (id: number, _opts?: any) => { switched.push(id); activeId = id; },
} as any;
}
test('iterates every tab, calls executeCommand for each, returns JSON results', async () => {
const tabs = [
{ id: 1, url: 'https://news.example.com', title: 'News', active: true },
{ id: 2, url: 'https://docs.example.com', title: 'Docs', active: false },
{ id: 3, url: 'https://github.com', title: 'GitHub', active: false },
];
const bm = mockBm(tabs);
const calls: Array<{ command: string; args?: string[]; tabId?: number }> = [];
const out = await handleMetaCommand(
'tab-each',
['snapshot', '-i'],
bm,
async () => {},
null,
{
executeCommand: async (body) => {
calls.push(body);
return { status: 200, result: `snap-of-${body.tabId}` };
},
},
);
const parsed = JSON.parse(out);
expect(parsed.command).toBe('snapshot');
expect(parsed.args).toEqual(['-i']);
expect(parsed.total).toBe(3);
expect(parsed.results.map((r: any) => r.tabId)).toEqual([1, 2, 3]);
expect(parsed.results.every((r: any) => r.status === 200)).toBe(true);
expect(parsed.results[0].output).toBe('snap-of-1');
// Inner command was dispatched 3 times, once per tab, with the right tabId.
expect(calls).toHaveLength(3);
expect(calls.map(c => c.tabId)).toEqual([1, 2, 3]);
expect(calls.every(c => c.command === 'snapshot')).toBe(true);
});
test('skips chrome:// pages with status=0 + "skipped" output', async () => {
const tabs = [
{ id: 1, url: 'chrome://newtab', title: 'New Tab', active: true },
{ id: 2, url: 'https://example.com', title: 'Example', active: false },
{ id: 3, url: 'chrome-extension://abc/page.html', title: 'Ext', active: false },
];
const bm = mockBm(tabs);
const calls: any[] = [];
const out = await handleMetaCommand(
'tab-each',
['text'],
bm,
async () => {},
null,
{
executeCommand: async (body) => {
calls.push(body);
return { status: 200, result: `text-of-${body.tabId}` };
},
},
);
const parsed = JSON.parse(out);
expect(parsed.total).toBe(3);
// chrome:// and chrome-extension:// → skipped (status 0).
expect(parsed.results[0].status).toBe(0);
expect(parsed.results[0].output).toContain('skipped');
expect(parsed.results[2].status).toBe(0);
// Only the real tab dispatched.
expect(calls).toHaveLength(1);
expect(calls[0].tabId).toBe(2);
});
test('restores the originally active tab even if a tab errors', async () => {
const tabs = [
{ id: 10, url: 'https://a.example', title: 'A', active: false },
{ id: 20, url: 'https://b.example', title: 'B', active: true }, // initially active
{ id: 30, url: 'https://c.example', title: 'C', active: false },
];
const bm = mockBm(tabs);
let calls = 0;
const out = await handleMetaCommand(
'tab-each',
['text'],
bm,
async () => {},
null,
{
executeCommand: async (body) => {
calls++;
if (body.tabId === 20) {
return { status: 500, result: JSON.stringify({ error: 'boom' }) };
}
return { status: 200, result: `ok-${body.tabId}` };
},
},
);
const parsed = JSON.parse(out);
expect(parsed.results.find((r: any) => r.tabId === 20).status).toBe(500);
expect(parsed.results.find((r: any) => r.tabId === 20).output).toBe('boom');
expect(parsed.results.find((r: any) => r.tabId === 10).status).toBe(200);
expect(parsed.results.find((r: any) => r.tabId === 30).status).toBe(200);
// Active tab restored to 20 (the one that was active when we started).
expect(bm.__activeId()).toBe(20);
});
test('throws on empty args (no inner command)', async () => {
const bm = mockBm([{ id: 1, url: 'https://x.example', title: 'X', active: true }]);
await expect(handleMetaCommand(
'tab-each',
[],
bm,
async () => {},
null,
{ executeCommand: async () => ({ status: 200, result: '' }) },
)).rejects.toThrow(/Usage/);
});
});

View File

@@ -0,0 +1,273 @@
/**
* Integration tests for terminal-agent.ts.
*
* Spawns the agent as a real subprocess in a temp state directory,
* exercises:
* 1. /internal/grant — loopback handshake with the internal token.
* 2. /ws Origin gate — non-extension Origin → 403.
* 3. /ws cookie gate — missing/invalid cookie → 401.
* 4. /ws full PTY round-trip — write `echo hi\n`, read `hi`.
* 5. resize control message — terminal accepts and stays alive.
* 6. close behavior — sending close terminates the PTY child.
*
* Uses /bin/bash via BROWSE_TERMINAL_BINARY override so CI doesn't need
* the `claude` binary installed.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts');
const BASH = '/bin/bash';
let stateDir: string;
let agentProc: any;
let agentPort: number;
let internalToken: string;
function readPortFile(): number {
for (let i = 0; i < 50; i++) {
try {
const v = parseInt(fs.readFileSync(path.join(stateDir, 'terminal-port'), 'utf-8').trim(), 10);
if (Number.isFinite(v) && v > 0) return v;
} catch {}
Bun.sleepSync(40);
}
throw new Error('terminal-agent never wrote port file');
}
function readTokenFile(): string {
for (let i = 0; i < 50; i++) {
try {
const t = fs.readFileSync(path.join(stateDir, 'terminal-internal-token'), 'utf-8').trim();
if (t.length > 16) return t;
} catch {}
Bun.sleepSync(40);
}
throw new Error('terminal-agent never wrote internal token');
}
beforeAll(() => {
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-term-'));
const stateFile = path.join(stateDir, 'browse.json');
// browse.json must exist so the agent's readBrowseToken doesn't throw.
fs.writeFileSync(stateFile, JSON.stringify({ token: 'test-browse-token' }));
agentProc = Bun.spawn(['bun', 'run', AGENT_SCRIPT], {
env: {
...process.env,
BROWSE_STATE_FILE: stateFile,
BROWSE_SERVER_PORT: '0', // not used in this test
BROWSE_TERMINAL_BINARY: BASH,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
agentPort = readPortFile();
internalToken = readTokenFile();
});
afterAll(() => {
try { agentProc?.kill?.(); } catch {}
try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {}
});
async function grantToken(token: string): Promise<Response> {
return fetch(`http://127.0.0.1:${agentPort}/internal/grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${internalToken}`,
},
body: JSON.stringify({ token }),
});
}
describe('terminal-agent: /internal/grant', () => {
test('accepts grants signed with the internal token', async () => {
const resp = await grantToken('test-cookie-token-very-long-yes');
expect(resp.status).toBe(200);
});
test('rejects grants with the wrong internal token', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/internal/grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer wrong-token',
},
body: JSON.stringify({ token: 'whatever' }),
});
expect(resp.status).toBe(403);
});
});
describe('terminal-agent: /ws gates', () => {
test('rejects upgrade attempts without an extension Origin', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`);
expect(resp.status).toBe(403);
expect(await resp.text()).toBe('forbidden origin');
});
test('rejects upgrade attempts from a non-extension Origin', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: { 'Origin': 'https://evil.example.com' },
});
expect(resp.status).toBe(403);
});
test('rejects extension-Origin upgrades without a granted cookie', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://abc123',
'Cookie': 'gstack_pty=never-granted',
},
});
expect(resp.status).toBe(401);
});
});
describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () => {
test('binary writes go to PTY stdin, output streams back', async () => {
const cookie = 'rt-token-must-be-at-least-seventeen-chars-long';
const granted = await grantToken(cookie);
expect(granted.status).toBe(200);
const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://test-extension-id',
'Cookie': `gstack_pty=${cookie}`,
},
} as any);
const collected: string[] = [];
let opened = false;
let closed = false;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('ws never opened')), 5000);
ws.addEventListener('open', () => { opened = true; clearTimeout(timer); resolve(); });
ws.addEventListener('error', (e: any) => { clearTimeout(timer); reject(new Error('ws error')); });
});
ws.addEventListener('message', (ev: any) => {
if (typeof ev.data === 'string') return; // ignore control frames
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data;
collected.push(new TextDecoder().decode(buf));
});
ws.addEventListener('close', () => { closed = true; });
// Lazy-spawn trigger: any binary frame causes the agent to spawn /bin/bash.
ws.send(new TextEncoder().encode('echo hello-pty-world\nexit\n'));
// Wait up to 5s for output and shutdown.
await new Promise<void>((resolve) => {
const start = Date.now();
const tick = () => {
const joined = collected.join('');
if (joined.includes('hello-pty-world')) return resolve();
if (Date.now() - start > 5000) return resolve();
setTimeout(tick, 50);
};
tick();
});
expect(opened).toBe(true);
const allOutput = collected.join('');
expect(allOutput).toContain('hello-pty-world');
try { ws.close(); } catch {}
// Give cleanup a moment.
await Bun.sleep(200);
});
test('Sec-WebSocket-Protocol auth path: browser-style upgrade with token in protocol', async () => {
// This is the path the actual browser extension takes. Cross-port
// SameSite=Strict cookies don't reliably survive the jump from the
// browse server (port A) to the agent (port B) when initiated from a
// chrome-extension origin, so we send the token via the only auth
// header the browser WebSocket API lets us set: Sec-WebSocket-Protocol.
//
// The browser sends `gstack-pty.<token>` and the agent must:
// 1) strip the gstack-pty. prefix
// 2) validate the token
// 3) ECHO the protocol back in the upgrade response
// Without (3) the browser closes the connection immediately, which
// is the exact bug the original cookie-only implementation hit in
// manual dogfood. This test catches that regression in CI.
const token = 'sec-protocol-token-must-be-at-least-seventeen-chars';
await grantToken(token);
// We exercise the protocol path by raw-handshaking via fetch+Upgrade,
// because Bun's test-client WebSocket constructor doesn't propagate
// `protocols` cleanly when also passed `headers` (the constructor
// detects the third-arg form unreliably). Real browsers (Chromium)
// use the standard protocols arg fine — the server-side handler is
// identical either way, so this test still locks the load-bearing
// invariant: the agent accepts a token via Sec-WebSocket-Protocol
// and echoes the protocol back so a browser would accept the upgrade.
const handshakeKey = 'dGhlIHNhbXBsZSBub25jZQ==';
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: {
'Connection': 'Upgrade',
'Upgrade': 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': handshakeKey,
'Sec-WebSocket-Protocol': `gstack-pty.${token}`,
'Origin': 'chrome-extension://test-extension-id',
},
});
// 101 Switching Protocols + protocol echoed back = browser would accept.
// 401/403/anything else = browser would close the connection immediately
// (the bug we hit in manual dogfood).
expect(resp.status).toBe(101);
expect(resp.headers.get('upgrade')?.toLowerCase()).toBe('websocket');
expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`);
});
test('Sec-WebSocket-Protocol auth: rejects unknown token even with valid Origin', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: {
'Connection': 'Upgrade',
'Upgrade': 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Protocol': 'gstack-pty.never-granted-token',
'Origin': 'chrome-extension://test-extension-id',
},
});
expect(resp.status).toBe(401);
});
test('text frame {type:"resize"} is accepted (no crash, ws stays open)', async () => {
const cookie = 'resize-token-must-be-at-least-seventeen-chars';
await grantToken(cookie);
const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://test-extension-id',
'Cookie': `gstack_pty=${cookie}`,
},
} as any);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('ws never opened')), 5000);
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('ws error')); });
});
// Send a resize before anything else (lazy-spawn won't fire).
ws.send(JSON.stringify({ type: 'resize', cols: 120, rows: 40 }));
// After resize, send a binary frame; should still work.
ws.send(new TextEncoder().encode('exit\n'));
await Bun.sleep(300);
// ws still readyState 1 (OPEN) or 3 (CLOSED after exit) — both fine.
expect([WebSocket.OPEN, WebSocket.CLOSED]).toContain(ws.readyState);
try { ws.close(); } catch {}
});
});

View File

@@ -0,0 +1,223 @@
/**
* Unit tests for the Terminal-tab PTY agent and its server-side glue.
*
* Coverage:
* - pty-session-cookie module: mint / validate / revoke / TTL pruning.
* - source-level guard: /pty-session and /terminal/* are NOT in TUNNEL_PATHS.
* - source-level guard: /health does not surface ptyToken.
* - source-level guard: terminal-agent binds 127.0.0.1 only.
* - source-level guard: terminal-agent enforces Origin AND cookie on /ws.
*
* These are read-only checks against source — they prevent silent surface
* widening during a routine refactor (matches the dual-listener.test.ts
* pattern). End-to-end behavior (real /bin/bash PTY round-trip,
* tunnel-surface 404 + denial-log) lives in
* `browse/test/terminal-agent-integration.test.ts`.
*/
import { describe, test, expect, beforeEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import {
mintPtySessionToken, validatePtySessionToken, revokePtySessionToken,
extractPtyCookie, buildPtySetCookie, buildPtyClearCookie,
PTY_COOKIE_NAME, __resetPtySessions,
} from '../src/pty-session-cookie';
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
const AGENT_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/terminal-agent.ts'), 'utf-8');
describe('pty-session-cookie: mint/validate/revoke', () => {
beforeEach(() => __resetPtySessions());
test('a freshly minted token validates', () => {
const { token } = mintPtySessionToken();
expect(validatePtySessionToken(token)).toBe(true);
});
test('null and unknown tokens fail validation', () => {
expect(validatePtySessionToken(null)).toBe(false);
expect(validatePtySessionToken(undefined)).toBe(false);
expect(validatePtySessionToken('')).toBe(false);
expect(validatePtySessionToken('not-a-real-token')).toBe(false);
});
test('revoke makes a token invalid', () => {
const { token } = mintPtySessionToken();
expect(validatePtySessionToken(token)).toBe(true);
revokePtySessionToken(token);
expect(validatePtySessionToken(token)).toBe(false);
});
test('Set-Cookie has HttpOnly + SameSite=Strict + Path=/ + Max-Age', () => {
const { token } = mintPtySessionToken();
const cookie = buildPtySetCookie(token);
expect(cookie).toContain(`${PTY_COOKIE_NAME}=${token}`);
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('SameSite=Strict');
expect(cookie).toContain('Path=/');
expect(cookie).toMatch(/Max-Age=\d+/);
// Secure is intentionally omitted — daemon binds 127.0.0.1 over HTTP.
expect(cookie).not.toContain('Secure');
});
test('clear-cookie has Max-Age=0', () => {
expect(buildPtyClearCookie()).toContain('Max-Age=0');
});
test('extractPtyCookie reads gstack_pty from a Cookie header', () => {
const { token } = mintPtySessionToken();
const req = new Request('http://127.0.0.1/ws', {
headers: { 'cookie': `othercookie=foo; gstack_pty=${token}; baz=qux` },
});
expect(extractPtyCookie(req)).toBe(token);
});
test('extractPtyCookie returns null when the cookie is missing', () => {
const req = new Request('http://127.0.0.1/ws', {
headers: { 'cookie': 'unrelated=value' },
});
expect(extractPtyCookie(req)).toBe(null);
});
});
describe('Source-level guard: /pty-session is not on the tunnel surface', () => {
test('TUNNEL_PATHS does not include /pty-session or /terminal/*', () => {
const start = SERVER_SRC.indexOf('const TUNNEL_PATHS = new Set<string>([');
expect(start).toBeGreaterThan(-1);
const end = SERVER_SRC.indexOf(']);', start);
const body = SERVER_SRC.slice(start, end);
expect(body).not.toContain('/pty-session');
expect(body).not.toContain('/terminal/');
expect(body).not.toContain('/terminal-');
});
});
describe('Source-level guard: /health does NOT surface ptyToken', () => {
test('/health response body does not include ptyToken', () => {
const healthIdx = SERVER_SRC.indexOf("url.pathname === '/health'");
expect(healthIdx).toBeGreaterThan(-1);
// Slice from /health through the response close-bracket.
const slice = SERVER_SRC.slice(healthIdx, healthIdx + 2000);
// The /health JSON.stringify body must not mention the cookie token.
// It's allowed to include `terminalPort` (a port number, not auth).
expect(slice).not.toContain('ptyToken');
expect(slice).not.toContain('gstack_pty');
expect(slice).toContain('terminalPort');
});
});
describe('Source-level guard: terminal-agent', () => {
test('binds 127.0.0.1 only, never 0.0.0.0', () => {
expect(AGENT_SRC).toContain("hostname: '127.0.0.1'");
expect(AGENT_SRC).not.toContain("hostname: '0.0.0.0'");
});
test('rejects /ws upgrades without chrome-extension:// Origin', () => {
// The Origin check must run BEFORE the cookie check — otherwise a
// missing-origin attempt would surface the 401 cookie message and
// signal to attackers that they need to forge a cookie.
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
expect(wsHandler).toContain('chrome-extension://');
expect(wsHandler).toContain('forbidden origin');
});
test('validates the session token against an in-memory token set', () => {
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
// Two transports: Sec-WebSocket-Protocol (preferred for browsers) and
// Cookie gstack_pty (fallback). Both verify against validTokens.
expect(wsHandler).toContain('sec-websocket-protocol');
expect(wsHandler).toContain('gstack_pty');
expect(wsHandler).toContain('validTokens.has');
});
test('Sec-WebSocket-Protocol auth: strips gstack-pty. prefix and echoes back', () => {
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
// Browsers send `Sec-WebSocket-Protocol: gstack-pty.<token>`. The agent
// must strip the prefix before checking validTokens, AND echo the
// protocol back in the upgrade response — without the echo, the
// browser closes the connection immediately.
expect(wsHandler).toContain("'gstack-pty.'");
expect(wsHandler).toContain('Sec-WebSocket-Protocol');
expect(wsHandler).toContain('acceptedProtocol');
});
test('lazy spawn: claude PTY is spawned in message handler, not on upgrade', () => {
// The whole point of lazy-spawn (codex finding #8) is that the WS
// upgrade itself does NOT call spawnClaude. Spawn happens on first
// message frame.
const upgradeBlock = AGENT_SRC.slice(
AGENT_SRC.indexOf("if (url.pathname === '/ws')"),
AGENT_SRC.indexOf("websocket: {"),
);
expect(upgradeBlock).not.toContain('spawnClaude(');
// Spawn must be invoked from the message handler (lazy on first byte).
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
expect(messageHandler).toContain('spawnClaude(');
expect(messageHandler).toContain('!session.spawned');
});
test('process.on uncaughtException + unhandledRejection handlers exist', () => {
expect(AGENT_SRC).toContain("process.on('uncaughtException'");
expect(AGENT_SRC).toContain("process.on('unhandledRejection'");
});
test('cleanup escalates SIGINT to SIGKILL after 3s on close', () => {
// disposeSession must be idempotent and use a SIGINT-then-SIGKILL pattern.
const dispose = AGENT_SRC.slice(AGENT_SRC.indexOf('function disposeSession'));
expect(dispose).toContain("'SIGINT'");
expect(dispose).toContain("'SIGKILL'");
expect(dispose).toContain('3000');
});
test('tabState frames write tabs.json + active-tab.json', () => {
expect(AGENT_SRC).toContain("msg?.type === 'tabState'");
expect(AGENT_SRC).toContain('function handleTabState');
const fn = AGENT_SRC.slice(AGENT_SRC.indexOf('function handleTabState'));
// Atomic write via tmp + rename for both files (so claude never reads
// a half-written JSON document).
expect(fn).toContain("'tabs.json'");
expect(fn).toContain("'active-tab.json'");
expect(fn).toContain('renameSync');
// Skip chrome:// and chrome-extension:// pages — they're not useful
// targets for browse commands.
expect(fn).toContain("startsWith('chrome://')");
expect(fn).toContain("startsWith('chrome-extension://')");
});
test('claude is spawned with --append-system-prompt tab-awareness hint', () => {
expect(AGENT_SRC).toContain('function buildTabAwarenessHint');
const hint = AGENT_SRC.slice(AGENT_SRC.indexOf('function buildTabAwarenessHint'));
// The hint must mention the live state files and the fanout command —
// those are the two affordances that distinguish a gstack-PTY claude
// from a plain `claude` session.
expect(hint).toContain('tabs.json');
expect(hint).toContain('active-tab.json');
expect(hint).toContain('tab-each');
// And it must be passed via --append-system-prompt at spawn time
// (NOT written into the PTY as user input — that would pollute the
// visible transcript).
const spawn = AGENT_SRC.slice(AGENT_SRC.indexOf('function spawnClaude'));
expect(spawn).toContain("'--append-system-prompt'");
expect(spawn).toContain('tabHint');
});
});
describe('Source-level guard: server.ts /pty-session route', () => {
test('validates AUTH_TOKEN, grants over loopback, returns token + Set-Cookie', () => {
const route = SERVER_SRC.slice(SERVER_SRC.indexOf("url.pathname === '/pty-session'"));
// Must check auth before minting.
const beforeMint = route.slice(0, route.indexOf('mintPtySessionToken'));
expect(beforeMint).toContain('validateAuth');
// Must call the loopback grant before responding (otherwise the
// agent's validTokens Set never sees the token and /ws would 401).
expect(route).toContain('grantPtyToken');
// Must return the token in the JSON body for the
// Sec-WebSocket-Protocol auth path (cross-port cookies don't survive
// SameSite=Strict from a chrome-extension origin).
expect(route).toContain('ptySessionToken');
// Set-Cookie is kept as a fallback for non-browser callers.
expect(route).toContain('Set-Cookie');
expect(route).toContain('buildPtySetCookie');
});
});