mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-05 09:38:02 +02:00
v1.20.0.0 feat: browser-skills runtime + gbrain-support carryover (#1233)
* feat(gbrain-sync): queue primitives + writer shims
Adds bin/gstack-brain-enqueue (atomic append to sync queue) and
bin/gstack-jsonl-merge (git merge driver, ts-sort with SHA-256 fallback).
Wires one backgrounded enqueue call into learnings-log, timeline-log,
review-log, and developer-profile --migrate. question-log and
question-preferences stay local per Codex v2 decision.
gstack-config gains gbrain_sync_mode (off/artifacts-only/full) and
gbrain_sync_mode_prompted keys, plus GSTACK_HOME env alignment so
tests don't leak into real ~/.gstack/config.yaml.
* feat(gbrain-sync): --once drain + secret scan + push
bin/gstack-brain-sync is the core sync binary. Subcommands: --once
(drain queue, allowlist-filter, privacy-class-filter, secret-scan
staged diff, commit with template, push with fetch+merge retry),
--status, --skip-file <path>, --drop-queue --yes, --discover-new
(cursor-based detection of artifact writes that skip the shim).
Secret regex families: AWS keys, GitHub tokens (ghp_/gho_/ghu_/ghs_/
ghr_/github_pat_), OpenAI sk-, PEM blocks, JWTs, bearer-token-in-JSON.
On hit: unstage, preserve queue, print remediation hint (--skip-file
or edit), exit clean. No daemon — invoked by preamble at skill
boundaries.
* feat(gbrain-sync): init, restore, uninstall, consumer registry
bin/gstack-brain-init: idempotent first-run. git init ~/.gstack/,
.gitignore=*, canonical .brain-allowlist + .brain-privacy-map.json,
pre-commit secret-scan hook (defense-in-depth), merge driver registration
via git config, gh repo create --private OR arbitrary --remote <url>,
initial push, ~/.gstack-brain-remote.txt for new-machine discovery,
GBrain consumer registration via HTTP POST.
bin/gstack-brain-restore: safe new-machine bootstrap. Refuses clobber
of existing allowlisted files, clones to staging, rsync-copies tracked
files, re-registers merge drivers (required — not cloned from remote),
rehydrates consumers.json, prompts for per-consumer tokens.
bin/gstack-brain-uninstall: clean off-ramp. Removes .git + .brain-*
files + consumers.json + config keys. Preserves user data (learnings,
plans, retros, profile). Optional --delete-remote for GitHub repos.
bin/gstack-brain-consumer + bin/gstack-brain-reader (symlink alias):
registry management. Internal 'consumer' term; user-facing 'reader'
per DX review decision.
* feat(gbrain-sync): preamble block — privacy gate + boundary sync
scripts/resolvers/preamble/generate-brain-sync-block.ts emits bash that
runs at every skill invocation:
- Detects ~/.gstack-brain-remote.txt on machines without local .git
and surfaces a restore-available hint (does NOT auto-run restore).
- Runs gstack-brain-sync --once at skill start to drain any pending
writes (and at skill end via prose instruction).
- Once-per-day auto-pull (cached via .brain-last-pull) for append-only
JSONL files.
- Emits BRAIN_SYNC: status line every skill run.
Also emits prose for the host LLM to fire the one-time privacy
stop-gate (full / artifacts-only / off) when gbrain is detected and
gbrain_sync_mode_prompted is false. Wired into preamble.ts composition.
* test(gbrain-sync): 27-test consolidated suite
test/brain-sync.test.ts covers:
- Config: validation, defaults, GSTACK_HOME env isolation
- Enqueue: no-op gates, skip list, concurrent atomicity, JSON escape
- JSONL merge driver: 3-way + ts-sort + SHA-256 fallback
- Init + sync: canonical file creation, merge driver registration,
push-reject + fetch+merge retry path
- Init refuses different remote (idempotency)
- Cross-machine restore round-trip (machine A write → machine B sees)
- Secret scan across all 6 regex families (AWS, GH, OpenAI, PEM, JWT,
bearer-JSON). --skip-file unblock remediation
- Uninstall removes sync config, preserves user data
- --discover-new idempotence via mtime+size cursor
Behaviors verified via integration smokes during implementation. Known
follow-up: bun-test 5s default timeout needs 30s wrapper for
spawnSync-heavy tests.
* docs(gbrain-sync): user guide + error lookup + README section
docs/gbrain-sync.md: setup walkthrough, privacy modes, cross-machine
workflow, secret protection, two-machine conflict handling, uninstall,
troubleshooting reference.
docs/gbrain-sync-errors.md: problem/cause/fix index for every
user-visible error. Patterned on Rust's error docs + Stripe's API
error reference.
README.md: 'Cross-machine memory with GBrain sync' section near the
top (discovery moment), plus docs-table entry.
* chore: bump version and changelog (v1.7.0.0)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: regenerate SKILL.md files for gbrain-sync preamble block
Re-runs bun run gen:skill-docs after adding generateBrainSyncBlock
to scripts/resolvers/preamble.ts in a2aa8a07. CI check-freshness
caught the drift. All 36 SKILL.md files regenerated with the new
skill-start bash block + privacy-gate prose + skill-end sync
instructions baked in.
* fix(test): session-awareness reads AskUserQuestion Format from a Tier 2+ SKILL.md
The test was reading ROOT/SKILL.md (browse skill, Tier 1) which never
contained '## AskUserQuestion Format' — that section is only emitted
for Tier 2+ skills by scripts/resolvers/preamble.ts. As a result the
agent was prompted with an empty format guide and only emitted
'RECOMMENDATION' intermittently, making the test flaky.
Pre-existing on main (same ROOT/SKILL.md shape there) — surfaced now
because the agent run didn't hit the RECOMMENDATION/recommend/option a
fallback strings in this particular attempt.
Fix: read from office-hours/SKILL.md (Tier 3, always has the section)
with a fallback that scans for the first top-level skill dir whose
SKILL.md contains the header. Future template moves won't break this
test again.
* feat(browse): domain-skills storage + state machine
New module browse/src/domain-skills.ts implements the per-site notes
the agent writes for itself, persisted as type:"domain" rows alongside
/learn's per-project learnings.
Three scopes layered: per-project default, global by explicit promotion.
Project-active shadows global for the same host.
State machine (T6 — codex outside-voice):
quarantined --3 uses w/o flag--> active(project) --promote--> global
^ |
+----- classifier flag during use
- Append-only JSONL with O_APPEND for atomic small writes
- Tolerant parser drops partial trailing line on read
- Tombstone for deletes (compactor cleans up later)
- Version log per (host, scope) enables rollback
- Hostname derived from active tab top-level origin (T3 confused-deputy fix)
- writeSkill rejects classifier_score >= 0.85 with structured error
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browse): domain-skills storage + state machine
14 tests covering:
- T3 hostname normalization (lowercase, www. strip, port/path/query strip,
subdomain-exact preserved)
- T4 scope shadowing (per-project active shadows global for same host)
- T5 persistence (version monotonicity, tolerant parser drops partial line)
- T6 state machine (quarantined → active after N=3 uses, classifier-flag
blocks promotion, save-time score >= 0.85 rejected)
- Rollback by version log (restore prior body, advance version counter)
- Tombstone deletion (read returns null after delete)
All 14 pass in 27ms via bun test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse): $B domain-skill subcommands
Wire the domain-skills storage layer into the browse CLI as a META command:
$B domain-skill save save body from stdin or --from-file
(host derived from active tab — T3)
$B domain-skill list list all skills visible to current project
$B domain-skill show <host> print skill body
$B domain-skill edit <host> open in $EDITOR
$B domain-skill promote-to-global <host> cross-project promotion (T4)
$B domain-skill rollback <host> [--global] restore prior version
$B domain-skill rm <host> [--global] tombstone
Save path runs L1-L3 content filters from content-security.ts (importable
in compiled binary, unlike L4 ML classifier — see CLAUDE.md). The L4
classifier scan happens in sidebar-agent at prompt-injection load time.
Output is structured (problem + cause + suggested-action) per DX D7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse): $B cdp escape hatch — deny-default allowlist + two-tier mutex
Codex T2: flip CDP posture to deny-default. Allowed methods enumerated in
cdp-allowlist.ts with (scope: tab|browser, output: trusted|untrusted,
justification) per entry.
Initial allowlist (~25 methods) covers:
- Accessibility tree extraction (read-only)
- DOM/CSS inspection (read-only)
- Performance metrics
- Tracing
- Emulation viewport/UA override
- Page screenshot/PDF capture (output is binary, no marker injection vector)
- Network.enable/disable (no bodies/cookies — those are exfil surfaces)
- Runtime.getProperties (NO evaluate/callFunctionOn — those would be RCE)
Page.navigate is INTENTIONALLY NOT allowed; agents use $B goto which
goes through the URL blocklist.
Codex T7: two-tier mutex. tab-scoped methods take per-tab lock; browser-
scoped take global lock that blocks all tab locks. 5s acquire timeout
yields CDPMutexAcquireTimeout (no silent hangs). All lock acquires use
try/finally so errors don't leak the lock.
Path A from spike: uses Playwright's newCDPSession() per page. No second
WebSocket, no need for --remote-debugging-port. CDPSession is cached
per page in a WeakMap and cleared on page close.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browse): CDP allowlist + two-tier mutex
13 tests:
- Allowlist linter: every entry has 4 required fields, no duplicates,
justification length > 20 chars
- Deny-list verification: dangerous methods (Runtime.evaluate, Page.navigate,
Network.getResponseBody, Browser.close, Target.attachToTarget, etc.) are
NOT allowed (Codex T2 categories 4-7)
- Per-tab mutex serializes ops on same tab
- Per-tab mutex allows parallel ops across different tabs
- Global lock blocks tab locks; tab locks block global lock
- Acquire timeout yields CDPMutexAcquireTimeout (no silent hang)
- Timeout error names the tab id and the timeout budget
Also extends Network.disable justification to satisfy linter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse): telemetry signals + project-slug helper
Lightweight telemetry per DX D9: piggybacks on ~/.gstack/analytics/ pattern.
Hostname + aggregate counters only, no body content. GSTACK_TELEMETRY_OFF=1
silences. Fire-and-forget — never blocks calling path.
Signals fired so far:
- domain_skill_saved {host, scope, state, bytes}
- domain_skill_save_blocked {host, reason}
(domain_skill_fired and cdp_method_* fired in subsequent commits.)
Also extracts project-slug resolution into project-slug.ts so server.ts
and domain-skill-commands.ts share one cached lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse): sidebar prompt-context injection + CDP telemetry
server.ts spawnClaude now:
- Imports per-project domain skill matching the active tab's hostname
via readDomainSkill()
- Wraps the body in UNTRUSTED EXTERNAL CONTENT envelope (so the L4
classifier in sidebar-agent sees it at load time per Eng D4)
- Appends as <domain-skill source="..." host="..." version="..."> block
- Fires domain_skill_fired telemetry (host, source, version)
- Calls recordSkillUse fire-and-forget so the auto-promote-after-N=3
state machine advances on each successful prompt injection
System prompt also gets a one-liner introducing $B domain-skill commands
to agents (DX D4 start-of-task discoverability hint).
cdp-bridge.ts fires:
- cdp_method_denied (drives next allow-list growth)
- cdp_method_lock_acquire_ms (P50/P99 quantile observability)
- cdp_method_called (allowed methods)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browse): telemetry module
3 tests covering:
- logTelemetry writes JSONL with ts injected
- GSTACK_TELEMETRY_OFF=1 silences all events
- logTelemetry never throws on disk failures
Uses GSTACK_HOME env var to redirect writes to a tmp dir; the telemetry
module reads HOME lazily so test mutations take effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: domain-skills reference + error lookup table
docs/domain-skills.md mirrors the layered shape of docs/gbrain-sync.md
(DX D8): how agents use it, state machine, storage layout, security model
(L1-L3 + L4 layered defense), error reference table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(readme): browser-harness-js plug + domain-skills section
New "Domain skills + raw CDP escape hatch" section under "The sprint"
covering both v1.8.0.0 features. Plugs browser-use/browser-harness-js
as the no-rails alternative for users who want raw CDP without gstack's
security stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.8.0.0)
Branch-scoped bump on top of merged 1.7.0.0 base. CHANGELOG entry covers
the full v1.8.0.0 scope: $B domain-skill, $B cdp escape hatch, two-tier
mutex, telemetry signals, sidebar prompt-context injection. Includes
Codex outside-voice trail (7 of 20 findings resolved, 12 mooted by T1
scope drop).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* todos: 7 follow-ups from v1.8.0.0 review trail
P1: Self-authoring $B commands with out-of-process worker isolation
(Codex T1 deferred from v1.8.0.0 — needs real isolation design)
P2: Migrate /learn to SQLite (Codex T5 long-term primitive fix)
P2: Remove plan-mode handshake from /plan-devex-review (skill bug)
P3: GBrain skillpack publishing for domain-skills
P3: Replay/record demonstrated flows to domain-skills
P3: $B commands review batch-mode UX (alternative to inline approval)
P3: Heuristic command-gap watcher (DX D4 alternative C)
Each entry has the standard What/Why/Pros/Cons/Context/Effort/Priority/
Depends-on shape so anyone picking these up later has full context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(browse): lazy GSTACK_HOME resolution in domain-skills
Module-level constants (GLOBAL_FILE, derived path) were evaluated at
module-load and cached. When E2E and unit tests run in the same Bun
test pass and set GSTACK_HOME differently, the second test sees the
first test's path. Switch to lazy gstackHome() / globalFile() / projectFile()
helpers so process.env mutations take effect.
Mirrors the pattern already used in telemetry.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browse): E2E gate-tier tests for domain-skills + CDP
domain-skills-e2e.test.ts (4 tests):
- save derives host from active tab top-level origin (T3)
- save lands quarantined; list surfaces it
- readSkill returns null until 3 uses without flag promote to active (T6)
- save without an active page errors with structured guidance
cdp-e2e.test.ts (8 tests):
- Accessibility.getFullAXTree returns wrapped JSON (allowed, untrusted-output)
- Performance.getMetrics returns plain JSON (allowed, trusted-output)
- Runtime.evaluate DENIED with structured guidance (T2 RCE block)
- Page.navigate DENIED (must use $B goto for blocklist routing)
- Network.getResponseBody DENIED (exfil block)
- malformed JSON params surfaces clear error
- non Domain.method format surfaces clear error
- $B cdp help returns help text
Both files boot a real Chromium via BrowserManager.launch() and exercise
the dispatch handlers end-to-end. Total 12 E2E tests in <2s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: regenerate SKILL.md files with new $B commands
bun run gen:skill-docs picks up the domain-skill and cdp META_COMMANDS
entries added in commands.ts. Both top-level SKILL.md and browse/SKILL.md
now list the new commands in their Meta and Inspection tables.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(fixtures): regenerate ship SKILL.md golden baselines for v1.7.0.0
Pre-existing failures inherited from garrytan/gbrain-support: the GBrain
Sync preamble block (added in v1.7.0.0) appears in regenerated SKILL.md
output but the golden baselines in test/fixtures/golden/ were never
updated. Three failures fixed:
golden-file regression > Claude ship skill matches golden baseline
golden-file regression > Codex ship skill matches golden baseline
golden-file regression > Factory ship skill matches golden baseline
Goldens regenerated by copying the current ship/SKILL.md, codex
.agents/skills/gstack-ship/SKILL.md, and .factory/skills/gstack-ship/SKILL.md
files. Diff is the v1.7.0.0 GBrain Sync preamble block + privacy stop-gate
(no behavioral changes — just preamble text).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(brain-sync): bearer-token regex catches values with leading space
Pre-existing bug from v1.7.0.0: the bearer-token-json secret pattern
required values matching [A-Za-z0-9_./+=-]{16,}, which rejected the
"Bearer <token>" form because the literal space after "Bearer" wasn't
in the character class. Real Authorization headers use "Bearer <token>"
syntax, and the test fixture
'"authorization":"Bearer abcdef1234567890abcdef1234567890"'
sat unscanned despite being a leak-class secret.
One-character fix: add space to the value character class. Test
'gstack-brain-sync secret scan > blocks bearer-json' now passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(brain-sync): GSTACK_HOME isolation test compares mtime, not content
Pre-existing flaky test: the GSTACK_HOME-overrides-real-config test asserted
the real ~/.gstack/config.yaml does NOT contain "gbrain_sync_mode: full"
after the test. That fails for any user whose real config legitimately has
that key set from prior usage — the test's invariant is "the command did
not modify the real file," not "the real file lacks any specific value."
Switch to mtime + content snapshot: capture both BEFORE running the command,
then verify both are unchanged after. Also add a positive assertion that
the tmpHome config DID get the new key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(skill-validation): exempt deliberate large fixtures from 2MB limit
Pre-existing failure: the "git tracks no files larger than 2MB" test
caught browse/test/fixtures/security-bench-haiku-responses.json (28.8MB
of replay data committed in v1.6.4.0 for security benchmark gate tests).
The test exists to catch accidentally-committed binaries (Mach-O dist
binaries, etc), not to forbid all large files. Add an explicit
LARGE_FIXTURE_EXEMPTIONS allowlist so deliberate replay fixtures pass
the gate while accidental binaries still fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(skill-token): mint scoped tokens per skill spawn
Wraps token-registry.createToken/revokeToken with skill-specific
clientId encoding (skill:<name>:<spawn-id>) and read+write defaults.
Skill scripts get a per-spawn capability token bound to browser-driving
commands; the daemon root token never leaves the harness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse-client): SDK for browser-skill scripts
Thin wrapper over POST /command with bearer auth. Resolves daemon
port + token from GSTACK_PORT + GSTACK_SKILL_TOKEN env vars first
(set by $B skill run when spawning), falls back to .gstack/browse.json
for standalone debug runs.
Convenience methods cover the read+write surface skills typically need:
goto, click, fill, text, html, snapshot, links, forms, accessibility,
attrs, media, data, scroll, press, type, select, wait, hover, screenshot.
Low-level command(cmd, args) escape hatch for anything else.
This is the canonical SDK source. Each browser-skill ships a sibling
copy at <skill>/_lib/browse-client.ts so each skill is fully portable
and version-pinned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browser-skills): 3-tier storage helpers
listBrowserSkills() walks project > global > bundled (first-wins),
parses SKILL.md frontmatter, no INDEX.json. readBrowserSkill() does
the same for a single name. tombstoneBrowserSkill() moves a skill
into .tombstones/<name>-<ts>/ for recoverability.
Frontmatter parser handles the subset browser-skills need: scalars
(host, description, trusted, version, source), string lists
(triggers), and arg-mapping lists ([{name, description}, ...]).
Quoted values handle colons; trusted defaults to false.
Bundled tier path is auto-detected from the binary install location;
project tier comes from git rev-parse; global is ~/.gstack/. All tier
paths are overridable for hermetic tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browser-skills): \$B skill list/show/run/test/rm subcommands
handleSkillCommand dispatches to per-subcommand handlers; spawnSkill is
the load-bearing function that:
1. Mints a per-spawn scoped token (read+write only) bound to the
skill name + spawn-id.
2. Builds the spawn env:
- trusted: passes process.env minus GSTACK_TOKEN (defense in depth).
- untrusted: minimal allowlist (LANG, LC_ALL, TERM, TZ) + locked
PATH; explicitly drops anything matching TOKEN/KEY/SECRET/etc.
Also drops AWS_/AZURE_/GCP_/GOOGLE_APPLICATION_/ANTHROPIC_/OPENAI_/
GITHUB_/GH_/SSH_/GPG_/NPM_TOKEN/PYPI_ patterns.
3. Always injects GSTACK_PORT + GSTACK_SKILL_TOKEN last (cannot be
overridden by parent env).
4. Spawns bun run script.ts -- <args> with cwd=skillDir, captures
stdout (1MB cap), stderr, and timeout-kills past the deadline.
5. Revokes the token in finally{}, always.
list output prints the resolved tier inline so "why did it run that
one?" never becomes a debugging mystery (Codex finding #4 mitigation).
server.ts threads the listen port to meta-commands via MetaCommandOpts.daemonPort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browser-skills): bundled hackernews-frontpage reference skill
Smallest interesting browser-skill: scrapes HN front page, returns
30 stories as JSON. No auth, stable HTML, fully fixture-tested.
Files:
SKILL.md frontmatter + prose
script.ts exports parseStoriesFromHtml(html)
main: goto + html + parse + JSON.stringify
_lib/browse-client.ts vendored copy of the SDK
fixtures/hn-2026-04-26.html captured front page (5 stories)
script.test.ts 13 assertions against the fixture
The parser is a pure function over HTML so script.test.ts runs
without a daemon (just imports parseStoriesFromHtml and asserts).
This exercises every Phase 1 component end-to-end:
- browse-client SDK (script imports browse from ./_lib/)
- 3-tier lookup (hackernews-frontpage lives in the bundled tier)
- scoped tokens (read+write is enough for goto + html)
- spawn lifecycle (\$B skill run hackernews-frontpage)
- file-fixture testing (\$B skill test hackernews-frontpage)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(skill-validation): cover bundled browser-skills
Adds 7 assertions per bundled skill at <root>/browser-skills/<name>/:
- SKILL.md exists
- frontmatter parses with required fields (name/host/triggers/args)
- script.ts exists
- _lib/browse-client.ts exists and matches the canonical SDK byte-for-byte
- script.test.ts exists
- script.ts imports browse from ./_lib/browse-client
The byte-identical SDK check enforces the version-pinning contract:
when the canonical SDK at browse/src/browse-client.ts changes, every
bundled skill's _lib/ copy must be re-synced or this test fails.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(designs): add BROWSER_SKILLS_V1 design doc
Captures the 13 locked decisions, two-axis trust model (daemon-side
scoped tokens + process-side env access), 3-tier lookup, file
layout, and full responses to all 8 Codex outside-voice findings.
Includes Phase 2-4 sketches for future branches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(todos): replace self-authoring-\$B P1 with browser-skills phases
Phase 1 of the browser-skills design shipped on this branch (sidesteps
the in-daemon isolation problem the original P1 was blocked on). The
new entries enumerate the work that remains:
P1: Phase 2 (/scrape + /automate skill templates)
P2: Phase 3 (resolver injection at session start)
P2: Phase 4 (eval infra + fixture staleness + OS sandbox)
Cross-references docs/designs/BROWSER_SKILLS_V1.md for the full
architecture and the 8 Codex review findings + responses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: v1.9.0.0 — browser-skills runtime
VERSION 1.8.0.0 → 1.9.0.0. CHANGELOG entry leads with what humans
can do today (hand-write deterministic browser scripts, run them in
200ms via \$B skill run). Notes explicitly that agent authoring
lands in next release; no fabricated perf numbers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browser-skills-e2e): exercise dispatch with bundled hackernews-frontpage
Covers the full \$B skill list/show/test pipeline against the real
bundled reference skill (defaultTierPaths picks up <repo>/browser-skills/).
Verifies frontmatter shape, the three-tier walk surfaces the bundled
entry, and \$B skill test successfully runs the bundled script.test.ts
in a child bun process.
\$B skill run end-to-end against the live network is intentionally NOT
covered here (would be flaky against news.ycombinator.com); the spawn
lifecycle is exercised in browser-skill-commands.test.ts using inline
synthetic skills.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: regen SKILL.md to surface the skill META command
bun run gen:skill-docs picked up the new \`skill\` command from
COMMAND_DESCRIPTIONS in browse/src/commands.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: bump v1.9.0.0 → v1.13.0.0
Main shipped through v1.11.1.0 while this branch was in flight; v1.12.x
is presumed claimed by another in-flight branch. Use v1.13.0.0 as the
next available slot.
Updated VERSION, package.json, and the CHANGELOG header. Entry body
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: bump v1.13.0.0 → v1.16.0.0
Main shipped v1.13.0.0 (claude outside-voice skill), v1.14.0.0
(sidebar REPL), and v1.15.0.0 (slim preamble + plan-mode E2E)
while this branch was in flight. Use v1.16.0.0 as the next
available slot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(browse-skills): atomic write helper for /skillify (D3)
stageSkill writes a candidate skill into ~/.gstack/.tmp/skillify-<spawnId>/
with restrictive perms. commitSkill does an atomic fs.renameSync into the
final tier path with realpath/lstat discipline (refuses symlinked staging
dirs, refuses to clobber existing skills). discardStaged is the cleanup
path for test failures and approval rejections, idempotent and bounded
to the per-spawn wrapper. validateSkillName enforces lowercase/digits/
dashes only, no path-escape characters.
Implements the D3 contract from the v1.19.0.0 plan review: never a
half-written skill on disk. Test fail or approval reject = rm -rf the
temp dir, no tombstone for never-approved skills.
Closes Codex finding #5 (atomic skill packaging) for Phase 2a.
34 unit assertions covering: stage validation, file-path escape rejection,
permission check, atomic rename, clobber refusal, symlink refusal, project
tier unresolved, idempotent discard, end-to-end happy + simulated test
failure + approval reject paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(scrape): /scrape <intent> skill template
One entry point for pulling page data. Three paths under the hood:
1. Match — agent reads $B skill list, semantically matches the user's
intent against each skill's triggers + description + host. Confident
match = $B skill run <name> in ~200ms.
2. Prototype — no match, drive the page with $B goto/text/html/links etc.
Return JSON, append a one-line "say /skillify" nudge.
3. Mutating refusal — verbs like submit/click/fill route to /automate
(Phase 2b P0); /scrape is read-only by contract.
Match decision lives in the agent, not the daemon. No new code in
browse/src/, no expanded daemon command surface, no new prompt-injection
blast radius.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(skillify): /skillify codifies last /scrape into permanent skill
The productivity multiplier. /scrape discovers the flow; /skillify writes
it as deterministic Playwright-via-browse-client code so the next /scrape
on the same intent runs in ~200ms.
11-step flow with three locked contracts from the v1.19.0.0 plan review:
D1 — Provenance guard. Walk back ≤10 agent turns for a clearly-bounded
/scrape result. Refuse with one specific message if cold. No silent
synthesis from chat fragments.
D2 — Synthesis input slice. Extract ONLY the final-attempt $B calls that
produced the JSON the user accepted, plus the user's intent string. Drop
failed selectors, drop unrelated chat, drop earlier-session content.
Closes Codex finding #6 by picking option (b) from the design doc:
re-prompt from agent's own context, not a structured recorder.
D3 — Atomic write. Stage to ~/.gstack/.tmp/skillify-<spawnId>/, run
$B skill test against the temp dir, only rename into the final tier path
on test pass + user approval. Test fail or approval reject = rm -rf the
temp dir entirely.
Default tier: global (~/.gstack/browser-skills/<name>/). --project flag
overrides to per-project. Generated test must include at least one ★★
assertion (parsed JSON has expected shape + non-empty key fields), not a
smoke ★ assertion.
Bun runtime distribution (Codex finding #7) carries over to Phase 4.
Documented in the skill's Limits section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(browser-skills): gate-tier E2E for /scrape + /skillify (D4)
Five scenarios cover the productivity loop and the contracts locked
during the v1.19.0.0 plan review:
scrape-match-path — intent matching bundled hackernews-frontpage
routes via $B skill run, no prototype phase
scrape-prototype-path — no matching skill, drives $B against a local
file:// fixture, returns JSON, suggests
/skillify
skillify-happy-path — /scrape then /skillify; skill written to
~/.gstack/browser-skills/<name>/ with the
full file tree; SKILL.md prose body must
not contain conversation fragments (D2)
skillify-provenance-refusal — cold /skillify with no prior /scrape refuses
with the D1 message; nothing on disk (D1)
skillify-approval-reject — /scrape then /skillify but reject in the
approval gate; temp dir is removed, nothing
at the final tier path (D3)
All five gate-tier (~$0.50-$1.50 each, ~$5 total per CI run). Set EVALS=1
to enable. Uses local file:// fixtures so prototype + skillify scenarios
run deterministically without network.
Touchfiles registers all 5 entries with proper deps on scrape/**,
skillify/**, browse/src/browser-skill-write.ts, and the Phase 1 runtime
modules. The match-path test depends on the bundled hackernews-frontpage
skill so its touchfile includes browser-skills/hackernews-frontpage/**.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(browser-skills): TODOS Phase 2a + design doc D1-D4 decisions
TODOS.md:
- Narrows existing P1 (was "/scrape and /automate") to "/scrape and
/skillify" — the /scrape + /skillify wedge ships in this branch.
Codex finding #6 (synthesis) removed from Cons (resolved by D2);
finding #7 (Bun runtime) stays as the open carry-over.
- Adds new ## P0 above PACING_UPDATES_V0 for the /automate follow-up.
Same skillify pattern as /scrape, different trust profile (per-step
confirmation gate when running non-codified). Reuses /skillify and
the D3 helper as-is. Effort M.
BROWSER_SKILLS_V1.md:
- Phase table re-organized into 1, 2a, 2b, 3, 4. Phase 1 + Phase 2a
consolidate into v1.19.0.0 ship (the v1.16.0.0 branch-internal
bump never landed on main).
- New "Phase 2a" sub-section captures the four decisions locked
during /plan-eng-review:
D1 — provenance guard (≤10 turn walk-back, refuse if cold)
D2 — synthesis input slice (final-attempt $B calls only,
closes Codex finding #6)
D3 — atomic write discipline (temp-dir-then-rename via new
browse/src/browser-skill-write.ts helper)
D4 — full test scope (5 gate E2E + 1 unit + smoke)
- New "Phase 2b" sketch for /automate: same skillify machinery,
per-mutating-step confirmation gate, deferred to next branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: v1.16.0.0 -> v1.19.0.0 — browser-skills Phase 1 + 2a
Consolidates the v1.16.0.0 branch-internal bump (Phase 1 runtime, never
landed on main) with Phase 2a (/scrape + /skillify + atomic-write helper)
into one v1.19.0.0 ship per CLAUDE.md "Never orphan branch-internal
versions" rule.
Headline: Browser-skills land end-to-end. /scrape <intent> first call
drives the page; second call runs the codified script in 200ms.
The unified CHANGELOG entry covers:
- Phase 1 runtime: $B skill list/show/run/test/rm, scoped tokens,
3-tier storage, bundled hackernews-frontpage reference.
- Phase 2a: /scrape + /skillify gstack skills, browser-skill-write.ts
atomic helper, 5 gate-tier E2E + 34 unit assertions.
Numbers table updated: 5 new modules (+browser-skill-write), 2 new
gstack skills, 6 of 8 Codex outside-voice findings resolved (synthesis
#6 closed by D2; Bun runtime #7 + OS sandbox #1 stay deferred to Phase 4).
/automate (Phase 2b) is split out as P0 in TODOS for the next branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(commands): tighten descriptions for LLM-judge baseline pinning
The skill-llm-eval test "baseline score pinning" failed CI on three
retry attempts: judge gave command_reference.actionability=3, baseline
demands ≥4. Judge cited 8 specific gaps in COMMAND_DESCRIPTIONS.
This commit closes 7 of 8 by tightening the descriptions:
- press: documents that key names are case-sensitive Playwright keys,
shows modifier syntax (Shift+Enter, Control+A), links the full key
list. Removes the "is this case-sensitive?" guesswork.
- is: documents that <sel> accepts either a CSS selector OR an @ref
token from a prior snapshot, and that property values are case-
sensitive.
- scroll: documents that there is no --by/--to amount option, points
at `js window.scrollTo(0, N)` for pixel-precise scrolling.
- js / eval: clarifies that both run in the same JS sandbox, the
difference is just inline expr (js) vs file (eval).
- storage: clarifies sessionStorage is read-only via this command,
points at `js sessionStorage.setItem(...)` for the write path.
- chain: walks through how to invoke (pipe a JSON array of arrays to
$B chain), confirms it stops at the first error.
- cdp: explains how to discover allowed methods (read cdp-allowlist.ts)
+ shows a concrete example invocation.
- domain-skill: explains that the "classifier flag" is set automatically
by the L4 prompt-injection scan (agents do not set it manually);
enumerates the full lifecycle verbs.
The 8th gap (storage set syntax conflict) is also resolved as part of
the storage rewrite.
Two pipe-character bugs caught by the existing
`no command description contains pipe character` guard at
`test/gen-skill-docs.test.ts:595`: the chain example originally used
`echo '[...]' | $B chain` (literal pipe) and the cdp description used
`tab|browser` / `trusted|untrusted` (also literal pipes). Both rewritten
to keep markdown table cells intact.
Verification: 696/0 pass on skill-validation + gen-skill-docs after
regen across all hosts. The CI llm-judge eval will re-run against the
new SKILL.md and should hit actionability ≥4 reliably.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(browser): rewrite BROWSER.md as complete reference
Full rewrite covering the gstack browser surface as of v1.19.0.0. Up from
488 to 1,299 lines, 26 top-level sections.
Adds previously-undocumented subsystems:
- The productivity loop: /scrape + /skillify with D1 (provenance guard),
D2 (final-attempt-only synthesis), D3 (atomic-write discipline) contracts.
- Browser-skills runtime: anatomy, three-tier storage, scoped tokens, trust
model (capability + env axes), sibling SDK distribution, atomic-write
helper, bundled hackernews-frontpage reference.
- Domain-skills: per-site agent notes with quarantined → active → global
state machine and the L4-classifier auto-promotion gate.
- Pair-agent: dual-listener architecture, 26-command tunnel allowlist,
canDispatchOverTunnel pure gate, three token types (root, setup key,
scoped), denial log path + salt model.
- Security stack L1-L6: layer table, thresholds (BLOCK/WARN/LOG_ONLY/
SOLO_CONTENT_BLOCK), ensemble rule, classifier model paths, env knobs.
- Side Panel deep dive: Terminal pane (Claude PTY) as the primary surface
with Activity/Refs/Inspector as debug overlays, WS auth via
Sec-WebSocket-Protocol, gstackInjectToTerminal cross-pane plumbing.
- CDP escape hatch: $B cdp deny-default allowlist, $B inspect CSS inspector,
$B ux-audit page structure extraction.
- Meta commands previously undocumented: tabs/frames/state/watch/inbox/
tab-each, with usage and storage paths.
- Authentication: three token types with lifetimes, SSE session cookie,
PTY session cookie, token registry behavior.
- Full source map: 30+ file inventory of browse/src/ vs the old 11-file
list.
Preserves from before: architecture diagram, daemon lifecycle, snapshot
ref staleness, screenshot modes, goto file:// vs load-html semantics,
batch endpoint, JS await wrapping, env vars, performance numbers vs MCP,
Playwright acknowledgments, dev guide.
Cross-links to ARCHITECTURE.md, CLAUDE.md, docs/REMOTE_BROWSER_ACCESS.md,
docs/designs/BROWSER_SKILLS_V1.md, scrape/SKILL.md, skillify/SKILL.md,
TODOS.md so anyone landing on BROWSER.md can navigate to the load-bearing
companion docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(server): tab-ownership gate keys on tabPolicy, not isWrite
Browser-skill spawns hit `403: Tab not owned by your agent` on every
first run because the gate at server.ts:639 fired for any non-root
write, regardless of the token's tabPolicy. The bundled
hackernews-frontpage reference skill failed identically. Every
/skillify-generated skill failed identically. The user's natural
tabs have no claimed owner — by design — so any skill driving
them via `goto` (a write) was 403'd.
The intent in skill-token.ts:79 was always correct: `tabPolicy: 'shared'`
with the comment "skill scripts may switch tabs as needed." The
enforcement just ignored it.
Two surgical changes:
browser-manager.ts:checkTabAccess — gate now keys on options.ownOnly
only. Shared-policy tokens (skill spawns, default scoped clients) get
permissive access — root-equivalent for the tab gate. Own-only tokens
(pair-agent over the ngrok tunnel) still require ownership for every
read and write. isWrite stays in the signature for callers that want
to log or branch elsewhere; it no longer gates the decision.
server.ts:639 — gate predicate narrowed from
(WRITE_COMMANDS.has(command) || tokenInfo.tabPolicy === 'own-only')
to just
tokenInfo.tabPolicy === 'own-only'
The 'newtab' exemption stays. Shared tokens skip the gate entirely;
own-only tokens still hit it. Comment block above the gate updated to
document the new predicate intent.
Pair-agent isolation is intact. Tunnel tokens still default to
tabPolicy: 'own-only', still must `newtab` first to get a tab they
can drive, still can't dispatch any of the 23 commands outside the
tunnel allowlist.
The capability gate (scope checks) and rate limits already constrain
what local scoped clients can do; tab ownership was never a security
boundary for them — only for pair-agent. This release makes the
enforcement match the original design intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(server): lock the shared-vs-own-only tab gate contract
The pre-fix tests at tab-isolation.test.ts:43,57 encoded the broken
behavior as the contract — they specifically asserted "scoped agent
cannot write to unowned tab," which was the exact failure mode that
broke browser-skills. They passed because they tested the wrong
invariant.
This commit replaces those tests with explicit shared-vs-own-only
coverage that documents what each policy actually means:
- Shared scoped agents (skill spawns, default scoped clients) can
read AND write any tab — unowned, their own, or another agent's.
The capability is gated by scope checks + rate limits, not by tab
ownership.
- Own-only scoped agents (pair-agent over tunnel) cannot read OR
write any tab they don't own. Pre-fix this case was conflated with
shared writes; now it's explicit.
9 unit assertions on checkTabAccess, up from 6. Each test names
the policy axis it's covering so a future refactor can't quietly
flip the contract.
Adds source-shape regression test 10a in server-auth.test.ts:
"tab gate predicate is own-only-scoped, not write-scoped." The
gate's `if (...)` line MUST contain `tabPolicy === 'own-only'` and
MUST NOT contain `WRITE_COMMANDS.has(command) ||`. If a future
refactor re-introduces the write-scoped gate, this fails immediately
in free-tier `bun test`.
Updates the marker for the existing newtab-excluded test to match
the new comment block ("Tab ownership check (own-only tokens /
pair-agent isolation)").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release: v1.19.0.0 -> v1.20.0.0 — fix tab-ownership footgun
Patch release on top of v1.19.0.0. The shipping headline of v1.19.0.0
(/scrape + /skillify productivity loop) was broken on first run in any
session where the daemon already had a tab. Bundled
hackernews-frontpage failed identically. Every /skillify-generated
skill failed identically.
The fix narrows the tab-ownership gate from "any non-root write" to
"tabPolicy === 'own-only' only." Pair-agent isolation (the v1.6.0.0
threat model) is intact; local skill spawns get their original
behavior back.
VERSION: 1.19.0.0 -> 1.20.0.0
package.json version: synced.
CHANGELOG entry leads with the user-visible impact: the productivity
loop works again, no half-second-stalls of confused 403s. Includes
before/after metrics on the bundled reference skill and the broken-
contract pre-fix tests that hid the regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(claude): sharpen CHANGELOG rule — diff between main and ship
Codifies what was already implicit in the existing "Never orphan
branch-internal versions" + "Only document what shipped between main
and this change" sections, but with sharper language and concrete
NEVER examples.
The rule: a CHANGELOG entry is the diff between main and the shipping
branch — what users get when they upgrade. NOT how the branch got
there. Branch-internal version bumps, mid-branch bug fixes, plan
review outcomes, and patch narratives all belong in PR descriptions
and commit messages, not in CHANGELOG.
Adds explicit examples of phrasing to NEVER use:
- "v1.X had a bug that v1.Y fixes" (mentions a branch-internal version)
- "The shipping headline of v1.X was broken because..." (apologizes
for never-released state)
- "Pre-fix tests encoded the broken behavior" (contributor's victory
lap, not user benefit)
- "Two surgical edits, both in the dispatch path" (micro-narrative
of the patch)
The constructive replacement: describe the released system as a
property, not as a fix. "Browser-skills run end-to-end with the
expected tab-access semantics." If a property is worth calling out,
document it in the trust-model section, not as a "we fixed X" callout.
Pairs with feedback_no_shame_changelog and
feedback_changelog_harden_against_critics memories — entries should
read as a flex even to a hostile screenshotter, never admit prior
breakage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(changelog): consolidate v1.20.0.0 as the diff vs main
Rewrites the v1.20.0.0 entry to describe what users get when they
upgrade from main (v1.17.0.0) to this release: browser-skills
end-to-end. Drops all branch-internal narrative — Phase 1 / Phase 2a
labels, the v1.8.0.0 P1 history paragraph, the test-counts-by-phase
split, and the patch micro-narrative for the tab-policy semantics.
The previously-separate v1.19.0.0 entry (a branch-internal version
that never landed on main) collapses into v1.20.0.0 per the
"Never orphan branch-internal versions" rule.
Tab-access policies are now documented as a property of the trust
model: `'shared'` (skill spawns) is permissive, `'own-only'`
(pair-agent over the tunnel) is strict. No "fix" framing, no
mention of an intermediate state where it was broken.
Adds the BROWSER.md rewrite and the new tab-isolation +
server-auth source-shape regression tests to the itemized changes.
The reverse-chronological order remains: v1.20.0.0 → v1.17.0.0 →
v1.16.0.0 → v1.15.0.0 → ... Gaps (v1.18, v1.19) are fine — those
were branch-internal version numbers that never landed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* browse-client — canonical SDK that browser-skill scripts import to drive the
|
||||
* gstack daemon over loopback HTTP.
|
||||
*
|
||||
* Distribution model:
|
||||
* This file is the canonical source. Each browser-skill ships a sibling
|
||||
* copy at `<skill>/_lib/browse-client.ts` (Phase 2's generator copies it
|
||||
* alongside every generated skill; Phase 1's bundled `hackernews-frontpage`
|
||||
* reference skill ships a hand-copied version). The skill imports the
|
||||
* sibling via relative path: `import { browse } from './_lib/browse-client'`.
|
||||
*
|
||||
* Why per-skill copies and not a single global SDK: each skill is fully
|
||||
* portable (copy the directory anywhere, it runs), version drift is
|
||||
* impossible (the SDK is frozen at the version the skill was authored
|
||||
* against), no npm publish workflow, no fixed-path tilde imports.
|
||||
*
|
||||
* Auth resolution:
|
||||
* 1. GSTACK_PORT + GSTACK_SKILL_TOKEN env vars (set by `$B skill run` when
|
||||
* spawning the script). The token is a per-spawn scoped capability bound
|
||||
* to read+write commands; it expires when the spawn ends.
|
||||
* 2. State file fallback: read `BROWSE_STATE_FILE` env or `<git-root>/.gstack/browse.json`
|
||||
* and use the `port` + `token` (the daemon root token). This path exists
|
||||
* for developers running a skill directly via `bun run script.ts` outside
|
||||
* the harness — your own authority, not an agent's.
|
||||
*
|
||||
* Trust:
|
||||
* The SDK exposes only the daemon's existing HTTP surface (POST /command).
|
||||
* No new capabilities. The token's scopes (read+write for spawned skills,
|
||||
* full root for standalone debug) determine what actually executes.
|
||||
*
|
||||
* Zero side effects on import. Safe to import from tests or plain scripts.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as cp from 'child_process';
|
||||
|
||||
export interface BrowseClientOptions {
|
||||
/** Override port. Default: GSTACK_PORT env or state file. */
|
||||
port?: number;
|
||||
/** Override token. Default: GSTACK_SKILL_TOKEN env, then state file root token. */
|
||||
token?: string;
|
||||
/** Tab id to target (every command can scope to a tab). Default: BROWSE_TAB env or undefined (active tab). */
|
||||
tabId?: number;
|
||||
/** Per-request timeout in milliseconds. Default: 30_000. */
|
||||
timeoutMs?: number;
|
||||
/** Override state-file path. Default: BROWSE_STATE_FILE env or <git-root>/.gstack/browse.json. */
|
||||
stateFile?: string;
|
||||
}
|
||||
|
||||
interface ResolvedAuth {
|
||||
port: number;
|
||||
token: string;
|
||||
source: 'env' | 'state-file';
|
||||
}
|
||||
|
||||
/** Resolve the daemon port + token. Throws a clear error if neither path works. */
|
||||
export function resolveBrowseAuth(opts: BrowseClientOptions = {}): ResolvedAuth {
|
||||
if (opts.port !== undefined && opts.token !== undefined) {
|
||||
return { port: opts.port, token: opts.token, source: 'env' };
|
||||
}
|
||||
|
||||
// 1. Env vars (set by $B skill run when spawning).
|
||||
const envPort = process.env.GSTACK_PORT;
|
||||
const envToken = process.env.GSTACK_SKILL_TOKEN;
|
||||
if (envPort && envToken) {
|
||||
const port = opts.port ?? parseInt(envPort, 10);
|
||||
if (!isNaN(port)) {
|
||||
return { port, token: opts.token ?? envToken, source: 'env' };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. State file fallback (developer running `bun run script.ts` directly).
|
||||
const stateFile = opts.stateFile ?? process.env.BROWSE_STATE_FILE ?? defaultStateFile();
|
||||
if (stateFile && fs.existsSync(stateFile)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
if (typeof data.port === 'number' && typeof data.token === 'string') {
|
||||
return {
|
||||
port: opts.port ?? data.port,
|
||||
token: opts.token ?? data.token,
|
||||
source: 'state-file',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through to error
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'browse-client: cannot find daemon port + token. Either spawn via `$B skill run` ' +
|
||||
'(sets GSTACK_PORT + GSTACK_SKILL_TOKEN) or run from a project with a live daemon ' +
|
||||
'(.gstack/browse.json must exist).'
|
||||
);
|
||||
}
|
||||
|
||||
function defaultStateFile(): string | null {
|
||||
try {
|
||||
const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000 });
|
||||
const root = proc.status === 0 ? proc.stdout.trim() : null;
|
||||
const base = root || process.cwd();
|
||||
return path.join(base, '.gstack', 'browse.json');
|
||||
} catch {
|
||||
return path.join(process.cwd(), '.gstack', 'browse.json');
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowseClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status?: number,
|
||||
public readonly body?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BrowseClientError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin client over the daemon's POST /command endpoint.
|
||||
*
|
||||
* Convenience methods cover the common cases (goto, click, text, snapshot,
|
||||
* etc.). For anything not exposed as a method, use `command(cmd, args)`.
|
||||
*/
|
||||
export class BrowseClient {
|
||||
readonly port: number;
|
||||
readonly token: string;
|
||||
readonly tabId?: number;
|
||||
readonly timeoutMs: number;
|
||||
|
||||
constructor(opts: BrowseClientOptions = {}) {
|
||||
const auth = resolveBrowseAuth(opts);
|
||||
this.port = auth.port;
|
||||
this.token = auth.token;
|
||||
this.tabId = opts.tabId ?? (process.env.BROWSE_TAB ? parseInt(process.env.BROWSE_TAB, 10) : undefined);
|
||||
this.timeoutMs = opts.timeoutMs ?? 30_000;
|
||||
}
|
||||
|
||||
// ─── Low-level dispatch ─────────────────────────────────────────
|
||||
|
||||
/** Send an arbitrary command; returns raw response text. Throws on non-2xx. */
|
||||
async command(cmd: string, args: string[] = []): Promise<string> {
|
||||
const body = JSON.stringify({
|
||||
command: cmd,
|
||||
args,
|
||||
...(this.tabId !== undefined && !isNaN(this.tabId) ? { tabId: this.tabId } : {}),
|
||||
});
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`http://127.0.0.1:${this.port}/command`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
throw new BrowseClientError(`browse-client: command "${cmd}" timed out after ${this.timeoutMs}ms`);
|
||||
}
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
throw new BrowseClientError(`browse-client: daemon not running on port ${this.port}`);
|
||||
}
|
||||
throw new BrowseClientError(`browse-client: ${err.message ?? err}`);
|
||||
}
|
||||
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
let message = `browse-client: command "${cmd}" failed with status ${resp.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed.error) message += `: ${parsed.error}`;
|
||||
} catch {
|
||||
if (text) message += `: ${text.slice(0, 200)}`;
|
||||
}
|
||||
throw new BrowseClientError(message, resp.status, text);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// ─── Navigation ─────────────────────────────────────────────────
|
||||
|
||||
async goto(url: string): Promise<string> { return this.command('goto', [url]); }
|
||||
async wait(arg: string): Promise<string> { return this.command('wait', [arg]); }
|
||||
|
||||
// ─── Reading ────────────────────────────────────────────────────
|
||||
|
||||
async text(selector?: string): Promise<string> {
|
||||
return this.command('text', selector ? [selector] : []);
|
||||
}
|
||||
async html(selector?: string): Promise<string> {
|
||||
return this.command('html', selector ? [selector] : []);
|
||||
}
|
||||
async links(): Promise<string> { return this.command('links'); }
|
||||
async forms(): Promise<string> { return this.command('forms'); }
|
||||
async accessibility(): Promise<string> { return this.command('accessibility'); }
|
||||
async attrs(selector: string): Promise<string> { return this.command('attrs', [selector]); }
|
||||
async media(...flags: string[]): Promise<string> { return this.command('media', flags); }
|
||||
async data(...flags: string[]): Promise<string> { return this.command('data', flags); }
|
||||
|
||||
// ─── Interaction ────────────────────────────────────────────────
|
||||
|
||||
async click(selector: string): Promise<string> { return this.command('click', [selector]); }
|
||||
async fill(selector: string, value: string): Promise<string> { return this.command('fill', [selector, value]); }
|
||||
async select(selector: string, value: string): Promise<string> { return this.command('select', [selector, value]); }
|
||||
async hover(selector: string): Promise<string> { return this.command('hover', [selector]); }
|
||||
async type(text: string): Promise<string> { return this.command('type', [text]); }
|
||||
async press(key: string): Promise<string> { return this.command('press', [key]); }
|
||||
async scroll(selector?: string): Promise<string> {
|
||||
return this.command('scroll', selector ? [selector] : []);
|
||||
}
|
||||
|
||||
// ─── Snapshot + screenshot ──────────────────────────────────────
|
||||
|
||||
/** Snapshot returns the ARIA tree. Pass flags like '-i' (interactive only), '-c' (compact). */
|
||||
async snapshot(...flags: string[]): Promise<string> { return this.command('snapshot', flags); }
|
||||
async screenshot(...args: string[]): Promise<string> { return this.command('screenshot', args); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Default singleton. Lazily resolves auth on first method call so a script can
|
||||
* import `browse` and immediately call `await browse.goto(...)` without
|
||||
* threading through a constructor.
|
||||
*/
|
||||
class LazyBrowseClient {
|
||||
private inner: BrowseClient | null = null;
|
||||
private get(): BrowseClient {
|
||||
if (!this.inner) this.inner = new BrowseClient();
|
||||
return this.inner;
|
||||
}
|
||||
// Mirror the BrowseClient surface; each method delegates to a freshly resolved instance.
|
||||
command(cmd: string, args: string[] = []) { return this.get().command(cmd, args); }
|
||||
goto(url: string) { return this.get().goto(url); }
|
||||
wait(arg: string) { return this.get().wait(arg); }
|
||||
text(selector?: string) { return this.get().text(selector); }
|
||||
html(selector?: string) { return this.get().html(selector); }
|
||||
links() { return this.get().links(); }
|
||||
forms() { return this.get().forms(); }
|
||||
accessibility() { return this.get().accessibility(); }
|
||||
attrs(selector: string) { return this.get().attrs(selector); }
|
||||
media(...flags: string[]) { return this.get().media(...flags); }
|
||||
data(...flags: string[]) { return this.get().data(...flags); }
|
||||
click(selector: string) { return this.get().click(selector); }
|
||||
fill(selector: string, value: string) { return this.get().fill(selector, value); }
|
||||
select(selector: string, value: string) { return this.get().select(selector, value); }
|
||||
hover(selector: string) { return this.get().hover(selector); }
|
||||
type(text: string) { return this.get().type(text); }
|
||||
press(key: string) { return this.get().press(key); }
|
||||
scroll(selector?: string) { return this.get().scroll(selector); }
|
||||
snapshot(...flags: string[]) { return this.get().snapshot(...flags); }
|
||||
screenshot(...args: string[]) { return this.get().screenshot(...args); }
|
||||
}
|
||||
|
||||
export const browse = new LazyBrowseClient();
|
||||
@@ -694,14 +694,32 @@ export class BrowserManager {
|
||||
|
||||
/**
|
||||
* Check if a client can access a tab.
|
||||
* If ownOnly or isWrite is true, requires ownership.
|
||||
* Otherwise (reads), allow by default.
|
||||
*
|
||||
* Two policies, distinguished by `options.ownOnly`:
|
||||
*
|
||||
* - **own-only (pair-agent over tunnel):** the strict mode. Token must own
|
||||
* the target tab for any access (reads or writes). Unowned user tabs
|
||||
* and tabs owned by other clients are off-limits. Remote agents must
|
||||
* `newtab` first to get a tab they can drive.
|
||||
*
|
||||
* - **shared (local skill spawns, default scoped tokens):** permissive on
|
||||
* tab access. The token can read/write any tab — capability is gated
|
||||
* elsewhere (scope checks at /command, rate limits, the dual-listener
|
||||
* allowlist for tunnel-bound traffic). Tab ownership is not a security
|
||||
* boundary for shared tokens; it only matters for pair-agent isolation.
|
||||
* This matches the contract documented in `skill-token.ts:79`
|
||||
* ("skill scripts may switch tabs as needed").
|
||||
*
|
||||
* Root is unconstrained.
|
||||
*
|
||||
* `isWrite` is preserved in the signature for callers that want to log or
|
||||
* branch on it elsewhere, but the access decision itself only depends on
|
||||
* `ownOnly` + ownership map state.
|
||||
*/
|
||||
checkTabAccess(tabId: number, clientId: string, options: { isWrite?: boolean; ownOnly?: boolean } = {}): boolean {
|
||||
if (clientId === 'root') return true;
|
||||
const owner = this.tabOwnership.get(tabId);
|
||||
if (options.ownOnly || options.isWrite) {
|
||||
if (!owner) return false;
|
||||
if (options.ownOnly) {
|
||||
const owner = this.tabOwnership.get(tabId);
|
||||
return owner === clientId;
|
||||
}
|
||||
return true;
|
||||
@@ -741,6 +759,80 @@ export class BrowserManager {
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Get the underlying Page for a tab id. Returns null if the tab doesn't exist.
|
||||
* Used by the CDP bridge (cdp-bridge.ts) to mint per-tab CDPSessions. */
|
||||
getPageForTab(tabId: number): Page | null {
|
||||
return this.pages.get(tabId) ?? null;
|
||||
}
|
||||
|
||||
// ─── Two-tier mutex (Codex T7) ─────────────────────────────
|
||||
// Per-tab and global locks for the CDP bridge. tab-scoped methods take the
|
||||
// per-tab mutex; browser-scoped methods take the global lock that blocks all
|
||||
// tab mutexes. Hard timeout on acquire so silent deadlock can't happen.
|
||||
// Every caller MUST use try { ... } finally { release() }.
|
||||
|
||||
private tabLocks: Map<number, Promise<void>> = new Map();
|
||||
private globalCdpLockTail: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Acquire the per-tab CDP lock with a timeout. Returns a release fn.
|
||||
* Locks chain: each acquire waits on the prior tail's resolution.
|
||||
* Browser-scoped global lock takes precedence: while the global lock is
|
||||
* held, no tab lock can be acquired (and vice versa).
|
||||
*/
|
||||
async acquireTabLock(tabId: number, timeoutMs: number): Promise<() => void> {
|
||||
const existing = this.tabLocks.get(tabId) ?? Promise.resolve();
|
||||
// Wait for any held global lock first (cross-tier serialization).
|
||||
const tail = Promise.all([existing, this.globalCdpLockTail]).then(() => undefined);
|
||||
let release!: () => void;
|
||||
const next = new Promise<void>((resolve) => { release = resolve; });
|
||||
this.tabLocks.set(tabId, tail.then(() => next));
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(
|
||||
`CDPMutexAcquireTimeout: tab ${tabId} lock not acquired within ${timeoutMs}ms.\n` +
|
||||
'Cause: a prior CDP or browser-scoped operation has held the lock too long.\n' +
|
||||
'Action: retry; if this repeats, the prior operation may be hung — file a bug.'
|
||||
)), timeoutMs),
|
||||
);
|
||||
try {
|
||||
await Promise.race([tail, timeoutPromise]);
|
||||
} catch (e) {
|
||||
// Acquisition failed; release the slot we reserved so we don't deadlock the queue.
|
||||
release();
|
||||
throw e;
|
||||
}
|
||||
return release;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the global CDP lock. Blocks until all tab locks are released, and
|
||||
* blocks new tab-lock acquisitions until released.
|
||||
*/
|
||||
async acquireGlobalCdpLock(timeoutMs: number): Promise<() => void> {
|
||||
const allTabTails = Array.from(this.tabLocks.values());
|
||||
const priorGlobal = this.globalCdpLockTail;
|
||||
const allPrior = Promise.all([priorGlobal, ...allTabTails]).then(() => undefined);
|
||||
let release!: () => void;
|
||||
const next = new Promise<void>((resolve) => { release = resolve; });
|
||||
this.globalCdpLockTail = allPrior.then(() => next);
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(
|
||||
`CDPMutexAcquireTimeout: global CDP lock not acquired within ${timeoutMs}ms.\n` +
|
||||
'Cause: in-flight tab operations have not completed.\n' +
|
||||
'Action: retry; if this repeats, file a bug — a tab op may be hung.'
|
||||
)), timeoutMs),
|
||||
);
|
||||
try {
|
||||
await Promise.race([allPrior, timeoutPromise]);
|
||||
} catch (e) {
|
||||
release();
|
||||
throw e;
|
||||
}
|
||||
return release;
|
||||
}
|
||||
|
||||
// ─── Page Access (delegates to active session) ─────────────
|
||||
getPage(): Page {
|
||||
return this.getActiveSession().page;
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* $B skill subcommands — CLI surface for browser-skills.
|
||||
*
|
||||
* Subcommands:
|
||||
* list — list all skills, with resolved tier
|
||||
* show <name> — print skill SKILL.md
|
||||
* run <name> [--arg ...] [--timeout=Ns] — spawn the skill script, return JSON
|
||||
* test <name> — run script.test.ts via bun test
|
||||
* rm <name> [--global] — tombstone a user-tier skill
|
||||
*
|
||||
* Load-bearing: spawnSkill mints a per-spawn scoped token (read+write scope)
|
||||
* and passes it via GSTACK_SKILL_TOKEN. The skill never sees the daemon root
|
||||
* token. Untrusted skills get a scrubbed env (no $HOME, $PATH minimal, no
|
||||
* secrets like $GITHUB_TOKEN/$OPENAI_API_KEY/etc.) and a locked cwd. Trusted
|
||||
* skills (frontmatter `trusted: true`) inherit the full process env.
|
||||
*
|
||||
* Output protocol: stdout = JSON, stderr = streaming logs, exit code 0/non-0.
|
||||
* stdout cap = 1MB (truncate + nonzero exit if exceeded). Default timeout 60s.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
listBrowserSkills,
|
||||
readBrowserSkill,
|
||||
tombstoneBrowserSkill,
|
||||
defaultTierPaths,
|
||||
type BrowserSkill,
|
||||
type TierPaths,
|
||||
} from './browser-skills';
|
||||
import { mintSkillToken, revokeSkillToken, generateSpawnId } from './skill-token';
|
||||
|
||||
const DEFAULT_TIMEOUT_SECONDS = 60;
|
||||
const MAX_STDOUT_BYTES = 1024 * 1024; // 1 MB
|
||||
|
||||
// ─── Public command dispatcher ──────────────────────────────────
|
||||
|
||||
export interface SkillCommandContext {
|
||||
/** Daemon port the skill should connect back to. */
|
||||
port: number;
|
||||
/** Optional override of tier paths (tests pass synthetic dirs). */
|
||||
tiers?: TierPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a `$B skill <subcommand>` invocation. Returns the response string
|
||||
* for the daemon to relay back to the CLI. Throws on invalid usage.
|
||||
*/
|
||||
export async function handleSkillCommand(args: string[], ctx: SkillCommandContext): Promise<string> {
|
||||
const sub = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
switch (sub) {
|
||||
case undefined:
|
||||
case 'help':
|
||||
case '--help':
|
||||
return formatUsage();
|
||||
case 'list':
|
||||
return handleList(ctx);
|
||||
case 'show':
|
||||
return handleShow(rest, ctx);
|
||||
case 'run':
|
||||
return handleRun(rest, ctx);
|
||||
case 'test':
|
||||
return handleTest(rest, ctx);
|
||||
case 'rm':
|
||||
return handleRm(rest, ctx);
|
||||
default:
|
||||
throw new Error(`Unknown skill subcommand: "${sub}". Try: list, show, run, test, rm.`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatUsage(): string {
|
||||
return [
|
||||
'Usage: $B skill <subcommand>',
|
||||
'',
|
||||
' list List all skills with resolved tier',
|
||||
' show <name> Print SKILL.md',
|
||||
' run <name> [--arg k=v]... [--timeout=Ns] Run the skill script',
|
||||
' test <name> Run script.test.ts',
|
||||
' rm <name> [--global] Tombstone a user-tier skill',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── list ───────────────────────────────────────────────────────
|
||||
|
||||
function handleList(ctx: SkillCommandContext): string {
|
||||
const tiers = ctx.tiers ?? defaultTierPaths();
|
||||
const skills = listBrowserSkills(tiers);
|
||||
if (skills.length === 0) {
|
||||
return 'No browser-skills found.\n\nTry: $B skill show <name> (none right now)\n';
|
||||
}
|
||||
const lines: string[] = ['NAME TIER HOST DESC'];
|
||||
for (const s of skills) {
|
||||
const desc = (s.frontmatter.description ?? '').slice(0, 40);
|
||||
lines.push(
|
||||
[
|
||||
s.name.padEnd(30),
|
||||
s.tier.padEnd(8),
|
||||
s.frontmatter.host.padEnd(28),
|
||||
desc,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// ─── show ───────────────────────────────────────────────────────
|
||||
|
||||
function handleShow(args: string[], ctx: SkillCommandContext): string {
|
||||
const name = args[0];
|
||||
if (!name) throw new Error('Usage: $B skill show <name>');
|
||||
const tiers = ctx.tiers ?? defaultTierPaths();
|
||||
const skill = readBrowserSkill(name, tiers);
|
||||
if (!skill) throw new Error(`Skill "${name}" not found in any tier.`);
|
||||
return readFile(path.join(skill.dir, 'SKILL.md'));
|
||||
}
|
||||
|
||||
function readFile(p: string): string {
|
||||
return fs.readFileSync(p, 'utf-8');
|
||||
}
|
||||
|
||||
// ─── run ────────────────────────────────────────────────────────
|
||||
|
||||
interface ParsedRunArgs {
|
||||
passthrough: string[];
|
||||
timeoutSeconds: number;
|
||||
}
|
||||
|
||||
export function parseSkillRunArgs(args: string[]): ParsedRunArgs {
|
||||
const passthrough: string[] = [];
|
||||
let timeoutSeconds = DEFAULT_TIMEOUT_SECONDS;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a.startsWith('--timeout=')) {
|
||||
const n = parseInt(a.slice('--timeout='.length), 10);
|
||||
if (!isNaN(n) && n > 0) timeoutSeconds = n;
|
||||
continue;
|
||||
}
|
||||
passthrough.push(a);
|
||||
}
|
||||
return { passthrough, timeoutSeconds };
|
||||
}
|
||||
|
||||
async function handleRun(args: string[], ctx: SkillCommandContext): Promise<string> {
|
||||
const name = args[0];
|
||||
if (!name) throw new Error('Usage: $B skill run <name> [--arg k=v]... [--timeout=Ns]');
|
||||
const tiers = ctx.tiers ?? defaultTierPaths();
|
||||
const skill = readBrowserSkill(name, tiers);
|
||||
if (!skill) throw new Error(`Skill "${name}" not found.`);
|
||||
|
||||
const { passthrough, timeoutSeconds } = parseSkillRunArgs(args.slice(1));
|
||||
const result = await spawnSkill({
|
||||
skill,
|
||||
skillArgs: passthrough,
|
||||
trusted: skill.frontmatter.trusted,
|
||||
timeoutSeconds,
|
||||
port: ctx.port,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0 || result.timedOut || result.truncated) {
|
||||
const summary = result.truncated
|
||||
? `truncated stdout at ${MAX_STDOUT_BYTES} bytes`
|
||||
: result.timedOut
|
||||
? `timed out after ${timeoutSeconds}s`
|
||||
: `exit ${result.exitCode}`;
|
||||
const err = new Error(`Skill "${name}" failed: ${summary}\n--- stderr ---\n${result.stderr.slice(0, 4096)}`);
|
||||
(err as any).exitCode = result.exitCode || 1;
|
||||
throw err;
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
// ─── test ───────────────────────────────────────────────────────
|
||||
|
||||
async function handleTest(args: string[], ctx: SkillCommandContext): Promise<string> {
|
||||
const name = args[0];
|
||||
if (!name) throw new Error('Usage: $B skill test <name>');
|
||||
const tiers = ctx.tiers ?? defaultTierPaths();
|
||||
const skill = readBrowserSkill(name, tiers);
|
||||
if (!skill) throw new Error(`Skill "${name}" not found.`);
|
||||
|
||||
const testFile = path.join(skill.dir, 'script.test.ts');
|
||||
if (!fs.existsSync(testFile)) {
|
||||
throw new Error(`Skill "${name}" has no script.test.ts at ${testFile}`);
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(['bun', 'test', testFile], {
|
||||
cwd: skill.dir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: process.env,
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
const stdout = proc.stdout ? await new Response(proc.stdout).text() : '';
|
||||
const stderr = proc.stderr ? await new Response(proc.stderr).text() : '';
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Skill "${name}" tests failed (exit ${exitCode}).\n${stderr}`);
|
||||
}
|
||||
return stderr || stdout || `tests passed for "${name}"`;
|
||||
}
|
||||
|
||||
// ─── rm ─────────────────────────────────────────────────────────
|
||||
|
||||
function handleRm(args: string[], ctx: SkillCommandContext): string {
|
||||
const name = args[0];
|
||||
if (!name) throw new Error('Usage: $B skill rm <name> [--global]');
|
||||
const isGlobal = args.includes('--global');
|
||||
const tier: 'project' | 'global' = isGlobal ? 'global' : 'project';
|
||||
|
||||
const tiers = ctx.tiers ?? defaultTierPaths();
|
||||
// For UX: if no project tier exists at all, default to global.
|
||||
const effectiveTier: 'project' | 'global' = (tier === 'project' && !tiers.project) ? 'global' : tier;
|
||||
|
||||
const dst = tombstoneBrowserSkill(name, effectiveTier, tiers);
|
||||
return `Tombstoned "${name}" (${effectiveTier} tier) → ${dst}\n`;
|
||||
}
|
||||
|
||||
// ─── spawnSkill (load-bearing) ──────────────────────────────────
|
||||
|
||||
export interface SpawnSkillOptions {
|
||||
skill: BrowserSkill;
|
||||
skillArgs: string[];
|
||||
trusted: boolean;
|
||||
timeoutSeconds: number;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface SpawnSkillResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
timedOut: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a skill script as a child process.
|
||||
*
|
||||
* 1. Mint a scoped token (read+write only; expires at timeout + 30s slack).
|
||||
* 2. Build the env: trusted=true → process.env; trusted=false → scrubbed.
|
||||
* GSTACK_PORT and GSTACK_SKILL_TOKEN are always set.
|
||||
* 3. Spawn `bun run script.ts -- <args>` with cwd=skill.dir.
|
||||
* 4. Capture stdout (capped at 1MB) and stderr; enforce timeout.
|
||||
* 5. On exit/timeout, revoke the token. Always.
|
||||
*/
|
||||
export async function spawnSkill(opts: SpawnSkillOptions): Promise<SpawnSkillResult> {
|
||||
const spawnId = generateSpawnId();
|
||||
const tokenInfo = mintSkillToken({
|
||||
skillName: opts.skill.name,
|
||||
spawnId,
|
||||
spawnTimeoutSeconds: opts.timeoutSeconds,
|
||||
});
|
||||
|
||||
try {
|
||||
const env = buildSpawnEnv({
|
||||
trusted: opts.trusted,
|
||||
port: opts.port,
|
||||
skillToken: tokenInfo.token,
|
||||
});
|
||||
const scriptPath = path.join(opts.skill.dir, 'script.ts');
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
throw new Error(`Skill "${opts.skill.name}" missing script.ts at ${scriptPath}`);
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(['bun', 'run', scriptPath, '--', ...opts.skillArgs], {
|
||||
cwd: opts.skill.dir,
|
||||
env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
const killer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { proc.kill(); } catch {}
|
||||
}, opts.timeoutSeconds * 1000);
|
||||
|
||||
const stdoutPromise = readCapped(proc.stdout, MAX_STDOUT_BYTES);
|
||||
const stderrPromise = readCapped(proc.stderr, MAX_STDOUT_BYTES);
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
clearTimeout(killer);
|
||||
|
||||
const stdoutResult = await stdoutPromise;
|
||||
const stderrResult = await stderrPromise;
|
||||
|
||||
return {
|
||||
stdout: stdoutResult.text,
|
||||
stderr: stderrResult.text,
|
||||
exitCode: timedOut ? 124 : exitCode,
|
||||
timedOut,
|
||||
truncated: stdoutResult.truncated,
|
||||
};
|
||||
} finally {
|
||||
revokeSkillToken(opts.skill.name, spawnId);
|
||||
}
|
||||
}
|
||||
|
||||
interface CappedRead { text: string; truncated: boolean; }
|
||||
|
||||
async function readCapped(stream: ReadableStream<Uint8Array> | undefined, capBytes: number): Promise<CappedRead> {
|
||||
if (!stream) return { text: '', truncated: false };
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let truncated = false;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.length;
|
||||
if (total > capBytes) {
|
||||
truncated = true;
|
||||
// Take only what fits; drop the rest of the stream (release reader).
|
||||
const fits = value.length - (total - capBytes);
|
||||
if (fits > 0) chunks.push(value.subarray(0, fits));
|
||||
try { await reader.cancel(); } catch {}
|
||||
break;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch {}
|
||||
}
|
||||
const buf = Buffer.concat(chunks.map(c => Buffer.from(c)));
|
||||
return { text: buf.toString('utf-8'), truncated };
|
||||
}
|
||||
|
||||
// ─── env construction (security-critical) ───────────────────────
|
||||
|
||||
/**
|
||||
* Env keys ALWAYS scrubbed for untrusted skills. These represent secrets,
|
||||
* authority, or developer-environment context that an agent-authored script
|
||||
* should not see.
|
||||
*/
|
||||
const SECRET_KEY_PATTERNS = [
|
||||
/TOKEN/i, /KEY/i, /SECRET/i, /PASSWORD/i, /CREDENTIAL/i,
|
||||
/^AWS_/, /^AZURE_/, /^GCP_/, /^GOOGLE_APPLICATION_/,
|
||||
/^ANTHROPIC_/, /^OPENAI_/, /^GITHUB_/, /^GH_/,
|
||||
/^SSH_/, /^GPG_/,
|
||||
/^NPM_TOKEN/, /^PYPI_/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Allowlist for untrusted spawns. Anything not in this list is dropped.
|
||||
* Includes: minimal PATH, locale, terminal type. Skills get GSTACK_PORT +
|
||||
* GSTACK_SKILL_TOKEN injected separately.
|
||||
*/
|
||||
const UNTRUSTED_ALLOWLIST = new Set([
|
||||
'LANG', 'LC_ALL', 'LC_CTYPE',
|
||||
'TERM',
|
||||
'TZ',
|
||||
]);
|
||||
|
||||
interface BuildEnvOptions {
|
||||
trusted: boolean;
|
||||
port: number;
|
||||
skillToken: string;
|
||||
}
|
||||
|
||||
export function buildSpawnEnv(opts: BuildEnvOptions): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
||||
if (opts.trusted) {
|
||||
// Trusted: pass through process.env, but always strip the daemon root token
|
||||
// if the parent had one in env (defense in depth).
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v === undefined) continue;
|
||||
if (k === 'GSTACK_TOKEN') continue; // never propagate root token
|
||||
out[k] = v;
|
||||
}
|
||||
// Set a minimal PATH if missing.
|
||||
if (!out.PATH) out.PATH = '/usr/local/bin:/usr/bin:/bin';
|
||||
} else {
|
||||
// Untrusted: minimal allowlist.
|
||||
for (const k of UNTRUSTED_ALLOWLIST) {
|
||||
const v = process.env[k];
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
// Provide a minimal PATH so `bun` is findable. Prefer the resolved bun dir
|
||||
// so scripts using a custom Bun install still work, but otherwise fall back
|
||||
// to /usr/local/bin:/usr/bin:/bin.
|
||||
out.PATH = resolveMinimalPath();
|
||||
}
|
||||
|
||||
// Drop anything that pattern-matches a secret. (Trusted path can have secrets
|
||||
// intentionally — e.g. an internal-tool skill — but we still strip GSTACK_TOKEN
|
||||
// above.)
|
||||
if (!opts.trusted) {
|
||||
for (const k of Object.keys(out)) {
|
||||
if (SECRET_KEY_PATTERNS.some(p => p.test(k))) delete out[k];
|
||||
}
|
||||
}
|
||||
|
||||
// Inject the daemon connection (always last so callers can't override).
|
||||
out.GSTACK_PORT = String(opts.port);
|
||||
out.GSTACK_SKILL_TOKEN = opts.skillToken;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveMinimalPath(): string {
|
||||
// Prefer the directory bun lives in; fall back to standard system dirs.
|
||||
const fallback = '/usr/local/bin:/usr/bin:/bin';
|
||||
const bunPath = process.execPath;
|
||||
if (bunPath && bunPath.includes('/bun')) {
|
||||
const dir = path.dirname(bunPath);
|
||||
return `${dir}:${fallback}`;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Atomic-write helper for agent-authored browser-skills (D3 from Phase 2 plan).
|
||||
*
|
||||
* /skillify stages a candidate skill into ~/.gstack/.tmp/skillify-<spawnId>/,
|
||||
* runs $B skill test against it, and only renames the directory into its final
|
||||
* tier path on success + user approval. On failure or rejection, the staged
|
||||
* directory is removed entirely — no half-written skill ever appears in
|
||||
* $B skill list, no tombstone for something the user never approved.
|
||||
*
|
||||
* stageSkill — write all files into the staging dir, return its path
|
||||
* commitSkill — atomic rename into the final tier path; refuses to clobber
|
||||
* discardStaged — rm -rf the staged dir (called on test fail or reject)
|
||||
*
|
||||
* Symlink discipline: lstat() the staging dir before rename to refuse moves
|
||||
* through symlinks; realpath() the final tier root to ensure the destination
|
||||
* lands inside the expected directory tree.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { isPathWithin } from './platform';
|
||||
import type { TierPaths } from './browser-skills';
|
||||
import { defaultTierPaths } from './browser-skills';
|
||||
|
||||
// ─── Naming validation ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Skill names must be safe directory names: lowercase letters, digits, dashes.
|
||||
* Starts with a letter, no consecutive dashes, no trailing dash, ≤64 chars.
|
||||
* Rejects '..', leading dots, slashes, anything that could escape the tier dir.
|
||||
*/
|
||||
const SKILL_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
||||
|
||||
export function validateSkillName(name: string): void {
|
||||
if (!name) throw new Error('Skill name is empty.');
|
||||
if (name.length > 64) throw new Error(`Skill name too long (${name.length} > 64).`);
|
||||
if (!SKILL_NAME_PATTERN.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid skill name "${name}". Must be lowercase letters/digits/dashes, ` +
|
||||
`start with a letter, no leading/trailing/consecutive dashes.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Staging ────────────────────────────────────────────────────
|
||||
|
||||
export interface StageSkillOptions {
|
||||
name: string;
|
||||
/** Map of relative path → contents. Path may contain '/' for nested dirs. */
|
||||
files: Map<string, string | Buffer>;
|
||||
/** Optional override (tests pass synthetic spawn ids). */
|
||||
spawnId?: string;
|
||||
/** Optional override (tests pass a fake tmp root). */
|
||||
tmpRoot?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a skill into the staging tree:
|
||||
* <tmpRoot>/.gstack/.tmp/skillify-<spawnId>/<name>/
|
||||
*
|
||||
* The leaf <name> directory is what gets renamed during commit. The wrapper
|
||||
* skillify-<spawnId>/ is per-spawn so concurrent /skillify invocations don't
|
||||
* collide. Returns the absolute path to the staged skill dir (ending in <name>).
|
||||
*/
|
||||
export function stageSkill(opts: StageSkillOptions): string {
|
||||
validateSkillName(opts.name);
|
||||
if (opts.files.size === 0) {
|
||||
throw new Error('stageSkill: files map is empty.');
|
||||
}
|
||||
|
||||
const spawnId = opts.spawnId ?? generateSpawnId();
|
||||
const tmpRoot = opts.tmpRoot ?? path.join(os.homedir(), '.gstack', '.tmp');
|
||||
const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`);
|
||||
const stagedDir = path.join(wrapperDir, opts.name);
|
||||
|
||||
fs.mkdirSync(wrapperDir, { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(stagedDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
for (const [relPath, contents] of opts.files) {
|
||||
if (relPath.startsWith('/') || relPath.includes('..')) {
|
||||
// Defense in depth: validateSkillName above bounds the leaf, but a
|
||||
// bad relPath in files could still write outside the staged dir.
|
||||
throw new Error(`Invalid file path in stageSkill: "${relPath}".`);
|
||||
}
|
||||
const filePath = path.join(stagedDir, relPath);
|
||||
const fileDir = path.dirname(filePath);
|
||||
fs.mkdirSync(fileDir, { recursive: true });
|
||||
fs.writeFileSync(filePath, contents);
|
||||
}
|
||||
|
||||
return stagedDir;
|
||||
}
|
||||
|
||||
// ─── Commit (atomic rename) ─────────────────────────────────────
|
||||
|
||||
export interface CommitSkillOptions {
|
||||
name: string;
|
||||
tier: 'project' | 'global';
|
||||
stagedDir: string;
|
||||
/** Optional override (tests pass synthetic tier paths). */
|
||||
tiers?: TierPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically move the staged skill into its final tier path. Refuses to
|
||||
* clobber an existing skill at the same path — the agent's approval gate
|
||||
* MUST surface name collisions before calling this.
|
||||
*
|
||||
* Returns the absolute path of the committed skill dir.
|
||||
*
|
||||
* Throws when:
|
||||
* - tier path is unresolved (project tier with no project root)
|
||||
* - destination already exists
|
||||
* - staged dir is a symlink (refuses to follow)
|
||||
* - resolved destination escapes the tier root (defense in depth)
|
||||
*/
|
||||
export function commitSkill(opts: CommitSkillOptions): string {
|
||||
validateSkillName(opts.name);
|
||||
|
||||
const tiers = opts.tiers ?? defaultTierPaths();
|
||||
const tierRoot = opts.tier === 'project' ? tiers.project : tiers.global;
|
||||
if (!tierRoot) {
|
||||
throw new Error(`commitSkill: tier "${opts.tier}" has no resolved path.`);
|
||||
}
|
||||
|
||||
// Refuse to follow a symlinked staging dir — caller should hand us the path
|
||||
// returned by stageSkill, which is always a real directory.
|
||||
let stagedStat: fs.Stats;
|
||||
try {
|
||||
stagedStat = fs.lstatSync(opts.stagedDir);
|
||||
} catch (err: any) {
|
||||
throw new Error(`commitSkill: staged dir "${opts.stagedDir}" not accessible: ${err.code ?? err.message}`);
|
||||
}
|
||||
if (stagedStat.isSymbolicLink()) {
|
||||
throw new Error(`commitSkill: staged dir "${opts.stagedDir}" is a symlink — refusing to commit.`);
|
||||
}
|
||||
if (!stagedStat.isDirectory()) {
|
||||
throw new Error(`commitSkill: staged path "${opts.stagedDir}" is not a directory.`);
|
||||
}
|
||||
|
||||
// Ensure the tier root exists, then resolve its real path so the final
|
||||
// destination check defends against tierRoot itself being a symlink.
|
||||
fs.mkdirSync(tierRoot, { recursive: true, mode: 0o755 });
|
||||
const realTierRoot = fs.realpathSync(tierRoot);
|
||||
|
||||
const dest = path.join(realTierRoot, opts.name);
|
||||
if (!isPathWithin(dest, realTierRoot)) {
|
||||
// Should be impossible after validateSkillName, but defense in depth.
|
||||
throw new Error(`commitSkill: destination "${dest}" escapes tier root.`);
|
||||
}
|
||||
|
||||
// Refuse to clobber. Both regular dirs and symlinks count.
|
||||
let destExists = false;
|
||||
try {
|
||||
fs.lstatSync(dest);
|
||||
destExists = true;
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
if (destExists) {
|
||||
throw new Error(
|
||||
`commitSkill: a skill named "${opts.name}" already exists at ${dest}. ` +
|
||||
`Pick a different name or remove the existing skill first ` +
|
||||
`($B skill rm ${opts.name}${opts.tier === 'global' ? ' --global' : ''}).`,
|
||||
);
|
||||
}
|
||||
|
||||
fs.renameSync(opts.stagedDir, dest);
|
||||
return dest;
|
||||
}
|
||||
|
||||
// ─── Discard (cleanup on failure or reject) ─────────────────────
|
||||
|
||||
/**
|
||||
* Remove the staged skill directory and its per-spawn wrapper. Called on
|
||||
* test failure (step 8 of /skillify) or approval rejection (step 9).
|
||||
*
|
||||
* Idempotent: missing dirs are not an error. Best-effort: failures are
|
||||
* swallowed (cleanup is fire-and-forget, not load-bearing).
|
||||
*/
|
||||
export function discardStaged(stagedDir: string): void {
|
||||
// Remove the leaf skill dir first, then the wrapper skillify-<spawnId>/.
|
||||
// If the wrapper was the only thing inside it, this tidies up that too.
|
||||
try {
|
||||
fs.rmSync(stagedDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
const wrapperDir = path.dirname(stagedDir);
|
||||
if (path.basename(wrapperDir).startsWith('skillify-')) {
|
||||
try {
|
||||
// Only remove the wrapper if it's now empty — concurrent /skillify
|
||||
// invocations get their own wrappers, but if a buggy caller passed
|
||||
// a stagedDir not under a skillify-<id> wrapper we should not nuke
|
||||
// an unrelated parent.
|
||||
const remaining = fs.readdirSync(wrapperDir);
|
||||
if (remaining.length === 0) {
|
||||
fs.rmdirSync(wrapperDir);
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Spawn id ───────────────────────────────────────────────────
|
||||
|
||||
/** Per-spawn id matching the format used by skill-token.ts. */
|
||||
function generateSpawnId(): string {
|
||||
// 8 random hex chars + millis suffix — collision risk negligible across
|
||||
// concurrent /skillify invocations on a single machine.
|
||||
const rand = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0');
|
||||
return `${rand}-${Date.now().toString(36)}`;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* browser-skills — storage helpers for per-task Playwright scripts.
|
||||
*
|
||||
* A browser-skill is a directory containing SKILL.md (frontmatter + prose),
|
||||
* script.ts (deterministic Playwright-via-browse-client script), an _lib/
|
||||
* with a copy of the SDK, fixtures/ for tests, and script.test.ts.
|
||||
*
|
||||
* Three tiers, walked in order project > global > bundled (first-wins):
|
||||
* project: <project>/.gstack/browser-skills/<name>/
|
||||
* global: ~/.gstack/browser-skills/<name>/
|
||||
* bundled: <gstack-install>/browser-skills/<name>/ (read-only, ships with gstack)
|
||||
*
|
||||
* No INDEX.json. `listBrowserSkills()` walks the three directories every call
|
||||
* (~5-10ms for 50 skills, invisible). Eliminates a whole class of "index
|
||||
* drifted from disk" bugs.
|
||||
*
|
||||
* Tombstones move a skill to `<tier>/.tombstones/<name>-<ts>/` so the user
|
||||
* can recover. `$B skill list` ignores tombstoned directories.
|
||||
*
|
||||
* Zero side effects on import. Safe to import from tests.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as cp from 'child_process';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export type SkillTier = 'project' | 'global' | 'bundled';
|
||||
|
||||
/** Required + optional fields from a browser-skill SKILL.md frontmatter. */
|
||||
export interface SkillFrontmatter {
|
||||
/** Skill name; must match the directory name. */
|
||||
name: string;
|
||||
/** One-line description (optional but recommended). */
|
||||
description?: string;
|
||||
/** Primary hostname this skill targets, e.g. "news.ycombinator.com". */
|
||||
host: string;
|
||||
/** Trigger phrases the resolver matches against ("scrape hn frontpage"). */
|
||||
triggers: string[];
|
||||
/**
|
||||
* Args the script accepts (passed via `$B skill run <name> --arg key=value`).
|
||||
* Phase 1 keeps this loose: each arg is just a name and optional description.
|
||||
*/
|
||||
args: SkillArg[];
|
||||
/**
|
||||
* Trust flag. true = full env passed to spawn (human-authored, audited).
|
||||
* false (default) = scrubbed env, locked cwd. Orthogonal to scoped-token
|
||||
* capabilities: untrusted skills still get a read+write daemon token.
|
||||
*/
|
||||
trusted: boolean;
|
||||
/** Optional semver-ish version string for skill upgrades. */
|
||||
version?: string;
|
||||
/** Whether the skill was hand-written or generated by the skillify flow. */
|
||||
source?: 'human' | 'agent';
|
||||
}
|
||||
|
||||
export interface SkillArg {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface BrowserSkill {
|
||||
name: string;
|
||||
tier: SkillTier;
|
||||
/** Absolute path to the skill directory. */
|
||||
dir: string;
|
||||
frontmatter: SkillFrontmatter;
|
||||
/** SKILL.md prose body (everything after the frontmatter block). */
|
||||
bodyMd: string;
|
||||
}
|
||||
|
||||
export interface TierPaths {
|
||||
/** May be null in non-project contexts (e.g. tests, standalone runs). */
|
||||
project: string | null;
|
||||
global: string;
|
||||
bundled: string;
|
||||
}
|
||||
|
||||
// ─── Tier resolution ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the three tier directories from runtime context.
|
||||
* Project tier requires git or a project hint; returns null when neither resolves.
|
||||
*/
|
||||
export function defaultTierPaths(opts: { projectRoot?: string; home?: string; bundledRoot?: string } = {}): TierPaths {
|
||||
const home = opts.home ?? os.homedir();
|
||||
const projectRoot = opts.projectRoot ?? detectProjectRoot();
|
||||
const bundledRoot = opts.bundledRoot ?? detectBundledRoot();
|
||||
|
||||
return {
|
||||
project: projectRoot ? path.join(projectRoot, '.gstack', 'browser-skills') : null,
|
||||
global: path.join(home, '.gstack', 'browser-skills'),
|
||||
bundled: path.join(bundledRoot, 'browser-skills'),
|
||||
};
|
||||
}
|
||||
|
||||
function detectProjectRoot(): string | null {
|
||||
try {
|
||||
const proc = cp.spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8', timeout: 2000 });
|
||||
if (proc.status === 0) {
|
||||
const out = proc.stdout.trim();
|
||||
return out || null;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectBundledRoot(): string {
|
||||
// The browse binary lives at <gstack-install>/browse/dist/browse.
|
||||
// The bundled browser-skills/ dir is a sibling of browse/ (i.e. <gstack-install>/browser-skills/).
|
||||
// For dev/source runs, process.execPath is bun itself — fall back to the source-tree
|
||||
// directory two levels up from this file.
|
||||
try {
|
||||
const exec = process.execPath;
|
||||
if (exec && /\/browse\/dist\/browse$/.test(exec)) {
|
||||
return path.resolve(path.dirname(exec), '..', '..');
|
||||
}
|
||||
} catch {}
|
||||
// Source/dev fallback: walk up from this file's dir to a directory that has both browse/ and browser-skills/.
|
||||
// browse/src/browser-skills.ts → ../../ (the gstack root).
|
||||
return path.resolve(__dirname, '..', '..');
|
||||
}
|
||||
|
||||
// ─── Frontmatter parsing ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a SKILL.md into { frontmatter, bodyMd }. Throws if the file is
|
||||
* missing required fields (host, triggers, args).
|
||||
*/
|
||||
export function parseSkillFile(content: string, opts: { skillName?: string } = {}): { frontmatter: SkillFrontmatter; bodyMd: string } {
|
||||
if (!content.startsWith('---\n')) {
|
||||
throw new Error('SKILL.md missing frontmatter block (expected starting "---\\n")');
|
||||
}
|
||||
const fmEnd = content.indexOf('\n---', 4);
|
||||
if (fmEnd === -1) {
|
||||
throw new Error('SKILL.md frontmatter block not terminated (expected "\\n---")');
|
||||
}
|
||||
const fmText = content.slice(4, fmEnd);
|
||||
const bodyMd = content.slice(fmEnd + 4).replace(/^\n+/, '');
|
||||
const fm = parseFrontmatterFields(fmText);
|
||||
|
||||
// Validate required fields.
|
||||
const errors: string[] = [];
|
||||
const name = fm.name ?? opts.skillName ?? '';
|
||||
if (!name) errors.push('missing required field: name (or skillName hint)');
|
||||
if (!fm.host) errors.push('missing required field: host');
|
||||
// triggers and args may be omitted — empty list is valid.
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`SKILL.md validation failed: ${errors.join('; ')}`);
|
||||
}
|
||||
|
||||
const frontmatter: SkillFrontmatter = {
|
||||
name,
|
||||
description: fm.description,
|
||||
host: fm.host as string,
|
||||
triggers: Array.isArray(fm.triggers) ? fm.triggers : [],
|
||||
args: Array.isArray(fm.args) ? fm.args : [],
|
||||
trusted: fm.trusted === true,
|
||||
version: typeof fm.version === 'string' ? fm.version : undefined,
|
||||
source: fm.source === 'agent' || fm.source === 'human' ? fm.source : undefined,
|
||||
};
|
||||
|
||||
return { frontmatter, bodyMd };
|
||||
}
|
||||
|
||||
interface RawFrontmatter {
|
||||
name?: string;
|
||||
description?: string;
|
||||
host?: string;
|
||||
triggers?: string[];
|
||||
args?: SkillArg[];
|
||||
trusted?: boolean;
|
||||
version?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny frontmatter parser tuned for the browser-skill subset:
|
||||
* - simple key: value scalars
|
||||
* - YAML list: `key:\n - item1\n - item2`
|
||||
* - args list of mappings: `args:\n - name: foo\n description: bar`
|
||||
*
|
||||
* Quoting: a value wrapped in "..." or '...' is taken literally (handles colons).
|
||||
* Anything more exotic should use a real YAML library — not in Phase 1 scope.
|
||||
*/
|
||||
function parseFrontmatterFields(fm: string): RawFrontmatter {
|
||||
const result: RawFrontmatter = {};
|
||||
const lines = fm.split('\n');
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip blank lines and comments
|
||||
if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
|
||||
|
||||
// Top-level scalar: `key: value`
|
||||
const scalar = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/);
|
||||
if (scalar && !line.startsWith(' ')) {
|
||||
const key = scalar[1];
|
||||
const rawVal = scalar[2];
|
||||
|
||||
// Empty value: list or mapping follows on next lines
|
||||
if (!rawVal) {
|
||||
// Peek to determine list vs unset
|
||||
const nextNonBlank = findNextNonBlank(lines, i + 1);
|
||||
if (nextNonBlank !== -1 && lines[nextNonBlank].match(/^\s+-\s/)) {
|
||||
// List — collect items
|
||||
if (key === 'args') {
|
||||
const { items, consumed } = collectArgsList(lines, i + 1);
|
||||
(result as any)[key] = items;
|
||||
i += 1 + consumed;
|
||||
} else {
|
||||
const { items, consumed } = collectStringList(lines, i + 1);
|
||||
(result as any)[key] = items;
|
||||
i += 1 + consumed;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline list: `key: []`
|
||||
if (rawVal === '[]') {
|
||||
(result as any)[key] = [];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline scalar
|
||||
(result as any)[key] = parseScalar(rawVal);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function findNextNonBlank(lines: string[], from: number): number {
|
||||
for (let i = from; i < lines.length; i++) {
|
||||
if (lines[i].trim()) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function collectStringList(lines: string[], from: number): { items: string[]; consumed: number } {
|
||||
const items: string[] = [];
|
||||
let i = from;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) { i++; continue; }
|
||||
const m = line.match(/^\s+-\s+(.*)$/);
|
||||
if (!m) break;
|
||||
items.push(stripQuotes(m[1]));
|
||||
i++;
|
||||
}
|
||||
return { items, consumed: i - from };
|
||||
}
|
||||
|
||||
function collectArgsList(lines: string[], from: number): { items: SkillArg[]; consumed: number } {
|
||||
const items: SkillArg[] = [];
|
||||
let i = from;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) { i++; continue; }
|
||||
// Item start: ` - name: foo` (with whatever indent)
|
||||
const itemStart = line.match(/^(\s+)-\s+(.+?):\s*(.*)$/);
|
||||
if (!itemStart) break;
|
||||
const indent = itemStart[1] + ' '; // continuation lines get 2 more spaces
|
||||
const arg: SkillArg = { name: '' };
|
||||
if (itemStart[2] === 'name') {
|
||||
arg.name = stripQuotes(itemStart[3]);
|
||||
} else if (itemStart[2] === 'description') {
|
||||
arg.description = stripQuotes(itemStart[3]);
|
||||
}
|
||||
i++;
|
||||
// Read continuation lines ` description: ...`
|
||||
while (i < lines.length) {
|
||||
const cont = lines[i];
|
||||
if (!cont.startsWith(indent) || !cont.trim()) break;
|
||||
const kv = cont.match(/^\s+([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/);
|
||||
if (!kv) break;
|
||||
if (kv[1] === 'name') arg.name = stripQuotes(kv[2]);
|
||||
else if (kv[1] === 'description') arg.description = stripQuotes(kv[2]);
|
||||
i++;
|
||||
}
|
||||
items.push(arg);
|
||||
}
|
||||
return { items, consumed: i - from };
|
||||
}
|
||||
|
||||
function parseScalar(raw: string): string | boolean | number {
|
||||
const v = raw.trim();
|
||||
if (v === 'true') return true;
|
||||
if (v === 'false') return false;
|
||||
if (/^-?\d+$/.test(v)) return parseInt(v, 10);
|
||||
return stripQuotes(v);
|
||||
}
|
||||
|
||||
function stripQuotes(v: string): string {
|
||||
const trimmed = v.trim();
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// ─── Listing + reading ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk all three tiers and return every visible skill (tombstones excluded).
|
||||
* Tier precedence: project > global > bundled. If the same skill name appears
|
||||
* in multiple tiers, the entry from the highest-priority tier wins.
|
||||
*/
|
||||
export function listBrowserSkills(tiers?: TierPaths): BrowserSkill[] {
|
||||
const t = tiers ?? defaultTierPaths();
|
||||
const seen = new Map<string, BrowserSkill>();
|
||||
|
||||
// Walk in priority order: project first, so it wins over global/bundled.
|
||||
const order: Array<{ tier: SkillTier; root: string | null }> = [
|
||||
{ tier: 'project', root: t.project },
|
||||
{ tier: 'global', root: t.global },
|
||||
{ tier: 'bundled', root: t.bundled },
|
||||
];
|
||||
|
||||
for (const { tier, root } of order) {
|
||||
if (!root || !fs.existsSync(root)) continue;
|
||||
let entries: string[];
|
||||
try { entries = fs.readdirSync(root); } catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('.') || entry === '.tombstones') continue;
|
||||
if (seen.has(entry)) continue; // higher-priority tier already claimed this name
|
||||
const dir = path.join(root, entry);
|
||||
let stat: fs.Stats;
|
||||
try { stat = fs.statSync(dir); } catch { continue; }
|
||||
if (!stat.isDirectory()) continue;
|
||||
|
||||
const skillFile = path.join(dir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillFile)) continue;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(skillFile, 'utf-8');
|
||||
const { frontmatter, bodyMd } = parseSkillFile(content, { skillName: entry });
|
||||
seen.set(entry, { name: entry, tier, dir, frontmatter, bodyMd });
|
||||
} catch {
|
||||
// Malformed skill — skip silently. listBrowserSkills is best-effort;
|
||||
// skill-validation tests catch these at build time.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single skill by name (first-tier-wins). Returns null if not found
|
||||
* in any tier.
|
||||
*/
|
||||
export function readBrowserSkill(name: string, tiers?: TierPaths): BrowserSkill | null {
|
||||
const t = tiers ?? defaultTierPaths();
|
||||
const order: Array<{ tier: SkillTier; root: string | null }> = [
|
||||
{ tier: 'project', root: t.project },
|
||||
{ tier: 'global', root: t.global },
|
||||
{ tier: 'bundled', root: t.bundled },
|
||||
];
|
||||
|
||||
for (const { tier, root } of order) {
|
||||
if (!root) continue;
|
||||
const dir = path.join(root, name);
|
||||
const skillFile = path.join(dir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillFile)) continue;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(skillFile, 'utf-8');
|
||||
const { frontmatter, bodyMd } = parseSkillFile(content, { skillName: name });
|
||||
return { name, tier, dir, frontmatter, bodyMd };
|
||||
} catch {
|
||||
// Malformed — try next tier.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Tombstone (rm) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Move a user-tier skill (project or global) into the tier's .tombstones/
|
||||
* directory. Returns the new path.
|
||||
*
|
||||
* Cannot tombstone bundled skills — they ship with gstack and are read-only.
|
||||
* To remove a bundled skill, override it with a global/project entry, or
|
||||
* remove the file from the gstack source tree.
|
||||
*/
|
||||
export function tombstoneBrowserSkill(name: string, tier: 'project' | 'global', tiers?: TierPaths): string {
|
||||
const t = tiers ?? defaultTierPaths();
|
||||
const root = tier === 'project' ? t.project : t.global;
|
||||
if (!root) {
|
||||
throw new Error(`tombstoneBrowserSkill: tier "${tier}" has no resolved path`);
|
||||
}
|
||||
const src = path.join(root, name);
|
||||
if (!fs.existsSync(src)) {
|
||||
throw new Error(`tombstoneBrowserSkill: skill "${name}" not found in tier "${tier}" at ${src}`);
|
||||
}
|
||||
const tombstoneDir = path.join(root, '.tombstones');
|
||||
fs.mkdirSync(tombstoneDir, { recursive: true });
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const dst = path.join(tombstoneDir, `${name}-${ts}`);
|
||||
fs.renameSync(src, dst);
|
||||
return dst;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* CDP method allow-list (T2: deny-default).
|
||||
*
|
||||
* Codex outside-voice T2: allow-default with a deny-list is backwards because
|
||||
* Target.*, Browser.*, Runtime.evaluate, Page.addScriptToEvaluateOnNewDocument,
|
||||
* Fetch.*, IO.read, etc. are all dangerous and easy to forget. Default-deny
|
||||
* inverts the failure mode: missing a method means it's blocked (annoying),
|
||||
* not exposed (silent compromise).
|
||||
*
|
||||
* Each entry has:
|
||||
* - domain.method unique CDP identifier
|
||||
* - scope "tab" | "browser" — controls T7 mutex tier
|
||||
* - output "trusted" | "untrusted" — wraps result if "untrusted"
|
||||
* - justification why this method is safe to allow
|
||||
*
|
||||
* Add entries via PR. CI lint (cdp-allowlist.test.ts) ensures every entry has all 4 fields.
|
||||
*/
|
||||
|
||||
export type CdpScope = 'tab' | 'browser';
|
||||
export type CdpOutput = 'trusted' | 'untrusted';
|
||||
|
||||
export interface CdpAllowEntry {
|
||||
domain: string;
|
||||
method: string;
|
||||
scope: CdpScope;
|
||||
output: CdpOutput;
|
||||
justification: string;
|
||||
}
|
||||
|
||||
export const CDP_ALLOWLIST: ReadonlyArray<CdpAllowEntry> = Object.freeze([
|
||||
// ─── Accessibility (read-only) ─────────────────────────────
|
||||
{
|
||||
domain: 'Accessibility',
|
||||
method: 'getFullAXTree',
|
||||
scope: 'tab',
|
||||
output: 'untrusted',
|
||||
justification: 'Read-only AX tree extraction. Output is third-party page content; wrap in UNTRUSTED.',
|
||||
},
|
||||
{
|
||||
domain: 'Accessibility',
|
||||
method: 'getPartialAXTree',
|
||||
scope: 'tab',
|
||||
output: 'untrusted',
|
||||
justification: 'Read-only AX tree subtree by node. Output is third-party page content.',
|
||||
},
|
||||
{
|
||||
domain: 'Accessibility',
|
||||
method: 'getRootAXNode',
|
||||
scope: 'tab',
|
||||
output: 'untrusted',
|
||||
justification: 'Read-only root AX node accessor.',
|
||||
},
|
||||
// ─── DOM (read-only inspection) ────────────────────────────
|
||||
{
|
||||
domain: 'DOM',
|
||||
method: 'describeNode',
|
||||
scope: 'tab',
|
||||
output: 'untrusted',
|
||||
justification: 'Inspect a DOM node by backend ID; pure read.',
|
||||
},
|
||||
{
|
||||
domain: 'DOM',
|
||||
method: 'getBoxModel',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Pure geometric data (box dimensions). No page content leaks; safe trusted.',
|
||||
},
|
||||
{
|
||||
domain: 'DOM',
|
||||
method: 'getNodeForLocation',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Pure coordinate→nodeId mapping; no content leak.',
|
||||
},
|
||||
// ─── CSS (read-only) ───────────────────────────────────────
|
||||
{
|
||||
domain: 'CSS',
|
||||
method: 'getMatchedStylesForNode',
|
||||
scope: 'tab',
|
||||
output: 'untrusted',
|
||||
justification: 'Read computed cascade for a node; output may contain attacker-controlled selectors.',
|
||||
},
|
||||
{
|
||||
domain: 'CSS',
|
||||
method: 'getComputedStyleForNode',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Computed style values are bounded (CSS keywords/numbers); safe trusted.',
|
||||
},
|
||||
{
|
||||
domain: 'CSS',
|
||||
method: 'getInlineStylesForNode',
|
||||
scope: 'tab',
|
||||
output: 'untrusted',
|
||||
justification: 'Inline style content may contain attacker-controlled custom-property values.',
|
||||
},
|
||||
// ─── Performance metrics ───────────────────────────────────
|
||||
{
|
||||
domain: 'Performance',
|
||||
method: 'getMetrics',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Pure numeric metrics (timing, layout count); safe.',
|
||||
},
|
||||
{
|
||||
domain: 'Performance',
|
||||
method: 'enable',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Domain enable; no content; required prerequisite for getMetrics.',
|
||||
},
|
||||
{
|
||||
domain: 'Performance',
|
||||
method: 'disable',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Domain disable; no content.',
|
||||
},
|
||||
// ─── Tracing (event capture) ───────────────────────────────
|
||||
// NOTE: Tracing.start can capture cross-tab data depending on categories.
|
||||
// We mark it browser-scoped to acquire the global lock when in use.
|
||||
{
|
||||
domain: 'Tracing',
|
||||
method: 'start',
|
||||
scope: 'browser',
|
||||
output: 'trusted',
|
||||
justification: 'Trace category capture. Browser-scoped to serialize against other CDP ops.',
|
||||
},
|
||||
{
|
||||
domain: 'Tracing',
|
||||
method: 'end',
|
||||
scope: 'browser',
|
||||
output: 'untrusted',
|
||||
justification: 'Trace dump may contain URLs and page data; wrap.',
|
||||
},
|
||||
// ─── Emulation (viewport/device) ───────────────────────────
|
||||
{
|
||||
domain: 'Emulation',
|
||||
method: 'setDeviceMetricsOverride',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Viewport/scale override on the active tab.',
|
||||
},
|
||||
{
|
||||
domain: 'Emulation',
|
||||
method: 'clearDeviceMetricsOverride',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Clear viewport override.',
|
||||
},
|
||||
{
|
||||
domain: 'Emulation',
|
||||
method: 'setUserAgentOverride',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'UA override on the active tab. NOTE: changes affect future requests; fine for tests.',
|
||||
},
|
||||
// ─── Page capture (output, not navigation) ─────────────────
|
||||
{
|
||||
domain: 'Page',
|
||||
method: 'captureScreenshot',
|
||||
scope: 'tab',
|
||||
output: 'untrusted',
|
||||
justification: 'Screenshot bytes; output is bounded image data (no marker injection vector).',
|
||||
},
|
||||
{
|
||||
domain: 'Page',
|
||||
method: 'printToPDF',
|
||||
scope: 'tab',
|
||||
output: 'untrusted',
|
||||
justification: 'PDF bytes; bounded binary output.',
|
||||
},
|
||||
// NOTE: Page.navigate is INTENTIONALLY NOT on the allowlist (Codex T2 cat 4).
|
||||
// Use $B goto for navigation; that path goes through the URL blocklist.
|
||||
// ─── Network metadata (NOT bodies/cookies — those exfil data) ──
|
||||
{
|
||||
domain: 'Network',
|
||||
method: 'enable',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Domain enable; required prerequisite. Does not return data.',
|
||||
},
|
||||
{
|
||||
domain: 'Network',
|
||||
method: 'disable',
|
||||
scope: 'tab',
|
||||
output: 'trusted',
|
||||
justification: 'Domain disable; mirrors Network.enable for cleanup symmetry.',
|
||||
},
|
||||
// NOTE: Network.getResponseBody, Network.getCookies, Network.replayXHR,
|
||||
// Network.loadNetworkResource are INTENTIONALLY NOT allowed (Codex T2 cat 7).
|
||||
// ─── Runtime (limited, NO evaluate/callFunctionOn) ──────────
|
||||
// Runtime.evaluate/callFunctionOn/compileScript/runScript = RCE if exposed (Codex T2 cat 6).
|
||||
// Only a tiny safe subset:
|
||||
{
|
||||
domain: 'Runtime',
|
||||
method: 'getProperties',
|
||||
scope: 'tab',
|
||||
output: 'untrusted',
|
||||
justification: 'Inspect properties of an existing remote object. Read-only; output may contain page data.',
|
||||
},
|
||||
]);
|
||||
|
||||
const CDP_ALLOWLIST_INDEX: Map<string, CdpAllowEntry> = new Map(
|
||||
CDP_ALLOWLIST.map((e) => [`${e.domain}.${e.method}`, e]),
|
||||
);
|
||||
|
||||
export function lookupCdpMethod(qualifiedName: string): CdpAllowEntry | null {
|
||||
return CDP_ALLOWLIST_INDEX.get(qualifiedName) ?? null;
|
||||
}
|
||||
|
||||
export function isCdpMethodAllowed(qualifiedName: string): boolean {
|
||||
return CDP_ALLOWLIST_INDEX.has(qualifiedName);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* CDP escape hatch — `$B cdp <Domain.method> [json-params]`.
|
||||
*
|
||||
* Path A from the spike: uses Playwright's newCDPSession() per page so we
|
||||
* piggyback Playwright's own CDP socket (no second WebSocket, no need for
|
||||
* --remote-debugging-port).
|
||||
*
|
||||
* Security posture (Codex T2):
|
||||
* - DENY-DEFAULT. Methods must be explicitly listed in cdp-allowlist.ts.
|
||||
* - Each entry is tagged scope (tab|browser) and output (trusted|untrusted).
|
||||
*
|
||||
* Concurrency posture (Codex T7):
|
||||
* - Two-tier lock from browser-manager.ts.
|
||||
* - tab-scoped methods take the per-tab mutex.
|
||||
* - browser-scoped methods take the global lock that blocks all tab mutexes.
|
||||
* - Hard 5s timeout on acquire → CDPMutexAcquireTimeout (no silent hangs).
|
||||
* - Every lock-holder uses try { ... } finally { release() } so errors don't leak locks.
|
||||
*/
|
||||
|
||||
import type { Page } from 'playwright';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { lookupCdpMethod, type CdpAllowEntry } from './cdp-allowlist';
|
||||
import { logTelemetry } from './telemetry';
|
||||
|
||||
const CDP_TIMEOUT_MS = 5000;
|
||||
const CDP_ACQUIRE_TIMEOUT_MS = 5000;
|
||||
|
||||
// Per-page CDPSession cache. Created lazily on first allow-listed call,
|
||||
// cleaned up when the page closes.
|
||||
const sessionCache: WeakMap<Page, any> = new WeakMap();
|
||||
|
||||
async function getCdpSession(page: Page): Promise<any> {
|
||||
let s = sessionCache.get(page);
|
||||
if (s) return s;
|
||||
s = await page.context().newCDPSession(page);
|
||||
sessionCache.set(page, s);
|
||||
// Clear cache on detach so we don't hold a stale handle.
|
||||
page.once('close', () => sessionCache.delete(page));
|
||||
return s;
|
||||
}
|
||||
|
||||
export interface CdpDispatchInput {
|
||||
domain: string;
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
tabId: number;
|
||||
bm: BrowserManager;
|
||||
}
|
||||
|
||||
export interface CdpDispatchResult {
|
||||
raw: unknown;
|
||||
entry: CdpAllowEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up + acquire mutex + send + release. Throws structured errors on:
|
||||
* - DENIED (method not on allowlist)
|
||||
* - CDPMutexAcquireTimeout (lock contention exceeded budget)
|
||||
* - CDPBridgeTimeout (CDP method itself didn't return in budget)
|
||||
* - CDPSessionInvalidated (Playwright recreated context, session stale)
|
||||
*/
|
||||
export async function dispatchCdpCall(input: CdpDispatchInput): Promise<CdpDispatchResult> {
|
||||
const qualified = `${input.domain}.${input.method}`;
|
||||
const entry = lookupCdpMethod(qualified);
|
||||
if (!entry) {
|
||||
// Surface the denial via telemetry — this is the data that drives the
|
||||
// next allow-list expansion (DX D9: cdp_method_denied counter).
|
||||
logTelemetry({ event: 'cdp_method_denied', domain: input.domain, method: input.method });
|
||||
throw new Error(
|
||||
`DENIED: ${qualified} is not on the CDP allowlist.\n` +
|
||||
`Cause: deny-default posture; method has not been audited and added to cdp-allowlist.ts.\n` +
|
||||
`Action: if this method is genuinely needed, open a PR adding it to CDP_ALLOWLIST with a one-line justification + scope (tab|browser) + output (trusted|untrusted).`
|
||||
);
|
||||
}
|
||||
// Acquire the right tier of lock.
|
||||
const acquireStart = Date.now();
|
||||
const release =
|
||||
entry.scope === 'browser'
|
||||
? await input.bm.acquireGlobalCdpLock(CDP_ACQUIRE_TIMEOUT_MS)
|
||||
: await input.bm.acquireTabLock(input.tabId, CDP_ACQUIRE_TIMEOUT_MS);
|
||||
const acquireMs = Date.now() - acquireStart;
|
||||
logTelemetry({ event: 'cdp_method_lock_acquire_ms', domain: input.domain, method: input.method, ms: acquireMs });
|
||||
logTelemetry({ event: 'cdp_method_called', domain: input.domain, method: input.method, allowed: true, scope: entry.scope });
|
||||
|
||||
try {
|
||||
const page = input.bm.getPageForTab(input.tabId);
|
||||
if (!page) {
|
||||
throw new Error(
|
||||
`Cannot dispatch: tab ${input.tabId} not found.\n` +
|
||||
'Cause: tab was closed between command queue and dispatch.\n' +
|
||||
'Action: $B tabs to list current tabs.'
|
||||
);
|
||||
}
|
||||
let session;
|
||||
try {
|
||||
session = await getCdpSession(page);
|
||||
} catch (e: any) {
|
||||
throw new Error(
|
||||
`CDPSessionInvalidated: ${e.message}\n` +
|
||||
'Cause: Playwright context was recreated (e.g., viewport scale change) and the prior CDP session is stale.\n' +
|
||||
'Action: retry the command; the bridge will create a fresh session.'
|
||||
);
|
||||
}
|
||||
// Race the call against a hard timeout.
|
||||
const callPromise = session.send(qualified, input.params);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`CDPBridgeTimeout: ${qualified} did not return within ${CDP_TIMEOUT_MS}ms`)), CDP_TIMEOUT_MS),
|
||||
);
|
||||
const raw = await Promise.race([callPromise, timeoutPromise]);
|
||||
return { raw, entry };
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* $B cdp <Domain.method> [json-params] — CLI surface for the CDP escape hatch.
|
||||
*
|
||||
* Output for trusted methods is a plain JSON pretty-print.
|
||||
* Output for untrusted methods is wrapped with the centralized UNTRUSTED EXTERNAL
|
||||
* CONTENT envelope so the sidebar-agent classifier sees it (matches the pattern
|
||||
* used by other untrusted-content commands in commands.ts).
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { dispatchCdpCall } from './cdp-bridge';
|
||||
import { wrapUntrustedContent } from './commands';
|
||||
|
||||
function parseQualified(name: string): { domain: string; method: string } {
|
||||
const idx = name.indexOf('.');
|
||||
if (idx <= 0 || idx === name.length - 1) {
|
||||
throw new Error(
|
||||
`Usage: $B cdp <Domain.method> [json-params]\n` +
|
||||
`Cause: '${name}' is not in Domain.method format.\n` +
|
||||
'Action: e.g. $B cdp Accessibility.getFullAXTree {}'
|
||||
);
|
||||
}
|
||||
return { domain: name.slice(0, idx), method: name.slice(idx + 1) };
|
||||
}
|
||||
|
||||
export async function handleCdpCommand(args: string[], bm: BrowserManager): Promise<string> {
|
||||
if (args.length === 0 || args[0] === 'help' || args[0] === '--help') {
|
||||
return [
|
||||
'$B cdp — raw CDP method dispatch (deny-default escape hatch)',
|
||||
'',
|
||||
'Usage: $B cdp <Domain.method> [json-params]',
|
||||
'',
|
||||
'Allowed methods are listed in browse/src/cdp-allowlist.ts. To add one,',
|
||||
'open a PR with a one-line justification and the (scope, output) tags.',
|
||||
'Examples:',
|
||||
' $B cdp Accessibility.getFullAXTree {}',
|
||||
' $B cdp Performance.getMetrics {}',
|
||||
' $B cdp DOM.describeNode \'{"backendNodeId":42,"depth":3}\'',
|
||||
].join('\n');
|
||||
}
|
||||
const qualified = args[0]!;
|
||||
const { domain, method } = parseQualified(qualified);
|
||||
// Optional second arg is JSON params; default to {}.
|
||||
let params: Record<string, unknown> = {};
|
||||
if (args[1]) {
|
||||
try {
|
||||
params = JSON.parse(args[1]) ?? {};
|
||||
} catch (e: any) {
|
||||
throw new Error(
|
||||
`Cannot parse params as JSON: ${e.message}\n` +
|
||||
`Cause: argument '${args[1]}' is not valid JSON.\n` +
|
||||
'Action: pass a JSON object literal, e.g. \'{"backendNodeId":42}\'.'
|
||||
);
|
||||
}
|
||||
}
|
||||
// Dispatch via the bridge (allowlist + mutex + timeout + finally-release).
|
||||
const tabId = bm.getActiveTabId();
|
||||
const { raw, entry } = await dispatchCdpCall({ domain, method, params, tabId, bm });
|
||||
const json = JSON.stringify(raw, null, 2);
|
||||
if (entry.output === 'untrusted') {
|
||||
return wrapUntrustedContent(json, `cdp:${qualified}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
+16
-7
@@ -42,6 +42,9 @@ export const META_COMMANDS = new Set([
|
||||
'state',
|
||||
'frame',
|
||||
'ux-audit',
|
||||
'domain-skill',
|
||||
'skill',
|
||||
'cdp',
|
||||
]);
|
||||
|
||||
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
||||
@@ -101,16 +104,16 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'media': { category: 'Reading', description: 'All media elements (images, videos, audio) with URLs, dimensions, types', usage: 'media [--images|--videos|--audio] [selector]' },
|
||||
'data': { category: 'Reading', description: 'Structured data: JSON-LD, Open Graph, Twitter Cards, meta tags', usage: 'data [--jsonld|--og|--meta|--twitter]' },
|
||||
// Inspection
|
||||
'js': { category: 'Inspection', description: 'Run JavaScript expression and return result as string', usage: 'js <expr>' },
|
||||
'eval': { category: 'Inspection', description: 'Run JavaScript from file and return result as string (path must be under /tmp or cwd)', usage: 'eval <file>' },
|
||||
'js': { category: 'Inspection', description: 'Run inline JavaScript expression in the page context and return result as string. Same JS sandbox as eval; the only difference is js takes an inline expr while eval reads from a file.', usage: 'js <expr>' },
|
||||
'eval': { category: 'Inspection', description: 'Run JavaScript from a file in the page context and return result as string. Path must resolve under /tmp or cwd (no traversal). Use eval for multi-line scripts; use js for one-liners.', usage: 'eval <file>' },
|
||||
'css': { category: 'Inspection', description: 'Computed CSS value', usage: 'css <sel> <prop>' },
|
||||
'attrs': { category: 'Inspection', description: 'Element attributes as JSON', usage: 'attrs <sel|@ref>' },
|
||||
'is': { category: 'Inspection', description: 'State check (visible/hidden/enabled/disabled/checked/editable/focused)', usage: 'is <prop> <sel>' },
|
||||
'is': { category: 'Inspection', description: 'State check on element. Valid <prop> values: visible, hidden, enabled, disabled, checked, editable, focused (case-sensitive). <sel> accepts a CSS selector OR an @ref token from a prior snapshot (e.g. @e3, @c1) — refs are interchangeable with selectors anywhere a selector is expected.', usage: 'is <prop> <sel|@ref>' },
|
||||
'console': { category: 'Inspection', description: 'Console messages (--errors filters to error/warning)', usage: 'console [--clear|--errors]' },
|
||||
'network': { category: 'Inspection', description: 'Network requests', usage: 'network [--clear]' },
|
||||
'dialog': { category: 'Inspection', description: 'Dialog messages', usage: 'dialog [--clear]' },
|
||||
'cookies': { category: 'Inspection', description: 'All cookies as JSON' },
|
||||
'storage': { category: 'Inspection', description: 'Read all localStorage + sessionStorage as JSON, or set <key> <value> to write localStorage', usage: 'storage [set k v]' },
|
||||
'storage': { category: 'Inspection', description: 'Read both localStorage and sessionStorage as JSON. With "set <key> <value>", write to localStorage only (sessionStorage is read-only via this command — set it with `js sessionStorage.setItem(...)`).', usage: 'storage | storage set <key> <value>' },
|
||||
'perf': { category: 'Inspection', description: 'Page load timings' },
|
||||
// Interaction
|
||||
'click': { category: 'Interaction', description: 'Click element', usage: 'click <sel>' },
|
||||
@@ -118,8 +121,8 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'select': { category: 'Interaction', description: 'Select dropdown option by value, label, or visible text', usage: 'select <sel> <val>' },
|
||||
'hover': { category: 'Interaction', description: 'Hover element', usage: 'hover <sel>' },
|
||||
'type': { category: 'Interaction', description: 'Type into focused element', usage: 'type <text>' },
|
||||
'press': { category: 'Interaction', description: 'Press key — Enter, Tab, Escape, ArrowUp/Down/Left/Right, Backspace, Delete, Home, End, PageUp, PageDown, or modifiers like Shift+Enter', usage: 'press <key>' },
|
||||
'scroll': { category: 'Interaction', description: 'Scroll element into view, or scroll to page bottom if no selector', usage: 'scroll [sel]' },
|
||||
'press': { category: 'Interaction', description: 'Press a Playwright keyboard key against the focused element. Names are case-sensitive: Enter, Tab, Escape, ArrowUp/Down/Left/Right, Backspace, Delete, Home, End, PageUp, PageDown. Modifiers combine with +: Shift+Enter, Control+A, Meta+K. Single printable chars (a, A, 1) work too. Full key list: https://playwright.dev/docs/api/class-keyboard#keyboard-press', usage: 'press <key>' },
|
||||
'scroll': { category: 'Interaction', description: 'With a selector, smooth-scrolls the element into view. Without a selector, jumps to page bottom. No --by/--to amount option; for pixel-precise scrolling use `js window.scrollTo(0, N)`.', usage: 'scroll [sel|@ref]' },
|
||||
'wait': { category: 'Interaction', description: 'Wait for element, network idle, or page load (timeout: 15s)', usage: 'wait <sel|--networkidle|--load>' },
|
||||
'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload <sel> <file> [file2...]' },
|
||||
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [<WxH>] [--scale <n>]' },
|
||||
@@ -151,7 +154,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'restart': { category: 'Server', description: 'Restart server' },
|
||||
// Meta
|
||||
'snapshot':{ category: 'Snapshot', description: 'Accessibility tree with @e refs for element selection. Flags: -i interactive only, -c compact, -d N depth limit, -s sel scope, -D diff vs previous, -a annotated screenshot, -o path output, -C cursor-interactive @c refs', usage: 'snapshot [flags]' },
|
||||
'chain': { category: 'Meta', description: 'Run commands from JSON stdin. Format: [["cmd","arg1",...],...]' },
|
||||
'chain': { category: 'Meta', description: 'Run a sequence of commands from JSON on stdin. One JSON array of arrays, each inner array is [cmd, ...args]. Output is one JSON result per command. Pipe a JSON array (e.g. `[["goto","https://example.com"],["text","h1"]]`) to `$B chain` and it runs the goto then the text command in order. Stops at the first error.', usage: 'chain (JSON via stdin)' },
|
||||
// Handoff
|
||||
'handoff': { category: 'Server', description: 'Open visible Chrome at current page for user takeover', usage: 'handoff [message]' },
|
||||
'resume': { category: 'Server', description: 'Re-snapshot after user takeover, return control to AI', usage: 'resume' },
|
||||
@@ -174,6 +177,12 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'prettyscreenshot': { category: 'Visual', description: 'Clean screenshot with optional cleanup, scroll positioning, and element hiding', usage: 'prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]' },
|
||||
// UX Audit
|
||||
'ux-audit': { category: 'Inspection', description: 'Extract page structure for UX behavioral analysis — site ID, nav, headings, text blocks, interactive elements. Returns JSON for agent interpretation.', usage: 'ux-audit' },
|
||||
// Domain skills (per-site notes the agent writes for itself)
|
||||
'domain-skill': { category: 'Meta', description: 'Per-site notes the agent writes for itself. Host is derived from the active tab. Lifecycle: `save` adds a quarantined note → after N=3 successful uses without the prompt-injection classifier flagging it, the note auto-promotes to "active" → `promote-to-global` lifts it to the global tier (machine-wide, all projects). The classifier flag is set automatically by the L4 prompt-injection scan; agents do not set it manually. Use `list` / `show` to inspect, `edit` to revise, `rollback` to demote, `rm` to tombstone.', usage: 'domain-skill save|list|show|edit|promote-to-global|rollback|rm <host?>' },
|
||||
// Browser-skills (hand-written or generated Playwright scripts the runtime spawns)
|
||||
'skill': { category: 'Meta', description: 'Run a browser-skill: deterministic Playwright script that drives the daemon over loopback HTTP. 3-tier lookup (project > global > bundled). Spawned scripts get a per-spawn scoped token (read+write only) — never the daemon root token.', usage: 'skill list|show|run|test|rm <name?> [--arg k=v]... [--timeout=Ns]' },
|
||||
// CDP escape hatch (deny-default; see browse/src/cdp-allowlist.ts)
|
||||
'cdp': { category: 'Inspection', description: 'Raw Chrome DevTools Protocol method dispatch. Deny-default: only methods enumerated in `browse/src/cdp-allowlist.ts` (CDP_ALLOWLIST const) are reachable; any other method 403s. Each allowlist entry declares scope (tab vs browser) and output (trusted vs untrusted) — untrusted methods (data-exfil-shaped, e.g. Network.getResponseBody) get UNTRUSTED-envelope wrapped output. To discover allowed methods: read `browse/src/cdp-allowlist.ts`. Example: `$B cdp Page.getLayoutMetrics`.', usage: 'cdp <Domain.method> [json-params]' },
|
||||
};
|
||||
|
||||
// Load-time validation: descriptions must cover exactly the command sets
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* $B domain-skill subcommands — CLI surface for the domain-skills storage layer.
|
||||
*
|
||||
* Subcommands:
|
||||
* save — save a skill body (host derived from active tab, T3)
|
||||
* list — list all skills (project + global) visible here
|
||||
* show <host> — print the body of a skill
|
||||
* edit <host> — round-trip through $EDITOR
|
||||
* promote-to-global <host> — promote active per-project skill to global
|
||||
* rollback <host> — restore prior version
|
||||
* rm <host> [--global] — tombstone a skill
|
||||
*
|
||||
* Design constraints:
|
||||
* - host is ALWAYS derived from the active tab's top-level origin (T3
|
||||
* confused-deputy fix). Never accepted as an arg.
|
||||
* - Save-time security uses content-security.ts L1-L3 filters (importable
|
||||
* from the compiled binary, unlike the L4 ML classifier). The full L4
|
||||
* scan happens in sidebar-agent.ts when the skill is loaded into a prompt.
|
||||
* - Output is structured: every success/error includes problem + cause +
|
||||
* suggested-action. Matches the gstack house style.
|
||||
*
|
||||
* The body for `save` is supplied via stdin or --from-file, NOT inline argv,
|
||||
* so multi-line markdown bodies don't get mangled by shell quoting.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import {
|
||||
deriveHostFromActiveTab,
|
||||
writeSkill,
|
||||
readSkill,
|
||||
listSkills,
|
||||
promoteToGlobal,
|
||||
rollbackSkill,
|
||||
deleteSkill,
|
||||
type DomainSkillRow,
|
||||
type SkillScope,
|
||||
} from './domain-skills';
|
||||
import { runContentFilters } from './content-security';
|
||||
import { getCurrentProjectSlug } from './project-slug';
|
||||
import { logTelemetry } from './telemetry';
|
||||
|
||||
// ─── Body input resolution ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read skill body from --from-file <path> or from stdin.
|
||||
* Body is NEVER taken from inline argv (shell quoting hazard for multi-line markdown).
|
||||
*/
|
||||
async function readBodyFromArgs(args: string[]): Promise<string> {
|
||||
const fromFileIdx = args.indexOf('--from-file');
|
||||
if (fromFileIdx >= 0 && fromFileIdx + 1 < args.length) {
|
||||
const filePath = args[fromFileIdx + 1]!;
|
||||
const body = await fs.readFile(filePath, 'utf8');
|
||||
return body;
|
||||
}
|
||||
// Read from stdin (the CLI may pipe content in)
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => (data += chunk));
|
||||
process.stdin.on('end', () => resolve(data));
|
||||
// If no stdin attached, end immediately with empty string
|
||||
if (process.stdin.isTTY) resolve('');
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Output formatting ──────────────────────────────────────────
|
||||
|
||||
function formatSavedOk(row: DomainSkillRow, slug: string): string {
|
||||
return [
|
||||
`Saved (state: ${row.state}, scope: ${row.scope}).`,
|
||||
`Host: ${row.host}`,
|
||||
`Bytes: ${row.body.length}`,
|
||||
`Version: ${row.version}`,
|
||||
`Stored at: ~/.gstack/projects/${slug}/learnings.jsonl`,
|
||||
'',
|
||||
`Next: skill is quarantined and won't fire in prompts until used 3 times`,
|
||||
` without classifier flags. Run $B domain-skill list to see state.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatSkillListing(list: { project: DomainSkillRow[]; global: DomainSkillRow[] }): string {
|
||||
if (list.project.length === 0 && list.global.length === 0) {
|
||||
return 'No domain-skills yet.\n\nNext: navigate to a site, then $B domain-skill save with a markdown body to begin.';
|
||||
}
|
||||
const lines: string[] = [];
|
||||
if (list.project.length > 0) {
|
||||
lines.push('Project (per-project):');
|
||||
for (const r of list.project) {
|
||||
lines.push(` [${r.state}] ${r.host} — v${r.version}, ${r.body.length} bytes, used ${r.use_count}× (${r.flag_count} flags)`);
|
||||
}
|
||||
}
|
||||
if (list.global.length > 0) {
|
||||
if (lines.length > 0) lines.push('');
|
||||
lines.push('Global (cross-project):');
|
||||
for (const r of list.global) {
|
||||
lines.push(` ${r.host} — v${r.version}, ${r.body.length} bytes`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ─── Subcommand handlers ────────────────────────────────────────
|
||||
|
||||
async function handleSave(args: string[], bm: BrowserManager): Promise<string> {
|
||||
const page = bm.getPage();
|
||||
const host = await deriveHostFromActiveTab(page);
|
||||
const body = await readBodyFromArgs(args);
|
||||
if (!body || !body.trim()) {
|
||||
throw new Error(
|
||||
'Save failed: empty body.\n' +
|
||||
'Cause: no content provided via --from-file or stdin.\n' +
|
||||
'Action: pipe markdown into $B domain-skill save, or pass --from-file <path>.'
|
||||
);
|
||||
}
|
||||
// L1-L3 content filters (datamarking, hidden-element strip, ARIA regex,
|
||||
// URL blocklist). The full L4 ML classifier runs at sidebar-agent prompt
|
||||
// injection time, not here (CLAUDE.md: classifier can't import in compiled binary).
|
||||
const filterResult = runContentFilters(body, page.url(), 'domain-skill-save');
|
||||
if (filterResult.blocked) {
|
||||
logTelemetry({ event: 'domain_skill_save_blocked', host, reason: filterResult.message });
|
||||
throw new Error(
|
||||
`Save blocked: ${filterResult.message}\n` +
|
||||
'Cause: skill body trips L1-L3 content filters (likely contains URL blocklist match or ARIA injection patterns).\n' +
|
||||
'Action: review the body for suspicious instruction-like content; rewrite and retry.'
|
||||
);
|
||||
}
|
||||
// L1-L3 score is binary (passed or not). For the L4 score field we leave 0
|
||||
// (meaning "not yet scanned by ML classifier") — sidebar-agent fills this
|
||||
// in on first prompt-injection load.
|
||||
const slug = getCurrentProjectSlug();
|
||||
const row = await writeSkill({
|
||||
host,
|
||||
body,
|
||||
projectSlug: slug,
|
||||
source: 'agent',
|
||||
classifierScore: 0, // L4 deferred to load-time
|
||||
});
|
||||
logTelemetry({ event: 'domain_skill_saved', host, scope: row.scope, state: row.state, bytes: body.length });
|
||||
return formatSavedOk(row, slug);
|
||||
}
|
||||
|
||||
async function handleList(_args: string[]): Promise<string> {
|
||||
const slug = getCurrentProjectSlug();
|
||||
const list = await listSkills(slug);
|
||||
return formatSkillListing(list);
|
||||
}
|
||||
|
||||
async function handleShow(args: string[]): Promise<string> {
|
||||
const host = args[0];
|
||||
if (!host) {
|
||||
throw new Error(
|
||||
'Usage: $B domain-skill show <host>\n' +
|
||||
'Cause: missing hostname argument.\n' +
|
||||
'Action: $B domain-skill list to see available hosts.'
|
||||
);
|
||||
}
|
||||
const slug = getCurrentProjectSlug();
|
||||
const result = await readSkill(host, slug);
|
||||
if (!result) {
|
||||
return `No active skill for ${host}.\n\nA quarantined skill may exist; run $B domain-skill list to see all states.`;
|
||||
}
|
||||
return [
|
||||
`# ${result.row.host} (${result.source} scope, ${result.row.state})`,
|
||||
`# version: ${result.row.version}, used: ${result.row.use_count}×, flags: ${result.row.flag_count}`,
|
||||
'',
|
||||
result.row.body,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function handleEdit(args: string[]): Promise<string> {
|
||||
const host = args[0];
|
||||
if (!host) {
|
||||
throw new Error('Usage: $B domain-skill edit <host>');
|
||||
}
|
||||
const slug = getCurrentProjectSlug();
|
||||
// Read current body to seed the editor
|
||||
const list = await listSkills(slug);
|
||||
const current = [...list.project, ...list.global].find((r) => r.host === host);
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
`Cannot edit: no skill for ${host}.\n` +
|
||||
'Cause: skill does not exist in this project or global scope.\n' +
|
||||
'Action: $B domain-skill save to create one first.'
|
||||
);
|
||||
}
|
||||
const editor = process.env.EDITOR || 'vi';
|
||||
const tmpFile = path.join(os.tmpdir(), `gstack-domain-skill-${process.pid}-${Date.now()}.md`);
|
||||
await fs.writeFile(tmpFile, current.body, 'utf8');
|
||||
const result = spawnSync(editor, [tmpFile], { stdio: 'inherit' });
|
||||
if (result.status !== 0) {
|
||||
await fs.unlink(tmpFile).catch(() => {});
|
||||
throw new Error(`Editor exited with status ${result.status}; no changes saved.`);
|
||||
}
|
||||
const newBody = await fs.readFile(tmpFile, 'utf8');
|
||||
await fs.unlink(tmpFile).catch(() => {});
|
||||
if (newBody === current.body) {
|
||||
return `No changes for ${host}.`;
|
||||
}
|
||||
// Re-save (always per-project; promotion is explicit)
|
||||
const page = (global as any).__bm?.getPage?.();
|
||||
void page; // we're in the daemon — page available, but for edit we trust the existing host
|
||||
const row = await writeSkill({
|
||||
host: current.host,
|
||||
body: newBody,
|
||||
projectSlug: slug,
|
||||
source: 'human',
|
||||
classifierScore: 0,
|
||||
});
|
||||
return formatSavedOk(row, slug);
|
||||
}
|
||||
|
||||
async function handlePromoteToGlobal(args: string[]): Promise<string> {
|
||||
const host = args[0];
|
||||
if (!host) {
|
||||
throw new Error('Usage: $B domain-skill promote-to-global <host>');
|
||||
}
|
||||
const slug = getCurrentProjectSlug();
|
||||
const row = await promoteToGlobal(host, slug);
|
||||
return [
|
||||
`Promoted ${row.host} to global scope (v${row.version}).`,
|
||||
`Stored at: ~/.gstack/global-domain-skills.jsonl`,
|
||||
'',
|
||||
`This skill now fires for all projects unless they have a per-project skill for the same host.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function handleRollback(args: string[]): Promise<string> {
|
||||
const host = args[0];
|
||||
if (!host) {
|
||||
throw new Error('Usage: $B domain-skill rollback <host>');
|
||||
}
|
||||
const scope: SkillScope = args.includes('--global') ? 'global' : 'project';
|
||||
const slug = getCurrentProjectSlug();
|
||||
const row = await rollbackSkill(host, slug, scope);
|
||||
return [
|
||||
`Rolled back ${row.host} (${scope} scope) to prior version.`,
|
||||
`New version: ${row.version} (content from earlier revision)`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function handleRm(args: string[]): Promise<string> {
|
||||
const host = args[0];
|
||||
if (!host) {
|
||||
throw new Error('Usage: $B domain-skill rm <host> [--global]');
|
||||
}
|
||||
const scope: SkillScope = args.includes('--global') ? 'global' : 'project';
|
||||
const slug = getCurrentProjectSlug();
|
||||
await deleteSkill(host, slug, scope);
|
||||
return `Tombstoned ${host} (${scope} scope). Use $B domain-skill rollback to restore.`;
|
||||
}
|
||||
|
||||
// ─── Top-level dispatcher ──────────────────────────────────────
|
||||
|
||||
export async function handleDomainSkillCommand(args: string[], bm: BrowserManager): Promise<string> {
|
||||
const sub = args[0];
|
||||
const rest = args.slice(1);
|
||||
switch (sub) {
|
||||
case 'save':
|
||||
return handleSave(rest, bm);
|
||||
case 'list':
|
||||
return handleList(rest);
|
||||
case 'show':
|
||||
return handleShow(rest);
|
||||
case 'edit':
|
||||
return handleEdit(rest);
|
||||
case 'promote-to-global':
|
||||
return handlePromoteToGlobal(rest);
|
||||
case 'rollback':
|
||||
return handleRollback(rest);
|
||||
case 'rm':
|
||||
case 'remove':
|
||||
case 'delete':
|
||||
return handleRm(rest);
|
||||
case undefined:
|
||||
case '':
|
||||
case 'help':
|
||||
return [
|
||||
'$B domain-skill — agent-authored per-site notes',
|
||||
'',
|
||||
'Subcommands:',
|
||||
' save save body from stdin or --from-file (host derived from active tab)',
|
||||
' list list all skills visible to current project',
|
||||
' show <host> print skill body',
|
||||
' edit <host> open in $EDITOR',
|
||||
' promote-to-global <host> promote active skill to global scope',
|
||||
' rollback <host> [--global] restore prior version',
|
||||
' rm <host> [--global] tombstone',
|
||||
].join('\n');
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown subcommand: ${sub}\n` +
|
||||
'Cause: not one of save|list|show|edit|promote-to-global|rollback|rm.\n' +
|
||||
'Action: $B domain-skill help for the full list.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Domain skills — per-site notes the agent writes for itself, persisted
|
||||
* alongside /learn's per-project learnings as type:"domain" rows.
|
||||
*
|
||||
* Scope:
|
||||
* - per-project: ~/.gstack/projects/<slug>/learnings.jsonl
|
||||
* - global: ~/.gstack/global-domain-skills.jsonl
|
||||
*
|
||||
* State machine (T6 — defense against persistent prompt poisoning):
|
||||
*
|
||||
* ┌──────────────┐ N=3 successful uses ┌────────┐ promote-to-global ┌────────┐
|
||||
* │ quarantined │ ─────────────────────▶ │ active │ ──────────────────▶ │ global │
|
||||
* │ (per-project)│ (no classifier flags) │(project)│ (manual command) │ │
|
||||
* └──────────────┘ └────────┘ └────────┘
|
||||
* ▲ │
|
||||
* │ classifier flag during use │ rollback (version log)
|
||||
* └───────────────────────────────────────┘
|
||||
*
|
||||
* - new save → quarantined (does NOT auto-fire in prompts)
|
||||
* - active skills fire in prompts for their project (wrapped in UNTRUSTED)
|
||||
* - global skills fire across all projects (cross-context, requires explicit promote)
|
||||
* - rollback restores prior version by sha256
|
||||
*
|
||||
* Storage discipline (T5):
|
||||
* - Append-only with O_APPEND (POSIX guarantees atomic appends < PIPE_BUF)
|
||||
* - Tombstone for deletes; idle compactor rewrites file
|
||||
* - Tolerant parser drops partial trailing line on read
|
||||
*
|
||||
* Hostname rules (T3, CEO-temporal):
|
||||
* - Derived from active tab's top-level origin — NEVER agent-supplied
|
||||
* - Lowercase, strip www., keep full subdomain (subdomain-exact match)
|
||||
* - Punycode hostnames stored as-encoded
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { open as fsOpen, constants as fsConstants } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createHash } from 'crypto';
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
export type SkillState = 'quarantined' | 'active' | 'global';
|
||||
export type SkillScope = 'project' | 'global';
|
||||
export type SkillSource = 'agent' | 'human';
|
||||
|
||||
export interface DomainSkillRow {
|
||||
type: 'domain';
|
||||
host: string;
|
||||
scope: SkillScope;
|
||||
state: SkillState;
|
||||
body: string;
|
||||
version: number;
|
||||
classifier_score: number;
|
||||
source: SkillSource;
|
||||
sha256: string;
|
||||
use_count: number;
|
||||
flag_count: number;
|
||||
created_ts: string;
|
||||
updated_ts: string;
|
||||
tombstone?: boolean;
|
||||
}
|
||||
|
||||
const PROMOTE_THRESHOLD = 3;
|
||||
|
||||
function gstackHome(): string {
|
||||
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
||||
}
|
||||
|
||||
function globalFile(): string {
|
||||
return path.join(gstackHome(), 'global-domain-skills.jsonl');
|
||||
}
|
||||
|
||||
function projectFile(slug: string): string {
|
||||
return path.join(gstackHome(), 'projects', slug, 'learnings.jsonl');
|
||||
}
|
||||
|
||||
// ─── Hostname normalization (T3) ──────────────────────────────
|
||||
|
||||
export function normalizeHost(input: string): string {
|
||||
let h = input.trim().toLowerCase();
|
||||
// strip protocol if present
|
||||
h = h.replace(/^https?:\/\//, '');
|
||||
// strip path/query
|
||||
h = h.split('/')[0]!.split('?')[0]!.split('#')[0]!;
|
||||
// strip port
|
||||
h = h.split(':')[0]!;
|
||||
// strip www. prefix
|
||||
h = h.replace(/^www\./, '');
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive hostname from the active tab's top-level origin.
|
||||
* Closes the confused-deputy bug (Codex T3): agent cannot supply a wrong
|
||||
* hostname even if it tried — host is read from the page state we control.
|
||||
*/
|
||||
export async function deriveHostFromActiveTab(page: Page): Promise<string> {
|
||||
const url = page.url();
|
||||
if (!url || url === 'about:blank' || url.startsWith('chrome://')) {
|
||||
throw new Error(
|
||||
'Cannot save domain-skill: no top-level URL on active tab.\n' +
|
||||
'Cause: tab is empty or on chrome:// page.\n' +
|
||||
'Action: navigate to the target site first with $B goto <url>.'
|
||||
);
|
||||
}
|
||||
return normalizeHost(url);
|
||||
}
|
||||
|
||||
// ─── File I/O (T5: append-only + flock-free atomic appends) ────
|
||||
|
||||
async function ensureDir(filePath: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a JSONL row atomically. POSIX guarantees atomicity for writes <
|
||||
* PIPE_BUF (typically 4KB) when O_APPEND is set. Each row is single-line JSON
|
||||
* well under that bound. fsync ensures durability before return.
|
||||
*/
|
||||
async function appendRow(filePath: string, row: DomainSkillRow): Promise<void> {
|
||||
await ensureDir(filePath);
|
||||
const line = JSON.stringify(row) + '\n';
|
||||
return new Promise((resolve, reject) => {
|
||||
fsOpen(filePath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_APPEND, 0o644, (err, fd) => {
|
||||
if (err) return reject(err);
|
||||
const buf = Buffer.from(line, 'utf8');
|
||||
const writeAndSync = () => {
|
||||
// Use fs.writeSync via fd to ensure single write call (atomic with O_APPEND).
|
||||
const fsSync = require('fs');
|
||||
try {
|
||||
fsSync.writeSync(fd, buf, 0, buf.length);
|
||||
fsSync.fsyncSync(fd);
|
||||
fsSync.closeSync(fd);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
try {
|
||||
fsSync.closeSync(fd);
|
||||
} catch {
|
||||
// Ignore close errors after a write failure — original error wins.
|
||||
}
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
writeAndSync();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all rows from a JSONL file. Tolerant of partial trailing line (drops it).
|
||||
* Returns rows in append order. Caller resolves latest-wins per (host, scope).
|
||||
*/
|
||||
async function readRows(filePath: string): Promise<DomainSkillRow[]> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf8');
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
const rows: DomainSkillRow[] = [];
|
||||
const lines = raw.split('\n');
|
||||
// Last line is empty (trailing newline) OR partial. Drop unconditionally if no parse.
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed && parsed.type === 'domain') rows.push(parsed as DomainSkillRow);
|
||||
} catch {
|
||||
// Partial-line corruption tolerated. Compactor will clean up.
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Latest-wins resolution ────────────────────────────────────
|
||||
|
||||
interface SkillKey {
|
||||
host: string;
|
||||
scope: SkillScope;
|
||||
}
|
||||
|
||||
function keyOf(row: DomainSkillRow): string {
|
||||
return `${row.scope}::${row.host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a row stream to latest-version-wins per (host, scope).
|
||||
* Tombstones win (deleted skill stays deleted).
|
||||
*/
|
||||
function resolveLatest(rows: DomainSkillRow[]): Map<string, DomainSkillRow> {
|
||||
const m = new Map<string, DomainSkillRow>();
|
||||
for (const row of rows) {
|
||||
const k = keyOf(row);
|
||||
const prior = m.get(k);
|
||||
if (!prior || row.version >= prior.version) {
|
||||
m.set(k, row);
|
||||
}
|
||||
}
|
||||
// Drop tombstoned entries from the result map for readers; rollback uses raw history.
|
||||
for (const [k, row] of m) {
|
||||
if (row.tombstone) m.delete(k);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────
|
||||
|
||||
export interface ReadSkillResult {
|
||||
row: DomainSkillRow;
|
||||
source: 'project' | 'global';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the active or global skill for a host visible to a given project.
|
||||
* Project-scoped active skills shadow global skills for the same host.
|
||||
* Quarantined skills are NEVER returned (they don't fire).
|
||||
*/
|
||||
export async function readSkill(host: string, projectSlug: string): Promise<ReadSkillResult | null> {
|
||||
const normalized = normalizeHost(host);
|
||||
// Project layer first
|
||||
const projectRows = await readRows(projectFile(projectSlug));
|
||||
const projectLatest = resolveLatest(projectRows);
|
||||
const projectHit = projectLatest.get(`project::${normalized}`);
|
||||
if (projectHit && projectHit.state === 'active') {
|
||||
return { row: projectHit, source: 'project' };
|
||||
}
|
||||
// Global layer fallback
|
||||
const globalRows = await readRows(globalFile());
|
||||
const globalLatest = resolveLatest(globalRows);
|
||||
const globalHit = globalLatest.get(`global::${normalized}`);
|
||||
if (globalHit && globalHit.state === 'global') {
|
||||
return { row: globalHit, source: 'global' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface WriteSkillInput {
|
||||
host: string;
|
||||
body: string; // markdown frontmatter + content
|
||||
projectSlug: string;
|
||||
source: SkillSource;
|
||||
classifierScore: number; // 0..1; caller invokes classifier before calling this
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new skill (always quarantined initially per T6).
|
||||
* Caller MUST run the classifier first and pass classifierScore.
|
||||
* Score >= 0.85 should fail-fast at caller, never reach here.
|
||||
*/
|
||||
export async function writeSkill(input: WriteSkillInput): Promise<DomainSkillRow> {
|
||||
if (input.classifierScore >= 0.85) {
|
||||
throw new Error(
|
||||
`Save blocked: classifier flagged content as potential injection (score: ${input.classifierScore.toFixed(2)}).\n` +
|
||||
'Cause: skill body contains patterns the L4 classifier marks as risky.\n' +
|
||||
'Action: rewrite the skill content removing instruction-like prose, retry.'
|
||||
);
|
||||
}
|
||||
const normalized = normalizeHost(input.host);
|
||||
const body = input.body;
|
||||
const now = new Date().toISOString();
|
||||
const sha = createHash('sha256').update(body, 'utf8').digest('hex');
|
||||
// Determine prior version for this (host, scope=project) so version counter increments.
|
||||
const projectRows = await readRows(projectFile(input.projectSlug));
|
||||
const projectLatest = resolveLatest(projectRows);
|
||||
const prior = projectLatest.get(`project::${normalized}`);
|
||||
const version = prior ? prior.version + 1 : 1;
|
||||
const row: DomainSkillRow = {
|
||||
type: 'domain',
|
||||
host: normalized,
|
||||
scope: 'project',
|
||||
state: 'quarantined',
|
||||
body,
|
||||
version,
|
||||
classifier_score: input.classifierScore,
|
||||
source: input.source,
|
||||
sha256: sha,
|
||||
use_count: 0,
|
||||
flag_count: 0,
|
||||
created_ts: prior?.created_ts ?? now,
|
||||
updated_ts: now,
|
||||
};
|
||||
await appendRow(projectFile(input.projectSlug), row);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a quarantined skill to active in its project after N=3 uses without
|
||||
* classifier flagging. Called by sidebar-agent on successful skill use.
|
||||
*
|
||||
* Auto-promote logic:
|
||||
* - increment use_count
|
||||
* - if use_count >= PROMOTE_THRESHOLD AND flag_count == 0 → state:active
|
||||
* - else stay quarantined with updated counter
|
||||
*/
|
||||
export async function recordSkillUse(host: string, projectSlug: string, classifierFlagged: boolean): Promise<DomainSkillRow | null> {
|
||||
const normalized = normalizeHost(host);
|
||||
const rows = await readRows(projectFile(projectSlug));
|
||||
const latest = resolveLatest(rows);
|
||||
const current = latest.get(`project::${normalized}`);
|
||||
if (!current) return null;
|
||||
const useCount = current.use_count + 1;
|
||||
const flagCount = current.flag_count + (classifierFlagged ? 1 : 0);
|
||||
let state: SkillState = current.state;
|
||||
if (state === 'quarantined' && useCount >= PROMOTE_THRESHOLD && flagCount === 0) {
|
||||
state = 'active';
|
||||
}
|
||||
const updated: DomainSkillRow = {
|
||||
...current,
|
||||
state,
|
||||
use_count: useCount,
|
||||
flag_count: flagCount,
|
||||
version: current.version + 1,
|
||||
updated_ts: new Date().toISOString(),
|
||||
};
|
||||
await appendRow(projectFile(projectSlug), updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote an active per-project skill to global. Explicit operator call only —
|
||||
* never auto-promoted across project boundaries (T4).
|
||||
*/
|
||||
export async function promoteToGlobal(host: string, projectSlug: string): Promise<DomainSkillRow> {
|
||||
const normalized = normalizeHost(host);
|
||||
const rows = await readRows(projectFile(projectSlug));
|
||||
const latest = resolveLatest(rows);
|
||||
const current = latest.get(`project::${normalized}`);
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
`Cannot promote: no skill for ${normalized} in project ${projectSlug}.\n` +
|
||||
'Cause: skill does not exist or is tombstoned.\n' +
|
||||
'Action: $B domain-skill list to see what exists in this project.'
|
||||
);
|
||||
}
|
||||
if (current.state !== 'active') {
|
||||
throw new Error(
|
||||
`Cannot promote: skill for ${normalized} is in state "${current.state}", expected "active".\n` +
|
||||
`Cause: skill must be active in this project (used ${PROMOTE_THRESHOLD}+ times without flag) before global promotion.\n` +
|
||||
'Action: use the skill in this project until it auto-promotes to active.'
|
||||
);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const globalRow: DomainSkillRow = {
|
||||
...current,
|
||||
scope: 'global',
|
||||
state: 'global',
|
||||
version: 1, // global file has its own version line
|
||||
use_count: 0,
|
||||
flag_count: 0,
|
||||
updated_ts: now,
|
||||
};
|
||||
await appendRow(globalFile(), globalRow);
|
||||
return globalRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to a prior version (by sha256 OR previous version number).
|
||||
* Re-emits the prior row as the latest, preserving the version counter monotonicity.
|
||||
*/
|
||||
export async function rollbackSkill(host: string, projectSlug: string, scope: SkillScope = 'project'): Promise<DomainSkillRow> {
|
||||
const normalized = normalizeHost(host);
|
||||
const file = scope === 'project' ? projectFile(projectSlug) : globalFile();
|
||||
const rows = await readRows(file);
|
||||
const matching = rows.filter((r) => r.host === normalized && r.scope === scope && !r.tombstone);
|
||||
if (matching.length < 2) {
|
||||
throw new Error(
|
||||
`Cannot rollback: ${normalized} has fewer than 2 versions in ${scope} scope.\n` +
|
||||
'Cause: no prior version to roll back to.\n' +
|
||||
'Action: $B domain-skill rm to delete instead, or wait for a future revision to roll back from.'
|
||||
);
|
||||
}
|
||||
// Sort by version desc; take second-latest as the rollback target.
|
||||
matching.sort((a, b) => b.version - a.version);
|
||||
const target = matching[1]!;
|
||||
const newVersion = matching[0]!.version + 1;
|
||||
const restored: DomainSkillRow = {
|
||||
...target,
|
||||
version: newVersion,
|
||||
updated_ts: new Date().toISOString(),
|
||||
};
|
||||
await appendRow(file, restored);
|
||||
return restored;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all non-tombstoned skills visible to a project (active project + active global).
|
||||
*/
|
||||
export async function listSkills(projectSlug: string): Promise<{ project: DomainSkillRow[]; global: DomainSkillRow[] }> {
|
||||
const projectRows = await readRows(projectFile(projectSlug));
|
||||
const globalRows = await readRows(globalFile());
|
||||
const projectLatest = Array.from(resolveLatest(projectRows).values());
|
||||
const globalLatest = Array.from(resolveLatest(globalRows).values()).filter((r) => r.state === 'global');
|
||||
return { project: projectLatest, global: globalLatest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tombstone a skill. Append a tombstone row; compactor cleans up later.
|
||||
*/
|
||||
export async function deleteSkill(host: string, projectSlug: string, scope: SkillScope = 'project'): Promise<void> {
|
||||
const normalized = normalizeHost(host);
|
||||
const file = scope === 'project' ? projectFile(projectSlug) : globalFile();
|
||||
const rows = await readRows(file);
|
||||
const latest = resolveLatest(rows);
|
||||
const current = latest.get(`${scope}::${normalized}`);
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
`Cannot delete: no skill for ${normalized} in ${scope} scope.\n` +
|
||||
'Cause: skill does not exist or is already tombstoned.\n' +
|
||||
'Action: $B domain-skill list to see what exists.'
|
||||
);
|
||||
}
|
||||
const tombstone: DomainSkillRow = {
|
||||
...current,
|
||||
version: current.version + 1,
|
||||
updated_ts: new Date().toISOString(),
|
||||
tombstone: true,
|
||||
};
|
||||
await appendRow(file, tombstone);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import type { BrowserManager } from './browser-manager';
|
||||
import { handleSnapshot } from './snapshot';
|
||||
import { getCleanText } from './read-commands';
|
||||
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand } from './commands';
|
||||
import { handleDomainSkillCommand } from './domain-skill-commands';
|
||||
import { handleSkillCommand } from './browser-skill-commands';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import { checkScope, type TokenInfo } from './token-registry';
|
||||
import { validateOutputPath, validateReadPath, SAFE_DIRECTORIES, escapeRegExp } from './path-security';
|
||||
@@ -234,6 +236,8 @@ export interface MetaCommandOpts {
|
||||
chainDepth?: number;
|
||||
/** Callback to route subcommands through the full security pipeline (handleCommandInternal) */
|
||||
executeCommand?: (body: { command: string; args?: string[]; tabId?: number }, tokenInfo?: TokenInfo | null) => Promise<{ status: number; result: string; json?: boolean }>;
|
||||
/** The port the daemon is listening on (needed by `$B skill run` to point spawned scripts at the daemon). */
|
||||
daemonPort?: number;
|
||||
}
|
||||
|
||||
export async function handleMetaCommand(
|
||||
@@ -1121,6 +1125,25 @@ export async function handleMetaCommand(
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
case 'domain-skill': {
|
||||
return await handleDomainSkillCommand(args, bm);
|
||||
}
|
||||
|
||||
case 'skill': {
|
||||
const port = opts?.daemonPort;
|
||||
if (port === undefined) {
|
||||
throw new Error('skill command requires daemonPort in MetaCommandOpts (server bug)');
|
||||
}
|
||||
return await handleSkillCommand(args, { port });
|
||||
}
|
||||
|
||||
case 'cdp': {
|
||||
// Lazy import — cdp-bridge introduces module deps we don't want loaded
|
||||
// for projects that never use the CDP escape hatch.
|
||||
const { handleCdpCommand } = await import('./cdp-commands');
|
||||
return await handleCdpCommand(args, bm);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown meta command: ${command}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Project slug resolution for the browse daemon.
|
||||
*
|
||||
* Used by domain-skills (per-project storage) and sidebar prompt-context
|
||||
* injection. Cached after first call — slug is derived from the daemon's
|
||||
* git remote (or env override) and doesn't change between commands.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
let cachedSlug: string | null = null;
|
||||
|
||||
export function getCurrentProjectSlug(): string {
|
||||
if (cachedSlug) return cachedSlug;
|
||||
const explicit = process.env.GSTACK_PROJECT_SLUG;
|
||||
if (explicit) {
|
||||
cachedSlug = explicit;
|
||||
return explicit;
|
||||
}
|
||||
try {
|
||||
const slugBin = path.join(os.homedir(), '.claude/skills/gstack/bin/gstack-slug');
|
||||
const out = execSync(slugBin, { encoding: 'utf8', timeout: 2000 }).trim();
|
||||
const m = out.match(/SLUG="?([^"\n]+)"?/);
|
||||
cachedSlug = m ? m[1]! : (out || 'unknown');
|
||||
} catch {
|
||||
cachedSlug = 'unknown';
|
||||
}
|
||||
return cachedSlug;
|
||||
}
|
||||
|
||||
/** Reset cache; for tests only. */
|
||||
export function _resetProjectSlugCache(): void {
|
||||
cachedSlug = null;
|
||||
}
|
||||
+20
-4
@@ -64,6 +64,14 @@ const AUTH_TOKEN = crypto.randomUUID();
|
||||
initRegistry(AUTH_TOKEN);
|
||||
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
||||
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
|
||||
|
||||
/**
|
||||
* Port the local listener bound to. Set once the daemon picks a port.
|
||||
* Used by `$B skill run` to point spawned skill scripts at the daemon over
|
||||
* loopback. Module-level so handleCommandInternal can read it without threading
|
||||
* the port through every dispatch.
|
||||
*/
|
||||
let LOCAL_LISTEN_PORT: number = 0;
|
||||
// Sidebar chat is always enabled in headed mode (ungated in v0.12.0)
|
||||
|
||||
// ─── Tunnel State ───────────────────────────────────────────────
|
||||
@@ -626,11 +634,17 @@ async function handleCommandInternal(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tab ownership check (for scoped tokens) ──────────────
|
||||
// Skip for newtab — it creates a new tab, doesn't access an existing one.
|
||||
if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && (WRITE_COMMANDS.has(command) || tokenInfo.tabPolicy === 'own-only')) {
|
||||
// ─── Tab ownership check (own-only tokens / pair-agent isolation) ──
|
||||
//
|
||||
// Only `own-only` tokens (pair-agent over tunnel) are bound to their own
|
||||
// tabs. `shared` tokens — the default for skill spawns and local scoped
|
||||
// clients — can drive any tab; the capability gate (scope checks above)
|
||||
// and rate limits already constrain what they can do.
|
||||
//
|
||||
// Skip for `newtab` — it creates a tab rather than accessing one.
|
||||
if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && tokenInfo.tabPolicy === 'own-only') {
|
||||
const targetTab = tabId ?? browserManager.getActiveTabId();
|
||||
if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, { isWrite: WRITE_COMMANDS.has(command), ownOnly: tokenInfo.tabPolicy === 'own-only' })) {
|
||||
if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, { isWrite: WRITE_COMMANDS.has(command), ownOnly: true })) {
|
||||
return {
|
||||
status: 403, json: true,
|
||||
result: JSON.stringify({
|
||||
@@ -728,6 +742,7 @@ async function handleCommandInternal(
|
||||
const chainDepth = (opts?.chainDepth ?? 0);
|
||||
result = await handleMetaCommand(command, args, browserManager, shutdown, tokenInfo, {
|
||||
chainDepth,
|
||||
daemonPort: LOCAL_LISTEN_PORT,
|
||||
executeCommand: (body, ti) => handleCommandInternal(body, ti, {
|
||||
skipRateCheck: true, // chain counts as 1 request
|
||||
skipActivity: true, // chain emits 1 event for all subcommands
|
||||
@@ -1003,6 +1018,7 @@ async function start() {
|
||||
safeUnlink(DIALOG_LOG_PATH);
|
||||
|
||||
const port = await findPort();
|
||||
LOCAL_LISTEN_PORT = port;
|
||||
|
||||
// Launch browser (headless or headed with extension)
|
||||
// BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Skill-token — scoped tokens minted per `$B skill run` invocation.
|
||||
*
|
||||
* Why this exists:
|
||||
* When `$B skill run <name>` spawns a browser-skill script, the script needs
|
||||
* to call back into the daemon over loopback HTTP. It MUST NOT receive the
|
||||
* daemon root token — a script that gets the root token can call any endpoint
|
||||
* with full authority, defeating the trusted/untrusted distinction.
|
||||
*
|
||||
* This module wraps `token-registry.ts` to mint per-spawn session tokens
|
||||
* bound to read+write scope (the 17-cmd browser-driving surface, minus the
|
||||
* `eval`/`js`/admin commands that live in the admin scope). The token's
|
||||
* clientId encodes the skill name and spawn id, so revocation is
|
||||
* deterministic when the script exits or times out.
|
||||
*
|
||||
* Lifecycle:
|
||||
* spawn start → mintSkillToken() → set GSTACK_SKILL_TOKEN in child env
|
||||
* ↓
|
||||
* script makes HTTP calls /command with Bearer <skill-token>
|
||||
* ↓
|
||||
* spawn exit / timeout → revokeSkillToken() → token invalidated
|
||||
*
|
||||
* Why scopes = ['read', 'write']:
|
||||
* These map to SCOPE_READ + SCOPE_WRITE in token-registry.ts and cover
|
||||
* navigation, reading, and interaction commands the bulk of skills need.
|
||||
* Excludes admin (eval/js/cookies/storage) deliberately — agent-authored
|
||||
* skills should not get arbitrary JS execution. Phase 2 may add an opt-in
|
||||
* `admin: true` frontmatter flag for cases that genuinely need it, gated
|
||||
* by stronger review at skillify time.
|
||||
*
|
||||
* Zero side effects on import. Safe to import from tests.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { createToken, revokeToken, type ScopeCategory, type TokenInfo } from './token-registry';
|
||||
|
||||
/** Length of TTL slack (in seconds) past the spawn timeout. */
|
||||
const TOKEN_TTL_SLACK = 30;
|
||||
|
||||
/** Default scopes for skill tokens. Excludes `admin` (eval/js) and `control`. */
|
||||
const DEFAULT_SKILL_SCOPES: ScopeCategory[] = ['read', 'write'];
|
||||
|
||||
/** Generate a fresh spawn id. Caller passes this to spawn AND revoke. */
|
||||
export function generateSpawnId(): string {
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
/** Build the canonical clientId for a skill spawn. */
|
||||
export function skillClientId(skillName: string, spawnId: string): string {
|
||||
return `skill:${skillName}:${spawnId}`;
|
||||
}
|
||||
|
||||
export interface MintSkillTokenOptions {
|
||||
skillName: string;
|
||||
spawnId: string;
|
||||
/** Spawn timeout in seconds. Token TTL = timeout + 30s slack. */
|
||||
spawnTimeoutSeconds: number;
|
||||
/**
|
||||
* Override the default scopes. Phase 1 callers should not pass this; reserved
|
||||
* for future opt-in flags (e.g. an `admin: true` frontmatter for trusted
|
||||
* human-authored skills that need eval/js).
|
||||
*/
|
||||
scopes?: ScopeCategory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh scoped token for a skill spawn.
|
||||
*
|
||||
* Returns the token info; the caller passes `info.token` to the child via the
|
||||
* GSTACK_SKILL_TOKEN env var. The clientId is deterministic from skillName +
|
||||
* spawnId so the corresponding `revokeSkillToken()` always finds the right
|
||||
* record.
|
||||
*/
|
||||
export function mintSkillToken(opts: MintSkillTokenOptions): TokenInfo {
|
||||
const clientId = skillClientId(opts.skillName, opts.spawnId);
|
||||
return createToken({
|
||||
clientId,
|
||||
scopes: opts.scopes ?? DEFAULT_SKILL_SCOPES,
|
||||
tabPolicy: 'shared', // skill scripts may switch tabs as needed
|
||||
rateLimit: 0, // skill scripts can run as fast as the daemon allows
|
||||
expiresSeconds: opts.spawnTimeoutSeconds + TOKEN_TTL_SLACK,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the token for a finished spawn. Idempotent — revoking an already-revoked
|
||||
* token returns false but is not an error.
|
||||
*/
|
||||
export function revokeSkillToken(skillName: string, spawnId: string): boolean {
|
||||
return revokeToken(skillClientId(skillName, spawnId));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Lightweight telemetry — DX D9 from /plan-devex-review.
|
||||
*
|
||||
* Piggybacks on ~/.gstack/analytics/skill-usage.jsonl pattern (existing
|
||||
* gstack telemetry). Hostname + aggregate counters only; no body content,
|
||||
* no agent text, no command args. Respects the user's telemetry tier
|
||||
* setting (off | anonymous | community) via gstack-config.
|
||||
*
|
||||
* Fire-and-forget: never blocks the calling path. Errors swallowed.
|
||||
*
|
||||
* Events:
|
||||
* domain_skill_saved {host, scope, state, bytes}
|
||||
* domain_skill_state_changed {host, from_state, to_state}
|
||||
* domain_skill_save_blocked {host, reason}
|
||||
* domain_skill_fired {host, source, version}
|
||||
* cdp_method_called {domain, method, allowed, scope}
|
||||
* cdp_method_denied {domain, method} ← drives next allow-list growth
|
||||
* cdp_method_lock_acquire_ms {domain, method, ms}
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
function gstackHome(): string {
|
||||
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
||||
}
|
||||
|
||||
function analyticsDir(): string {
|
||||
return path.join(gstackHome(), 'analytics');
|
||||
}
|
||||
|
||||
function telemetryFile(): string {
|
||||
return path.join(analyticsDir(), 'browse-telemetry.jsonl');
|
||||
}
|
||||
|
||||
let lastEnsuredDir: string | null = null;
|
||||
async function ensureDir(): Promise<void> {
|
||||
const dir = analyticsDir();
|
||||
if (lastEnsuredDir === dir) return;
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
lastEnsuredDir = dir;
|
||||
}
|
||||
|
||||
let telemetryDisabled: boolean | null = null;
|
||||
function isDisabled(): boolean {
|
||||
if (telemetryDisabled !== null) return telemetryDisabled;
|
||||
// Check env (set by preamble or test harnesses).
|
||||
if (process.env.GSTACK_TELEMETRY_OFF === '1') {
|
||||
telemetryDisabled = true;
|
||||
return true;
|
||||
}
|
||||
// Conservative default: telemetry ON unless explicitly off. Users opt out via
|
||||
// gstack-config set telemetry off (preamble reads this; we trust the env hint).
|
||||
telemetryDisabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface TelemetryEvent {
|
||||
event: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Fire-and-forget log. Never throws. */
|
||||
export function logTelemetry(payload: TelemetryEvent): void {
|
||||
if (isDisabled()) return;
|
||||
const enriched = { ...payload, ts: new Date().toISOString() };
|
||||
ensureDir()
|
||||
.then(() => fs.appendFile(telemetryFile(), JSON.stringify(enriched) + '\n', 'utf8'))
|
||||
.catch(() => {
|
||||
// Telemetry must never crash the caller. If the disk is full or perms
|
||||
// are wrong, swallow silently — there's nothing useful to do here.
|
||||
});
|
||||
}
|
||||
|
||||
/** Test-only: reset cached state. */
|
||||
export function _resetTelemetryCache(): void {
|
||||
telemetryDisabled = null;
|
||||
lastEnsuredDir = null;
|
||||
}
|
||||
Reference in New Issue
Block a user