Files
gstack/extension/sidepanel.js
Garry Tan 54d4cde773 security: tunnel dual-listener + SSRF + envelope + path wave (v1.6.0.0) (#1137)
* refactor(security): loosen /connect rate limit from 3/min to 300/min

Setup keys are 24 random bytes (unbruteforceable), so a tight rate limit
does not meaningfully prevent key guessing. It exists only to cap
bandwidth, CPU, and log-flood damage from someone who discovered the
ngrok URL. A legitimate pair-agent session hits /connect once; 300/min
is 60x that pattern and never hit accidentally.

3/min caused pairing to fail on any retry flow (network blip, second
paired client) with no upside. Per-IP tracking was considered and
rejected — adds a bounded Map + LRU for defense already adequate at the
global layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(security): add tunnel-denial-log module for attack visibility

Append-only log of tunnel-surface auth denials to
~/.gstack/security/attempts.jsonl. Gives operators visibility into who
is probing tunneled daemons so the next security wave can be driven by
real attack data instead of speculation.

Design notes:
- Async via fs.promises.appendFile. Never appendFileSync — blocking the
  event loop on every denial during a flood is what an attacker wants
  (prior learning: sync-audit-log-io, 10/10 confidence).
- In-process rate cap at 60 writes/minute globally. Excess denials are
  counted in memory but not written to disk — prevents disk DoS.
- Writes to the same ~/.gstack/security/attempts.jsonl used by the
  prompt-injection attempt log. File rotation is handled by the existing
  security pipeline (10MB, 5 generations).

No consumers in this commit; wired up in the dual-listener refactor that
follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(security): dual-listener tunnel architecture

The /health endpoint leaked AUTH_TOKEN to any caller that hit the ngrok
URL (spoofing chrome-extension:// origin, or catching headed mode).
Surfaced by @garagon in PR #1026; the original fix was header-inference
on the single port. Codex's outside-voice review during /plan-ceo-review
called that approach brittle (ngrok header behavior could change, local
proxies would false-positive), and pushed for the structural fix.

This is that fix. Stop making /health a root-token bootstrap endpoint on
any surface the tunnel can reach. The server now binds two HTTP
listeners when a tunnel is active. The local listener (extension, CLI,
sidebar) stays on 127.0.0.1 and is never exposed to ngrok. ngrok
forwards only to the tunnel listener, which serves only /connect
(unauth, rate-limited) and /command with a locked allowlist of
browser-driving commands. Security property comes from physical port
separation, not from header inference — a tunnel caller cannot reach
/health or /cookie-picker or /inspector because they live on a
different TCP socket.

What this commit adds to browse/src/server.ts:
  * Surface type ('local' | 'tunnel') and TUNNEL_PATHS +
    TUNNEL_COMMANDS allowlists near the top of the file.
  * makeFetchHandler(surface) factory replacing the single fetch arrow;
    closure-captures the surface so the filter that runs before route
    dispatch knows which socket accepted the request.
  * Tunnel filter at dispatch entry: 404s anything not on TUNNEL_PATHS,
    403s root-token bearers with a clear pairing hint, 401s non-/connect
    requests that lack a scoped token. Every denial is logged via
    logTunnelDenial (from tunnel-denial-log).
  * GET /connect alive probe (unauth on both surfaces) so /pair and
    /tunnel/start can detect dead ngrok tunnels without reaching
    /health — /health is no longer tunnel-reachable.
  * Lazy tunnel listener lifecycle. /tunnel/start binds a dedicated
    Bun.serve on an ephemeral port, points ngrok.forward at THAT port
    (not the local port), hard-fails on bind error (no local fallback),
    tears down cleanly on ngrok failure. BROWSE_TUNNEL=1 startup uses
    the same pattern.
  * closeTunnel() helper — single teardown path for both the ngrok
    listener and the tunnel Bun.serve listener.
  * resolveNgrokAuthtoken() helper — shared authtoken lookup across
    /tunnel/start and BROWSE_TUNNEL=1 startup (was duplicated).
  * TUNNEL_COMMANDS check in /command dispatch: on the tunnel surface,
    commands outside the allowlist return 403 with a list of allowed
    commands as a hint.
  * Probe paths in /pair and /tunnel/start migrated from /health to
    GET /connect — the only unauth path reachable on the tunnel surface
    under the new architecture.

Test updates in browse/test/server-auth.test.ts:
  * /pair liveness-verify test: assert via closeTunnel() helper instead
    of the inline `tunnelActive = false; tunnelUrl = null` lines that
    the helper subsumes.
  * /tunnel/start cached-tunnel test: same closeTunnel() adaptation.

Credit
  Derived from PR #1026 by @garagon — thanks for flagging the critical
  bug that drove the architectural rewrite. The per-request
  isTunneledRequest approach from #1026 is superseded by physical port
  separation here; the underlying report remains the root cause for the
  entire v1.6.0.0 wave.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(security): add source-level guards for dual-listener architecture

23 source-level assertions that keep future contributors from silently
widening the tunnel surface during a routine refactor. Covers:

  * Surface type + tunnelServer state variable shape
  * TUNNEL_PATHS is a closed set of /connect, /command, /sidebar-chat
    (and NOT /health, /welcome, /cookie-picker, /inspector/*, /pair,
    /token, /refs, /activity/stream, /tunnel/{start,stop})
  * TUNNEL_COMMANDS includes browser-driving ops only (and NOT
    launch-browser, tunnel-start, token-mint, cookie-import, etc.)
  * makeFetchHandler(surface) factory exists and is wired to both
    listeners with the correct surface parameter
  * Tunnel filter runs BEFORE any route dispatch, with 404/403/401
    responses and logged denials for each reason
  * GET /connect returns {alive: true} unauth
  * /command dispatch enforces TUNNEL_COMMANDS on tunnel surface
  * closeTunnel() helper tears down ngrok + Bun.serve listener
  * /tunnel/start binds on ephemeral port, points ngrok at TUNNEL_PORT
    (not local port), hard-fails on bind error (no fallback), probes
    cached tunnel via GET /connect (not /health), tears down on
    ngrok.forward failure
  * BROWSE_TUNNEL=1 startup uses the dual-listener pattern
  * logTunnelDenial wired for all three denial reasons
  * /connect rate limit is 300/min, not 3/min

All 23 tests pass. Behavioral integration tests (spawn subprocess, real
network) live in the E2E suite that lands later in this wave.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security: gate download + scrape through validateNavigationUrl (SSRF)

The `goto` command was correctly wired through validateNavigationUrl,
but `download` and `scrape` called page.request.fetch(url, ...) directly.
A caller with the default write scope could hit the /command endpoint
and ask the daemon to fetch http://169.254.169.254/latest/meta-data/
(AWS IMDSv1) or the GCP/Azure/internal equivalents. The response body
comes back as base64 or lands on disk where GET /file serves it.

Fix: call validateNavigationUrl(url) immediately before each
page.request.fetch() call site in download and in the scrape loop.
Same blocklist that already protects `goto`: file://, javascript:,
data:, chrome://, cloud metadata (IPv4 all encodings, IPv6 ULA,
metadata.*.internal).

Tests: extend browse/test/url-validation.test.ts with a source-level
guard that walks every `await page.request.fetch(` call site and
asserts a validateNavigationUrl call precedes it within the same
branch. Regression trips before code review if a future refactor
drops the gate.

* security: route splitForScoped through envelope sentinel escape

The scoped-token snapshot path in snapshot.ts built its untrusted
block by pushing the raw accessibility-tree lines between the literal
`═══ BEGIN UNTRUSTED WEB CONTENT ═══` / `═══ END UNTRUSTED WEB CONTENT ═══`
sentinels. The full-page wrap path in content-security.ts already
applied a zero-width-space escape on those exact strings to prevent
sentinel injection, but the scoped path skipped it.

Net effect: a page whose rendered text contains the literal sentinel
can close the envelope early from inside untrusted content and forge
a fake "trusted" block for the LLM. That includes fabricating
interactive `@eN` references the agent will act on.

Fix:
  * Extract the zero-width-space escape into a named, exported helper
    `escapeEnvelopeSentinels(content)` in content-security.ts.
  * Have `wrapUntrustedPageContent` call it (behavior unchanged on
    that path — same bytes out).
  * Import the helper in snapshot.ts and map it over `untrustedLines`
    in the `splitForScoped` branch before pushing the BEGIN sentinel.

Tests: add a describe block in content-security.test.ts that covers
  * `escapeEnvelopeSentinels` defuses BEGIN and END markers;
  * `escapeEnvelopeSentinels` leaves normal text untouched;
  * `wrapUntrustedPageContent` still emits exactly one real envelope
    pair when hostile content contains forged sentinels;
  * snapshot.ts imports the helper;
  * the scoped-snapshot branch calls `escapeEnvelopeSentinels` before
    pushing the BEGIN sentinel (source-level regression — if a future
    refactor reorders this, the test trips).

* security: extend hidden-element detection to all DOM-reading channels

The Confusion Protocol envelope wrap (`wrapUntrustedPageContent`)
covers every scoped PAGE_CONTENT_COMMAND, but the hidden-element
ARIA-injection detection layer only ran for `text`. Other DOM-reading
channels (html, links, forms, accessibility, attrs, data, media,
ux-audit) returned their output through the envelope with no hidden-
content filter, so a page serving a display:none div that instructs
the agent to disregard prior system messages, or an aria-label that
claims to put the LLM in admin mode, leaked the injection payload on
any non-text channel. The envelope alone does not mitigate this, and
the page itself never rendered the hostile content to the human
operator.

Fix:
  * New export `DOM_CONTENT_COMMANDS` in commands.ts — the subset of
    PAGE_CONTENT_COMMANDS that derives its output from the live DOM.
    Console and dialog stay out; they read separate runtime state.
  * server.ts runs `markHiddenElements` + `cleanupHiddenMarkers` for
    every scoped command in this set. `text` keeps its existing
    `getCleanTextWithStripping` path (hidden elements physically
    stripped before the read). All other channels keep their output
    format but emit flagged elements as CONTENT WARNINGS on the
    envelope, so the LLM sees what it would otherwise have consumed
    silently.
  * Hidden-element descriptions merge into `combinedWarnings`
    alongside content-filter warnings before the wrap call.

Tests: new describe block in content-security.test.ts covering
  * `DOM_CONTENT_COMMANDS` export shape and channel membership;
  * dispatch gates on `DOM_CONTENT_COMMANDS.has(command)`, not the
    literal `text` string;
  * hiddenContentWarnings plumbs into `combinedWarnings` and reaches
    wrapUntrustedPageContent;
  * DOM_CONTENT_COMMANDS is a strict subset of PAGE_CONTENT_COMMANDS.

Existing datamarking, envelope wrap, centralized-wrapping, and chain
security suites stay green (52 pass, 0 fail).

* security: validate --from-file payload paths for parity with direct paths

The direct `load-html <file>` path runs every caller-supplied file path
through validateReadPath() so reads stay confined to SAFE_DIRECTORIES
(cwd, TEMP_DIR). The `load-html --from-file <payload.json>` shortcut
and its sibling `pdf --from-file <payload.json>` skipped that check and
went straight to fs.readFileSync(). An MCP caller that picks the
payload path (or any caller whose payload argument is reachable from
attacker-influenced text) could use --from-file as a read-anywhere
escape hatch for the safe-dirs policy.

Fix: call validateReadPath(path.resolve(payloadPath)) before readFileSync
at both sites. Error surface mirrors the direct-path branch so ops and
agent errors stay consistent.

Test coverage in browse/test/from-file-path-validation.test.ts:
  - source-level: validateReadPath precedes readFileSync in the load-html
    --from-file branch (write-commands.ts) and the pdf --from-file parser
    (meta-commands.ts)
  - error-message parity: both sites reference SAFE_DIRECTORIES

Related security audit pattern: R3 F002 (validateNavigationUrl gap on
download/scrape) and R3 F008 (markHiddenElements gap on 10 DOM commands)
were the same shape — a defense that existed on the primary code path
but not its shortcut sibling. This PR closes the same class of gap on
the --from-file shortcuts.

* fix(design): escape url.origin when injecting into served HTML

serve.ts injected url.origin into a single-quoted JS string in
the response body. A local request with a crafted Host header
(e.g. Host: "evil'-alert(1)-'x") would break out of the string
and execute JS in the 127.0.0.1:<port> origin opened by the
design board. Low severity — bound to localhost, requires a
local attacker — but no reason not to escape.

Fix: JSON.stringify(url.origin) produces a properly quoted,
escaped JS string literal in one call.

Also includes Prettier reformatting (single→double quotes,
trailing commas, line wrapping) applied by the repo's
PostToolUse formatter hook. Security change is the one line
in the HTML injection; everything else is whitespace/style.

* fix(scripts): drop shell:true from slop-diff npx invocations

spawnSync('npx', [...], { shell: true }) invokes /bin/sh -c
with the args concatenated, subjecting them to shell parsing
(word splitting, glob expansion, metacharacter interpretation).
No user input reaches these calls today, so not exploitable —
but the posture is wrong: npx + shell args should be direct.

Fix: scope shell:true to process.platform === 'win32' where
npx is actually a .cmd requiring the shell. POSIX runs the
npx binary directly with array-form args.

Also includes Prettier reformatting (single→double quotes,
trailing commas, line wrapping) applied by the repo's
PostToolUse formatter hook. Security-relevant change is just
the two shell:true -> shell: process.platform === 'win32'
lines; everything else is whitespace/style.

* security(E3): gate GSTACK_SLUG on /welcome path traversal

The /welcome handler interpolates GSTACK_SLUG directly into the filesystem
path used to locate the project-local welcome page. Without validation, a
slug like "../../etc/passwd" would resolve to
~/.gstack/projects/../../etc/passwd/designs/welcome-page-20260331/finalized.html
— classic path traversal.

Not exploitable today: GSTACK_SLUG is set by the gstack CLI at daemon launch,
and an attacker would already need local env-var access to poison it. But
the gate is one regex (^[a-z0-9_-]+$), and a defense-in-depth pass costs us
nothing when the cost of being wrong is arbitrary file read via /welcome.

Fall back to the safe 'unknown' literal when the slug fails validation —
same fallback the code already uses when GSTACK_SLUG is unset. No behavior
change for legitimate slugs (they all match the regex).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security(N1): replace ?token= SSE auth with HttpOnly session cookie

Activity stream and inspector events SSE endpoints accepted the root
AUTH_TOKEN via `?token=` query param (EventSource can't send Authorization
headers). URLs leak to browser history, referer headers, server logs,
crash reports, and refactoring accidents. Codex flagged this during the
/plan-ceo-review outside voice pass.

New auth model: the extension calls POST /sse-session with a Bearer token
and receives a view-only session cookie (HttpOnly, SameSite=Strict, 30-min
TTL). EventSource is opened with `withCredentials: true` so the browser
sends the cookie back on the SSE connection. The ?token= query param is
GONE — no more URL-borne secrets.

Scope isolation (prior learning cookie-picker-auth-isolation, 10/10
confidence): the SSE session cookie grants access to /activity/stream and
/inspector/events ONLY. The token is never valid against /command, /token,
or any mutating endpoint. A leaked cookie can watch activity; it cannot
execute browser commands.

Components
  * browse/src/sse-session-cookie.ts — registry: mint/validate/extract/
    build-cookie. 256-bit tokens, 30-min TTL, lazy expiry pruning,
    no imports from token-registry (scope isolation enforced by module
    boundary).
  * browse/src/server.ts — POST /sse-session mint endpoint (requires
    Bearer). /activity/stream and /inspector/events now accept Bearer
    OR the session cookie, and reject ?token= query param.
  * extension/sidepanel.js — ensureSseSessionCookie() bootstrap call,
    EventSource opened with withCredentials:true on both SSE endpoints.
    Tested via the source guards; behavioral test is the E2E pairing
    flow that lands later in the wave.
  * browse/test/sse-session-cookie.test.ts — 20 unit tests covering
    mint entropy, TTL enforcement, cookie flag invariants, cookie
    parsing from multi-cookie headers, and scope-isolation contract
    guard (module must not import token-registry).
  * browse/test/server-auth.test.ts — existing /activity/stream auth
    test updated to assert the new cookie-based gate and the absence
    of the ?token= query param.

Cookie flag choices:
  * HttpOnly: token not readable from page JS (mitigates XSS
    exfiltration).
  * SameSite=Strict: cookie not sent on cross-site requests (mitigates
    CSRF). Fine for SSE because the extension connects to 127.0.0.1
    directly.
  * Path=/: cookie scoped to the whole origin.
  * Max-Age=1800: 30 minutes, matches TTL. Extension re-mints on
    reconnect when daemon restarts.
  * Secure NOT set: daemon binds to 127.0.0.1 over plain HTTP. Adding
    Secure would block the browser from ever sending the cookie back.
    Add Secure when gstack ships over HTTPS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security(N2): document Windows v20 ABE elevation path on CDP port

The existing comment around the cookie-import-browser --remote-debugging-port
launch claimed "threat model: no worse than baseline." That's wrong on
Windows with App-Bound Encryption v20. A same-user local process that
opens the cookie SQLite DB directly CANNOT decrypt v20 values (DPAPI
context is bound to the browser process). The CDP port lets them bypass
that: connect to the debug port, call Network.getAllCookies inside Chrome,
walk away with decrypted v20 cookies.

The correct fix is to switch from TCP --remote-debugging-port to
--remote-debugging-pipe so the CDP transport is a stdio pipe, not a
socket. That requires restructuring the CDP WebSocket client in this
module and Playwright doesn't expose the pipe transport out of the box.
Non-trivial, deferred from the v1.6.0.0 wave.

This commit updates the comment to correctly describe the threat and
points at the tracking issue. No code change to the launch itself.
Follow-up: #1136.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(E2): document dual-listener tunnel architecture in ARCHITECTURE.md

Adds an explicit per-endpoint disposition table to the Security model
section, covering the v1.6.0.0 dual-listener refactor. Every HTTP
endpoint now has a documented local-vs-tunnel answer. Future audits
(and future contributors wondering "is it safe to add X to the tunnel
surface?") can read this instead of reverse-engineering server.ts.

Also documents:
  * Why physical port separation beats per-request header inference
    (ngrok behavior drift, local proxies can forge headers, etc.)
  * Tunnel surface denial logging → ~/.gstack/security/attempts.jsonl
  * SSE session cookie model (gstack_sse, 30-min TTL, stream-scope only,
    module-boundary-enforced scope isolation)
  * N2 non-goal for Windows v20 ABE via CDP port (tracking #1136)

No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(E1): end-to-end pair-agent flow against a spawned daemon

Spawns the browse daemon as a subprocess with BROWSE_HEADLESS_SKIP=1 so
the HTTP layer runs without a real browser.  Exercises:

  * GET /health — token delivery for chrome-extension origin, withheld
    otherwise (the F1 + PR #1026 invariant)
  * GET /connect — alive probe returns {alive:true} unauth
  * POST /pair — root Bearer required (403 without), returns setup_key
  * POST /connect — setup_key exchange mints a distinct scoped token
  * POST /command — 401 without auth
  * POST /sse-session — Bearer required, Set-Cookie has HttpOnly +
    SameSite=Strict (the N1 invariant)
  * GET /activity/stream — 401 without auth
  * GET /activity/stream?token= — 401 (the old ?token= query param is
    REJECTED, which is the whole point of N1)
  * GET /welcome — serves HTML, does not leak /etc/passwd content under
    the default 'unknown' slug (E3 regex gate)

12 behavioral tests, ~220ms end-to-end, no network dependencies, no
ngrok, no real browser.  This is the receipt for the wave's central
'pair-agent still works + the security boundary holds' claim.

Tunnel-port binding (/tunnel/start) is deliberately NOT exercised here
— it requires an ngrok authtoken and live network.  The dual-listener
route allowlist is covered by source-level guards in
dual-listener.test.ts; behavioral tunnel testing belongs in a separate
paid-evals harness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* release(v1.6.0.0): bump VERSION + CHANGELOG for security wave

Architectural bump, not patch: dual-listener HTTP refactor changes the
daemon's tunnel-exposure model.  See CHANGELOG for the full release
summary (~950 words) covering the five root causes this wave closes:

  1. /health token leak over ngrok (F1 + E3 + test infra)
  2. /cookie-picker + /inspector exposed over the tunnel (F1)
  3. ?token=<ROOT> in SSE URLs leaking to logs/referer/history (N1)
  4. /welcome GSTACK_SLUG path traversal (E3)
  5. Windows v20 ABE elevation via CDP port (N2 — documented non-goal,
     tracked as #1136)

Plus the base PRs: SSRF gate (#1029), envelope sentinel escape (#1031),
DOM-channel hidden-element coverage (#1032), --from-file path validation
(#1103), and 2 commits from #1073 (@theqazi).

VERSION + package.json bumped to 1.6.0.0.  CHANGELOG entry covers
credits (@garagon, @Hybirdss, @HMAKT99, @theqazi), review lineage (CEO
→ Codex outside voice → Eng), and the non-goal tracking issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: pre-landing review findings (4 auto-fixes)

Addresses 4 findings from the Claude adversarial subagent on the
v1.6.0.0 security wave diff.  No user-visible behavior change; all
are defense-in-depth hardening of newly-introduced code.

1. GET /connect rate-limited (was POST-only) [HIGH conf 8/10]
   Attacker discovering the ngrok URL could probe unlimited GETs for
   daemon enumeration.  Now shares the global /connect counter.

2. ngrok listener leak on tunnel startup failure [MEDIUM conf 8/10]
   If ngrok.forward() resolved but tunnelListener.url() or the
   state-file write threw, the Bun listener was torn down but the
   ngrok session was leaked.  Fixed in BOTH /tunnel/start and
   BROWSE_TUNNEL=1 startup paths.

3. GSTACK_SKILL_ROOT path-traversal gate [MEDIUM conf 8/10]
   Symmetric with E3's GSTACK_SLUG regex gate — reject values
   containing '..' before interpolating into the welcome-page path.

4. SSE session registry pruning [LOW conf 7/10]
   pruneExpired() only checked 10 entries per mint call.  Now runs
   on every validate too, checks 20 entries, with a hard 10k cap as
   backstop.  Prevents registry growth under sustained extension
   reconnect pressure.

Tests remain green (56/56 in sse-session-cookie + dual-listener +
pair-agent-e2e suites).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v1.6.0.0

Reflect the dual-listener tunnel architecture, SSE session cookies,
SSRF guards, and Windows v20 ABE non-goal across the three docs
users actually read for remote-agent and browser auth context:

- docs/REMOTE_BROWSER_ACCESS.md: rewrote Architecture diagram for
  dual listeners, fixed /connect rate limit (3/min → 300/min),
  removed stale "/health requires no auth" (now 404 on tunnel),
  added SSE cookie auth, expanded Security Model with tunnel
  allowlist, SSRF guards, /welcome path traversal defense, and
  the Windows v20 ABE tracking note.
- BROWSER.md: added dual-listener paragraph to Authentication and
  linked to ARCHITECTURE.md endpoint table. Replaced the stale
  ?token= SSE auth note with the HttpOnly gstack_sse cookie flow.
- CLAUDE.md: added Transport-layer security section above the
  sidebar prompt-injection stack so contributors editing server.ts,
  sse-session-cookie.ts, or tunnel-denial-log.ts see the load-bearing
  module boundaries before touching them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(make-pdf): write --from-file payload to /tmp, not os.tmpdir()

make-pdf's browseClient wrote its --from-file payload to os.tmpdir(),
which is /var/folders/... on macOS. v1.6.0.0's PR #1103 cherry-pick
tightened browse load-html --from-file to validate against the
safe-dirs allowlist ([TEMP_DIR, cwd] where TEMP_DIR is '/tmp' on
macOS/Linux, os.tmpdir() on Windows). This closed a CLI/API parity
gap but broke make-pdf on macOS because /var/folders/... is outside
the allowlist.

Fix: mirror browse's TEMP_DIR convention — use '/tmp' on non-Windows,
os.tmpdir() on Windows. The make-pdf-gate CI failure on macOS-latest
(run 72440797490) is caused by exactly this: the payload file was
rejected by validateReadPath.

Verified locally: the combined-gate e2e test now passes after
rebuilding make-pdf/dist/pdf.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sidebar): killAgent resets per-tab state; align tests with current agent event format

Two pre-existing bugs surfaced while running the full e2e suite on the
sec-wave branch.  Both pre-date v1.6.0.0 (same failures on main at
e23ff280) but blocked the ship verification, so fixing now.

### Bug 1: killAgent leaked stale per-tab state

`killAgent()` reset the legacy globals (agentProcess, agentStatus,
etc.) but never touched the per-tab `tabAgents` Map.  Meanwhile
`/sidebar-command` routes on `tabState.status` from that Map, not the
legacy globals.  Consequence: after a kill (including the implicit
kill in `/sidebar-session/new`), the next /sidebar-command on the
same tab saw `tabState.status === 'processing'` and fell into the
queue branch, silently NOT spawning an agent.  Integration tests that
called resetState between cases all failed with empty queues.

Fix: when targetTabId is supplied, reset that one tab's state; when
called without a tab (session-new, full kill), reset ALL tab states.
Matches the semantic boundary already used for the cancel-file write.

### Bug 2: sidebar-integration tests drifted from current event format

`agent events appear in /sidebar-chat` posted the raw Claude streaming
format (`{type: 'assistant', message: {content: [...]}}`) but
`processAgentEvent` in server.ts only handles the simplified types
that sidebar-agent.ts pre-processes into (text, text_delta, tool_use,
result, agent_error, security_event).  The architecture moved
pre-processing into sidebar-agent.ts at some point and this test
never got updated.  Fixed by sending the pre-processed `{type:
'text', text: '...'}` format — which is actually what the server sees
in production.

Also removed the `entry.prompt` URL-containment check in the
queue-write test.  The URL is carried on entry.pageUrl (metadata) by
design: the system prompt tells Claude to run `browse url` to fetch
the actual page rather than trust any URL in the prompt body.  That's
the URL-based prompt-injection defense.  The prompt SHOULD NOT
contain the URL, so the test assertion was wrong for the current
security posture.

### Verification

- `bun test browse/test/sidebar-integration.test.ts` → 13/13 pass
  (was 6/13 on both main and branch before this commit)
- Full `bun run test` → exit 0, zero fail markers
- No behavior change for production sidebar flows: killAgent was
  already supposed to return the agent to idle; it just wasn't fully
  doing so.  Per-tab reset now matches the documented semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: gus <gustavoraularagon@gmail.com>
Co-authored-by: Mohammed Qazi <10266060+theqazi@users.noreply.github.com>
2026-04-21 21:58:27 -07:00

1920 lines
72 KiB
JavaScript

/**
* gstack browse — Side Panel
*
* Chat tab: two-way messaging with Claude Code via file queue.
* Debug tabs: activity feed (SSE) + refs (REST).
* Polls /sidebar-chat for new messages every 1s.
*/
const NAV_COMMANDS = new Set(['goto', 'back', 'forward', 'reload']);
const INTERACTION_COMMANDS = new Set(['click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'upload']);
const OBSERVE_COMMANDS = new Set(['snapshot', 'screenshot', 'diff', 'console', 'network', 'text', 'html', 'links', 'forms', 'accessibility', 'cookies', 'storage', 'perf']);
let lastId = 0;
let eventSource = null;
let serverUrl = null;
let serverToken = null;
let chatLineCount = 0;
let chatPollInterval = null;
let connState = 'disconnected'; // disconnected | connected | reconnecting | dead
let lastOptimisticMsg = null; // track optimistically rendered user msg to avoid dupes
let sidebarActiveTabId = null; // which browser tab's chat we're showing
const chatLineCountByTab = {}; // tabId -> last seen chatLineCount
const chatDomByTab = {}; // tabId -> saved DocumentFragment (never serialized HTML)
let pollInProgress = false; // reentrancy guard — prevents concurrent/recursive pollChat calls
let reconnectAttempts = 0;
let reconnectTimer = null;
const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead"
// Auth headers for sidebar endpoints
function authHeaders() {
const h = { 'Content-Type': 'application/json' };
if (serverToken) h['Authorization'] = `Bearer ${serverToken}`;
return h;
}
// ─── Connection State Machine ─────────────────────────────────────
function setConnState(state) {
const prev = connState;
connState = state;
const banner = document.getElementById('conn-banner');
const bannerText = document.getElementById('conn-banner-text');
const bannerActions = document.getElementById('conn-banner-actions');
if (state === 'connected') {
if (prev === 'reconnecting' || prev === 'dead') {
// Show "reconnected" toast that fades
banner.style.display = '';
banner.className = 'conn-banner reconnected';
bannerText.textContent = 'Reconnected';
bannerActions.style.display = 'none';
setTimeout(() => { banner.style.display = 'none'; }, 5000);
} else {
banner.style.display = 'none';
}
reconnectAttempts = 0;
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
} else if (state === 'reconnecting') {
banner.style.display = '';
banner.className = 'conn-banner reconnecting';
bannerText.textContent = `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`;
bannerActions.style.display = 'none';
} else if (state === 'dead') {
banner.style.display = '';
banner.className = 'conn-banner dead';
bannerText.textContent = 'Server offline';
bannerActions.style.display = '';
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
} else {
banner.style.display = 'none';
}
}
function startReconnect() {
if (reconnectTimer) return;
setConnState('reconnecting');
reconnectTimer = setInterval(() => {
reconnectAttempts++;
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
setConnState('dead');
return;
}
setConnState('reconnecting');
tryConnect();
}, 2000);
}
// ─── Chat ───────────────────────────────────────────────────────
const chatMessages = document.getElementById('chat-messages');
const commandInput = document.getElementById('command-input');
const sendBtn = document.getElementById('send-btn');
const commandHistory = [];
let historyIndex = -1;
function formatChatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
}
// Current streaming state
let agentContainer = null; // The container for the current agent response
let agentTextEl = null; // The text accumulator element
let agentText = ''; // Accumulated text
// Dedup: track which entry IDs have already been rendered to prevent
// repeat rendering on reconnect or tab switch (server replays from disk)
const renderedEntryIds = new Set();
// Security banner (variant A from /plan-design-review 2026-04-19).
// Renders on security_event — canary leaks, ML classifier BLOCK verdicts.
// Defense-in-depth trust UX — user sees WHICH layer fired at WHAT confidence.
const SECURITY_LAYER_LABELS = {
testsavant_content: 'Content ML',
transcript_classifier: 'Transcript ML',
aria_regex: 'ARIA pattern',
canary: 'Canary leak',
};
function showSecurityBanner(event) {
const banner = document.getElementById('security-banner');
if (!banner) return;
const title = document.getElementById('security-banner-title');
const subtitle = document.getElementById('security-banner-subtitle');
const layersEl = document.getElementById('security-banner-layers');
const expandBtn = document.getElementById('security-banner-expand');
const details = document.getElementById('security-banner-details');
const chevron = banner.querySelector('.security-banner-chevron');
const suspectLabel = document.getElementById('security-banner-suspect-label');
const suspectEl = document.getElementById('security-banner-suspect');
const actions = document.getElementById('security-banner-actions');
const btnAllow = document.getElementById('security-banner-btn-allow');
const btnBlock = document.getElementById('security-banner-btn-block');
// Reviewable path: the agent paused and is waiting for our decision.
// Title + subtitle change to framing-as-review, action buttons appear,
// suspected-text excerpt shows in the expandable details.
const reviewable = !!event.reviewable;
const tabId = Number(event.tabId);
// Title + subtitle
if (title) title.textContent = reviewable ? 'Review suspected injection' : 'Session terminated';
if (subtitle) {
const fromDomain = event.domain ? ` from ${event.domain}` : '';
const toolLabel = event.tool ? ` in ${event.tool} output` : '';
subtitle.textContent = reviewable
? `possible prompt injection${toolLabel}${fromDomain} — allow to continue, block to end session`
: `— prompt injection detected${fromDomain}`;
}
// Suspected text excerpt (reviewable only)
if (suspectEl && suspectLabel) {
if (reviewable && typeof event.suspected_text === 'string' && event.suspected_text.length > 0) {
suspectEl.textContent = event.suspected_text;
suspectEl.hidden = false;
suspectLabel.hidden = false;
} else {
suspectEl.textContent = '';
suspectEl.hidden = true;
suspectLabel.hidden = true;
}
}
// Action buttons — wire fresh handlers each render so we capture the
// current tabId. Remove previous listeners by cloning the node.
if (actions && btnAllow && btnBlock) {
actions.hidden = !reviewable;
if (reviewable) {
const freshAllow = btnAllow.cloneNode(true);
const freshBlock = btnBlock.cloneNode(true);
btnAllow.parentNode.replaceChild(freshAllow, btnAllow);
btnBlock.parentNode.replaceChild(freshBlock, btnBlock);
freshAllow.addEventListener('click', () => postSecurityDecision(tabId, 'allow'));
freshBlock.addEventListener('click', () => postSecurityDecision(tabId, 'block'));
}
}
// Layer signals list (mono scores)
if (layersEl) {
layersEl.innerHTML = '';
const rows = [];
// If we got a primary layer + confidence, show that first
if (event.layer) {
rows.push({ layer: event.layer, confidence: event.confidence ?? 1.0 });
}
// Any additional signals the agent sent
if (Array.isArray(event.signals)) {
for (const s of event.signals) {
if (s.layer && !rows.some(r => r.layer === s.layer)) {
rows.push({ layer: s.layer, confidence: s.confidence ?? 0 });
}
}
}
for (const row of rows) {
const label = SECURITY_LAYER_LABELS[row.layer] || row.layer;
const score = Number(row.confidence).toFixed(2);
const div = document.createElement('div');
div.className = 'security-banner-layer';
const nameSpan = document.createElement('span');
nameSpan.className = 'security-banner-layer-name';
nameSpan.textContent = label;
const scoreSpan = document.createElement('span');
scoreSpan.className = 'security-banner-layer-score';
scoreSpan.textContent = score;
div.appendChild(nameSpan);
div.appendChild(scoreSpan);
layersEl.appendChild(div);
}
}
// Reset expand state on each render. For reviewable banners, auto-expand
// so the user sees the suspected text without an extra click — they need
// that context to decide.
if (expandBtn && details) {
expandBtn.setAttribute('aria-expanded', reviewable ? 'true' : 'false');
details.hidden = !reviewable;
if (chevron) chevron.style.transform = reviewable ? 'rotate(180deg)' : 'rotate(0deg)';
}
banner.style.display = 'block';
}
function hideSecurityBanner() {
const banner = document.getElementById('security-banner');
if (banner) banner.style.display = 'none';
}
/**
* Send the user's decision on a reviewable BLOCK event to the server.
* Server writes a per-tab decision file that sidebar-agent polls.
*/
async function postSecurityDecision(tabId, decision) {
if (!serverUrl || !Number.isFinite(tabId)) {
hideSecurityBanner();
return;
}
try {
await fetch(`${serverUrl}/security-decision`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(serverToken ? { Authorization: `Bearer ${serverToken}` } : {}),
},
body: JSON.stringify({ tabId, decision, reason: 'user' }),
});
} catch (err) {
console.error('[sidepanel] postSecurityDecision failed', err);
}
// Hide the banner optimistically. If the user chose "allow", the session
// continues. If "block", sidebar-agent will kill and emit agent_error,
// which shows up in chat regardless.
hideSecurityBanner();
}
// Shield icon state update — consumes /health.security.status.
// status ∈ { 'protected', 'degraded', 'inactive' }.
// 'protected' = all layers ok. 'degraded' = at least one ML layer off or failed
// (sidebar still defended by canary + architectural controls).
// 'inactive' = security module crashed — only architectural controls active.
const SHIELD_LABELS = {
protected: { label: 'SEC', aria: 'Security status: protected' },
degraded: { label: 'SEC', aria: 'Security status: degraded (some layers offline)' },
inactive: { label: 'SEC', aria: 'Security status: inactive (architectural controls only)' },
};
function updateSecurityShield(securityState) {
const shield = document.getElementById('security-shield');
const labelEl = document.getElementById('security-shield-label');
if (!shield || !securityState) return;
const status = securityState.status || 'inactive';
const info = SHIELD_LABELS[status] || SHIELD_LABELS.inactive;
shield.setAttribute('data-status', status);
shield.setAttribute('aria-label', info.aria);
shield.style.display = 'inline-flex';
if (labelEl) labelEl.textContent = info.label;
// Hover tooltip gives layer-level detail for debugging.
if (securityState.layers) {
const parts = Object.entries(securityState.layers).map(([k, v]) => `${k}:${v}`);
shield.setAttribute('title', `Security — ${status}\n${parts.join('\n')}`);
} else {
shield.setAttribute('title', `Security — ${status}`);
}
}
// Wire up banner interactivity once on load
document.addEventListener('DOMContentLoaded', () => {
const closeBtn = document.getElementById('security-banner-close');
const expandBtn = document.getElementById('security-banner-expand');
const banner = document.getElementById('security-banner');
if (closeBtn) {
closeBtn.addEventListener('click', hideSecurityBanner);
}
if (expandBtn) {
expandBtn.addEventListener('click', () => {
const details = document.getElementById('security-banner-details');
const chevron = banner && banner.querySelector('.security-banner-chevron');
if (!details) return;
const open = !details.hidden;
details.hidden = open;
expandBtn.setAttribute('aria-expanded', String(!open));
if (chevron) chevron.style.transform = open ? 'rotate(0deg)' : 'rotate(180deg)';
});
}
// Escape dismisses the banner (a11y)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && banner && banner.style.display !== 'none') {
hideSecurityBanner();
}
});
});
function addChatEntry(entry) {
// Dedup by entry ID — prevent repeat rendering on reconnect/replay
if (entry.id !== undefined) {
if (renderedEntryIds.has(entry.id)) return;
renderedEntryIds.add(entry.id);
}
// Remove welcome message on first real message
const welcome = chatMessages.querySelector('.chat-welcome');
if (welcome) welcome.remove();
// User messages → chat bubble (skip if we already rendered it optimistically)
if (entry.role === 'user') {
if (lastOptimisticMsg === entry.message) {
lastOptimisticMsg = null; // consumed — don't skip next identical msg
return;
}
const bubble = document.createElement('div');
bubble.className = 'chat-bubble user';
bubble.innerHTML = `${escapeHtml(entry.message)}<span class="chat-time">${formatChatTime(entry.ts)}</span>`;
chatMessages.appendChild(bubble);
bubble.scrollIntoView({ behavior: 'smooth', block: 'end' });
return;
}
// Legacy assistant messages (from /sidebar-response)
if (entry.role === 'assistant') {
const bubble = document.createElement('div');
bubble.className = 'chat-bubble assistant';
let content = escapeHtml(entry.message);
content = content.replace(/```([\s\S]*?)```/g, '<pre>$1</pre>');
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
content = content.replace(/\n/g, '<br>');
bubble.innerHTML = `${content}<span class="chat-time">${formatChatTime(entry.ts)}</span>`;
chatMessages.appendChild(bubble);
bubble.scrollIntoView({ behavior: 'smooth', block: 'end' });
return;
}
// System notifications (cleanup, screenshot, errors)
if (entry.type === 'notification') {
const note = document.createElement('div');
note.className = 'chat-notification';
note.textContent = entry.message;
chatMessages.appendChild(note);
note.scrollIntoView({ behavior: 'smooth', block: 'end' });
return;
}
// Agent streaming events
if (entry.role === 'agent') {
handleAgentEvent(entry);
return;
}
}
function handleAgentEvent(entry) {
if (entry.type === 'agent_start') {
// If we already showed thinking dots optimistically in sendMessage(),
// don't duplicate. Just ensure fast polling is on.
if (agentContainer && document.getElementById('agent-thinking')) {
startFastPoll();
updateStopButton(true);
return;
}
// Create a new agent response container
agentText = '';
agentContainer = document.createElement('div');
agentContainer.className = 'agent-response';
agentTextEl = null;
chatMessages.appendChild(agentContainer);
// Add thinking indicator
const thinking = document.createElement('div');
thinking.className = 'agent-thinking';
thinking.id = 'agent-thinking';
thinking.innerHTML = '<span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span>';
agentContainer.appendChild(thinking);
agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' });
startFastPoll();
updateStopButton(true);
return;
}
if (entry.type === 'agent_done') {
// Remove thinking indicator
const thinking = document.getElementById('agent-thinking');
if (thinking) thinking.remove();
updateStopButton(false);
stopFastPoll();
// Collapse tool calls into a "See reasoning" disclosure
if (agentContainer) {
const tools = agentContainer.querySelectorAll('.agent-tool');
if (tools.length > 0) {
const details = document.createElement('details');
details.className = 'agent-reasoning';
const summary = document.createElement('summary');
summary.textContent = `See reasoning (${tools.length} step${tools.length > 1 ? 's' : ''})`;
details.appendChild(summary);
for (const tool of tools) {
details.appendChild(tool);
}
// Insert the disclosure before the text response (if any)
const textEl = agentContainer.querySelector('.agent-text');
if (textEl) {
agentContainer.insertBefore(details, textEl);
} else {
agentContainer.appendChild(details);
}
}
// Add timestamp
const ts = document.createElement('span');
ts.className = 'chat-time';
ts.textContent = formatChatTime(entry.ts);
agentContainer.appendChild(ts);
}
agentContainer = null;
agentTextEl = null;
return;
}
if (entry.type === 'security_event') {
showSecurityBanner(entry);
return;
}
if (entry.type === 'agent_error') {
// Suppress timeout errors that fire after agent_done (cleanup noise)
if (entry.error && entry.error.includes('Timed out') && !agentContainer) {
return;
}
const thinking = document.getElementById('agent-thinking');
if (thinking) thinking.remove();
updateStopButton(false);
stopFastPoll();
if (!agentContainer) {
agentContainer = document.createElement('div');
agentContainer.className = 'agent-response';
chatMessages.appendChild(agentContainer);
}
const err = document.createElement('div');
err.className = 'agent-error';
err.textContent = entry.error || 'Unknown error';
agentContainer.appendChild(err);
agentContainer = null;
return;
}
if (!agentContainer) {
agentContainer = document.createElement('div');
agentContainer.className = 'agent-response';
chatMessages.appendChild(agentContainer);
}
// Remove thinking indicator on first real content
const thinking = document.getElementById('agent-thinking');
if (thinking) thinking.remove();
if (entry.type === 'tool_use') {
const toolName = entry.tool || 'Tool';
const toolInput = entry.input || '';
// Skip tool uses with no description (e.g. internal tool-result file reads)
if (!toolInput) return;
const toolEl = document.createElement('div');
toolEl.className = 'agent-tool';
// Use the verbose description as the primary text
// The tool name becomes a subtle badge
const toolIcon = toolName === 'Bash' ? '▸' : toolName === 'Read' ? '📄' : toolName === 'Grep' ? '🔍' : toolName === 'Glob' ? '📁' : '⚡';
toolEl.innerHTML = `<span class="tool-icon">${toolIcon}</span> <span class="tool-description">${escapeHtml(toolInput)}</span>`;
agentContainer.appendChild(toolEl);
agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' });
return;
}
if (entry.type === 'text' || entry.type === 'result') {
// Full text replacement
agentText = entry.text || '';
if (!agentTextEl) {
agentTextEl = document.createElement('div');
agentTextEl.className = 'agent-text';
agentContainer.appendChild(agentTextEl);
}
let content = escapeHtml(agentText);
content = content.replace(/```([\s\S]*?)```/g, '<pre>$1</pre>');
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
content = content.replace(/\n/g, '<br>');
agentTextEl.innerHTML = content;
agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' });
return;
}
if (entry.type === 'text_delta') {
// Incremental text append
agentText += entry.text || '';
if (!agentTextEl) {
agentTextEl = document.createElement('div');
agentTextEl.className = 'agent-text';
agentContainer.appendChild(agentTextEl);
}
let content = escapeHtml(agentText);
content = content.replace(/```([\s\S]*?)```/g, '<pre>$1</pre>');
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
content = content.replace(/\n/g, '<br>');
agentTextEl.innerHTML = content;
agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' });
return;
}
}
async function sendMessage() {
const msg = commandInput.value.trim();
if (!msg) return;
commandHistory.push(msg);
historyIndex = commandHistory.length;
commandInput.value = '';
commandInput.disabled = true;
sendBtn.disabled = true;
// Show user bubble + thinking dots IMMEDIATELY — don't wait for poll.
// This eliminates up to 1000ms of perceived latency.
lastOptimisticMsg = msg;
const welcome = chatMessages.querySelector('.chat-welcome');
if (welcome) welcome.remove();
const userBubble = document.createElement('div');
userBubble.className = 'chat-bubble user';
userBubble.innerHTML = `${escapeHtml(msg)}<span class="chat-time">${formatChatTime(new Date().toISOString())}</span>`;
chatMessages.appendChild(userBubble);
agentText = '';
agentContainer = document.createElement('div');
agentContainer.className = 'agent-response';
agentTextEl = null;
chatMessages.appendChild(agentContainer);
const thinking = document.createElement('div');
thinking.className = 'agent-thinking';
thinking.id = 'agent-thinking';
thinking.innerHTML = '<span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span>';
agentContainer.appendChild(thinking);
agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' });
updateStopButton(true);
// Speed up polling while agent is working
startFastPoll();
const result = await new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'sidebar-command', message: msg, tabId: sidebarActiveTabId }, resolve);
});
commandInput.disabled = false;
sendBtn.disabled = false;
commandInput.focus();
if (result?.ok) {
// Poll immediately to sync server state
pollChat();
} else {
commandInput.classList.add('error');
commandInput.placeholder = result?.error || 'Failed to send';
setTimeout(() => {
commandInput.classList.remove('error');
commandInput.placeholder = 'Message Claude Code...';
}, 2000);
}
}
commandInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); sendMessage(); }
if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIndex > 0) { historyIndex--; commandInput.value = commandHistory[historyIndex]; }
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex < commandHistory.length - 1) { historyIndex++; commandInput.value = commandHistory[historyIndex]; }
else { historyIndex = commandHistory.length; commandInput.value = ''; }
}
});
sendBtn.addEventListener('click', sendMessage);
document.getElementById('stop-agent-btn').addEventListener('click', stopAgent);
// Poll for new chat messages
let initialLoadDone = false;
async function pollChat() {
if (pollInProgress) return;
pollInProgress = true;
if (!serverUrl || !serverToken) { pollInProgress = false; return; }
try {
// Request chat for the currently displayed tab
const tabParam = sidebarActiveTabId !== null ? `&tabId=${sidebarActiveTabId}` : '';
const resp = await fetch(`${serverUrl}/sidebar-chat?after=${chatLineCount}${tabParam}`, {
headers: authHeaders(),
signal: AbortSignal.timeout(3000),
});
if (!resp.ok) {
console.warn(`[gstack sidebar] Chat poll failed: ${resp.status} ${resp.statusText}`);
return;
}
const data = await resp.json();
// Detect tab switch from server — swap chat context.
// IMPORTANT: return before cleaning up thinking dots — the agent may be
// processing on the NEW tab while the OLD tab is idle. Removing the
// thinking indicator here would kill the optimistic UI before the switch.
if (data.activeTabId !== undefined && data.activeTabId !== sidebarActiveTabId) {
switchChatTab(data.activeTabId);
return; // switchChatTab triggers a fresh poll on the correct tab
}
// First successful poll — hide loading spinner
if (!initialLoadDone) {
initialLoadDone = true;
sidebarActiveTabId = data.activeTabId ?? null;
const loading = document.getElementById('chat-loading');
const welcome = document.getElementById('chat-welcome');
if (loading) loading.style.display = 'none';
// Show welcome only if no chat history
if (data.total === 0 && welcome) welcome.style.display = '';
}
// Shield icon state rides the chat poll (every 300ms in fast mode,
// slower when idle). When the ML classifier finishes warming after
// initial connect — typically 30s on first run — the shield flips
// from 'off' to 'protected' without the user needing to reload.
if (data.security) updateSecurityShield(data.security);
if (data.entries && data.entries.length > 0) {
// Hide welcome on first real entry
const welcome = document.getElementById('chat-welcome');
if (welcome) welcome.style.display = 'none';
for (const entry of data.entries) {
addChatEntry(entry);
}
chatLineCount = data.total;
}
// Clean up orphaned thinking indicators after replay.
// Only remove if we're on the CORRECT tab and the agent is truly idle.
// Don't clean up during tab switches — the agent may be processing on
// the new tab while the old tab shows idle.
const thinking = document.getElementById('agent-thinking');
if (thinking && data.agentStatus !== 'processing') {
thinking.remove();
agentContainer = null;
agentTextEl = null;
}
// Show/hide stop button based on agent status
updateStopButton(data.agentStatus === 'processing');
} catch (err) {
console.error('[gstack sidebar] Chat poll error:', err.message);
} finally {
pollInProgress = false;
}
}
/** Switch the sidebar to show a different tab's chat context */
function switchChatTab(newTabId) {
if (newTabId === sidebarActiveTabId) return;
// Save current tab's chat DOM + scroll position
if (sidebarActiveTabId !== null) {
const frag = document.createDocumentFragment();
while (chatMessages.firstChild) {
frag.appendChild(chatMessages.firstChild);
}
chatDomByTab[sidebarActiveTabId] = frag;
chatLineCountByTab[sidebarActiveTabId] = chatLineCount;
}
sidebarActiveTabId = newTabId;
// Restore saved chat for new tab, or carry over current DOM if we're
// mid-message (the server may have switched tabs because the user's
// Chrome tab changed, but we still want to show the optimistic UI).
if (chatDomByTab[newTabId]) {
while (chatMessages.firstChild) chatMessages.removeChild(chatMessages.firstChild);
chatMessages.appendChild(chatDomByTab[newTabId]);
chatLineCount = chatLineCountByTab[newTabId] || 0;
// Reset agent state for restored tab
agentContainer = null;
agentTextEl = null;
agentText = '';
} else if (lastOptimisticMsg && document.getElementById('agent-thinking')) {
// We're mid-send with optimistic UI — keep it, don't blow it away.
// The poll for the new tab will pick up the entries and sync naturally.
chatLineCount = 0;
// agentContainer/agentTextEl are already set from sendMessage()
} else {
while (chatMessages.firstChild) chatMessages.removeChild(chatMessages.firstChild);
const welcomeDiv = document.createElement('div');
welcomeDiv.className = 'chat-welcome';
welcomeDiv.id = 'chat-welcome';
const iconDiv = document.createElement('div');
iconDiv.className = 'chat-welcome-icon';
iconDiv.textContent = 'G';
welcomeDiv.appendChild(iconDiv);
const p1 = document.createElement('p');
p1.textContent = 'Send a message about this page.';
welcomeDiv.appendChild(p1);
const p2 = document.createElement('p');
p2.className = 'muted';
p2.textContent = 'Each tab has its own conversation.';
welcomeDiv.appendChild(p2);
chatMessages.appendChild(welcomeDiv);
chatLineCount = 0;
// Reset agent state for fresh tab
agentContainer = null;
agentTextEl = null;
agentText = '';
}
// Immediately poll the new tab's chat
setTimeout(pollChat, 0);
}
function updateStopButton(agentRunning) {
const stopBtn = document.getElementById('stop-agent-btn');
if (!stopBtn) return;
stopBtn.style.display = agentRunning ? '' : 'none';
}
async function stopAgent() {
if (!serverUrl) return;
try {
const resp = await fetch(`${serverUrl}/sidebar-agent/stop`, { method: 'POST', headers: authHeaders() });
if (!resp.ok) console.warn(`[gstack sidebar] Stop agent failed: ${resp.status}`);
} catch (err) {
console.error('[gstack sidebar] Stop agent error:', err.message);
}
// Immediately clean up UI
const thinking = document.getElementById('agent-thinking');
if (thinking) thinking.remove();
if (agentContainer) {
const notice = document.createElement('div');
notice.className = 'agent-text';
notice.style.color = 'var(--text-meta)';
notice.style.fontStyle = 'italic';
notice.textContent = 'Stopped';
agentContainer.appendChild(notice);
agentContainer = null;
agentTextEl = null;
}
updateStopButton(false);
stopFastPoll();
}
// ─── Adaptive poll speed ─────────────────────────────────────────
// 300ms while agent is working (fast first-token), 1000ms when idle.
const FAST_POLL_MS = 300;
const SLOW_POLL_MS = 1000;
function startFastPoll() {
if (chatPollInterval) clearInterval(chatPollInterval);
chatPollInterval = setInterval(pollChat, FAST_POLL_MS);
}
function stopFastPoll() {
if (chatPollInterval) clearInterval(chatPollInterval);
chatPollInterval = setInterval(pollChat, SLOW_POLL_MS);
}
// ─── Browser Tab Bar ─────────────────────────────────────────────
let tabPollInterval = null;
let lastTabJson = '';
async function pollTabs() {
if (!serverUrl || !serverToken) return;
try {
// Tell the server which Chrome tab the user is actually looking at.
// This syncs manual tab switches in the browser → server activeTabId.
let activeTabUrl = null;
try {
const chromeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
activeTabUrl = chromeTabs?.[0]?.url || null;
} catch (err) {
console.debug('[gstack sidebar] Failed to get active tab URL:', err.message);
}
const resp = await fetch(`${serverUrl}/sidebar-tabs${activeTabUrl ? '?activeUrl=' + encodeURIComponent(activeTabUrl) : ''}`, {
headers: authHeaders(),
signal: AbortSignal.timeout(2000),
});
if (!resp.ok) {
console.warn(`[gstack sidebar] Tab poll failed: ${resp.status} ${resp.statusText}`);
return;
}
const data = await resp.json();
if (!data.tabs) return;
// Only re-render if tabs changed
const json = JSON.stringify(data.tabs);
if (json === lastTabJson) return;
lastTabJson = json;
renderTabBar(data.tabs);
} catch (err) {
console.error('[gstack sidebar] Tab poll error:', err.message);
}
}
function renderTabBar(tabs) {
const bar = document.getElementById('browser-tabs');
if (!bar) return;
if (!tabs || tabs.length <= 1) {
bar.style.display = 'none';
return;
}
bar.style.display = '';
bar.innerHTML = '';
for (const tab of tabs) {
const el = document.createElement('div');
el.className = 'browser-tab' + (tab.active ? ' active' : '');
el.title = tab.url || '';
// Show favicon-style domain + title
let label = tab.title || '';
if (!label && tab.url) {
try { label = new URL(tab.url).hostname; } catch { label = tab.url; }
}
if (label.length > 20) label = label.slice(0, 20) + '…';
el.textContent = label || `Tab ${tab.id}`;
el.dataset.tabId = tab.id;
el.addEventListener('click', () => switchBrowserTab(tab.id));
bar.appendChild(el);
}
}
async function switchBrowserTab(tabId) {
if (!serverUrl) return;
try {
await fetch(`${serverUrl}/sidebar-tabs/switch`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ id: tabId }),
});
// Switch chat context + re-poll tabs
switchChatTab(tabId);
pollTabs();
} catch (err) {
console.error('[gstack sidebar] Failed to switch browser tab:', err.message);
}
}
// ─── Clear Chat ─────────────────────────────────────────────────
document.getElementById('clear-chat').addEventListener('click', async () => {
if (!serverUrl) return;
try {
const resp = await fetch(`${serverUrl}/sidebar-chat/clear`, { method: 'POST', headers: authHeaders() });
if (!resp.ok) console.warn(`[gstack sidebar] Clear chat failed: ${resp.status}`);
} catch (err) {
console.error('[gstack sidebar] Clear chat error:', err.message);
}
// Reset local state
chatLineCount = 0;
renderedEntryIds.clear();
agentContainer = null;
agentTextEl = null;
agentText = '';
chatMessages.innerHTML = `
<div class="chat-welcome" id="chat-welcome">
<div class="chat-welcome-icon">G</div>
<p>Send a message to Claude Code.</p>
<p class="muted">Your agent will see it and act on it.</p>
</div>`;
});
// ─── Reload Sidebar ─────────────────────────────────────────────
document.getElementById('reload-sidebar').addEventListener('click', () => {
location.reload();
});
// ─── Copy Cookies ───────────────────────────────────────────────
document.getElementById('chat-cookies-btn').addEventListener('click', async () => {
if (!serverUrl) return;
// Navigate the browser to the cookie picker page hosted by the browse server
try {
await fetch(`${serverUrl}/command`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ command: 'goto', args: [`${serverUrl}/cookie-picker`] }),
});
} catch (err) {
console.error('[gstack sidebar] Failed to open cookie picker:', err.message);
}
});
// ─── Debug Tabs ─────────────────────────────────────────────────
const debugToggle = document.getElementById('debug-toggle');
const debugTabs = document.getElementById('debug-tabs');
const closeDebug = document.getElementById('close-debug');
let debugOpen = false;
debugToggle.addEventListener('click', () => {
debugOpen = !debugOpen;
debugToggle.classList.toggle('active', debugOpen);
debugTabs.style.display = debugOpen ? 'flex' : 'none';
if (!debugOpen) {
// Close debug panels, show chat
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById('tab-chat').classList.add('active');
document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
}
});
closeDebug.addEventListener('click', () => {
debugOpen = false;
debugToggle.classList.remove('active');
debugTabs.style.display = 'none';
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById('tab-chat').classList.add('active');
});
document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
if (tab.dataset.tab === 'refs') fetchRefs();
});
});
// ─── Activity Feed ──────────────────────────────────────────────
function getEntryClass(entry) {
if (entry.status === 'error') return 'error';
if (entry.type === 'command_start') return 'pending';
const cmd = entry.command || '';
if (NAV_COMMANDS.has(cmd)) return 'nav';
if (INTERACTION_COMMANDS.has(cmd)) return 'interaction';
if (OBSERVE_COMMANDS.has(cmd)) return 'observe';
return '';
}
function formatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
let pendingEntries = new Map();
function createEntryElement(entry) {
const div = document.createElement('div');
div.className = `activity-entry ${getEntryClass(entry)}`;
div.setAttribute('role', 'article');
div.tabIndex = 0;
const argsText = entry.args ? entry.args.join(' ') : '';
const statusIcon = entry.status === 'ok' ? '\u2713' : entry.status === 'error' ? '\u2717' : '';
const statusClass = entry.status === 'ok' ? 'ok' : entry.status === 'error' ? 'err' : '';
const duration = entry.duration ? `${entry.duration}ms` : '';
div.innerHTML = `
<div class="entry-header">
<span class="entry-time">${formatTime(entry.timestamp)}</span>
<span class="entry-command">${escapeHtml(entry.command || entry.type)}</span>
</div>
${argsText ? `<div class="entry-args">${escapeHtml(argsText)}</div>` : ''}
${entry.type === 'command_end' ? `
<div class="entry-status">
<span class="${statusClass}">${statusIcon}</span>
<span class="duration">${duration}</span>
</div>
` : ''}
${entry.result ? `
<div class="entry-detail">
<div class="entry-result">${escapeHtml(entry.result)}</div>
</div>
` : ''}
`;
div.addEventListener('click', () => div.classList.toggle('expanded'));
return div;
}
function addEntry(entry) {
const feed = document.getElementById('activity-feed');
const empty = document.getElementById('empty-state');
if (empty) empty.style.display = 'none';
if (entry.type === 'command_end') {
for (const [id, el] of pendingEntries) {
if (el.querySelector('.entry-command')?.textContent === entry.command) {
el.remove();
pendingEntries.delete(id);
break;
}
}
}
const el = createEntryElement(entry);
feed.appendChild(el);
if (entry.type === 'command_start') pendingEntries.set(entry.id, el);
el.scrollIntoView({ behavior: 'smooth', block: 'end' });
if (entry.url) document.getElementById('footer-url')?.textContent && (document.getElementById('footer-url').textContent = new URL(entry.url).hostname);
lastId = Math.max(lastId, entry.id);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
// DOM text-node serialization escapes &, <, > but NOT " or '. Call sites
// that interpolate escapeHtml output inside an attribute value (title="...",
// data-x="...") need those escaped too or an attacker-controlled value can
// break out of the attribute. Add both manually.
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ─── SSE Connection ─────────────────────────────────────────────
// Fetch a view-only SSE session cookie before opening EventSource.
// EventSource can't send Authorization headers, and putting the root
// token in the URL (the old ?token= path) leaks it to logs, referer
// headers, and browser history. POST /sse-session issues an HttpOnly
// SameSite=Strict cookie scoped to SSE reads only; withCredentials:true
// on EventSource makes the browser send it back.
async function ensureSseSessionCookie() {
if (!serverUrl || !serverToken) return false;
try {
const resp = await fetch(`${serverUrl}/sse-session`, {
method: 'POST',
credentials: 'include',
headers: { 'Authorization': `Bearer ${serverToken}` },
});
return resp.ok;
} catch (err) {
console.warn('[gstack sidebar] Failed to mint SSE session cookie:', err && err.message);
return false;
}
}
async function connectSSE() {
if (!serverUrl) return;
if (eventSource) { eventSource.close(); eventSource = null; }
await ensureSseSessionCookie();
const url = `${serverUrl}/activity/stream?after=${lastId}`;
eventSource = new EventSource(url, { withCredentials: true });
eventSource.addEventListener('activity', (e) => {
try { addEntry(JSON.parse(e.data)); } catch (err) {
console.error('[gstack sidebar] Failed to parse activity event:', err.message);
}
});
eventSource.addEventListener('gap', (e) => {
try {
const data = JSON.parse(e.data);
const feed = document.getElementById('activity-feed');
const banner = document.createElement('div');
banner.className = 'gap-banner';
banner.textContent = `Missed ${data.availableFrom - data.gapFrom} events`;
feed.appendChild(banner);
} catch (err) {
console.error('[gstack sidebar] Failed to parse gap event:', err.message);
}
});
}
// ─── Refs Tab ───────────────────────────────────────────────────
async function fetchRefs() {
if (!serverUrl) return;
try {
const headers = {};
if (serverToken) headers['Authorization'] = `Bearer ${serverToken}`;
const resp = await fetch(`${serverUrl}/refs`, { signal: AbortSignal.timeout(3000), headers });
if (!resp.ok) return;
const data = await resp.json();
const list = document.getElementById('refs-list');
const empty = document.getElementById('refs-empty');
const footer = document.getElementById('refs-footer');
if (!data.refs || data.refs.length === 0) {
empty.style.display = '';
list.innerHTML = '';
footer.textContent = '';
return;
}
empty.style.display = 'none';
list.innerHTML = data.refs.map(r => `
<div class="ref-row">
<span class="ref-id">${escapeHtml(r.ref)}</span>
<span class="ref-role">${escapeHtml(r.role)}</span>
<span class="ref-name">"${escapeHtml(r.name)}"</span>
</div>
`).join('');
footer.textContent = `${data.refs.length} refs`;
} catch (err) {
console.error('[gstack sidebar] Failed to fetch refs:', err.message);
}
}
// ─── Inspector Tab ──────────────────────────────────────────────
let inspectorPickerActive = false;
let inspectorData = null; // last inspect result
let inspectorModifications = []; // tracked style changes
let inspectorSSE = null;
// Inspector DOM refs
const inspectorPickBtn = document.getElementById('inspector-pick-btn');
const inspectorSelected = document.getElementById('inspector-selected');
const inspectorModeBadge = document.getElementById('inspector-mode-badge');
const inspectorEmpty = document.getElementById('inspector-empty');
const inspectorLoading = document.getElementById('inspector-loading');
const inspectorError = document.getElementById('inspector-error');
const inspectorPanels = document.getElementById('inspector-panels');
const inspectorBoxmodel = document.getElementById('inspector-boxmodel');
const inspectorRules = document.getElementById('inspector-rules');
const inspectorRuleCount = document.getElementById('inspector-rule-count');
const inspectorComputed = document.getElementById('inspector-computed');
const inspectorQuickedit = document.getElementById('inspector-quickedit');
const inspectorSend = document.getElementById('inspector-send');
const inspectorSendBtn = document.getElementById('inspector-send-btn');
// Pick button
inspectorPickBtn.addEventListener('click', () => {
if (inspectorPickerActive) {
inspectorPickerActive = false;
inspectorPickBtn.classList.remove('active');
chrome.runtime.sendMessage({ type: 'stopInspector' });
} else {
inspectorPickerActive = true;
inspectorPickBtn.classList.add('active');
inspectorShowLoading(false); // don't show loading yet, just activate
chrome.runtime.sendMessage({ type: 'startInspector' }, (result) => {
if (result?.error) {
inspectorPickerActive = false;
inspectorPickBtn.classList.remove('active');
inspectorShowError(result.error);
}
});
}
});
function inspectorShowEmpty() {
inspectorEmpty.style.display = '';
inspectorLoading.style.display = 'none';
inspectorError.style.display = 'none';
inspectorPanels.style.display = 'none';
inspectorSend.style.display = 'none';
}
function inspectorShowLoading(show) {
if (show) {
inspectorEmpty.style.display = 'none';
inspectorLoading.style.display = '';
inspectorError.style.display = 'none';
inspectorPanels.style.display = 'none';
} else {
inspectorLoading.style.display = 'none';
}
}
function inspectorShowError(message) {
inspectorEmpty.style.display = 'none';
inspectorLoading.style.display = 'none';
inspectorError.style.display = '';
inspectorError.textContent = message;
inspectorPanels.style.display = 'none';
}
function inspectorShowData(data) {
inspectorData = data;
inspectorModifications = [];
inspectorEmpty.style.display = 'none';
inspectorLoading.style.display = 'none';
inspectorError.style.display = 'none';
inspectorPanels.style.display = '';
inspectorSend.style.display = '';
// Update toolbar
const tag = data.tagName || '?';
const cls = data.classes && data.classes.length > 0 ? '.' + data.classes.join('.') : '';
const idStr = data.id ? '#' + data.id : '';
inspectorSelected.textContent = `<${tag}>${idStr}${cls}`;
inspectorSelected.title = data.selector;
// Mode badge
if (data.mode === 'basic') {
inspectorModeBadge.textContent = 'Basic mode';
inspectorModeBadge.style.display = '';
inspectorModeBadge.className = 'inspector-mode-badge basic';
} else if (data.mode === 'cdp') {
inspectorModeBadge.textContent = 'CDP';
inspectorModeBadge.style.display = '';
inspectorModeBadge.className = 'inspector-mode-badge cdp';
} else {
inspectorModeBadge.style.display = 'none';
}
// Render sections
renderBoxModel(data);
renderMatchedRules(data);
renderComputedStyles(data);
renderQuickEdit(data);
updateSendButton();
}
// ─── Box Model Rendering ────────────────────────────────────────
function renderBoxModel(data) {
const box = data.basicData?.boxModel || data.boxModel;
if (!box) { inspectorBoxmodel.innerHTML = '<span class="inspector-no-data">No box model data</span>'; return; }
const m = box.margin || {};
const b = box.border || {};
const p = box.padding || {};
const c = box.content || {};
inspectorBoxmodel.innerHTML = `
<div class="boxmodel-margin">
<span class="boxmodel-label">margin</span>
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(m.top)}</span>
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(m.right)}</span>
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(m.bottom)}</span>
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(m.left)}</span>
<div class="boxmodel-border">
<span class="boxmodel-label">border</span>
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(b.top)}</span>
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(b.right)}</span>
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(b.bottom)}</span>
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(b.left)}</span>
<div class="boxmodel-padding">
<span class="boxmodel-label">padding</span>
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(p.top)}</span>
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(p.right)}</span>
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(p.bottom)}</span>
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(p.left)}</span>
<div class="boxmodel-content">
<span>${Math.round(c.width || 0)} x ${Math.round(c.height || 0)}</span>
</div>
</div>
</div>
</div>
`;
}
function fmtBoxVal(v) {
if (v === undefined || v === null) return '-';
const n = typeof v === 'number' ? v : parseFloat(v);
if (isNaN(n) || n === 0) return '0';
return Math.round(n * 10) / 10;
}
// ─── Matched Rules Rendering ────────────────────────────────────
function renderMatchedRules(data) {
const rules = data.matchedRules || data.basicData?.matchedRules || [];
inspectorRuleCount.textContent = rules.length > 0 ? `(${rules.length})` : '';
if (rules.length === 0) {
inspectorRules.innerHTML = '<div class="inspector-no-data">No matched rules</div>';
return;
}
// Separate UA rules from author rules
const authorRules = [];
const uaRules = [];
for (const rule of rules) {
if (rule.origin === 'user-agent' || rule.isUA) {
uaRules.push(rule);
} else {
authorRules.push(rule);
}
}
let html = '';
// Author rules (expanded)
for (const rule of authorRules) {
html += renderRule(rule, false);
}
// UA rules (collapsed by default)
if (uaRules.length > 0) {
html += `
<div class="inspector-ua-rules">
<button class="inspector-ua-toggle collapsed" aria-expanded="false">
<span class="inspector-toggle-arrow">&#x25B6;</span>
User Agent (${uaRules.length})
</button>
<div class="inspector-ua-body collapsed">
`;
for (const rule of uaRules) {
html += renderRule(rule, true);
}
html += '</div></div>';
}
inspectorRules.innerHTML = html;
// Bind UA toggle
const uaToggle = inspectorRules.querySelector('.inspector-ua-toggle');
if (uaToggle) {
uaToggle.addEventListener('click', () => {
const body = inspectorRules.querySelector('.inspector-ua-body');
const isCollapsed = uaToggle.classList.contains('collapsed');
uaToggle.classList.toggle('collapsed', !isCollapsed);
uaToggle.setAttribute('aria-expanded', isCollapsed);
uaToggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '&#x25BC;' : '&#x25B6;';
body.classList.toggle('collapsed', !isCollapsed);
});
}
}
function renderRule(rule, isUA) {
const selectorText = escapeHtml(rule.selector || '');
const truncatedSelector = selectorText.length > 35 ? selectorText.slice(0, 35) + '...' : selectorText;
const source = rule.source || '';
const sourceDisplay = source.includes('/') ? source.split('/').pop() : source;
const specificity = rule.specificity || '';
let propsHtml = '';
const props = rule.properties || [];
for (const prop of props) {
const overridden = prop.overridden ? ' overridden' : '';
const nameHtml = escapeHtml(prop.name);
const valText = escapeHtml(prop.value || '');
const truncatedVal = valText.length > 30 ? valText.slice(0, 30) + '...' : valText;
const priority = prop.priority === 'important' ? ' <span class="inspector-important">!important</span>' : '';
propsHtml += `<div class="inspector-prop${overridden}"><span class="inspector-prop-name">${nameHtml}</span>: <span class="inspector-prop-value" title="${valText}">${truncatedVal}</span>${priority};</div>`;
}
return `
<div class="inspector-rule" role="treeitem">
<div class="inspector-rule-header">
<span class="inspector-selector" title="${selectorText}">${truncatedSelector}</span>
${specificity ? `<span class="inspector-specificity">${escapeHtml(specificity)}</span>` : ''}
</div>
<div class="inspector-rule-props">${propsHtml}</div>
${sourceDisplay ? `<div class="inspector-rule-source">${escapeHtml(sourceDisplay)}</div>` : ''}
</div>
`;
}
// ─── Computed Styles Rendering ──────────────────────────────────
function renderComputedStyles(data) {
const styles = data.computedStyles || data.basicData?.computedStyles || {};
const keys = Object.keys(styles);
if (keys.length === 0) {
inspectorComputed.innerHTML = '<div class="inspector-no-data">No computed styles</div>';
return;
}
let html = '';
for (const key of keys) {
const val = styles[key];
if (!val || val === 'none' || val === 'normal' || val === 'auto' || val === '0px' || val === 'rgba(0, 0, 0, 0)') continue;
html += `<div class="inspector-computed-row"><span class="inspector-prop-name">${escapeHtml(key)}</span>: <span class="inspector-prop-value">${escapeHtml(val)}</span></div>`;
}
if (!html) {
html = '<div class="inspector-no-data">All values are defaults</div>';
}
inspectorComputed.innerHTML = html;
}
// ─── Quick Edit ─────────────────────────────────────────────────
function renderQuickEdit(data) {
const selector = data.selector;
if (!selector) { inspectorQuickedit.innerHTML = ''; return; }
// Show common editable properties with current values
const editableProps = ['color', 'background-color', 'font-size', 'padding', 'margin', 'border', 'display', 'opacity'];
const computed = data.computedStyles || data.basicData?.computedStyles || {};
let html = '<div class="inspector-quickedit-list">';
for (const prop of editableProps) {
const val = computed[prop] || '';
html += `
<div class="inspector-quickedit-row" data-prop="${escapeHtml(prop)}">
<span class="inspector-prop-name">${escapeHtml(prop)}</span>:
<span class="inspector-quickedit-value" data-selector="${escapeHtml(selector)}" data-prop="${escapeHtml(prop)}" tabindex="0" role="button" title="Click to edit">${escapeHtml(val || '(none)')}</span>
</div>
`;
}
html += '</div>';
inspectorQuickedit.innerHTML = html;
// Bind click-to-edit
inspectorQuickedit.querySelectorAll('.inspector-quickedit-value').forEach(el => {
el.addEventListener('click', () => startQuickEdit(el));
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startQuickEdit(el); }
});
});
}
function startQuickEdit(valueEl) {
if (valueEl.querySelector('input')) return; // already editing
const currentVal = valueEl.textContent === '(none)' ? '' : valueEl.textContent;
const prop = valueEl.dataset.prop;
const selector = valueEl.dataset.selector;
const input = document.createElement('input');
input.type = 'text';
input.className = 'inspector-quickedit-input';
input.value = currentVal;
valueEl.textContent = '';
valueEl.appendChild(input);
input.focus();
input.select();
function commit() {
const newVal = input.value.trim();
valueEl.textContent = newVal || '(none)';
if (newVal && newVal !== currentVal) {
chrome.runtime.sendMessage({
type: 'applyStyle',
selector,
property: prop,
value: newVal,
});
inspectorModifications.push({ property: prop, value: newVal, selector });
updateSendButton();
}
}
function cancel() {
valueEl.textContent = currentVal || '(none)';
}
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { e.preventDefault(); input.removeEventListener('blur', commit); cancel(); }
});
}
// ─── Send to Agent ──────────────────────────────────────────────
function updateSendButton() {
if (inspectorModifications.length > 0) {
inspectorSendBtn.textContent = 'Send to Code';
inspectorSendBtn.title = `${inspectorModifications.length} modification(s) to send`;
} else {
inspectorSendBtn.textContent = 'Send to Agent';
inspectorSendBtn.title = 'Send full inspector data';
}
}
inspectorSendBtn.addEventListener('click', () => {
if (!inspectorData) return;
let message;
if (inspectorModifications.length > 0) {
// Format modification diff
const diffs = inspectorModifications.map(m =>
` ${m.property}: ${m.value} (selector: ${m.selector})`
).join('\n');
message = `CSS Inspector modifications:\n\nSelector: ${inspectorData.selector}\n\nChanges:\n${diffs}`;
// Include source file info if available
const rules = inspectorData.matchedRules || inspectorData.basicData?.matchedRules || [];
const sources = rules.filter(r => r.source && r.source !== 'inline').map(r => r.source);
if (sources.length > 0) {
message += `\n\nSource files:\n${[...new Set(sources)].map(s => ` ${s}`).join('\n')}`;
}
} else {
// Send full inspector data
message = `CSS Inspector data for: ${inspectorData.selector}\n\n${JSON.stringify(inspectorData, null, 2)}`;
}
chrome.runtime.sendMessage({ type: 'sidebar-command', message });
});
// ─── Quick Action Helpers (shared between chat toolbar + inspector) ──
async function runCleanup(...buttons) {
if (!serverUrl || !serverToken) {
return;
}
buttons.forEach(b => b?.classList.add('loading'));
// Smart cleanup: send a chat message to the sidebar agent (an LLM).
// The agent snapshots the page, understands it semantically, and removes
// clutter intelligently. Much better than brittle CSS selectors.
const cleanupPrompt = [
'Clean up this page for reading. First run a quick deterministic pass:',
'$B cleanup --all',
'',
'Then take a snapshot to see what\'s left:',
'$B snapshot -i',
'',
'Look at the snapshot and identify remaining non-content elements:',
'- Ad placeholders, "ADVERTISEMENT" labels, sponsored content',
'- Cookie/consent banners, newsletter popups, login walls',
'- Audio/podcast player widgets, video autoplay',
'- Sidebar widgets (puzzles, games, "most popular", recommendations)',
'- Social share buttons, follow prompts, "See more on Google"',
'- Floating chat widgets, feedback buttons',
'- Navigation drawers, mega-menus (unless they ARE the page content)',
'- Empty whitespace from removed ads',
'',
'KEEP: the site header/masthead/logo, article headline, article body,',
'article images, author byline, date. The page should still look like',
'the site it is, just without the crap.',
'',
'For each element to remove, run JavaScript via $B to hide it:',
'$B eval "document.querySelector(\'SELECTOR\').style.display=\'none\'"',
'',
'Also unlock scrolling if the page is scroll-locked:',
'$B eval "document.body.style.overflow=\'auto\';document.documentElement.style.overflow=\'auto\'"',
].join('\n');
try {
// Send as a sidebar command (spawns the agent)
const resp = await fetch(`${serverUrl}/sidebar-command`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ message: cleanupPrompt }),
signal: AbortSignal.timeout(5000),
});
if (resp.ok) {
addChatEntry({ type: 'notification', message: 'Cleaning up page (agent is analyzing...)' });
} else {
addChatEntry({ type: 'notification', message: 'Failed to start cleanup' });
}
} catch (err) {
addChatEntry({ type: 'notification', message: 'Cleanup failed: ' + err.message });
} finally {
// Remove loading after a short delay (agent runs async)
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 2000);
}
}
async function runScreenshot(...buttons) {
if (!serverUrl || !serverToken) {
return;
}
buttons.forEach(b => b?.classList.add('loading'));
try {
const resp = await fetch(`${serverUrl}/command`, {
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'screenshot', args: [] }),
signal: AbortSignal.timeout(15000),
});
const text = await resp.text();
if (resp.ok) {
addChatEntry({ type: 'notification', message: text || 'Screenshot saved' });
} else {
const err = JSON.parse(text).error || 'Screenshot failed';
addChatEntry({ type: 'notification', message: 'Error: ' + err });
}
} catch (err) {
addChatEntry({ type: 'notification', message: 'Screenshot failed: ' + err.message });
} finally {
buttons.forEach(b => b?.classList.remove('loading'));
}
}
// ─── Wire up all cleanup/screenshot buttons (inspector + chat toolbar) ──
const inspectorCleanupBtn = document.getElementById('inspector-cleanup-btn');
const inspectorScreenshotBtn = document.getElementById('inspector-screenshot-btn');
const chatCleanupBtn = document.getElementById('chat-cleanup-btn');
const chatScreenshotBtn = document.getElementById('chat-screenshot-btn');
if (inspectorCleanupBtn) inspectorCleanupBtn.addEventListener('click', () => runCleanup(inspectorCleanupBtn, chatCleanupBtn));
if (inspectorScreenshotBtn) inspectorScreenshotBtn.addEventListener('click', () => runScreenshot(inspectorScreenshotBtn, chatScreenshotBtn));
if (chatCleanupBtn) chatCleanupBtn.addEventListener('click', () => runCleanup(chatCleanupBtn, inspectorCleanupBtn));
if (chatScreenshotBtn) chatScreenshotBtn.addEventListener('click', () => runScreenshot(chatScreenshotBtn, inspectorScreenshotBtn));
// ─── Section Toggles ────────────────────────────────────────────
document.querySelectorAll('.inspector-section-toggle').forEach(toggle => {
toggle.addEventListener('click', () => {
const section = toggle.dataset.section;
const body = document.getElementById(`inspector-${section}`);
const isCollapsed = toggle.classList.contains('collapsed');
toggle.classList.toggle('collapsed', !isCollapsed);
toggle.setAttribute('aria-expanded', isCollapsed);
toggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '&#x25BC;' : '&#x25B6;';
body.classList.toggle('collapsed', !isCollapsed);
});
});
// ─── Inspector SSE ──────────────────────────────────────────────
async function connectInspectorSSE() {
if (!serverUrl || !serverToken) return;
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
// Same session-cookie pattern as connectSSE. ?token= is gone (see N1
// in the v1.6.0.0 security wave plan).
await ensureSseSessionCookie();
const url = `${serverUrl}/inspector/events?_=${Date.now()}`;
try {
inspectorSSE = new EventSource(url, { withCredentials: true });
inspectorSSE.addEventListener('inspectResult', (e) => {
try {
const data = JSON.parse(e.data);
inspectorShowData(data);
} catch (err) {
console.error('[gstack sidebar] Failed to parse inspectResult:', err.message);
}
});
inspectorSSE.addEventListener('error', () => {
// SSE connection failed — inspector works without it (basic mode)
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
});
} catch (err) {
console.debug('[gstack sidebar] Inspector SSE not available:', err.message);
}
}
// ─── Server Discovery ───────────────────────────────────────────
function setActionButtonsEnabled(enabled) {
const btns = document.querySelectorAll('.quick-action-btn, .inspector-action-btn');
btns.forEach(btn => {
btn.disabled = !enabled;
btn.classList.toggle('disabled', !enabled);
});
}
function updateConnection(url, token) {
const wasConnected = !!serverUrl;
serverUrl = url;
serverToken = token || null;
if (url) {
document.getElementById('footer-dot').className = 'dot connected';
const port = new URL(url).port;
document.getElementById('footer-port').textContent = `:${port}`;
setConnState('connected');
setActionButtonsEnabled(true);
// Tell the active tab's content script the sidebar is open — this hides
// the welcome page arrow hint. Only fires on actual sidebar connection.
chrome.runtime.sendMessage({ type: 'sidebarOpened' }).catch(() => {});
connectSSE();
connectInspectorSSE();
if (chatPollInterval) clearInterval(chatPollInterval);
chatPollInterval = setInterval(pollChat, SLOW_POLL_MS);
pollChat();
// Poll browser tabs every 2s (lightweight, just tab list)
if (tabPollInterval) clearInterval(tabPollInterval);
tabPollInterval = setInterval(pollTabs, 2000);
pollTabs();
} else {
document.getElementById('footer-dot').className = 'dot';
document.getElementById('footer-port').textContent = '';
setActionButtonsEnabled(false);
if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = null; }
if (tabPollInterval) { clearInterval(tabPollInterval); tabPollInterval = null; }
if (wasConnected) {
startReconnect();
}
}
}
// ─── Port Configuration ─────────────────────────────────────────
const portLabel = document.getElementById('footer-port');
const portInput = document.getElementById('port-input');
portLabel.addEventListener('click', () => {
portLabel.style.display = 'none';
portInput.style.display = '';
chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => {
portInput.value = resp?.port || '';
portInput.focus();
portInput.select();
});
});
function savePort() {
const port = parseInt(portInput.value, 10);
if (port > 0 && port < 65536) {
chrome.runtime.sendMessage({ type: 'setPort', port });
}
portInput.style.display = 'none';
portLabel.style.display = '';
}
portInput.addEventListener('blur', savePort);
portInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') savePort();
if (e.key === 'Escape') { portInput.style.display = 'none'; portLabel.style.display = ''; }
});
// ─── Reconnect / Copy Buttons ────────────────────────────────────
document.getElementById('conn-reconnect').addEventListener('click', () => {
reconnectAttempts = 0;
startReconnect();
});
document.getElementById('conn-copy').addEventListener('click', () => {
navigator.clipboard.writeText('/open-gstack-browser').then(() => {
const btn = document.getElementById('conn-copy');
btn.textContent = 'copied!';
setTimeout(() => { btn.textContent = '/open-gstack-browser'; }, 2000);
});
});
// Try to connect immediately, retry every 2s until connected.
// Show exactly what's happening at each step so the user is never
// staring at a blank "Connecting..." with no info.
let connectAttempts = 0;
function setLoadingStatus(msg, debug) {
const status = document.getElementById('loading-status');
const dbg = document.getElementById('loading-debug');
if (status) status.textContent = msg;
if (dbg && debug !== undefined) dbg.textContent = debug;
}
async function tryConnect() {
connectAttempts++;
setLoadingStatus(
`Looking for browse server... (attempt ${connectAttempts})`,
`Asking background.js for server port...`
);
// Step 1: Ask background for the port
const resp = await new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'getPort' }, (r) => {
if (chrome.runtime.lastError) {
resolve({ error: chrome.runtime.lastError.message });
} else {
resolve(r || {});
}
});
});
if (resp.error) {
setLoadingStatus(
`Extension error (attempt ${connectAttempts})`,
`chrome.runtime.sendMessage failed:\n${resp.error}`
);
setTimeout(tryConnect, 2000);
return;
}
const port = resp.port || 34567;
// Step 2: If background says connected + has token, use that
if (resp.port && resp.connected && resp.token) {
setLoadingStatus(
`Server found on port ${port}, connecting...`,
`token: yes\nStarting SSE + chat polling...`
);
updateConnection(`http://127.0.0.1:${port}`, resp.token);
return;
}
// Step 3: Background not connected yet. Try hitting /health directly.
// This bypasses the background.js health poll timing gap.
setLoadingStatus(
`Checking server directly... (attempt ${connectAttempts})`,
`port: ${port}\nbackground connected: ${resp.connected || false}\nTrying GET http://127.0.0.1:${port}/health ...`
);
try {
const healthResp = await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(2000)
});
if (healthResp.ok) {
const data = await healthResp.json();
if (data.status === 'healthy' && data.token) {
setLoadingStatus(
`Server healthy on port ${port}, connecting...`,
`token: yes (from /health)\nStarting SSE + chat polling...`
);
updateConnection(`http://127.0.0.1:${port}`, data.token);
// Shield state arrives on /health alongside the auth token.
if (data.security) updateSecurityShield(data.security);
return;
}
setLoadingStatus(
`Server responded but not healthy (attempt ${connectAttempts})`,
`status: ${data.status}\ntoken: ${data.token ? 'yes' : 'no'}`
);
} else {
setLoadingStatus(
`Server returned ${healthResp.status} (attempt ${connectAttempts})`,
`GET /health → ${healthResp.status} ${healthResp.statusText}`
);
}
} catch (e) {
setLoadingStatus(
`Server not reachable on port ${port} (attempt ${connectAttempts})`,
`GET /health failed: ${e.message}\n\nThe browse server may still be starting.\nRun /open-gstack-browser in Claude Code.`
);
}
setTimeout(tryConnect, 2000);
}
tryConnect();
// ─── Message Listener ───────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'health') {
if (msg.data) {
const url = `http://127.0.0.1:${msg.data.port || 34567}`;
// Request token via targeted sendResponse (not broadcast) to limit exposure
chrome.runtime.sendMessage({ type: 'getToken' }, (resp) => {
updateConnection(url, resp?.token || null);
});
applyChatEnabled(!!msg.data.chatEnabled);
} else {
updateConnection(null);
}
}
if (msg.type === 'refs') {
if (document.querySelector('.tab[data-tab="refs"].active')) {
fetchRefs();
}
}
if (msg.type === 'inspectResult') {
inspectorPickerActive = false;
inspectorPickBtn.classList.remove('active');
if (msg.data) {
inspectorShowData(msg.data);
} else {
inspectorShowError('Element not found, try picking again');
}
}
if (msg.type === 'pickerCancelled') {
inspectorPickerActive = false;
inspectorPickBtn.classList.remove('active');
}
// Instant tab switch — background.js fires this on chrome.tabs.onActivated
if (msg.type === 'browserTabActivated') {
// Tell the server which tab is now active, then switch chat context
if (serverUrl && serverToken) {
fetch(`${serverUrl}/sidebar-tabs?activeUrl=${encodeURIComponent(msg.url || '')}`, {
headers: authHeaders(),
signal: AbortSignal.timeout(2000),
}).then(r => r.json()).then(data => {
if (data.tabs) {
renderTabBar(data.tabs);
// Find the server-side tab ID for this Chrome tab
const activeTab = data.tabs.find(t => t.active);
if (activeTab && activeTab.id !== sidebarActiveTabId) {
switchChatTab(activeTab.id);
}
}
}).catch(() => {});
}
}
});
// ─── Chat Gate ──────────────────────────────────────────────────
// Show/hide Chat tab + command bar based on chatEnabled from server
function applyChatEnabled(enabled) {
const commandBar = document.querySelector('.command-bar');
const chatTab = document.getElementById('tab-chat');
const banner = document.getElementById('experimental-banner');
const clearBtn = document.getElementById('clear-chat');
if (enabled) {
// Chat is enabled: show command bar, chat tab, experimental banner
if (commandBar) commandBar.style.display = '';
if (chatTab) chatTab.style.display = '';
if (banner) banner.style.display = '';
if (clearBtn) clearBtn.style.display = '';
} else {
// Chat disabled: hide command bar, chat content, clear button
if (commandBar) commandBar.style.display = 'none';
if (banner) banner.style.display = 'none';
if (clearBtn) clearBtn.style.display = 'none';
// If currently on chat tab, switch to activity
if (chatTab && chatTab.classList.contains('active')) {
chatTab.classList.remove('active');
// Open debug tabs and show activity
const debugToggle = document.getElementById('debug-toggle');
const debugTabs = document.getElementById('debug-tabs');
if (debugToggle) debugToggle.classList.add('active');
if (debugTabs) debugTabs.style.display = 'flex';
const activityTab = document.getElementById('tab-activity');
if (activityTab) activityTab.classList.add('active');
const activityBtn = document.querySelector('.tab[data-tab="activity"]');
if (activityBtn) activityBtn.classList.add('active');
}
}
}