mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-17 01:31:26 +08:00
feat: content security — 4-layer prompt injection defense for pair-agent (#815)
* feat: token registry for multi-agent browser access Per-agent scoped tokens with read/write/admin/meta command categories, domain glob restrictions, rate limiting, expiry, and revocation. Setup key exchange for the /pair-agent ceremony (5-min one-time key → 24h session token). Idempotent exchange handles tunnel drops. 39 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: integrate token registry + scoped auth into browse server Server changes for multi-agent browser access: - /connect endpoint: setup key exchange for /pair-agent ceremony - /token endpoint: root-only minting of scoped sub-tokens - /token/:clientId DELETE: revoke agent tokens - /agents endpoint: list connected agents (root-only) - /health: strips root token when tunnel is active (P0 security fix) - /command: scope/rate/domain checks via token registry before dispatch - Idle timer skips shutdown when tunnel is active Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: ngrok tunnel integration + @ngrok/ngrok dependency BROWSE_TUNNEL=1 env var starts an ngrok tunnel after Bun.serve(). Reads NGROK_AUTHTOKEN from env or ~/.gstack/ngrok.env. Reads NGROK_DOMAIN for dedicated domain (stable URL). Updates state file with tunnel URL. Feasibility spike confirmed: SDK works in compiled Bun binary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: tab isolation for multi-agent browser access Add per-tab ownership tracking to BrowserManager. Scoped agents must create their own tab via newtab before writing. Unowned tabs (pre-existing, user-opened) are root-only for writes. Read access always allowed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: tab enforcement + POST /pair endpoint + activity attribution Server-side tab ownership check blocks scoped agents from writing to unowned tabs. Special-case newtab records ownership for scoped tokens. POST /pair endpoint creates setup keys for the pairing ceremony. Activity events now include clientId for attribution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: pair-agent CLI command + instruction block generator One command to pair a remote agent: $B pair-agent. Creates a setup key via POST /pair, prints a copy-pasteable instruction block with curl commands. Smart tunnel fallback (tunnel URL > auto-start > localhost). Flags: --for HOST, --local HOST, --admin, --client NAME. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: tab isolation + instruction block generator tests 14 tests covering tab ownership lifecycle (access checks, unowned tabs, transferTab) and instruction block generator (scopes, URLs, admin flag, troubleshooting section). Fix server-auth test that used fragile sliceBetween boundaries broken by new endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.15.9.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: CSO security fixes — token leak, domain bypass, input validation 1. Remove root token from /health endpoint entirely (CSO #1 CRITICAL). Origin header is spoofable. Extension reads from ~/.gstack/.auth.json. 2. Add domain check for newtab URL (CSO #5). Previously only goto was checked, allowing domain-restricted agents to bypass via newtab. 3. Validate scope values, rateLimit, expiresSeconds in createToken() (CSO #4). Rejects invalid scopes and negative values. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /pair-agent skill — syntactic sugar for browser sharing Users remember /pair-agent, not $B pair-agent. The skill walks through agent selection (OpenClaw, Hermes, Codex, Cursor, generic), local vs remote setup, tunnel configuration, and includes platform-specific notes for each agent type. Wraps the CLI command with context. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: remote browser access reference for paired agents Full API reference, snapshot→@ref pattern, scopes, tab isolation, error codes, ngrok setup, and same-machine shortcuts. The instruction block points here for deeper reading. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: improved instruction block with snapshot→@ref pattern The paste-into-agent instruction block now teaches the snapshot→@ref workflow (the most powerful browsing pattern), shows the server URL prominently, and uses clearer formatting. Tests updated to match. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: smart ngrok detection + auto-tunnel in pair-agent The pair-agent command now checks ngrok's native config (not just ~/.gstack/ngrok.env) and auto-starts the tunnel when ngrok is available. The skill template walks users through ngrok install and auth if not set up, instead of just printing a dead localhost URL. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: on-demand tunnel start via POST /tunnel/start pair-agent now auto-starts the ngrok tunnel without restarting the server. New POST /tunnel/start endpoint reads authtoken from env, ~/.gstack/ngrok.env, or ngrok's native config. CLI detects ngrok availability and calls the endpoint automatically. Zero manual steps when ngrok is installed and authed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: pair-agent skill must output the instruction block verbatim Added CRITICAL instruction: the agent MUST output the full instruction block so the user can copy it. Previously the agent could summarize over it, leaving the user with nothing to paste. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: scoped tokens rejected on /command — auth gate ordering bug The blanket validateAuth() gate (root-only) sat above the /command endpoint, rejecting all scoped tokens with 401 before they reached getTokenInfo(). Moved /command above the gate so both root and scoped tokens are accepted. This was the bug Wintermute hit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: pair-agent auto-launches headed mode before pairing When pair-agent detects headless mode, it auto-switches to headed (visible Chromium window) so the user can watch what the remote agent does. Use --headless to skip this. Fixed compiled binary path resolution (process.execPath, not process.argv[1] which is virtual /$bunfs/ in Bun compiled binaries). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: comprehensive tests for auth ordering, tunnel, ngrok, headed mode 16 new tests covering: - /command sits above blanket auth gate (Wintermute bug) - /command uses getTokenInfo not validateAuth - /tunnel/start requires root, checks native ngrok config, returns already_active - /pair creates setup keys not session tokens - Tab ownership checked before command dispatch - Activity events include clientId - Instruction block teaches snapshot→@ref pattern - pair-agent auto-headed mode, process.execPath, --headless skip - isNgrokAvailable checks all 3 sources (gstack env, env var, native config) - handlePairAgent calls /tunnel/start not server restart Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: chain scope bypass + /health info leak when tunneled 1. Chain command now pre-validates ALL subcommand scopes before executing any. A read+meta token can no longer escalate to admin via chain (eval, js, cookies were dispatched without scope checks). tokenInfo flows through handleMetaCommand into the chain handler. Rejects entire chain if any subcommand fails. 2. /health strips sensitive fields (currentUrl, agent.currentMessage, session) when tunnel is active. Only operational metadata (status, mode, uptime, tabs) exposed to the internet. Previously anyone reaching the ngrok URL could surveil browsing activity. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: tout /pair-agent as headline feature in CHANGELOG + README Lead with what it does for the user: type /pair-agent, paste into your other agent, done. First time AI agents from different companies can coordinate through a shared browser with real security boundaries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: expand /pair-agent, /design-shotgun, /design-html in README Each skill gets a real narrative paragraph explaining the workflow, not just a table cell. design-shotgun: visual exploration with taste memory. design-html: production HTML with Pretext computed layout. pair-agent: cross-vendor AI agent coordination through shared browser. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: split handleCommand into handleCommandInternal + HTTP wrapper Chain subcommands now route through handleCommandInternal for full security enforcement (scope, domain, tab ownership, rate limiting, content wrapping). Adds recursion guard for nested chains, rate-limit exemption for chain subcommands, and activity event suppression (1 event per chain, not per sub). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add content-security.ts with datamarking, envelope, and filter hooks Four-layer prompt injection defense for pair-agent browser sharing: - Datamarking: session-scoped watermark for text exfiltration detection - Content envelope: trust boundary wrapping with ZWSP marker escaping - Content filter hooks: extensible filter pipeline with warn/block modes - Built-in URL blocklist: requestbin, pipedream, webhook.site, etc. BROWSE_CONTENT_FILTER env var controls mode: off|warn|block (default: warn) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: centralize content wrapping in handleCommandInternal response path Single wrapping location replaces fragmented per-handler wrapping: - Scoped tokens: content filters + datamarking + enhanced envelope - Root tokens: existing basic wrapping (backward compat) - Chain subcommands exempt from top-level wrapping (wrapped individually) - Adds 'attrs' to PAGE_CONTENT_COMMANDS (ARIA value exposure defense) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: hidden element stripping for scoped token text extraction Detects CSS-hidden elements (opacity, font-size, off-screen, same-color, clip-path) and ARIA label injection patterns. Marks elements with data-gstack-hidden, extracts text from a clean clone (no DOM mutation), then removes markers. Only active for scoped tokens on text command. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: snapshot split output format for scoped tokens Scoped tokens get a split snapshot: trusted @refs section (for click/fill) separated from untrusted web content in an envelope. Ref names truncated to 50 chars in trusted section. Root tokens unchanged (backward compat). Resume command also uses split format for scoped tokens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add SECURITY section to pair-agent instruction block Instructs remote agents to treat content inside untrusted envelopes as potentially malicious. Lists common injection phrases to watch for. Directs agents to only use @refs from the trusted INTERACTIVE ELEMENTS section, not from page content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add 4 prompt injection test fixtures - injection-visible.html: visible injection in product review text - injection-hidden.html: 7 CSS hiding techniques + ARIA injection + false positive - injection-social.html: social engineering in legitimate-looking content - injection-combined.html: all attack types + envelope escape attempt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: comprehensive content security tests (47 tests) Covers all 4 defense layers: - Datamarking: marker format, session consistency, text-only application - Content envelope: wrapping, ZWSP marker escaping, filter warnings - Content filter hooks: URL blocklist, custom filters, warn/block modes - Instruction block: SECURITY section content, ordering, generation - Centralized wrapping: source-level verification of integration - Chain security: recursion guard, rate-limit exemption, activity suppression - Hidden element stripping: 7 CSS techniques, ARIA injection, false positives - Snapshot split format: scoped vs root output, resume integration Also fixes: visibility:hidden detection, case-insensitive ARIA pattern matching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: pair-agent skill compliance + fix all 16 pre-existing test failures Root cause: pair-agent was added without completing the gen-skill-docs compliance checklist. All 16 failures traced back to this. Fixes: - Sync package.json version to VERSION (0.15.9.0) - Add "(gstack)" to pair-agent description for discoverability - Add pair-agent to Codex path exception (legitimately documents ~/.codex/) - Add CLI_COMMANDS (status, pair-agent, tunnel) to skill parser allowlist - Regenerate SKILL.md for all hosts (claude, codex, factory, kiro, etc.) - Update golden file baselines for ship skill - Fix relink tests: pass GSTACK_INSTALL_DIR to auto-relink calls so they use the fast mock install instead of scanning real ~/.claude/skills/gstack Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.15.12.0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: E2E exit reason precedence + worktree prune race condition Two fixes for E2E test reliability: 1. session-runner.ts: error_max_turns was misclassified as error_api because is_error flag was checked before subtype. Now known subtypes like error_max_turns are preserved even when is_error is set. The is_error override only applies when subtype=success (API failure). 2. worktree.ts: pruneStale() now skips worktrees < 1 hour old to avoid deleting worktrees from concurrent test runs still in progress. Previously any second test execution would kill the first's worktrees. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: restore token in /health for localhost extension auth The CSO security fix stripped the token from /health to prevent leaking when tunneled. But the extension needs it to authenticate on localhost. Now returns token only when not tunneled (safe: localhost-only path). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: verify /health token is localhost-only, never served through tunnel Updated tests to match the restored token behavior: - Test 1: token assignment exists AND is inside the !tunnelActive guard - Test 1b: tunnel branch (else block) does not contain AUTH_TOKEN Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add security rationale for token in /health on localhost Explains why this is an accepted risk (no escalation over file-based token access), CORS protection, and tunnel guard. Prevents future CSO scans from stripping it without providing an alternative auth path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: verify tunnel is alive before returning URL to pair-agent Root cause: when ngrok dies externally (pkill, crash, timeout), the server still reports tunnelActive=true with a dead URL. pair-agent prints an instruction block pointing at a dead tunnel. The remote agent gets "endpoint offline" and the user has to manually restart everything. Three-layer fix: - Server /pair endpoint: probes tunnel URL before returning it. If dead, resets tunnelActive/tunnelUrl and returns null (triggers CLI restart). - Server /tunnel/start: probes cached tunnel before returning already_active. If dead, falls through to restart ngrok automatically. - CLI pair-agent: double-checks tunnel URL from server before printing instruction block. Falls through to auto-start on failure. 4 regression tests verify all three probe points + CLI verification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add POST /batch endpoint for multi-command batching Remote agents controlling GStack Browser through a tunnel pay 2-5s of latency per HTTP round-trip. A typical "navigate and read" takes 4 sequential commands = 10-20 seconds. The /batch endpoint collapses N commands into a single HTTP round-trip, cutting a 20-tab crawl from ~60s to ~5s. Sequential execution through the full security pipeline (scope, domain, tab ownership, content wrapping). Rate limiting counts the batch as 1 request. Activity events emitted at batch level, not per-command. Max 50 commands per batch. Nested batches rejected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add source-level security tests for /batch endpoint 8 tests verifying: auth gate placement, scoped token support, max command limit, nested batch rejection, rate limiting bypass, batch-level activity events, command field validation, and tabId passthrough. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: correct CHANGELOG date from 2026-04-06 to 2026-04-05 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: consolidate Hermes into generic HTTP option in pair-agent Hermes doesn't have a host-specific config — it uses the same generic curl instructions as any other agent. Removing the dedicated option simplifies the menu and eliminates a misleading distinction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump VERSION to 0.15.14.0, add CHANGELOG entry for batch endpoint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate pair-agent/SKILL.md after main merge Vendoring deprecation section from main's template wasn't reflected in the generated file. Fixes check-freshness CI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: checkTabAccess uses options object, add own-only tab policy Refactors checkTabAccess(tabId, clientId, isWrite) to use an options object { isWrite?, ownOnly? }. Adds tabPolicy === 'own-only' support in the server command dispatch — scoped tokens with this policy are restricted to their own tabs for all commands, not just writes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add --domain flag to pair-agent CLI for domain restrictions Allows passing --domain to pair-agent to restrict the remote agent's navigation to specific domains (comma-separated). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * revert: remove batch commands CHANGELOG entry and VERSION bump The batch endpoint work belongs on the browser-batch-multitab branch (port-louis), not this branch. Reverting VERSION to 0.15.14.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: adopt main's headed-mode /health token serving Our merge kept the old !tunnelActive guard which conflicted with main's security-audit-r2 tests that require no currentUrl/currentMessage in /health. Adopts main's approach: serve token conditionally based on headed mode or chrome-extension origin. Updates server-auth tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: improve snapshot flags docs completeness for LLM judge Adds $B placeholder explanation, explicit syntax line, and detailed flag behavior (-d depth values, -s CSS selector syntax, -D unified diff format and baseline persistence, -a screenshot vs text output relationship). Fixes snapshot flags reference LLM eval scoring completeness < 4. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,18 @@ import { handleMetaCommand } from './meta-commands';
|
||||
import { handleCookiePickerRoute } from './cookie-picker-routes';
|
||||
import { sanitizeExtensionUrl } from './sidebar-utils';
|
||||
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands';
|
||||
import {
|
||||
wrapUntrustedPageContent, datamarkContent,
|
||||
runContentFilters, type ContentFilterResult,
|
||||
markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers,
|
||||
} from './content-security';
|
||||
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
||||
import {
|
||||
initRegistry, validateToken as validateScopedToken, checkScope, checkDomain,
|
||||
checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken,
|
||||
rotateRoot, listTokens, serializeRegistry, restoreRegistry, recordCommand,
|
||||
isRootToken, checkConnectRateLimit, type TokenInfo,
|
||||
} from './token-registry';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||
import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector';
|
||||
@@ -37,15 +48,41 @@ ensureStateDir(config);
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────
|
||||
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
|
||||
// Sidebar chat is always enabled in headed mode (ungated in v0.12.0)
|
||||
|
||||
// ─── Tunnel State ───────────────────────────────────────────────
|
||||
let tunnelActive = false;
|
||||
let tunnelUrl: string | null = null;
|
||||
let tunnelListener: any = null; // ngrok listener handle
|
||||
|
||||
function validateAuth(req: Request): boolean {
|
||||
const header = req.headers.get('authorization');
|
||||
return header === `Bearer ${AUTH_TOKEN}`;
|
||||
}
|
||||
|
||||
/** Extract bearer token from request. Returns the token string or null. */
|
||||
function extractToken(req: Request): string | null {
|
||||
const header = req.headers.get('authorization');
|
||||
if (!header?.startsWith('Bearer ')) return null;
|
||||
return header.slice(7);
|
||||
}
|
||||
|
||||
/** Validate token and return TokenInfo. Returns null if invalid/expired. */
|
||||
function getTokenInfo(req: Request): TokenInfo | null {
|
||||
const token = extractToken(req);
|
||||
if (!token) return null;
|
||||
return validateScopedToken(token);
|
||||
}
|
||||
|
||||
/** Check if request is from root token (local use). */
|
||||
function isRootRequest(req: Request): boolean {
|
||||
const token = extractToken(req);
|
||||
return token !== null && isRootToken(token);
|
||||
}
|
||||
|
||||
// ─── Sidebar Model Router ────────────────────────────────────────
|
||||
// Fast model for navigation/interaction, smart model for reading/analysis.
|
||||
// The delta between sonnet and opus on "click @e24" is 5-10x in latency
|
||||
@@ -691,6 +728,8 @@ const idleCheckInterval = setInterval(() => {
|
||||
// Headed mode: the user is looking at the browser. Never auto-die.
|
||||
// Only shut down when the user explicitly disconnects or closes the window.
|
||||
if (browserManager.getConnectionMode() === 'headed') return;
|
||||
// Tunnel mode: remote agents may send commands sporadically. Never auto-die.
|
||||
if (tunnelActive) return;
|
||||
if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
|
||||
console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`);
|
||||
shutdown();
|
||||
@@ -800,14 +839,81 @@ function wrapError(err: any): string {
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function handleCommand(body: any): Promise<Response> {
|
||||
/** Internal command result — used by handleCommand and chain subcommand routing */
|
||||
interface CommandResult {
|
||||
status: number;
|
||||
result: string;
|
||||
headers?: Record<string, string>;
|
||||
json?: boolean; // true if result is JSON (errors), false for text/plain
|
||||
}
|
||||
|
||||
/**
|
||||
* Core command execution logic. Returns a structured result instead of HTTP Response.
|
||||
* Used by both the HTTP handler (handleCommand) and chain subcommand routing.
|
||||
*
|
||||
* Options:
|
||||
* skipRateCheck: true when called from chain (chain counts as 1 request)
|
||||
* skipActivity: true when called from chain (chain emits 1 event for all subcommands)
|
||||
* chainDepth: recursion guard — reject nested chains (depth > 0 means inside a chain)
|
||||
*/
|
||||
async function handleCommandInternal(
|
||||
body: { command: string; args?: string[]; tabId?: number },
|
||||
tokenInfo?: TokenInfo | null,
|
||||
opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number },
|
||||
): Promise<CommandResult> {
|
||||
const { command, args = [], tabId } = body;
|
||||
|
||||
if (!command) {
|
||||
return new Response(JSON.stringify({ error: 'Missing "command" field' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return { status: 400, result: JSON.stringify({ error: 'Missing "command" field' }), json: true };
|
||||
}
|
||||
|
||||
// ─── Recursion guard: reject nested chains ──────────────────
|
||||
if (command === 'chain' && (opts?.chainDepth ?? 0) > 0) {
|
||||
return { status: 400, result: JSON.stringify({ error: 'Nested chain commands are not allowed' }), json: true };
|
||||
}
|
||||
|
||||
// ─── Scope check (for scoped tokens) ──────────────────────────
|
||||
if (tokenInfo && tokenInfo.clientId !== 'root') {
|
||||
if (!checkScope(tokenInfo, command)) {
|
||||
return {
|
||||
status: 403, json: true,
|
||||
result: JSON.stringify({
|
||||
error: `Command "${command}" not allowed by your token scope`,
|
||||
hint: `Your scopes: ${tokenInfo.scopes.join(', ')}. Ask the user to re-pair with --admin for eval/cookies/storage access.`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Domain check for navigation commands
|
||||
if ((command === 'goto' || command === 'newtab') && args[0]) {
|
||||
if (!checkDomain(tokenInfo, args[0])) {
|
||||
return {
|
||||
status: 403, json: true,
|
||||
result: JSON.stringify({
|
||||
error: `Domain not allowed by your token scope`,
|
||||
hint: `Allowed domains: ${tokenInfo.domains?.join(', ') || 'none configured'}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Rate check (skipped for chain subcommands — chain counts as 1 request)
|
||||
if (!opts?.skipRateCheck) {
|
||||
const rateResult = checkRate(tokenInfo);
|
||||
if (!rateResult.allowed) {
|
||||
return {
|
||||
status: 429, json: true,
|
||||
result: JSON.stringify({
|
||||
error: 'Rate limit exceeded',
|
||||
hint: `Max ${tokenInfo.rateLimit} requests/second. Retry after ${rateResult.retryAfterMs}ms.`,
|
||||
}),
|
||||
headers: { 'Retry-After': String(Math.ceil((rateResult.retryAfterMs || 1000) / 1000)) },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Record command execution for idempotent key exchange tracking
|
||||
if (!opts?.skipRateCheck && tokenInfo.token) recordCommand(tokenInfo.token);
|
||||
}
|
||||
|
||||
// Pin to a specific tab if requested (set by BROWSE_TAB env var in sidebar agents).
|
||||
@@ -822,39 +928,90 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// Block mutation commands while watching (read-only observation mode)
|
||||
if (browserManager.isWatching() && WRITE_COMMANDS.has(command)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Cannot run mutation commands while watching. Run `$B watch stop` first.',
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
// ─── Tab ownership check (for scoped tokens) ──────────────
|
||||
if (tokenInfo && tokenInfo.clientId !== 'root' && (WRITE_COMMANDS.has(command) || 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' })) {
|
||||
return {
|
||||
status: 403, json: true,
|
||||
result: JSON.stringify({
|
||||
error: 'Tab not owned by your agent. Use newtab to create your own tab.',
|
||||
hint: `Tab ${targetTab} is owned by ${browserManager.getTabOwner(targetTab) || 'root'}. Your agent: ${tokenInfo.clientId}.`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Activity: emit command_start
|
||||
// ─── newtab with ownership for scoped tokens ──────────────
|
||||
if (command === 'newtab' && tokenInfo && tokenInfo.clientId !== 'root') {
|
||||
const newId = await browserManager.newTab(args[0] || undefined, tokenInfo.clientId);
|
||||
return {
|
||||
status: 200, json: true,
|
||||
result: JSON.stringify({
|
||||
tabId: newId,
|
||||
owner: tokenInfo.clientId,
|
||||
hint: 'Include "tabId": ' + newId + ' in subsequent commands to target this tab.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Block mutation commands while watching (read-only observation mode)
|
||||
if (browserManager.isWatching() && WRITE_COMMANDS.has(command)) {
|
||||
return {
|
||||
status: 400, json: true,
|
||||
result: JSON.stringify({ error: 'Cannot run mutation commands while watching. Run `$B watch stop` first.' }),
|
||||
};
|
||||
}
|
||||
|
||||
// Activity: emit command_start (skipped for chain subcommands)
|
||||
const startTime = Date.now();
|
||||
emitActivity({
|
||||
type: 'command_start',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
if (!opts?.skipActivity) {
|
||||
emitActivity({
|
||||
type: 'command_start',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
clientId: tokenInfo?.clientId,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let result: string;
|
||||
|
||||
if (READ_COMMANDS.has(command)) {
|
||||
result = await handleReadCommand(command, args, browserManager);
|
||||
if (PAGE_CONTENT_COMMANDS.has(command)) {
|
||||
result = wrapUntrustedContent(result, browserManager.getCurrentUrl());
|
||||
const isScoped = tokenInfo && tokenInfo.clientId !== 'root';
|
||||
// Hidden element stripping for scoped tokens on text command
|
||||
if (isScoped && command === 'text') {
|
||||
const page = browserManager.getPage();
|
||||
const strippedDescs = await markHiddenElements(page);
|
||||
if (strippedDescs.length > 0) {
|
||||
console.warn(`[browse] Content security: stripped ${strippedDescs.length} hidden elements for ${tokenInfo.clientId}`);
|
||||
}
|
||||
try {
|
||||
const target = browserManager.getActiveFrameOrPage();
|
||||
result = await getCleanTextWithStripping(target);
|
||||
} finally {
|
||||
await cleanupHiddenMarkers(page);
|
||||
}
|
||||
} else {
|
||||
result = await handleReadCommand(command, args, browserManager);
|
||||
}
|
||||
} else if (WRITE_COMMANDS.has(command)) {
|
||||
result = await handleWriteCommand(command, args, browserManager);
|
||||
} else if (META_COMMANDS.has(command)) {
|
||||
result = await handleMetaCommand(command, args, browserManager, shutdown);
|
||||
// Pass chain depth + executeCommand callback so chain routes subcommands
|
||||
// through the full security pipeline (scope, domain, tab, wrapping).
|
||||
const chainDepth = (opts?.chainDepth ?? 0);
|
||||
result = await handleMetaCommand(command, args, browserManager, shutdown, tokenInfo, {
|
||||
chainDepth,
|
||||
executeCommand: (body, ti) => handleCommandInternal(body, ti, {
|
||||
skipRateCheck: true, // chain counts as 1 request
|
||||
skipActivity: true, // chain emits 1 event for all subcommands
|
||||
chainDepth: chainDepth + 1, // recursion guard
|
||||
}),
|
||||
});
|
||||
// Start periodic snapshot interval when watch mode begins
|
||||
if (command === 'watch' && args[0] !== 'stop' && browserManager.isWatching()) {
|
||||
const watchInterval = setInterval(async () => {
|
||||
@@ -873,32 +1030,61 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
}
|
||||
} else if (command === 'help') {
|
||||
const helpText = generateHelpText();
|
||||
return new Response(helpText, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
return { status: 200, result: helpText };
|
||||
} else {
|
||||
return new Response(JSON.stringify({
|
||||
error: `Unknown command: ${command}`,
|
||||
hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`,
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return {
|
||||
status: 400, json: true,
|
||||
result: JSON.stringify({
|
||||
error: `Unknown command: ${command}`,
|
||||
hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Activity: emit command_end (success)
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration: Date.now() - startTime,
|
||||
status: 'ok',
|
||||
result: result,
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
// ─── Centralized content wrapping (single location for all commands) ───
|
||||
// Scoped tokens: content filter + enhanced envelope + datamarking
|
||||
// Root tokens: basic untrusted content wrapper (backward compat)
|
||||
// Chain exempt from top-level wrapping (each subcommand wrapped individually)
|
||||
if (PAGE_CONTENT_COMMANDS.has(command) && command !== 'chain') {
|
||||
const isScoped = tokenInfo && tokenInfo.clientId !== 'root';
|
||||
if (isScoped) {
|
||||
// Run content filters
|
||||
const filterResult: ContentFilterResult = runContentFilters(
|
||||
result, browserManager.getCurrentUrl(), command,
|
||||
);
|
||||
if (filterResult.blocked) {
|
||||
return { status: 403, json: true, result: JSON.stringify({ error: filterResult.message }) };
|
||||
}
|
||||
// Datamark text command output only (not html, forms, or structured data)
|
||||
if (command === 'text') {
|
||||
result = datamarkContent(result);
|
||||
}
|
||||
// Enhanced envelope wrapping for scoped tokens
|
||||
result = wrapUntrustedPageContent(
|
||||
result, command,
|
||||
filterResult.warnings.length > 0 ? filterResult.warnings : undefined,
|
||||
);
|
||||
} else {
|
||||
// Root token: basic wrapping (backward compat, Decision 2)
|
||||
result = wrapUntrustedContent(result, browserManager.getCurrentUrl());
|
||||
}
|
||||
}
|
||||
|
||||
// Activity: emit command_end (skipped for chain subcommands)
|
||||
if (!opts?.skipActivity) {
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration: Date.now() - startTime,
|
||||
status: 'ok',
|
||||
result: result,
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
clientId: tokenInfo?.clientId,
|
||||
});
|
||||
}
|
||||
|
||||
browserManager.resetFailures();
|
||||
// Restore original active tab if we pinned to a specific one
|
||||
@@ -907,10 +1093,7 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
console.warn('[browse] Failed to restore tab after command:', restoreErr.message);
|
||||
}
|
||||
}
|
||||
return new Response(result, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
return { status: 200, result };
|
||||
} catch (err: any) {
|
||||
// Restore original active tab even on error
|
||||
if (savedTabId !== null) {
|
||||
@@ -919,30 +1102,40 @@ async function handleCommand(body: any): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// Activity: emit command_end (error)
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration: Date.now() - startTime,
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
});
|
||||
// Activity: emit command_end (error) — skipped for chain subcommands
|
||||
if (!opts?.skipActivity) {
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command,
|
||||
args,
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration: Date.now() - startTime,
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
clientId: tokenInfo?.clientId,
|
||||
});
|
||||
}
|
||||
|
||||
browserManager.incrementFailures();
|
||||
let errorMsg = wrapError(err);
|
||||
const hint = browserManager.getFailureHint();
|
||||
if (hint) errorMsg += '\n' + hint;
|
||||
return new Response(JSON.stringify({ error: errorMsg }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return { status: 500, result: JSON.stringify({ error: errorMsg }), json: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP wrapper — converts CommandResult to Response */
|
||||
async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<Response> {
|
||||
const cr = await handleCommandInternal(body, tokenInfo);
|
||||
const contentType = cr.json ? 'application/json' : 'text/plain';
|
||||
return new Response(cr.result, {
|
||||
status: cr.status,
|
||||
headers: { 'Content-Type': contentType, ...cr.headers },
|
||||
});
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
@@ -1143,6 +1336,255 @@ async function start() {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── /connect — setup key exchange for /pair-agent ceremony ────
|
||||
if (url.pathname === '/connect' && req.method === 'POST') {
|
||||
if (!checkConnectRateLimit()) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Too many connection attempts. Wait 1 minute.',
|
||||
}), { status: 429, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
try {
|
||||
const connectBody = await req.json() as { setup_key?: string };
|
||||
if (!connectBody.setup_key) {
|
||||
return new Response(JSON.stringify({ error: 'Missing setup_key' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const session = exchangeSetupKey(connectBody.setup_key);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid, expired, or already-used setup key',
|
||||
}), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
console.log(`[browse] Remote agent connected: ${session.clientId} (scopes: ${session.scopes.join(',')})`);
|
||||
return new Response(JSON.stringify({
|
||||
token: session.token,
|
||||
expires: session.expiresAt,
|
||||
scopes: session.scopes,
|
||||
agent: session.clientId,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /token — mint scoped tokens (root-only) ──────────────────
|
||||
if (url.pathname === '/token' && req.method === 'POST') {
|
||||
if (!isRootRequest(req)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Only the root token can mint sub-tokens',
|
||||
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
try {
|
||||
const tokenBody = await req.json() as any;
|
||||
if (!tokenBody.clientId) {
|
||||
return new Response(JSON.stringify({ error: 'Missing clientId' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const session = createToken({
|
||||
clientId: tokenBody.clientId,
|
||||
scopes: tokenBody.scopes,
|
||||
domains: tokenBody.domains,
|
||||
tabPolicy: tokenBody.tabPolicy,
|
||||
rateLimit: tokenBody.rateLimit,
|
||||
expiresSeconds: tokenBody.expiresSeconds,
|
||||
});
|
||||
return new Response(JSON.stringify({
|
||||
token: session.token,
|
||||
expires: session.expiresAt,
|
||||
scopes: session.scopes,
|
||||
agent: session.clientId,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /token/:clientId — revoke a scoped token (root-only) ─────
|
||||
if (url.pathname.startsWith('/token/') && req.method === 'DELETE') {
|
||||
if (!isRootRequest(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Root token required' }), {
|
||||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const clientId = url.pathname.slice('/token/'.length);
|
||||
const revoked = revokeToken(clientId);
|
||||
if (!revoked) {
|
||||
return new Response(JSON.stringify({ error: `Agent "${clientId}" not found` }), {
|
||||
status: 404, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
console.log(`[browse] Revoked token for: ${clientId}`);
|
||||
return new Response(JSON.stringify({ revoked: clientId }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── /agents — list connected agents (root-only) ──────────────
|
||||
if (url.pathname === '/agents' && req.method === 'GET') {
|
||||
if (!isRootRequest(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Root token required' }), {
|
||||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const agents = listTokens().map(t => ({
|
||||
clientId: t.clientId,
|
||||
scopes: t.scopes,
|
||||
domains: t.domains,
|
||||
expiresAt: t.expiresAt,
|
||||
commandCount: t.commandCount,
|
||||
createdAt: t.createdAt,
|
||||
}));
|
||||
return new Response(JSON.stringify({ agents }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── /pair — create setup key for pair-agent ceremony (root-only) ───
|
||||
if (url.pathname === '/pair' && req.method === 'POST') {
|
||||
if (!isRootRequest(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Root token required' }), {
|
||||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const pairBody = await req.json() as any;
|
||||
const scopes = pairBody.admin
|
||||
? ['read', 'write', 'admin', 'meta'] as const
|
||||
: (pairBody.scopes || ['read', 'write']) as const;
|
||||
const setupKey = createSetupKey({
|
||||
clientId: pairBody.clientId,
|
||||
scopes: [...scopes],
|
||||
domains: pairBody.domains,
|
||||
rateLimit: pairBody.rateLimit,
|
||||
});
|
||||
// Verify tunnel is actually alive before reporting it (ngrok may have died externally)
|
||||
let verifiedTunnelUrl: string | null = null;
|
||||
if (tunnelActive && tunnelUrl) {
|
||||
try {
|
||||
const probe = await fetch(`${tunnelUrl}/health`, {
|
||||
headers: { 'ngrok-skip-browser-warning': 'true' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (probe.ok) {
|
||||
verifiedTunnelUrl = tunnelUrl;
|
||||
} else {
|
||||
console.warn(`[browse] Tunnel probe failed (HTTP ${probe.status}), marking tunnel as dead`);
|
||||
tunnelActive = false;
|
||||
tunnelUrl = null;
|
||||
tunnelListener = null;
|
||||
}
|
||||
} catch {
|
||||
console.warn('[browse] Tunnel probe timed out or unreachable, marking tunnel as dead');
|
||||
tunnelActive = false;
|
||||
tunnelUrl = null;
|
||||
tunnelListener = null;
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
setup_key: setupKey.token,
|
||||
expires_at: setupKey.expiresAt,
|
||||
scopes: setupKey.scopes,
|
||||
tunnel_url: verifiedTunnelUrl,
|
||||
server_url: `http://127.0.0.1:${server?.port || 0}`,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /tunnel/start — start ngrok tunnel on demand (root-only) ──
|
||||
if (url.pathname === '/tunnel/start' && req.method === 'POST') {
|
||||
if (!isRootRequest(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Root token required' }), {
|
||||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (tunnelActive && tunnelUrl) {
|
||||
// Verify tunnel is still alive before returning cached URL
|
||||
try {
|
||||
const probe = await fetch(`${tunnelUrl}/health`, {
|
||||
headers: { 'ngrok-skip-browser-warning': 'true' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (probe.ok) {
|
||||
return new Response(JSON.stringify({ url: tunnelUrl, already_active: true }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
// Tunnel is dead, reset and fall through to restart
|
||||
console.warn('[browse] Cached tunnel is dead, restarting...');
|
||||
tunnelActive = false;
|
||||
tunnelUrl = null;
|
||||
tunnelListener = null;
|
||||
}
|
||||
try {
|
||||
// Read ngrok authtoken: env var > ~/.gstack/ngrok.env > ngrok native config
|
||||
let authtoken = process.env.NGROK_AUTHTOKEN;
|
||||
if (!authtoken) {
|
||||
const ngrokEnvPath = path.join(process.env.HOME || '', '.gstack', 'ngrok.env');
|
||||
if (fs.existsSync(ngrokEnvPath)) {
|
||||
const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8');
|
||||
const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m);
|
||||
if (match) authtoken = match[1].trim();
|
||||
}
|
||||
}
|
||||
if (!authtoken) {
|
||||
// Check ngrok's native config files
|
||||
const ngrokConfigs = [
|
||||
path.join(process.env.HOME || '', 'Library', 'Application Support', 'ngrok', 'ngrok.yml'),
|
||||
path.join(process.env.HOME || '', '.config', 'ngrok', 'ngrok.yml'),
|
||||
path.join(process.env.HOME || '', '.ngrok2', 'ngrok.yml'),
|
||||
];
|
||||
for (const conf of ngrokConfigs) {
|
||||
try {
|
||||
const content = fs.readFileSync(conf, 'utf-8');
|
||||
const match = content.match(/authtoken:\s*(.+)/);
|
||||
if (match) { authtoken = match[1].trim(); break; }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (!authtoken) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'No ngrok authtoken found',
|
||||
hint: 'Run: ngrok config add-authtoken YOUR_TOKEN',
|
||||
}), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const ngrok = await import('@ngrok/ngrok');
|
||||
const domain = process.env.NGROK_DOMAIN;
|
||||
const forwardOpts: any = { addr: server!.port, authtoken };
|
||||
if (domain) forwardOpts.domain = domain;
|
||||
|
||||
tunnelListener = await ngrok.forward(forwardOpts);
|
||||
tunnelUrl = tunnelListener.url();
|
||||
tunnelActive = true;
|
||||
console.log(`[browse] Tunnel started on demand: ${tunnelUrl}`);
|
||||
|
||||
// Update state file
|
||||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||||
stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() };
|
||||
const tmpState = config.stateFile + '.tmp';
|
||||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmpState, config.stateFile);
|
||||
|
||||
return new Response(JSON.stringify({ url: tunnelUrl }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({
|
||||
error: `Failed to start tunnel: ${err.message}`,
|
||||
}), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
// Refs endpoint — auth required, does NOT reset idle timer
|
||||
if (url.pathname === '/refs') {
|
||||
if (!validateAuth(req)) {
|
||||
@@ -1494,7 +1936,115 @@ async function start() {
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// ─── Auth-required endpoints ──────────────────────────────────
|
||||
// ─── Batch endpoint — N commands, 1 HTTP round-trip ─────────────
|
||||
// Accepts both root AND scoped tokens (same as /command).
|
||||
// Executes commands sequentially through the full security pipeline.
|
||||
// Designed for remote agents where tunnel latency dominates.
|
||||
if (url.pathname === '/batch' && req.method === 'POST') {
|
||||
const tokenInfo = getTokenInfo(req);
|
||||
if (!tokenInfo) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
resetIdleTimer();
|
||||
const body = await req.json();
|
||||
const { commands } = body;
|
||||
|
||||
if (!Array.isArray(commands) || commands.length === 0) {
|
||||
return new Response(JSON.stringify({ error: '"commands" must be a non-empty array' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (commands.length > 50) {
|
||||
return new Response(JSON.stringify({ error: 'Max 50 commands per batch' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
emitActivity({
|
||||
type: 'command_start',
|
||||
command: 'batch',
|
||||
args: [`${commands.length} commands`],
|
||||
url: browserManager.getCurrentUrl(),
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
clientId: tokenInfo?.clientId,
|
||||
});
|
||||
|
||||
const results: Array<{ index: number; status: number; result: string; command: string; tabId?: number }> = [];
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const cmd = commands[i];
|
||||
if (!cmd || typeof cmd.command !== 'string') {
|
||||
results.push({ index: i, status: 400, result: JSON.stringify({ error: 'Missing "command" field' }), command: '' });
|
||||
continue;
|
||||
}
|
||||
// Reject nested batches
|
||||
if (cmd.command === 'batch') {
|
||||
results.push({ index: i, status: 400, result: JSON.stringify({ error: 'Nested batch commands are not allowed' }), command: 'batch' });
|
||||
continue;
|
||||
}
|
||||
const cr = await handleCommandInternal(
|
||||
{ command: cmd.command, args: cmd.args, tabId: cmd.tabId },
|
||||
tokenInfo,
|
||||
{ skipRateCheck: true, skipActivity: true },
|
||||
);
|
||||
results.push({
|
||||
index: i,
|
||||
status: cr.status,
|
||||
result: cr.result,
|
||||
command: cmd.command,
|
||||
tabId: cmd.tabId,
|
||||
});
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
emitActivity({
|
||||
type: 'command_end',
|
||||
command: 'batch',
|
||||
args: [`${commands.length} commands`],
|
||||
url: browserManager.getCurrentUrl(),
|
||||
duration,
|
||||
status: 'ok',
|
||||
result: `${results.filter(r => r.status === 200).length}/${commands.length} succeeded`,
|
||||
tabs: browserManager.getTabCount(),
|
||||
mode: browserManager.getConnectionMode(),
|
||||
clientId: tokenInfo?.clientId,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
results,
|
||||
duration,
|
||||
total: commands.length,
|
||||
succeeded: results.filter(r => r.status === 200).length,
|
||||
failed: results.filter(r => r.status !== 200).length,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Command endpoint (accepts both root AND scoped tokens) ────
|
||||
// Must be checked BEFORE the blanket root-only auth gate below,
|
||||
// because scoped tokens from /connect are valid for /command.
|
||||
if (url.pathname === '/command' && req.method === 'POST') {
|
||||
const tokenInfo = getTokenInfo(req);
|
||||
if (!tokenInfo) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
resetIdleTimer();
|
||||
const body = await req.json();
|
||||
return handleCommand(body, tokenInfo);
|
||||
}
|
||||
|
||||
// ─── Auth-required endpoints (root token only) ─────────────────
|
||||
|
||||
if (!validateAuth(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
@@ -1654,14 +2204,6 @@ async function start() {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Command endpoint ──────────────────────────────────────────
|
||||
|
||||
if (url.pathname === '/command' && req.method === 'POST') {
|
||||
resetIdleTimer(); // Only commands reset idle timer
|
||||
const body = await req.json();
|
||||
return handleCommand(body);
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
@@ -1721,6 +2263,51 @@ async function start() {
|
||||
|
||||
// Initialize sidebar session (load existing or create new)
|
||||
initSidebarSession();
|
||||
|
||||
// ─── Tunnel startup (optional) ────────────────────────────────
|
||||
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set.
|
||||
// Reads NGROK_AUTHTOKEN from env or ~/.gstack/ngrok.env.
|
||||
// Reads NGROK_DOMAIN for dedicated domain (stable URL).
|
||||
if (process.env.BROWSE_TUNNEL === '1') {
|
||||
try {
|
||||
// Read ngrok authtoken from env or config file
|
||||
let authtoken = process.env.NGROK_AUTHTOKEN;
|
||||
if (!authtoken) {
|
||||
const ngrokEnvPath = path.join(process.env.HOME || '', '.gstack', 'ngrok.env');
|
||||
if (fs.existsSync(ngrokEnvPath)) {
|
||||
const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8');
|
||||
const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m);
|
||||
if (match) authtoken = match[1].trim();
|
||||
}
|
||||
}
|
||||
if (!authtoken) {
|
||||
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
|
||||
} else {
|
||||
const ngrok = await import('@ngrok/ngrok');
|
||||
const domain = process.env.NGROK_DOMAIN;
|
||||
const forwardOpts: any = {
|
||||
addr: port,
|
||||
authtoken,
|
||||
};
|
||||
if (domain) forwardOpts.domain = domain;
|
||||
|
||||
tunnelListener = await ngrok.forward(forwardOpts);
|
||||
tunnelUrl = tunnelListener.url();
|
||||
tunnelActive = true;
|
||||
|
||||
console.log(`[browse] Tunnel active: ${tunnelUrl}`);
|
||||
|
||||
// Update state file with tunnel URL
|
||||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||||
stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() };
|
||||
const tmpState = config.stateFile + '.tmp';
|
||||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmpState, config.stateFile);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[browse] Failed to start tunnel: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
|
||||
Reference in New Issue
Block a user