mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-14 08:18:40 +08:00
* feat(browse): TabSession loadedHtml + command aliases + DX polish primitives
Adds the foundation layer for Puppeteer-parity features:
- TabSession.loadedHtml + setTabContent/getLoadedHtml/clearLoadedHtml —
enables load-html content to survive context recreation (viewport --scale)
via in-memory replay. ASCII lifecycle diagram in the source explains the
clear-before-navigation contract.
- COMMAND_ALIASES + canonicalizeCommand() helper — single source of truth
for name aliases (setcontent / set-content / setContent → load-html),
consumed by server dispatch and chain prevalidation.
- buildUnknownCommandError() pure function — rich error messages with
Levenshtein-based "Did you mean" suggestions (distance ≤ 2, input
length ≥ 4 to skip 2-letter noise) and NEW_IN_VERSION upgrade hints.
- load-html registered in WRITE_COMMANDS + SCOPE_WRITE so scoped write
tokens can use it.
- screenshot and viewport descriptions updated for upcoming flags.
- New browse/test/dx-polish.test.ts (15 tests): alias canonicalization,
Levenshtein threshold + alphabetical tiebreak, short-input guard,
NEW_IN_VERSION upgrade hint, alias + scope integration invariants.
No consumers yet — pure additive foundation. Safe to bisect on its own.
* feat(browse): accept file:// in goto with smart cwd/home-relative parsing
Extends validateNavigationUrl to accept file:// URLs scoped to safe dirs
(cwd + TEMP_DIR) via the existing validateReadPath policy. The workhorse is a
new normalizeFileUrl() helper that handles non-standard relative forms BEFORE
the WHATWG URL parser sees them:
file:///abs/path.html → unchanged
file://./docs/page.html → file://<cwd>/docs/page.html
file://~/Documents/page.html → file://<HOME>/Documents/page.html
file://docs/page.html → file://<cwd>/docs/page.html
file://localhost/abs/path → unchanged
file://host.example.com/... → rejected (UNC/network)
file:// and file:/// → rejected (would list a directory)
Host heuristic rejects segments with '.', ':', '\\', '%', IPv6 brackets, or
Windows drive-letter patterns — so file://docs.v1/page.html, file://127.0.0.1/x,
file://[::1]/x, and file://C:/Users/x are explicit errors.
Uses fileURLToPath() + pathToFileURL() from node:url (never string-concat) so
URL escapes like %20 decode correctly and Node rejects encoded-slash traversal
(%2F..%2F) outright.
Signature change: validateNavigationUrl now returns Promise<string> (the
normalized URL) instead of Promise<void>. Existing callers that ignore the
return value still compile — they just don't benefit from smart-parsing until
updated in follow-up commits. Callers will be migrated in the next few commits
(goto, diff, newTab, restoreState).
Rewrites the url-validation test file: updates existing tests for the new
return type, adds 20+ new tests covering every normalizeFileUrl shape variant,
URL-encoding edge cases, and path-traversal rejection.
References: codex consult v3 P1 findings on URL parser semantics and fileURLToPath.
* feat(browse): BrowserManager deviceScaleFactor + setContent replay + file:// plumbing
Three tightly-coupled changes to BrowserManager, all in service of the
Puppeteer-parity workflow:
1. deviceScaleFactor + currentViewport tracking. New private fields (default
scale=1, viewport=1280x720) + setDeviceScaleFactor(scale, w, h) method.
deviceScaleFactor is a context-level Playwright option — changing it
requires recreateContext(). The method validates (finite number, 1-3 cap,
headed-mode rejected), stores new values, calls recreateContext(), and
rolls back the fields on failure so a bad call doesn't leave inconsistent
state. Context options at all three sites (launch, recreate happy path,
recreate fallback) now honor the stored values instead of hardcoding
1280x720.
2. BrowserState.loadedHtml + loadedHtmlWaitUntil. saveState captures per-tab
loadedHtml from the session; restoreState replays it via newSession.
setTabContent() — NOT bare page.setContent() — so TabSession.loadedHtml
is rehydrated and survives *subsequent* scale changes. In-memory only,
never persisted to disk (HTML may contain secrets or customer data).
3. newTab + restoreState now consume validateNavigationUrl's normalized
return value. file://./x, file://~/x, and bare-segment forms now take
effect at every navigation site, not just the top-level goto command.
Together these enable: load-html → viewport --scale 2 → viewport --scale 1.5
→ screenshot, with content surviving both context recreations. Codex v2 P0
flagged that bare page.setContent in restoreState would lose content on the
second scale change — this commit implements the rehydration path.
References: codex v2 P0 (TabSession rehydration), codex v3 P1 (4-caller
return value), plan Feature 3 + Feature 4.
* feat(browse): load-html, screenshot --selector, viewport --scale, alias dispatch
Wires the new handlers and dispatch logic that the previous commits made
possible:
write-commands.ts
- New 'load-html' case: validateReadPath for safe-dir scoping, stat-based
actionable errors (not found, directory, oversize), extension allowlist
(.html/.htm/.xhtml/.svg), magic-byte sniff with UTF-8 BOM strip accepting
any <[a-zA-Z!?] markup opener (not just <!doctype — bare fragments like
<div>...</div> work for setContent), 50MB cap via GSTACK_BROWSE_MAX_HTML_BYTES
override, frame-context rejection. Calls session.setTabContent() so replay
metadata is rehydrated.
- viewport command extended: optional [<WxH>], optional [--scale <n>],
scale-only variant reads current size via page.viewportSize(). Invalid
scale (NaN, Infinity, empty, out of 1-3) throws with named value. Headed
mode rejected explicitly.
- clearLoadedHtml() called BEFORE goto/back/forward/reload navigation
(not after) so a timed-out goto post-commit doesn't leave stale metadata
that could resurrect on a later context recreation. Codex v2 P1 catch.
- goto uses validateNavigationUrl's normalized return value.
meta-commands.ts
- screenshot --selector <css> flag: explicit element-screenshot form.
Rejects alongside positional selector (both = error), preserves --clip
conflict at line 161, composes with --base64 at lines 168-174.
- chain canonicalizes each step with canonicalizeCommand — step shape is
now { rawName, name, args } so prevalidation, dispatch, WRITE_COMMANDS.has,
watch blocking, and result labels all use canonical names while audit
labels show 'rawName→name' when aliased. Codex v3 P2 catch — prior shape
only canonicalized at prevalidation and diverged everywhere else.
- diff command consumes validateNavigationUrl return value for both URLs.
server.ts
- Command canonicalization inserted immediately after parse, before scope /
watch / tab-ownership / content-wrapping checks. rawCommand preserved for
future audit (not wired into audit log in this commit — follow-up).
- Unknown-command handler replaced with buildUnknownCommandError() from
commands.ts — produces 'Unknown command: X. Did you mean Y?' with optional
upgrade hint for NEW_IN_VERSION entries.
security-audit-r2.test.ts
- Updated chain-loop marker from 'for (const cmd of commands)' to
'for (const c of commands)' to match the new chain step shape. Same
isWatching + BLOCKED invariants still asserted.
* chore: bump version and changelog (v1.1.0.0)
- VERSION: 1.0.0.0 → 1.1.0.0 (MINOR bump — new user-facing commands)
- package.json: matching version bump
- CHANGELOG.md: new 1.1.0.0 entry describing load-html, screenshot --selector,
viewport --scale, file:// support, setContent replay, and DX polish in user
voice with a dedicated Security section for file:// safe-dirs policy
- browse/SKILL.md.tmpl: adds pattern #12 "Render local HTML", pattern #13
"Retina screenshots", and a full Puppeteer → browse cheatsheet with side-by-
side API mapping and a worked tweet-renderer migration example
- browse/SKILL.md + SKILL.md: regenerated from templates via `bun run gen:skill-docs`
to reflect the new command descriptions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: pre-landing review fixes (9 findings from specialist + adversarial review)
Adversarial review (Claude subagent + Codex) surfaced 9 bugs across
CRITICAL/HIGH severity. All fixed:
1. tab-session.ts:setTabContent — state mutation moved AFTER the setContent
await. Prior order left phantom HTML in replay metadata if setContent
threw (timeout, browser crash), which a later viewport --scale would
silently replay. Now loadedHtml is only recorded on successful load.
2. browser-manager.ts:setDeviceScaleFactor — rollback now forces a second
recreateContext after restoring the old fields. The fallback path in
the original recreateContext builds a blank context using whatever
this.deviceScaleFactor/currentViewport hold at that moment (which were
the NEW values we were trying to apply). Rolling back the fields without
a second recreate left the live context at new-scale while state tracked
old-scale. Now: restore fields, force re-recreate with old values, only
if that ALSO fails do we return a combined error.
3. commands.ts:buildUnknownCommandError — Levenshtein tiebreak simplified
to 'd <= 2 && d < bestDist' (strict less). Candidates are pre-sorted
alphabetically, so first equal-distance wins by default. The prior
'(d === bestDist && best !== undefined && cand < best)' clause was dead
code.
4. tab-session.ts:onMainFrameNavigated — now clears loadedHtml, not just
refs + frame. Without this, a user who load-html'd then clicked a link
(or had a form submit / JS redirect / OAuth flow) would retain the stale
replay metadata. The next viewport --scale would silently revert the
tab to the ORIGINAL loaded HTML, losing whatever the post-navigation
content was. Silent data corruption. Browser-emitted navigations trigger
this path via wirePageEvents.
5. browser-manager.ts:saveState + restoreState — tab ownership now flows
through BrowserState.owner. Without this, a scoped agent's viewport
--scale would strand them: tab IDs change during recreate, ownership
map held stale IDs, owner lookup failed. New IDs had no owner, so
writes without tabId were denied (DoS). Worse, if the agent sent a
stale tabId the server's swallowed-tab-switch-error path would let the
command hit whatever tab was currently active (cross-tab authz bypass).
Now: clear ownership before restore, re-add per-tab with new IDs.
6. meta-commands.ts:state load — disk-loaded state.pages is now explicit
allowlist (url, isActive, storage:null) instead of object spread.
Spreading accepted loadedHtml, loadedHtmlWaitUntil, and owner from a
user-writable state file, letting a tampered state.json smuggle HTML
past load-html's safe-dirs / extension / magic-byte / 50MB-cap
validators, or forge tab ownership. Now stripped at the boundary.
7. url-validation.ts:normalizeFileUrl — preserves query string + fragment
across normalization. file://./app.html?route=home#login previously
resolved to a filesystem path that URL-encoded '?' as %3F and '#' as
%23, or (for absolute forms) pathToFileURL dropped them entirely. SPAs
and fixture URLs with query params 404'd or loaded the wrong route.
Now: split on ?/# before path resolution, reattach after.
8. url-validation.ts:validateNavigationUrl — reattaches parsed.search +
parsed.hash to the normalized file:// URL. Same fix at the main
validator for absolute paths that go through fileURLToPath round-trip.
9. server.ts:writeAuditEntry — audit entries now include aliasOf when the
user typed an alias ('setcontent' → cmd: 'load-html', aliasOf:
'setcontent'). Previously the isAliased variable was computed but
dropped, losing the raw input from the forensic trail. Completes the
plan's codex v3 P2 requirement.
Also added bm.getCurrentViewport() and switched 'viewport --scale'-
without-size to read from it (more reliable than page.viewportSize() on
headed/transition contexts).
Tests pass: exit 0, no failures. Build clean.
* test: integration coverage for load-html, screenshot --selector, viewport --scale, replay, aliases
Adds 28 Playwright-integration tests that close the coverage gap flagged
by the ship-workflow coverage audit (50% → expected ~80%+).
**load-html (12 tests):**
- happy path loads HTML file, page text matches
- bare HTML fragments (<div>...</div>) accepted, not just full documents
- missing file arg throws usage
- non-.html extension rejected by allowlist
- /etc/passwd.html rejected by safe-dirs policy
- ENOENT path rejected with actionable "not found" error
- directory target rejected
- binary file (PNG magic bytes) disguised as .html rejected by magic-byte check
- UTF-8 BOM stripped before magic-byte check — BOM-prefixed HTML accepted
- --wait-until networkidle exercises non-default branch
- invalid --wait-until value rejected
- unknown flag rejected
**screenshot --selector (5 tests):**
- --selector flag captures element, validates Screenshot saved (element)
- conflicts with positional selector (both = error)
- conflicts with --clip (mutually exclusive)
- composes with --base64 (returns data:image/png;base64,...)
- missing value throws usage
**viewport --scale (5 tests):**
- WxH --scale 2 produces PNG with 2x element dimensions (parses IHDR bytes 16-23)
- --scale without WxH keeps current size + applies scale
- non-finite value (abc) throws "not a finite number"
- out-of-range (4, 0.5) throws "between 1 and 3"
- missing value throws
**setContent replay across context recreation (3 tests):**
- load-html → viewport --scale 2: content survives (hits setTabContent replay path)
- double cycle 2x → 1.5x: content still survives (proves TabSession rehydration)
- goto after load-html clears replay: subsequent viewport --scale does NOT
resurrect the stale HTML (validates the onMainFrameNavigated fix)
**Command aliases (2 tests):**
- setcontent routes to load-html via chain canonicalization
- set-content (hyphenated) also routes — both end-to-end through chain dispatch
Fixture paths use /tmp (SAFE_DIRECTORIES entry) instead of $TMPDIR which is
/var/folders/... on macOS and outside the safe-dirs boundary. Chain result
labels use rawName→name format when an alias is resolved (matches the
meta-commands.ts chain refactor).
Full suite: exit 0, 223/223 pass.
* docs: update BROWSER.md + CHANGELOG for v1.1.0.0
BROWSER.md:
- Command reference table updated: goto now lists file:// support,
load-html added to Navigate row, viewport flagged with --scale
option, screenshot row shows --selector + --base64 flags
- Screenshot modes table adds the fifth mode (element crop via
--selector flag) and notes the tag-selector-not-caught-positionally
gotcha
- New "Retina screenshots — viewport --scale" subsection explains
deviceScaleFactor mechanics, context recreation side effects, and
headed-mode rejection
- New "Loading local HTML — goto file:// vs load-html" subsection
explains the two paths, their tradeoffs (URL state, relative asset
resolution), the safe-dirs policy, extension allowlist + magic-byte
sniff, 50MB cap, setContent replay across recreateContext, and the
alias routing (setcontent → load-html before scope check)
CHANGELOG.md (v1.1.0.0 security section expanded, no existing content
removed):
- State files cannot smuggle HTML or forge tab ownership (allowlist
on disk-loaded page fields)
- Audit log records aliasOf when a canonical command was reached via
an alias (setcontent → load-html)
- load-html content clears on real navigations (clicks, form submits,
JS redirects) — not just explicit goto. Also notes SPA query/fragment
preservation for goto file://
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
489 lines
14 KiB
TypeScript
489 lines
14 KiB
TypeScript
/**
|
|
* Token registry — per-agent scoped tokens for multi-agent browser access.
|
|
*
|
|
* Architecture:
|
|
* Root token (from server startup) → POST /token → scoped sub-tokens
|
|
* POST /connect (setup key exchange) → session token
|
|
*
|
|
* Token lifecycle:
|
|
* createSetupKey() → exchangeSetupKey() → session token (24h default)
|
|
* createToken() → direct session token (for CLI/local use)
|
|
* revokeToken() → immediate invalidation
|
|
* rotateRoot() → new root, all scoped tokens invalidated
|
|
*
|
|
* Scope categories (derived from commands.ts READ/WRITE/META sets):
|
|
* read — snapshot, text, html, links, forms, console, etc.
|
|
* write — goto, click, fill, scroll, newtab, etc.
|
|
* admin — eval, js, cookies, storage, useragent, state (destructive)
|
|
* meta — tab, diff, chain, frame, responsive
|
|
*
|
|
* Security invariants:
|
|
* 1. Only root token can mint sub-tokens (POST /token, POST /connect)
|
|
* 2. admin scope denied by default — must be explicitly granted
|
|
* 3. chain command scope-checks each subcommand individually
|
|
* 4. Root token never in connection strings or pasted instructions
|
|
*
|
|
* Zero side effects on import. Safe to import from tests.
|
|
*/
|
|
|
|
import * as crypto from 'crypto';
|
|
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
|
|
|
// ─── Scope Definitions ─────────────────────────────────────────
|
|
// Derived from commands.ts, but reclassified by actual side effects.
|
|
// The key insight (from Codex adversarial review): commands.ts READ_COMMANDS
|
|
// includes js/eval/cookies/storage which are actually dangerous. The scope
|
|
// model here overrides the commands.ts classification.
|
|
|
|
/** Commands safe for read-only agents */
|
|
export const SCOPE_READ = new Set([
|
|
'snapshot', 'text', 'html', 'links', 'forms', 'accessibility',
|
|
'console', 'network', 'perf', 'dialog', 'is', 'inspect',
|
|
'url', 'tabs', 'status', 'screenshot', 'pdf', 'css', 'attrs',
|
|
'media', 'data',
|
|
]);
|
|
|
|
/** Commands that modify page state or navigate */
|
|
export const SCOPE_WRITE = new Set([
|
|
'goto', 'back', 'forward', 'reload',
|
|
'load-html',
|
|
'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
|
|
'upload', 'viewport', 'newtab', 'closetab',
|
|
'dialog-accept', 'dialog-dismiss',
|
|
'download', 'scrape', 'archive',
|
|
]);
|
|
|
|
/** Page-level power tools — JS execution, credential access, page mutations */
|
|
export const SCOPE_ADMIN = new Set([
|
|
'eval', 'js', 'cookies', 'storage',
|
|
'cookie', 'cookie-import', 'cookie-import-browser',
|
|
'header', 'useragent',
|
|
'style', 'cleanup', 'prettyscreenshot',
|
|
]);
|
|
|
|
/** Browser-wide destructive commands — can kill the server, disconnect headed mode */
|
|
export const SCOPE_CONTROL = new Set([
|
|
'state', 'handoff', 'resume', 'stop', 'restart', 'connect', 'disconnect',
|
|
]);
|
|
|
|
/** Meta commands — generally safe but some need scope checking */
|
|
export const SCOPE_META = new Set([
|
|
'tab', 'diff', 'frame', 'responsive', 'snapshot',
|
|
'watch', 'inbox', 'focus',
|
|
]);
|
|
|
|
export type ScopeCategory = 'read' | 'write' | 'admin' | 'meta' | 'control';
|
|
|
|
const SCOPE_MAP: Record<ScopeCategory, Set<string>> = {
|
|
read: SCOPE_READ,
|
|
write: SCOPE_WRITE,
|
|
admin: SCOPE_ADMIN,
|
|
control: SCOPE_CONTROL,
|
|
meta: SCOPE_META,
|
|
};
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────
|
|
|
|
export interface TokenInfo {
|
|
token: string;
|
|
clientId: string;
|
|
type: 'session' | 'setup';
|
|
scopes: ScopeCategory[];
|
|
domains?: string[]; // glob patterns, e.g. ['*.myapp.com']
|
|
tabPolicy: 'own-only' | 'shared';
|
|
rateLimit: number; // requests per second (0 = unlimited)
|
|
expiresAt: string | null; // ISO8601, null = never
|
|
createdAt: string;
|
|
usesRemaining?: number; // for setup keys only
|
|
issuedSessionToken?: string; // for setup keys: the session token that was issued
|
|
commandCount: number; // how many commands have been executed
|
|
}
|
|
|
|
export interface CreateTokenOptions {
|
|
clientId: string;
|
|
scopes?: ScopeCategory[];
|
|
domains?: string[];
|
|
tabPolicy?: 'own-only' | 'shared';
|
|
rateLimit?: number;
|
|
expiresSeconds?: number | null; // null = never, default = 86400 (24h)
|
|
}
|
|
|
|
export interface TokenRegistryState {
|
|
agents: Record<string, Omit<TokenInfo, 'commandCount'>>;
|
|
}
|
|
|
|
// ─── Rate Limiter ───────────────────────────────────────────────
|
|
|
|
interface RateBucket {
|
|
count: number;
|
|
windowStart: number;
|
|
}
|
|
|
|
const rateBuckets = new Map<string, RateBucket>();
|
|
|
|
function checkRateLimit(clientId: string, limit: number): { allowed: boolean; retryAfterMs?: number } {
|
|
if (limit <= 0) return { allowed: true };
|
|
|
|
const now = Date.now();
|
|
const bucket = rateBuckets.get(clientId);
|
|
|
|
if (!bucket || now - bucket.windowStart >= 1000) {
|
|
rateBuckets.set(clientId, { count: 1, windowStart: now });
|
|
return { allowed: true };
|
|
}
|
|
|
|
if (bucket.count >= limit) {
|
|
const retryAfterMs = 1000 - (now - bucket.windowStart);
|
|
return { allowed: false, retryAfterMs: Math.max(retryAfterMs, 100) };
|
|
}
|
|
|
|
bucket.count++;
|
|
return { allowed: true };
|
|
}
|
|
|
|
// ─── Token Registry ─────────────────────────────────────────────
|
|
|
|
const tokens = new Map<string, TokenInfo>();
|
|
let rootToken: string = '';
|
|
|
|
export function initRegistry(root: string): void {
|
|
rootToken = root;
|
|
}
|
|
|
|
export function getRootToken(): string {
|
|
return rootToken;
|
|
}
|
|
|
|
export function isRootToken(token: string): boolean {
|
|
return token === rootToken;
|
|
}
|
|
|
|
function generateToken(prefix: string): string {
|
|
return `${prefix}${crypto.randomBytes(24).toString('hex')}`;
|
|
}
|
|
|
|
/**
|
|
* Create a scoped session token (for direct minting via CLI or /token endpoint).
|
|
* Only callable by root token holder.
|
|
*/
|
|
export function createToken(opts: CreateTokenOptions): TokenInfo {
|
|
const {
|
|
clientId,
|
|
scopes = ['read', 'write'],
|
|
domains,
|
|
tabPolicy = 'own-only',
|
|
rateLimit = 10,
|
|
expiresSeconds = 86400, // 24h default
|
|
} = opts;
|
|
|
|
// Validate inputs
|
|
const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta', 'control'];
|
|
for (const s of scopes) {
|
|
if (!validScopes.includes(s as ScopeCategory)) {
|
|
throw new Error(`Invalid scope: ${s}. Valid: ${validScopes.join(', ')}`);
|
|
}
|
|
}
|
|
if (rateLimit < 0) throw new Error('rateLimit must be >= 0');
|
|
if (expiresSeconds !== null && expiresSeconds !== undefined && expiresSeconds < 0) {
|
|
throw new Error('expiresSeconds must be >= 0 or null');
|
|
}
|
|
|
|
const token = generateToken('gsk_sess_');
|
|
const now = new Date();
|
|
const expiresAt = expiresSeconds === null
|
|
? null
|
|
: new Date(now.getTime() + expiresSeconds * 1000).toISOString();
|
|
|
|
const info: TokenInfo = {
|
|
token,
|
|
clientId,
|
|
type: 'session',
|
|
scopes,
|
|
domains,
|
|
tabPolicy,
|
|
rateLimit,
|
|
expiresAt,
|
|
createdAt: now.toISOString(),
|
|
commandCount: 0,
|
|
};
|
|
|
|
// Overwrite if clientId already exists (re-pairing)
|
|
// First revoke the old session token (but NOT setup keys — they track their issued session)
|
|
for (const [t, existing] of tokens) {
|
|
if (existing.clientId === clientId && existing.type === 'session') {
|
|
tokens.delete(t);
|
|
break;
|
|
}
|
|
}
|
|
|
|
tokens.set(token, info);
|
|
return info;
|
|
}
|
|
|
|
/**
|
|
* Create a one-time setup key for the /pair-agent ceremony.
|
|
* Setup keys expire in 5 minutes and can only be exchanged once.
|
|
*/
|
|
export function createSetupKey(opts: Omit<CreateTokenOptions, 'clientId'> & { clientId?: string }): TokenInfo {
|
|
const token = generateToken('gsk_setup_');
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 min
|
|
|
|
const info: TokenInfo = {
|
|
token,
|
|
clientId: opts.clientId || `remote-${Date.now()}`,
|
|
type: 'setup',
|
|
scopes: opts.scopes || ['read', 'write'],
|
|
domains: opts.domains,
|
|
tabPolicy: opts.tabPolicy || 'own-only',
|
|
rateLimit: opts.rateLimit || 10,
|
|
expiresAt,
|
|
createdAt: now.toISOString(),
|
|
usesRemaining: 1,
|
|
commandCount: 0,
|
|
};
|
|
|
|
tokens.set(token, info);
|
|
return info;
|
|
}
|
|
|
|
/**
|
|
* Exchange a setup key for a session token.
|
|
* Idempotent: if the same key is presented again and the prior session
|
|
* has 0 commands, returns the same session token (handles tunnel drops).
|
|
*/
|
|
export function exchangeSetupKey(setupKey: string, sessionExpiresSeconds?: number | null): TokenInfo | null {
|
|
const setup = tokens.get(setupKey);
|
|
if (!setup) return null;
|
|
if (setup.type !== 'setup') return null;
|
|
|
|
// Check expiry
|
|
if (setup.expiresAt && new Date(setup.expiresAt) < new Date()) {
|
|
tokens.delete(setupKey);
|
|
return null;
|
|
}
|
|
|
|
// Idempotent: if already exchanged but session has 0 commands, return existing
|
|
if (setup.usesRemaining === 0) {
|
|
if (setup.issuedSessionToken) {
|
|
const existing = tokens.get(setup.issuedSessionToken);
|
|
if (existing && existing.commandCount === 0) {
|
|
return existing;
|
|
}
|
|
}
|
|
return null; // Session used or gone — can't re-issue
|
|
}
|
|
|
|
// Consume the setup key
|
|
setup.usesRemaining = 0;
|
|
|
|
// Create the session token
|
|
const session = createToken({
|
|
clientId: setup.clientId,
|
|
scopes: setup.scopes,
|
|
domains: setup.domains,
|
|
tabPolicy: setup.tabPolicy,
|
|
rateLimit: setup.rateLimit,
|
|
expiresSeconds: sessionExpiresSeconds ?? 86400,
|
|
});
|
|
|
|
// Track which session token was issued from this setup key
|
|
setup.issuedSessionToken = session.token;
|
|
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* Validate a token and return its info if valid.
|
|
* Returns null for expired, revoked, or unknown tokens.
|
|
* Root token returns a special root info object.
|
|
*/
|
|
export function validateToken(token: string): TokenInfo | null {
|
|
if (isRootToken(token)) {
|
|
return {
|
|
token: rootToken,
|
|
clientId: 'root',
|
|
type: 'session',
|
|
scopes: ['read', 'write', 'admin', 'meta', 'control'],
|
|
tabPolicy: 'shared',
|
|
rateLimit: 0, // unlimited
|
|
expiresAt: null,
|
|
createdAt: '',
|
|
commandCount: 0,
|
|
};
|
|
}
|
|
|
|
const info = tokens.get(token);
|
|
if (!info) return null;
|
|
|
|
// Check expiry
|
|
if (info.expiresAt && new Date(info.expiresAt) < new Date()) {
|
|
tokens.delete(token);
|
|
return null;
|
|
}
|
|
|
|
return info;
|
|
}
|
|
|
|
/**
|
|
* Check if a command is allowed by the token's scopes.
|
|
* The `chain` command is special: it's allowed if the token has meta scope,
|
|
* but each subcommand within chain must be individually scope-checked.
|
|
*/
|
|
export function checkScope(info: TokenInfo, command: string): boolean {
|
|
if (info.clientId === 'root') return true;
|
|
|
|
// Special case: chain is in SCOPE_META but requires that the caller
|
|
// has scopes covering ALL subcommands. The actual subcommand check
|
|
// happens at dispatch time, not here.
|
|
if (command === 'chain' && info.scopes.includes('meta')) return true;
|
|
|
|
for (const scope of info.scopes) {
|
|
if (SCOPE_MAP[scope]?.has(command)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if a URL is allowed by the token's domain restrictions.
|
|
* Returns true if no domain restrictions, or if the URL matches any glob.
|
|
*/
|
|
export function checkDomain(info: TokenInfo, url: string): boolean {
|
|
if (info.clientId === 'root') return true;
|
|
if (!info.domains || info.domains.length === 0) return true;
|
|
|
|
try {
|
|
const parsed = new URL(url);
|
|
const hostname = parsed.hostname;
|
|
|
|
for (const pattern of info.domains) {
|
|
if (matchDomainGlob(hostname, pattern)) return true;
|
|
}
|
|
|
|
return false;
|
|
} catch {
|
|
return false; // Invalid URL — deny
|
|
}
|
|
}
|
|
|
|
function matchDomainGlob(hostname: string, pattern: string): boolean {
|
|
// Simple glob: *.example.com matches sub.example.com
|
|
// Exact: example.com matches example.com only
|
|
if (pattern.startsWith('*.')) {
|
|
const suffix = pattern.slice(1); // .example.com
|
|
return hostname.endsWith(suffix) || hostname === pattern.slice(2);
|
|
}
|
|
return hostname === pattern;
|
|
}
|
|
|
|
/**
|
|
* Check rate limit for a client. Returns { allowed, retryAfterMs? }.
|
|
*/
|
|
export function checkRate(info: TokenInfo): { allowed: boolean; retryAfterMs?: number } {
|
|
if (info.clientId === 'root') return { allowed: true };
|
|
return checkRateLimit(info.clientId, info.rateLimit);
|
|
}
|
|
|
|
/**
|
|
* Record that a command was executed by this token.
|
|
*/
|
|
export function recordCommand(token: string): void {
|
|
const info = tokens.get(token);
|
|
if (info) info.commandCount++;
|
|
}
|
|
|
|
/**
|
|
* Revoke a token by client ID. Returns true if found and revoked.
|
|
*/
|
|
export function revokeToken(clientId: string): boolean {
|
|
for (const [token, info] of tokens) {
|
|
if (info.clientId === clientId) {
|
|
tokens.delete(token);
|
|
rateBuckets.delete(clientId);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Rotate the root token. All scoped tokens are invalidated.
|
|
* Returns the new root token.
|
|
*/
|
|
export function rotateRoot(): string {
|
|
rootToken = crypto.randomUUID();
|
|
tokens.clear();
|
|
rateBuckets.clear();
|
|
return rootToken;
|
|
}
|
|
|
|
/**
|
|
* List all active (non-expired) scoped tokens.
|
|
*/
|
|
export function listTokens(): TokenInfo[] {
|
|
const now = new Date();
|
|
const result: TokenInfo[] = [];
|
|
|
|
for (const [token, info] of tokens) {
|
|
if (info.expiresAt && new Date(info.expiresAt) < now) {
|
|
tokens.delete(token);
|
|
continue;
|
|
}
|
|
if (info.type === 'session') {
|
|
result.push(info);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Serialize the token registry for state file persistence.
|
|
*/
|
|
export function serializeRegistry(): TokenRegistryState {
|
|
const agents: TokenRegistryState['agents'] = {};
|
|
|
|
for (const info of tokens.values()) {
|
|
if (info.type === 'session') {
|
|
const { commandCount, ...rest } = info;
|
|
agents[info.clientId] = rest;
|
|
}
|
|
}
|
|
|
|
return { agents };
|
|
}
|
|
|
|
/**
|
|
* Restore the token registry from persisted state file data.
|
|
*/
|
|
export function restoreRegistry(state: TokenRegistryState): void {
|
|
tokens.clear();
|
|
const now = new Date();
|
|
|
|
for (const [clientId, data] of Object.entries(state.agents)) {
|
|
// Skip expired tokens
|
|
if (data.expiresAt && new Date(data.expiresAt) < now) continue;
|
|
|
|
tokens.set(data.token, {
|
|
...data,
|
|
clientId,
|
|
commandCount: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Connect endpoint rate limiter (brute-force protection) ─────
|
|
|
|
let connectAttempts: { ts: number }[] = [];
|
|
const CONNECT_RATE_LIMIT = 3; // attempts per minute
|
|
const CONNECT_WINDOW_MS = 60000;
|
|
|
|
export function checkConnectRateLimit(): boolean {
|
|
const now = Date.now();
|
|
connectAttempts = connectAttempts.filter(a => now - a.ts < CONNECT_WINDOW_MS);
|
|
if (connectAttempts.length >= CONNECT_RATE_LIMIT) return false;
|
|
connectAttempts.push({ ts: now });
|
|
return true;
|
|
}
|