v1.68.2.0 fix: tunnel revoke exists and revokes everything — setup keys included, verified live (#2646)

* fix(browse): revokeToken deletes ALL tokens for a clientId, not the first Map hit

revokeToken deleted the first Map entry matching the clientId and returned
true. After a normal pairing, two entries share one clientId: the spent setup
key (kept by exchangeSetupKey for idempotent re-exchange) and the session
token, in that insertion order. Revoke ate the setup key, reported success,
and the live session survived: DELETE /token/<id> returned a false 200 while
/agents kept listing the agent. Worse, an unspent setup key created after the
session survived revoke, so a "revoked" agent could POST /connect and mint a
fresh session within the key's 5-minute validity window.

revokeToken now deletes every matching entry and returns the delete count
(truthy-compatible with the old boolean). The DELETE /token handler logs
"Revoked N token(s)" and returns tokens_deleted so the multi-token class
stays visible; revokeSkillToken wraps Boolean() to keep its documented
contract. Regression tests pin shapes a (spent-key shadowing), b (re-grant
hole), c (multiple pending keys), and bystander isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(browse): tunnel revoke/agents CLI with post-revoke verification

`$B tunnel revoke <name>` was documented in the instruction block,
pair-agent/SKILL.md, and REMOTE_BROWSER_ACCESS.md but implemented nowhere:
the CLI forwarded it to the daemon as Unknown command 'tunnel', and nothing
in the repo called DELETE /token/:clientId or GET /agents.

New pre-server short-circuit (#2254 pattern: tokens are memory-only, never
boot a daemon to revoke against it). `tunnel revoke <name>` DELETEs the
token, prints the deleted count ("(count unknown)" for old daemons that
answer {revoked} without tokens_deleted), then RE-READS GET /agents to prove
the agent is gone. The still-listed branch is the version-skew net: a new
CLI against a still-running old daemon with the first-match revoke bug exits
1 and says to re-run (each old-daemon call deletes the next match) or stop.
An alive pid with an unreachable port reports "Could not reach daemon"
(exit 1), never a false "no daemon". `tunnel agents` lists sessions plus
pending (unexchanged) setup keys, which GET /agents now exposes via
listTokens({includeSetup}) — without them the revocation view was blind to
a paired-but-never-connected agent. Setup-key tokens never leave the server.
DELETE /token/ now decodeURIComponents the clientId (400 on malformed
encoding) so CLI-encoded names round-trip.

Tests: subprocess CLI coverage (usage paths, no-daemon exit 0 without
spawning, live pair/connect/revoke loop, pending-key listing), stub-daemon
pins for the skew and unreachable branches, and e2e pins for revoke-all
semantics, percent-encoded ids, and the second-DELETE-is-404 regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): CLI always sends explicit pair scopes via shared DEFAULT_PAIR_SCOPES

The effective pairing default lived in two places: the CLI omitted scopes
unless --restrict was passed, and the server filled in its own literal.
handlePairAgent now always sends an explicit scopes list and both sides
reference one exported constant, DEFAULT_PAIR_SCOPES, so the default cannot
silently drift again (pinned by a server-auth source tripwire).

Three input traps closed in the same surface:
- Bare --restrict (or --restrict swallowing the next flag) parsed as "no
  restriction" and silently granted FULL access, the opposite of the user's
  intent. validatePairAgentFlags rejects it pre-server, before any consent
  gate, so an arg error never boots a daemon.
- A scopes list could smuggle the control scope past the explicit flag:
  --restrict "read,control" minted a control-scoped session with no
  --control. /pair now 400s on control in a scopes list without the control
  flag, and the CLI points the user at --control.
- Option typos validated only at exchange time: createSetupKey stored any
  scope string and any rateLimit, so /pair returned 200 with a poisoned
  setup key whose failure surfaced to the REMOTE agent at /connect as a
  misleading "Invalid request body". Shared validation now runs in both
  creators and throws typed InvalidScopeError; /pair and /token 400 with the
  message, naming the bad scope or negative rateLimit. Also
  `opts.rateLimit || 10` became `?? 10` so the documented "0 = unlimited"
  survives the /pair path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): 403 hint stops recommending --admin; invariant names both scope defaults

The scope-denied hint told restricted agents to "re-pair with --admin for
eval/cookies/storage" — but --admin is a legacy alias for --control, so
following it over-granted browser-wide destructive commands on top of the
admin scope the default already carries. The hint now matches the CLI's
sibling wording: re-pair without --restrict for page access, --control for
browser control.

Registry invariant #2 claimed "admin scope denied by default" three releases
after b73f3644 deliberately made /pair grant admin. It now names BOTH
defaults precisely (registry API functions default read+write; the /pair
ceremony grants DEFAULT_PAIR_SCOPES) so the header cannot lie one layer down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(pair-agent): document the full-access default, --restrict, and real revocation

The pairing docs still described the pre-b73f3644 model: read+write default,
--admin as the opt-in for JS/cookies/storage. Reality for three releases:
/pair grants read+write+admin+meta (the pairing ceremony is the trust
boundary) and --admin is a legacy alias for --control. A user following the
skill believed they granted a sandboxed session and actually granted JS
execution on their logged-in browser.

pair-agent/SKILL.md.tmpl (SKILL.md regenerated in this commit) now states
the real default, the tunnel-allowlist nuance (eval works remotely; the
js/cookies/storage commands are local-only), --restrict for sandboxed
sessions with an untrusted-content advisory (scope caps prompt-injection
blast radius), and --control for browser-wide ops. "Revoking access"
documents the now-real tunnel revoke (deletes session + pending setup keys,
verifies against the agent list) and tunnel agents, and replaces the
never-implemented `tunnel rotate` with `$B stop` — tokens are memory-only,
so a daemon restart already rotates everything.

REMOTE_BROWSER_ACCESS.md: /connect example shows the real default scopes,
the scope table gains the control row, the 403 hint row matches the new
server wording, and the false claim that /sidebar-chat is on the tunnel
allowlist is gone (TUNNEL_PATHS is /connect + /command; /sidebar-chat no
longer exists in server.ts at all). ARCHITECTURE.md drops the same phantom
endpoint from the allowlist prose and endpoint table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v1.68.2.0: revoke-all, real tunnel revoke, truthful pairing docs

Version slot allocated against the live remote via bin/gstack-next-version
(clean patch bump from 1.68.1.0, no collision). CHANGELOG entry covers the
revoke-all fix, the new tunnel revoke/agents CLI, the explicit-scopes wire
contract, and the pairing-docs truth pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): adversarial-review hardening — 6 findings fixed, regression-pinned

Pre-push adversarial review (4 lenses, refute-style verification: 13 raw
findings, 7 refuted, 6 confirmed) caught these; each fix carries a pin:

1. --restrict=read (equals form) sailed past validatePairAgentFlags —
   hasFlag/parseFlag are exact-token matches — so the user asked for a
   read-only sandbox and silently got FULL access: the exact failure mode
   this branch claims to close. The equals form is now a hard error before
   any server work.
2. handleTunnel trimmed the agent name but clientIds are stored verbatim,
   so a space-padded agent was unrevocable by the documented kill switch
   (trimmed DELETE 404'd while the grant stayed live). Names now pass
   through verbatim; the live-daemon test revokes ' padded'.
3. The sole pin for "CLI always sends explicit scopes" passed vacuously on
   a simulated revert: toContain('DEFAULT_PAIR_SCOPES') was satisfied by a
   comment. The tripwire now matches the code shape with a regex and bans
   the conditional spread formatting-insensitively.
4. The rewritten 403 scope hint was unpinned — new e2e asserts it names
   --restrict and --control and never --admin.
5. tunnelRevoke's verify-failure and HTTP-error branches and tunnelAgents'
   unreadable-list branch had no coverage — three stub-daemon pins added
   (an unreadable list must never render as "No paired agents").
6. CHANGELOG claimed "40+ new test cases"; the honest count is 35.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-19 22:33:46 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 9da6692930
commit 51932eceef
15 changed files with 1186 additions and 70 deletions
+1 -2
View File
@@ -92,7 +92,7 @@ When a user runs `pair-agent --client`, the daemon starts an ngrok tunnel so a r
The fix is **two HTTP listeners**, not one:
- **Local listener** (`127.0.0.1:LOCAL_PORT`) — always bound. Serves token bootstrap (`POST /extension-token`, released only to the pinned extension identity), `/health` (liveness/status only — never a token), `/cookie-picker`, `/inspector/*`, `/welcome`, `/refs`, the sidebar-agent API, and the full command surface. Never forwarded.
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`) — bound lazily on `/tunnel/start`, torn down on `/tunnel/stop`. Serves a locked allowlist: `/connect` (pairing ceremony, unauth + rate-limited), `/command` (scoped tokens only, further restricted to a browser-driving command allowlist), and `/sidebar-chat`. Everything else 404s.
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`) — bound lazily on `/tunnel/start`, torn down on `/tunnel/stop`. Serves a locked allowlist: `/connect` (pairing ceremony, unauth + rate-limited) and `/command` (scoped tokens only, further restricted to a browser-driving command allowlist). Everything else 404s.
ngrok forwards only the tunnel port. The security property comes from **physical port separation**: a tunnel caller cannot reach `/health` or `/cookie-picker` because those paths don't exist on that TCP socket. Header inference (check `x-forwarded-for`, check origin) is unreliable (ngrok header behavior changes; local proxies can add these headers); socket separation isn't.
@@ -103,7 +103,6 @@ ngrok forwards only the tunnel port. The security property comes from **physical
| `GET /connect` | public (`{alive:true}`) | public (`{alive:true}`) | Probe path for tunnel liveness |
| `POST /connect` | public (rate-limited 300/min) | public (rate-limited) | Setup-key exchange for pair-agent |
| `POST /command` | auth (Bearer root OR scoped) | auth (scoped only, allowlisted commands) | Root token on tunnel = 403 |
| `POST /sidebar-chat` | auth | auth | Lets remote agent post into local sidebar |
| `POST /pair` | root-only | 404 | Pairing mint — local operator action |
| `POST /tunnel/{start,stop}` | root-only | 404 | Daemon configuration |
| `POST /token`, `DELETE /token/:id` | root-only | 404 | Scoped token mint/revoke |
+44
View File
@@ -1,5 +1,49 @@
# Changelog
## [1.68.2.0] - 2026-08-20
**Revoking a paired agent now revokes everything it holds, and the**
**documented kill switch is real: tunnel revoke deletes, then proves it.**
Revoking a remote agent was broken twice over. `revokeToken` deleted only the first token matching the agent's name, and the spent setup key kept for connection retries always sat first in line. So `DELETE /token/<agent>` returned 200 while the live session kept working, a leftover unspent setup key could mint a brand-new session for a "revoked" agent (inside the key's 5-minute validity), and a second DELETE returned 200 again. Meanwhile the documented way out, `$B tunnel revoke`, did not exist: the CLI forwarded it to the daemon as an unknown command. The pairing docs also promised a read+write sandbox three releases after pairing deliberately switched to full page access.
### The numbers that matter
Source: the before/after curl transcript in the PR (a `BROWSE_HEADLESS_SKIP=1` daemon on each branch) and the regression tests in `browse/test/token-registry.test.ts` and `browse/test/tunnel-revoke-cli.test.ts`, which fail on the previous release.
| Metric | Before | After |
|--------|--------|-------|
| DELETE /token with a pending setup key | 200, session survives | 200, all 3 tokens deleted |
| Revoked agent re-connects via leftover key | new session minted | 401 |
| `$B tunnel revoke <name>` | Unknown command 'tunnel' | revokes, then verifies against /agents |
| Second DELETE for the same agent | 200 again | 404 |
| Bare `--restrict` (forgotten value) | silent FULL access | hard error, exit 1 |
| Docs on default pairing scopes | "read+write, no JS" | read+write+admin+meta, stated plainly |
### What this means for you
Revoke means revoked: one command deletes the session and every setup key, prints the count, and re-reads the agent list to prove the agent is gone. `$B tunnel agents` shows everyone paired, pending setup keys included. The pairing docs now tell the truth about default access, when to reach for `--restrict` (agents reading untrusted pages), and that `$B stop` clears every token at once. Scope typos fail at `/pair` naming the bad scope instead of surfacing to the remote agent as a body error, and a scopes list can no longer smuggle in the `control` scope.
### Itemized changes
### Added
- `tunnel revoke <name>` and `tunnel agents` CLI subcommands: pre-server (never boot a daemon to revoke against it), post-revoke verification re-read, truthful exit codes for unknown names, unreachable daemons, and old daemons that claim success while the agent stays listed.
- `GET /agents` lists pending (unexchanged) setup keys, marked `pending`; setup-key tokens never leave the server. `DELETE /token` responses carry `tokens_deleted` and the daemon logs the count.
### Changed
- The CLI always sends an explicit scopes list; both CLI and server reference one exported `DEFAULT_PAIR_SCOPES` constant, pinned by a source tripwire so the defaults cannot drift apart again.
- The scope-denied 403 hint recommends re-pairing without `--restrict` or with `--control`; it no longer suggests `--admin`, which over-granted browser control.
- pair-agent/SKILL.md, REMOTE_BROWSER_ACCESS.md, and ARCHITECTURE.md document the real default, `--restrict`, and the tunnel allowlist nuance (`eval` works remotely; `js`/`cookies`/`storage` are local-only). The never-implemented `tunnel rotate` is replaced by `$B stop`, and the phantom `/sidebar-chat` tunnel entries are gone.
### Fixed
- `revokeToken` deletes ALL tokens for a client id: the session plus spent and pending setup keys. Closes the false-200 revoke and the re-grant hole.
- Bare `--restrict` (or `--restrict` swallowing the next flag) errors out instead of silently granting full access; `--restrict` can never grant `control`.
- Scope and rateLimit typos are rejected at `/pair` and `/token` with the field named; `rateLimit: 0` (unlimited) survives the /pair path.
- `DELETE /token/:id` decodes percent-encoded client ids, so names with spaces round-trip from the CLI.
### For contributors
- 35 new test cases: revoke-all regression shapes, a subprocess CLI harness with stub daemons pinning the version-skew net ("Revocation incomplete" on a lying daemon) and every CLI error branch, e2e scope-contract and 403-hint pins, and code-shape tripwires for `DEFAULT_PAIR_SCOPES` and the decode path.
## [1.68.1.0] - 2026-08-18
**Phantom hook errors are dead. Your settings.json now heals itself**
+1 -1
View File
@@ -1 +1 @@
1.68.1.0
1.68.2.0
+180 -2
View File
@@ -18,6 +18,9 @@ import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled } fr
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
import { redactProxyUrl } from './proxy-redact';
import { spawnTerminalAgent } from './terminal-agent-control';
// Zero side effects on import (documented invariant in token-registry.ts) —
// safe to pull the shared pairing default into the CLI.
import { DEFAULT_PAIR_SCOPES } from './token-registry';
const config = resolveConfig();
const IS_WINDOWS = process.platform === 'win32';
@@ -1101,6 +1104,164 @@ export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): G
};
}
// ─── Tunnel token management (pre-server, #2254 pattern) ────────
// Tokens live in daemon memory, so a dead daemon means "nothing is paired" —
// a success state, not an error. Never boot a daemon to serve these, and
// never mutate the state file (stale-state cleanup stays stop's job).
/** Live-daemon check for tunnel subcommands. Dead pid AND failed health →
* null. An alive pid with an unreachable port falls through to the HTTP
* call, whose failure is reported truthfully (exit 1), not as "no daemon". */
async function tunnelDaemonState(): Promise<ServerState | null> {
const state = readState();
if (!state) return null;
if (!isProcessAlive(state.pid) && !(await isServerHealthy(state.port))) return null;
return state;
}
/** Fetch active agent clientIds (sessions + pending setup keys). Returns
* null when the list can't be read — callers must not treat that as empty. */
async function fetchAgentList(state: ServerState): Promise<Array<{ clientId: string; scopes: string[]; domains?: string[]; expiresAt: string | null; commandCount: number; pending?: boolean }> | null> {
try {
const resp = await fetch(`http://127.0.0.1:${state.port}/agents`, {
headers: { 'Authorization': `Bearer ${state.token}` },
signal: AbortSignal.timeout(5000),
});
if (!resp.ok) return null;
const body = await resp.json() as { agents?: unknown };
if (!Array.isArray(body.agents)) return null;
return body.agents as Array<{ clientId: string; scopes: string[]; domains?: string[]; expiresAt: string | null; commandCount: number; pending?: boolean }>;
} catch {
return null;
}
}
async function tunnelRevoke(name: string): Promise<number> {
const state = await tunnelDaemonState();
if (!state) {
console.log('No daemon running - tokens live in daemon memory, so nothing is paired.');
return 0;
}
let resp: Response;
try {
resp = await fetch(`http://127.0.0.1:${state.port}/token/${encodeURIComponent(name)}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${state.token}` },
signal: AbortSignal.timeout(5000),
});
} catch (err) {
console.error(`[browse] Could not reach daemon: ${err instanceof Error ? err.message : String(err)}`);
return 1;
}
if (resp.status === 404) {
console.error(`No paired agent named "${name}".`);
const agents = await fetchAgentList(state);
if (agents && agents.length) {
console.error(`Active agents: ${agents.map(a => a.clientId).join(', ')}`);
} else if (agents) {
console.error('No agents are currently paired.');
}
return 1;
}
if (!resp.ok) {
let msg = `HTTP ${resp.status}`;
try {
const body = await resp.json() as { error?: string };
if (body.error) msg = body.error;
} catch { /* keep the status-line message */ }
console.error(`[browse] Revoke failed: ${msg}`);
return 1;
}
let deleted: number | undefined;
try {
const body = await resp.json() as { tokens_deleted?: number };
if (typeof body.tokens_deleted === 'number') deleted = body.tokens_deleted;
} catch { /* old daemons answer {revoked} only — count stays unknown */ }
console.log(deleted === undefined
? `Revoked "${name}" (count unknown).`
: `Revoked "${name}" (${deleted} token${deleted === 1 ? '' : 's'}).`);
// Post-revoke verification: re-read the agent list to PROVE it's gone.
// This is also the version-skew net — an old daemon with the first-match
// revoke bug returns 200 while the session survives; catch it here.
const agents = await fetchAgentList(state);
if (agents === null) {
console.error('[browse] Revoked, but could not verify against the agent list.');
return 1;
}
if (agents.some(a => a.clientId === name)) {
console.error(`[browse] Revocation incomplete: "${name}" is still listed (old daemon or concurrent re-pair). Re-run "tunnel revoke ${name}", or run "stop" to clear every token.`);
return 1;
}
console.log('Verified: not in the active agent list.');
return 0;
}
async function tunnelAgents(): Promise<number> {
const state = await tunnelDaemonState();
if (!state) {
console.log('No daemon running - no paired agents.');
return 0;
}
const agents = await fetchAgentList(state);
if (agents === null) {
console.error('[browse] Could not read the agent list from the daemon.');
return 1;
}
if (agents.length === 0) {
console.log('No paired agents.');
return 0;
}
for (const a of agents) {
const pending = a.pending ? ' (pending setup key)' : '';
const domains = a.domains && a.domains.length ? a.domains.join(',') : 'any';
console.log(`${a.clientId}${pending} scopes=${(a.scopes || []).join(',')} domains=${domains} expires=${a.expiresAt ?? 'never'} commands=${a.commandCount ?? 0}`);
}
return 0;
}
/** Reject pair-agent scope-flag misuse BEFORE any consent or server work.
* Bare `--restrict` (or a flag-shaped value from a forgotten argument) used
* to parse as "no restriction" and silently grant FULL access — the exact
* opposite of the user's intent. And `control` never rides in via --restrict:
* browser-wide destructive ops stay behind the explicit --control flag. */
function validatePairAgentFlags(args: string[]): void {
// hasFlag/parseFlag are exact-token matches, so `--restrict=read` would
// sail past every check below and silently grant FULL access.
if (args.some(a => a.startsWith('--restrict='))) {
console.error('[browse] --restrict takes a space-separated value: --restrict read or --restrict "read,write". The --restrict=... form is not supported.');
process.exit(1);
}
if (!hasFlag(args, '--restrict')) return;
const restrict = parseFlag(args, '--restrict');
if (!restrict || !restrict.trim() || restrict.startsWith('--')) {
console.error('[browse] --restrict needs a scope list, e.g. --restrict read or --restrict "read,write". Bare --restrict would silently grant FULL access.');
process.exit(1);
}
if (hasFlag(args, '--control') || hasFlag(args, '--admin')) {
// Server-side, the control flag wins and the scopes list is ignored.
console.warn('[browse] --restrict is ignored when --control/--admin is set (control implies full access).');
return;
}
if (restrict.split(',').map(s => s.trim()).includes('control')) {
console.error('[browse] The control scope is not granted via --restrict. Re-run with --control.');
process.exit(1);
}
}
async function handleTunnel(args: string[]): Promise<never> {
const sub = args[0];
// The name passes through VERBATIM: clientIds are stored untrimmed, so a
// space-padded name must stay revocable (encodeURIComponent handles it).
if (sub === 'revoke' && args.length === 2 && args[1]) {
process.exit(await tunnelRevoke(args[1]));
}
if (sub === 'agents' && args.length === 1) {
process.exit(await tunnelAgents());
}
console.error('usage: browse tunnel <revoke <agent-name> | agents>');
process.exit(1);
}
async function handlePairAgent(state: ServerState, args: string[]): Promise<void> {
const clientName = parseFlag(args, '--client') || `remote-${Date.now()}`;
const domains = parseFlag(args, '--domain')?.split(',').map(d => d.trim());
@@ -1109,8 +1270,12 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
const localHost = parseFlag(args, '--local');
// Call POST /pair to create a setup key
// Default: full access (read+write+admin+meta). --control adds browser-wide ops.
// Default: DEFAULT_PAIR_SCOPES (full page access). --control adds browser-wide ops.
// --restrict limits: --restrict read (read-only), --restrict "read,write" (no admin)
// Scopes are ALWAYS sent explicitly so the effective default lives in one
// place (token-registry) instead of drifting between CLI omission and
// server fallback. Flag misuse was rejected pre-server by
// validatePairAgentFlags.
const pairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, {
method: 'POST',
headers: {
@@ -1121,7 +1286,9 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
domains,
clientId: clientName,
control,
...(restrict ? { scopes: restrict.split(',').map(s => s.trim()) } : {}),
scopes: restrict
? restrict.split(',').map(s => s.trim())
: [...DEFAULT_PAIR_SCOPES],
}),
signal: AbortSignal.timeout(5000),
});
@@ -1303,6 +1470,7 @@ Multi-step: chain (reads JSON from stdin)
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
Server: status | cookie <n>=<v> | header <n>:<v>
useragent <str> | stop | restart
tunnel revoke <name> | tunnel agents (paired-agent tokens)
--force-restart: replace a live-but-busy daemon (any command;
LOSES tabs/cookies/logins — never done automatically)
Dialogs: dialog-accept [text] | dialog-dismiss
@@ -1641,6 +1809,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
// sendCommand('stop') path (graceful shutdown; busy semantics apply).
}
// ─── Tunnel token management (pre-server short-circuit, #2254) ──
// Tokens live in daemon memory; a dead daemon has nothing to revoke or
// list, so never boot one to serve these.
if (command === 'tunnel') {
await handleTunnel(commandArgs); // always exits
}
// Special case: chain reads from stdin
if (command === 'chain' && commandArgs.length === 0) {
const stdin = await Bun.stdin.text();
@@ -1656,6 +1831,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
// state, so replacing it kills nothing the user had.
let pairAgentPreexistingDaemonAlive = false;
if (command === 'pair-agent') {
// Scope-flag misuse is rejected before consent gates and ensureServer —
// an arg error must never boot a daemon.
validatePairAgentFlags(commandArgs);
const preState = readState();
pairAgentPreexistingDaemonAlive = Boolean(preState?.pid && isProcessAlive(preState.pid));
}
+48 -13
View File
@@ -31,7 +31,8 @@ import {
initRegistry, validateToken as validateScopedToken, checkScope, checkDomain,
checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken,
listTokens, recordCommand,
isRootToken, checkConnectRateLimit, type TokenInfo,
isRootToken, checkConnectRateLimit, type TokenInfo, type ScopeCategory,
DEFAULT_PAIR_SCOPES, InvalidScopeError,
} from './token-registry';
import { validateTempPath } from './path-security';
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
@@ -999,7 +1000,7 @@ async function handleCommandInternalImpl(
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.`,
hint: `Your scopes: ${tokenInfo.scopes.join(', ')}. Ask the user to re-pair without --restrict for full page access, or with --control for browser control commands.`,
}),
};
}
@@ -2316,7 +2317,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
scopes: session.scopes,
agent: session.clientId,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch {
} catch (err) {
// Name the caller's typo (bad scope, negative rateLimit) instead of
// hiding it behind the generic body error.
if (err instanceof InvalidScopeError) {
return new Response(JSON.stringify({ error: err.message }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
@@ -2330,15 +2338,23 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
const clientId = url.pathname.slice('/token/'.length);
// decodeURIComponent so CLI-encoded names (spaces, UTF-8) round-trip.
let clientId: string;
try {
clientId = decodeURIComponent(url.pathname.slice('/token/'.length));
} catch {
return new Response(JSON.stringify({ error: 'Malformed client ID encoding' }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
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 }), {
console.log(`[browse] Revoked ${revoked} token(s) for: ${clientId}`);
return new Response(JSON.stringify({ revoked: clientId, tokens_deleted: revoked }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
}
@@ -2350,13 +2366,17 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
const agents = listTokens().map(t => ({
// includeSetup: pending (unexchanged) setup keys are live grants the
// operator must be able to see — without them, revoking a paired-but-
// never-connected agent "works" while the list shows nothing.
const agents = listTokens({ includeSetup: true }).map(t => ({
clientId: t.clientId,
scopes: t.scopes,
domains: t.domains,
expiresAt: t.expiresAt,
commandCount: t.commandCount,
createdAt: t.createdAt,
pending: t.type === 'setup',
}));
return new Response(JSON.stringify({ agents }), {
status: 200, headers: { 'Content-Type': 'application/json' },
@@ -2372,12 +2392,20 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
}
try {
const pairBody = await req.json() as any;
// Default: full access (read+write+admin+meta). The trust boundary is
// the pairing ceremony itself, not the scope. --control adds browser-wide
// destructive commands (stop, restart, disconnect). --restrict limits scope.
// Default: DEFAULT_PAIR_SCOPES (full page access). The trust boundary
// is the pairing ceremony itself, not the scope. --control adds
// browser-wide destructive commands (stop, restart, disconnect).
// --restrict limits scope — but can never grant control: that scope
// stays behind the explicit control flag.
if (!pairBody.control && !pairBody.admin
&& Array.isArray(pairBody.scopes) && pairBody.scopes.includes('control')) {
return new Response(JSON.stringify({
error: 'The control scope requires the control flag (--control); it cannot be granted via a scopes list.',
}), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
const scopes = pairBody.control || pairBody.admin
? ['read', 'write', 'admin', 'meta', 'control'] as const
: (pairBody.scopes || ['read', 'write', 'admin', 'meta']) as const;
? [...DEFAULT_PAIR_SCOPES, 'control' as const]
: ((pairBody.scopes || [...DEFAULT_PAIR_SCOPES]) as ScopeCategory[]);
const setupKey = createSetupKey({
clientId: pairBody.clientId,
scopes: [...scopes],
@@ -2413,7 +2441,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
tunnel_url: verifiedTunnelUrl,
server_url: `http://127.0.0.1:${browsePort}`,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch {
} catch (err) {
// Name the caller's typo (bad scope, negative rateLimit) instead of
// hiding it behind the generic body error.
if (err instanceof InvalidScopeError) {
return new Response(JSON.stringify({ error: err.message }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
+1 -1
View File
@@ -87,5 +87,5 @@ export function mintSkillToken(opts: MintSkillTokenOptions): TokenInfo {
* token returns false but is not an error.
*/
export function revokeSkillToken(skillName: string, spawnId: string): boolean {
return revokeToken(skillClientId(skillName, spawnId));
return Boolean(revokeToken(skillClientId(skillName, spawnId)));
}
+64 -17
View File
@@ -19,7 +19,12 @@
*
* Security invariants:
* 1. Only root token can mint sub-tokens (POST /token, POST /connect)
* 2. admin scope denied by default — must be explicitly granted
* 2. control scope denied by default — must be explicitly flagged.
* Registry API defaults (createToken/createSetupKey with no scopes)
* stay ['read','write']; the /pair ceremony explicitly grants
* DEFAULT_PAIR_SCOPES (read+write+admin+meta — the pairing ceremony
* is the trust boundary; --restrict narrows, --control must be
* explicit and never rides in via a scopes list)
* 3. chain command scope-checks each subcommand individually
* 4. Root token never in connection strings or pasted instructions
*
@@ -82,6 +87,35 @@ const SCOPE_MAP: Record<ScopeCategory, Set<string>> = {
meta: SCOPE_META,
};
/**
* Scopes granted by POST /pair when nothing narrower is requested.
* Deliberately full page access (b73f3644 / #907): the trust boundary is the
* pairing ceremony, not the scope. 'control' (browser-wide destructive ops)
* is the only scope that stays opt-in via the control flag. Referenced by
* BOTH server.ts (/pair default) and cli.ts (explicit send) so the two
* defaults cannot silently drift apart again.
*/
export const DEFAULT_PAIR_SCOPES: readonly ScopeCategory[] = ['read', 'write', 'admin', 'meta'];
/**
* Typed error for caller-supplied token options (unknown scope, negative
* rateLimit). HTTP handlers catch it to 400 with the message at the endpoint
* where the typo happened — pre-fix, a bad scope sailed through /pair into a
* poisoned setup key and surfaced as a misleading "Invalid request body" to
* the remote agent at /connect.
*/
export class InvalidScopeError extends Error {}
function assertValidTokenOptions(scopes: readonly string[], rateLimit: number): void {
const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta', 'control'];
for (const s of scopes) {
if (!validScopes.includes(s as ScopeCategory)) {
throw new InvalidScopeError(`Invalid scope: ${s}. Valid: ${validScopes.join(', ')}`);
}
}
if (rateLimit < 0) throw new InvalidScopeError('rateLimit must be >= 0');
}
// ─── Types ──────────────────────────────────────────────────────
export interface TokenInfo {
@@ -200,13 +234,7 @@ export function createToken(opts: CreateTokenOptions): TokenInfo {
} = 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');
assertValidTokenOptions(scopes, rateLimit);
if (expiresSeconds !== null && expiresSeconds !== undefined && expiresSeconds < 0) {
throw new Error('expiresSeconds must be >= 0 or null');
}
@@ -248,6 +276,13 @@ export function createToken(opts: CreateTokenOptions): TokenInfo {
* Setup keys expire in 5 minutes and can only be exchanged once.
*/
export function createSetupKey(opts: Omit<CreateTokenOptions, 'clientId'> & { clientId?: string }): TokenInfo {
const scopes = opts.scopes || ['read', 'write'];
// ?? not ||: rateLimit 0 is documented as "unlimited" and must survive.
const rateLimit = opts.rateLimit ?? 10;
// Validate HERE, not only at exchange time in createToken — otherwise a
// typo mints a poisoned setup key whose failure surfaces to the wrong
// party (the remote agent, at /connect, as "Invalid request body").
assertValidTokenOptions(scopes, rateLimit);
const token = generateToken('gsk_setup_');
const now = new Date();
const expiresAt = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 min
@@ -256,10 +291,10 @@ export function createSetupKey(opts: Omit<CreateTokenOptions, 'clientId'> & { cl
token,
clientId: opts.clientId || `remote-${Date.now()}`,
type: 'setup',
scopes: opts.scopes || ['read', 'write'],
scopes,
domains: opts.domains,
tabPolicy: opts.tabPolicy || 'own-only',
rateLimit: opts.rateLimit || 10,
rateLimit,
expiresAt,
createdAt: now.toISOString(),
usesRemaining: 1,
@@ -417,17 +452,23 @@ export function recordCommand(token: string): void {
}
/**
* Revoke a token by client ID. Returns true if found and revoked.
* Revoke ALL tokens for a client ID — the session token and every setup key,
* spent or unspent. Deleting only the first match left two holes: a spent
* setup key (kept for idempotent re-exchange) shadowed the session token, so
* revoke reported success while the live session survived; and an unspent
* setup key surviving revoke let a "revoked" agent POST /connect into a
* fresh session. Returns the number of tokens deleted (0 = nothing found).
*/
export function revokeToken(clientId: string): boolean {
export function revokeToken(clientId: string): number {
let deleted = 0;
for (const [token, info] of tokens) {
if (info.clientId === clientId) {
tokens.delete(token);
rateBuckets.delete(clientId);
return true;
tokens.delete(token); // Map tolerates delete during for...of iteration
deleted++;
}
}
return false;
if (deleted > 0) rateBuckets.delete(clientId);
return deleted;
}
/**
@@ -443,8 +484,12 @@ export function rotateRoot(): string {
/**
* List all active (non-expired) scoped tokens.
* With includeSetup, unexchanged ("pending") setup keys are listed too —
* they are live grants an operator must be able to see and revoke. Spent
* keys stay hidden: they are re-exchange bookkeeping for a session that is
* already listed.
*/
export function listTokens(): TokenInfo[] {
export function listTokens(opts?: { includeSetup?: boolean }): TokenInfo[] {
const now = new Date();
const result: TokenInfo[] = [];
@@ -455,6 +500,8 @@ export function listTokens(): TokenInfo[] {
}
if (info.type === 'session') {
result.push(info);
} else if (opts?.includeSetup && info.type === 'setup' && info.usesRemaining !== 0) {
result.push(info);
}
}
+211
View File
@@ -194,6 +194,217 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
expect(Array.isArray(scopes)).toBe(true);
});
// ─── Pair scope contract: defaults, explicit lists, typo naming ───────
test('default /connect scopes are exactly read,write,admin,meta', async () => {
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'default-scopes' }),
});
const { setup_key, scopes: pairScopes } = await pairResp.json() as any;
expect(pairScopes).toEqual(['read', 'write', 'admin', 'meta']);
const connectResp = await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key }),
});
const { scopes } = await connectResp.json() as any;
expect(scopes).toEqual(['read', 'write', 'admin', 'meta']);
});
test('explicit scopes are honored end-to-end (the --restrict wire contract)', async () => {
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'restricted-scopes', scopes: ['read'] }),
});
const { setup_key } = await pairResp.json() as any;
const connectResp = await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key }),
});
const { scopes } = await connectResp.json() as any;
expect(scopes).toEqual(['read']);
});
test('POST /pair with a scope typo fails fast, naming the scope', async () => {
// Regression: pre-fix this returned 200 with a poisoned setup key whose
// failure surfaced at /connect as a misleading "Invalid request body".
const resp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'typo-agent', scopes: ['raed'] }),
});
expect(resp.status).toBe(400);
const body = await resp.json() as any;
expect(body.error).toContain('Invalid scope: raed');
});
test('POST /token with a scope typo names the scope too', async () => {
const resp = await fetch(`${daemon.baseUrl}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'typo-token', scopes: ['wirte'] }),
});
expect(resp.status).toBe(400);
const body = await resp.json() as any;
expect(body.error).toContain('Invalid scope: wirte');
});
test('control cannot ride in through a /pair scopes list without the control flag', async () => {
const resp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'sneaky', scopes: ['read', 'control'] }),
});
expect(resp.status).toBe(400);
const body = await resp.json() as any;
expect(body.error).toContain('control');
});
test('scope-denied 403 hint points at --restrict/--control, never --admin', async () => {
// Regression: the old hint said "re-pair with --admin", which is a legacy
// alias for --control — following it over-granted browser-wide control.
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'hint-agent', scopes: ['read'] }),
});
const { setup_key } = await pairResp.json() as any;
const connectResp = await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key }),
});
const { token } = await connectResp.json() as any;
const resp = await fetch(`${daemon.baseUrl}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ command: 'goto', args: ['https://example.com'] }),
});
expect(resp.status).toBe(403);
const body = await resp.json() as any;
expect(body.hint).toContain('--restrict');
expect(body.hint).toContain('--control');
expect(body.hint).not.toContain('--admin');
});
// ─── Revocation e2e: revoke-all + the /agents verification surface ────
test('DELETE /token revokes session AND setup keys; agent leaves /agents; token 401s; re-connect fails', async () => {
const pair = async () => {
const resp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'revoke-e2e' }),
});
return (await resp.json() as any).setup_key as string;
};
const key1 = await pair();
const connectResp = await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key: key1 }),
});
const { token: scopedToken } = await connectResp.json() as any;
// A second, UNSPENT setup key for the same clientId (the re-grant hole).
const key2 = await pair();
const pre = await fetch(`${daemon.baseUrl}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${scopedToken}` },
body: JSON.stringify({ command: 'status', args: [] }),
});
expect(pre.status).not.toBe(401);
// /agents lists the session AND the pending setup key, never the token.
const agentsPre = await (await fetch(`${daemon.baseUrl}/agents`, {
headers: { Authorization: `Bearer ${daemon.token}` },
})).json() as any;
expect(agentsPre.agents.some((a: any) => a.clientId === 'revoke-e2e' && !a.pending)).toBe(true);
expect(agentsPre.agents.some((a: any) => a.clientId === 'revoke-e2e' && a.pending)).toBe(true);
for (const a of agentsPre.agents) expect(a.token).toBeUndefined();
// Regression: pre-fix this deleted only the spent setup key and returned
// a false 200 while the session survived. Count covers session + spent
// key + pending key.
const del = await fetch(`${daemon.baseUrl}/token/revoke-e2e`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${daemon.token}` },
});
expect(del.status).toBe(200);
const delBody = await del.json() as any;
expect(delBody.revoked).toBe('revoke-e2e');
expect(delBody.tokens_deleted).toBe(3);
// Assert per-clientId absence, NOT list-empty: this file shares one
// daemon and other tests' agents remain listed.
const agentsPost = await (await fetch(`${daemon.baseUrl}/agents`, {
headers: { Authorization: `Bearer ${daemon.token}` },
})).json() as any;
expect(agentsPost.agents.some((a: any) => a.clientId === 'revoke-e2e')).toBe(false);
const post = await fetch(`${daemon.baseUrl}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${scopedToken}` },
body: JSON.stringify({ command: 'status', args: [] }),
});
expect(post.status).toBe(401);
// The leftover unspent key is dead too (re-grant hole closed).
const reconnect = await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key: key2 }),
});
expect(reconnect.status).toBe(401);
});
test('second DELETE /token for the same clientId returns 404, not a false 200', async () => {
// Regression: pre-fix, consecutive DELETEs both returned 200 — the first
// consumed the spent setup key, the second the session. Depends on the
// previous test having revoked 'revoke-e2e' (bun runs file tests in order).
const del = await fetch(`${daemon.baseUrl}/token/revoke-e2e`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${daemon.token}` },
});
expect(del.status).toBe(404);
});
test('DELETE /token decodes percent-encoded clientIds', async () => {
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
body: JSON.stringify({ clientId: 'space agent' }),
});
const { setup_key } = await pairResp.json() as any;
await fetch(`${daemon.baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key }),
});
const del = await fetch(`${daemon.baseUrl}/token/${encodeURIComponent('space agent')}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${daemon.token}` },
});
expect(del.status).toBe(200);
const agents = await (await fetch(`${daemon.baseUrl}/agents`, {
headers: { Authorization: `Bearer ${daemon.token}` },
})).json() as any;
expect(agents.agents.some((a: any) => a.clientId === 'space agent')).toBe(false);
});
test('DELETE /token with malformed percent-encoding returns 400', async () => {
const del = await fetch(`${daemon.baseUrl}/token/%E0%A4%A`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${daemon.token}` },
});
expect(del.status).toBe(400);
});
test('POST /command with no auth returns 401', async () => {
const resp = await fetch(`${daemon.baseUrl}/command`, {
method: 'POST',
+31
View File
@@ -409,3 +409,34 @@ describe('Server auth security', () => {
expect(routeSrc).toContain('SameSite=Strict');
});
});
describe('Pair scope defaults and revocation surface', () => {
// Regression: the CLI only sent scopes when --restrict was passed, so the
// effective pairing default lived in two places (CLI omission + server
// fallback) and could silently drift. Both sides must reference the shared
// DEFAULT_PAIR_SCOPES constant, and the CLI must send scopes
// unconditionally (the old conditional-spread shape is banned).
test('/pair default and CLI pairing body share DEFAULT_PAIR_SCOPES', () => {
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "url.pathname === '/tunnel/start'");
expect(pairBlock).toContain('DEFAULT_PAIR_SCOPES');
const cliBlock = sliceBetween(CLI_SRC, 'async function handlePairAgent', 'Determine the URL to use');
// Match the CODE shape, not a comment: a bare toContain('DEFAULT_PAIR_SCOPES')
// is satisfied by the explanatory comment and passes vacuously on a revert.
expect(cliBlock).toMatch(/scopes:\s*restrict\s*\?[\s\S]{0,200}?:\s*\[\.\.\.DEFAULT_PAIR_SCOPES\]/);
expect(cliBlock).not.toMatch(/\.\.\.\(restrict\s*\?/);
});
// control is the only scope behind an explicit flag; a scopes list must
// not be able to smuggle it into a pairing grant.
test('/pair rejects control inside a scopes list without the control flag', () => {
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "url.pathname === '/tunnel/start'");
expect(pairBlock).toContain("pairBody.scopes.includes('control')");
});
// CLI-encoded clientIds (spaces, UTF-8) must round-trip through the revoke
// route; slicing the raw pathname 404s on every encoded name.
test('DELETE /token decodes the clientId path segment', () => {
const revokeBlock = sliceBetween(SERVER_SRC, "url.pathname.startsWith('/token/')", "url.pathname === '/agents'");
expect(revokeBlock).toContain('decodeURIComponent');
});
});
+85 -3
View File
@@ -6,6 +6,7 @@ import {
revokeToken, rotateRoot, listTokens, recordCommand,
serializeRegistry, restoreRegistry, checkConnectRateLimit,
SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_CONTROL, SCOPE_META,
DEFAULT_PAIR_SCOPES, InvalidScopeError,
__resetRegistry,
} from '../src/token-registry';
@@ -298,12 +299,81 @@ describe('token-registry', () => {
describe('revokeToken', () => {
it('revokes existing token', () => {
const info = createToken({ clientId: 'to-revoke' });
expect(revokeToken('to-revoke')).toBe(true);
// revokeToken returns the delete count, not a boolean (truthy for callers)
expect(revokeToken('to-revoke')).toBe(1);
expect(validateToken(info.token)).toBeNull();
});
it('returns false for non-existent client', () => {
expect(revokeToken('no-such-client')).toBe(false);
it('returns 0 for non-existent client', () => {
expect(revokeToken('no-such-client')).toBe(0);
});
// Regression: revokeToken deleted only the FIRST matching Map entry. The
// spent setup key (kept for idempotent re-exchange) is inserted before the
// session token, so it shadowed the session: revoke reported success while
// the live session survived and DELETE /token returned a false 200.
it('revokes the session even when a spent setup key precedes it (shape a)', () => {
const setup = createSetupKey({ clientId: 'shadowed' });
const session = exchangeSetupKey(setup.token)!;
expect(revokeToken('shadowed')).toBe(2);
expect(validateToken(session.token)).toBeNull();
expect(exchangeSetupKey(setup.token)).toBeNull();
});
// Regression: an UNSPENT setup key created after the session survived the
// old first-match revoke, so a "revoked" agent could POST /connect and
// mint a brand-new session within the key's 5-minute validity window.
it('closes the re-grant hole: unspent setup key dies with the revoke (shape b)', () => {
const first = createSetupKey({ clientId: 'regrant' });
exchangeSetupKey(first.token);
const second = createSetupKey({ clientId: 'regrant' });
expect(revokeToken('regrant')).toBe(3);
expect(exchangeSetupKey(second.token)).toBeNull();
expect(listTokens().filter(t => t.clientId === 'regrant')).toHaveLength(0);
});
it('revokes multiple pending setup keys for one clientId in a single call (shape c)', () => {
const keys = [1, 2, 3].map(() => createSetupKey({ clientId: 'multi' }));
expect(revokeToken('multi')).toBe(3);
for (const k of keys) expect(exchangeSetupKey(k.token)).toBeNull();
expect(revokeToken('multi')).toBe(0); // idempotent: second call finds nothing
});
it('does not touch other clients\' tokens', () => {
const bystander = createToken({ clientId: 'bystander' });
createSetupKey({ clientId: 'target' });
createToken({ clientId: 'target' });
expect(revokeToken('target')).toBe(2);
expect(validateToken(bystander.token)).not.toBeNull();
});
});
describe('pair defaults and option validation', () => {
it('DEFAULT_PAIR_SCOPES is exactly read,write,admin,meta (b73f3644: the ceremony is the trust boundary)', () => {
expect([...DEFAULT_PAIR_SCOPES]).toEqual(['read', 'write', 'admin', 'meta']);
});
// Regression: only createToken validated options, so a scope typo minted
// a poisoned setup key at /pair and surfaced to the REMOTE agent at
// /connect as a misleading "Invalid request body".
it('createSetupKey rejects an unknown scope with InvalidScopeError naming it', () => {
expect(() => createSetupKey({ scopes: ['raed' as never] }))
.toThrow(InvalidScopeError);
expect(() => createSetupKey({ scopes: ['raed' as never] }))
.toThrow('Invalid scope: raed');
});
it('createSetupKey rejects a negative rateLimit', () => {
expect(() => createSetupKey({ rateLimit: -5 })).toThrow(InvalidScopeError);
});
// Regression: `opts.rateLimit || 10` coerced the documented "0 = unlimited"
// into 10 on the /pair path while /token honored it.
it('createSetupKey preserves rateLimit 0 (unlimited)', () => {
const setup = createSetupKey({ rateLimit: 0 });
expect(setup.rateLimit).toBe(0);
const session = exchangeSetupKey(setup.token)!;
expect(session.rateLimit).toBe(0);
});
});
@@ -326,6 +396,18 @@ describe('token-registry', () => {
createSetupKey({}); // setup keys not listed
expect(listTokens()).toHaveLength(2);
});
it('includeSetup lists pending setup keys but hides spent ones', () => {
createToken({ clientId: 'sess' });
createSetupKey({ clientId: 'pending' });
const spent = createSetupKey({ clientId: 'spent' });
exchangeSetupKey(spent.token);
expect(listTokens().map(t => t.clientId).sort()).toEqual(['sess', 'spent']);
const withSetup = listTokens({ includeSetup: true });
// Pending key = a live grant the operator must see; the SPENT key is
// re-exchange bookkeeping for the already-listed session and stays hidden.
expect(withSetup.filter(t => t.type === 'setup').map(t => t.clientId)).toEqual(['pending']);
});
});
describe('serialization', () => {
+420
View File
@@ -0,0 +1,420 @@
/**
* Behavior tests for the `tunnel revoke` / `tunnel agents` CLI subcommands.
*
* Three harness shapes:
* 1. No/dead daemon scratch BROWSE_STATE_FILE, no processes (the
* stop-dead-daemon.test.ts pattern): tunnel must exit 0 WITHOUT booting
* a daemon (#2254 tokens are memory-only, a dead daemon has nothing
* to revoke).
* 2. Live daemon real server subprocess with BROWSE_HEADLESS_SKIP=1
* (the pair-agent-e2e.test.ts pattern): the full revoke + verify loop.
* 3. Stub daemon a test-local Bun.serve behind a hand-written state file
* with an ALIVE pid (this test process). Pins the version-skew net (an
* OLD daemon with the first-match revoke bug returns 200 while /agents
* keeps listing the agent) and the unreachable-daemon branch. The pid
* decides the dead-daemon vs unreachable branch, so stubs MUST carry an
* alive pid.
*/
import { describe, test, expect } from 'bun:test';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '../..');
const SERVER_ENTRY = path.join(ROOT, 'browse/src/server.ts');
function runCli(args: string[], env: Record<string, string>, timeoutMs = 30_000):
Promise<{ code: number; stdout: string; stderr: string }> {
const cliPath = path.resolve(import.meta.dir, '../src/cli.ts');
return new Promise((resolve) => {
const proc = spawn('bun', ['run', cliPath, ...args], { timeout: timeoutMs, env });
let stdout = ''; let stderr = '';
proc.stdout.on('data', (d) => stdout += d.toString());
proc.stderr.on('data', (d) => stderr += d.toString());
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
function baseEnv(stateFile: string): Record<string, string> {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v;
}
env.BROWSE_STATE_FILE = stateFile;
return env;
}
/** Grab a port that is definitely closed (bind, read, release). */
async function closedPort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address();
if (!addr || typeof addr === 'string') { reject(new Error('bad address')); return; }
const port = addr.port;
srv.close(() => resolve(port));
});
});
}
function writeStateFile(stateFile: string, pid: number, port: number): void {
fs.writeFileSync(stateFile, JSON.stringify({
pid,
port,
token: 'fake-root-token',
startedAt: new Date().toISOString(),
serverPath: '',
mode: 'launched' as const,
}, null, 2));
}
describe('tunnel subcommand parsing', () => {
test('bare tunnel / unknown sub / empty name / extra args → usage, exit 1', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-usage-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
for (const args of [
['tunnel'],
['tunnel', 'rotate'],
['tunnel', 'revoke'],
['tunnel', 'revoke', ''],
['tunnel', 'revoke', 'a', 'b'],
['tunnel', 'agents', 'extra'],
]) {
const result = await runCli(args, baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('usage: browse tunnel');
}
// Arg errors must never boot a daemon.
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 60_000);
});
// pair-agent scope-flag misuse shares this file's subprocess harness: like
// tunnel, the validation must run pre-server, so "no daemon spawned" is the
// load-bearing assertion.
describe('pair-agent scope-flag validation (pre-server)', () => {
test('bare --restrict → exit 1 usage error, NO daemon spawned (was: silent FULL grant)', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-restrict-bare-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['pair-agent', '--restrict'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('--restrict needs a scope list');
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('--restrict=read (equals form) → exit 1, NO daemon spawned', async () => {
// Regression: hasFlag/parseFlag are exact-token matches, so the equals
// form sailed past validatePairAgentFlags AND handlePairAgent's parse —
// the user asked for a read-only sandbox and silently got FULL access.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-restrict-eq-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['pair-agent', '--restrict=read'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('--restrict takes a space-separated value');
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('--restrict swallowing the next flag → exit 1, NO daemon spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-restrict-flag-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['pair-agent', '--restrict', '--client', 'bob'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('--restrict needs a scope list');
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('--restrict "read,control" → exit 1 pointing at --control, NO daemon spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-restrict-ctl-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['pair-agent', '--restrict', 'read,control'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('Re-run with --control');
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
});
describe('tunnel against no daemon (#2254 — never boot one)', () => {
test('revoke → exit 0, "No daemon running", nothing spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-dead-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['tunnel', 'revoke', 'ghost'], baseEnv(stateFile));
expect(result.code).toBe(0);
expect(result.stdout).toContain('No daemon running');
// A spawned daemon would have written the state file.
expect(fs.existsSync(stateFile)).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('agents → exit 0; stale state (dead pid + closed port) is NOT mutated — cleanup stays stop\'s job', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-stale-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
writeStateFile(stateFile, 2147483646, await closedPort());
const before = fs.readFileSync(stateFile, 'utf-8');
const result = await runCli(['tunnel', 'agents'], baseEnv(stateFile));
expect(result.code).toBe(0);
expect(result.stdout).toContain('No daemon running');
expect(fs.readFileSync(stateFile, 'utf-8')).toBe(before);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
});
describe('tunnel against a live daemon (HTTP only, no browser)', () => {
test('pair → connect → revoke: verified gone, token 401s; agents lists pending keys', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-live-'));
const stateFile = path.join(tmpDir, 'browse.json');
const port = 20000 + Math.floor(Math.random() * 20000);
const daemon = Bun.spawn(['bun', 'run', SERVER_ENTRY], {
cwd: ROOT,
env: {
...process.env,
BROWSE_HEADLESS_SKIP: '1',
BROWSE_PORT: String(port),
BROWSE_STATE_FILE: stateFile,
BROWSE_PARENT_PID: '0',
BROWSE_IDLE_TIMEOUT: '600000',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
const baseUrl = `http://127.0.0.1:${port}`;
try {
const deadline = Date.now() + 15_000;
let ready = false;
while (Date.now() < deadline && !ready) {
try {
const resp = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1000) });
ready = resp.ok;
} catch { /* not ready yet */ }
if (!ready) await new Promise(r => setTimeout(r, 200));
}
expect(ready).toBe(true);
const rootToken = (JSON.parse(fs.readFileSync(stateFile, 'utf-8')) as { token: string }).token;
// Pair + connect a session, plus a second pending setup key.
const pair = async () => {
const resp = await fetch(`${baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${rootToken}` },
body: JSON.stringify({ clientId: 'cli-agent' }),
});
return (await resp.json() as { setup_key: string }).setup_key;
};
const key1 = await pair();
const connectResp = await fetch(`${baseUrl}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setup_key: key1 }),
});
const { token: scopedToken } = await connectResp.json() as { token: string };
await pair(); // pending key
// tunnel agents shows the session AND the pending key.
const list = await runCli(['tunnel', 'agents'], baseEnv(stateFile));
expect(list.code).toBe(0);
expect(list.stdout).toContain('cli-agent');
expect(list.stdout).toContain('(pending setup key)');
// Unknown name → truthful failure with the active list.
const miss = await runCli(['tunnel', 'revoke', 'nobody'], baseEnv(stateFile));
expect(miss.code).toBe(1);
expect(miss.stderr).toContain('No paired agent named "nobody"');
expect(miss.stderr).toContain('cli-agent');
// The real revoke: counted, verified, and the token actually dies.
const revoke = await runCli(['tunnel', 'revoke', 'cli-agent'], baseEnv(stateFile));
expect(revoke.code).toBe(0);
expect(revoke.stdout).toContain('Revoked "cli-agent" (3 tokens)');
expect(revoke.stdout).toContain('Verified: not in the active agent list.');
const post = await fetch(`${baseUrl}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${scopedToken}` },
body: JSON.stringify({ command: 'status', args: [] }),
});
expect(post.status).toBe(401);
const empty = await runCli(['tunnel', 'agents'], baseEnv(stateFile));
expect(empty.code).toBe(0);
expect(empty.stdout).toContain('No paired agents.');
// Regression: handleTunnel used to trim() the name, but clientIds are
// stored verbatim — a space-padded agent became unrevocable by the
// documented kill switch (trimmed DELETE 404'd while the grant lived).
const padPair = await fetch(`${baseUrl}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${rootToken}` },
body: JSON.stringify({ clientId: ' padded' }),
});
expect(padPair.status).toBe(200);
const padRevoke = await runCli(['tunnel', 'revoke', ' padded'], baseEnv(stateFile));
expect(padRevoke.code).toBe(0);
expect(padRevoke.stdout).toContain('Verified: not in the active agent list.');
} finally {
try { daemon.kill('SIGKILL'); } catch { /* already gone */ }
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 60_000);
});
describe('tunnel against a lying or unreachable daemon (stub harness)', () => {
test('old daemon 200s the DELETE but keeps listing the agent → "Revocation incomplete", exit 1', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-skew-'));
const stateFile = path.join(tmpDir, 'browse.json');
// Old daemons answer {revoked} with no tokens_deleted — this also pins
// the "(count unknown)" print (never undefined/NaN).
const stub = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
const url = new URL(req.url);
if (req.method === 'DELETE' && url.pathname.startsWith('/token/')) {
return Response.json({ revoked: 'mallory' });
}
if (url.pathname === '/agents') {
return Response.json({
agents: [{ clientId: 'mallory', scopes: ['read'], expiresAt: null, commandCount: 1, createdAt: '' }],
});
}
return Response.json({ status: 'healthy' });
},
});
try {
writeStateFile(stateFile, process.pid, stub.port);
const result = await runCli(['tunnel', 'revoke', 'mallory'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stdout).toContain('count unknown');
expect(result.stderr).toContain('Revocation incomplete: "mallory" is still listed');
} finally {
stub.stop(true);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('DELETE succeeds but /agents errors → "could not verify", exit 1 (never a silent success)', async () => {
// Regression pin: a revoke whose verification read fails must NOT report
// clean success — the whole point of the re-read is proving the deletion.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-noverify-'));
const stateFile = path.join(tmpDir, 'browse.json');
const stub = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
const url = new URL(req.url);
if (req.method === 'DELETE' && url.pathname.startsWith('/token/')) {
return Response.json({ revoked: 'mallory', tokens_deleted: 1 });
}
if (url.pathname === '/agents') {
return new Response('boom', { status: 500 });
}
return Response.json({ status: 'healthy' });
},
});
try {
writeStateFile(stateFile, process.pid, stub.port);
const result = await runCli(['tunnel', 'revoke', 'mallory'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('Revoked, but could not verify against the agent list.');
} finally {
stub.stop(true);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('DELETE returns 500 → "Revoke failed" with the body error, exit 1', async () => {
// Regression pin: a non-404 HTTP failure must surface the daemon's error,
// not fall through to a success print.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-500-'));
const stateFile = path.join(tmpDir, 'browse.json');
const stub = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
const url = new URL(req.url);
if (req.method === 'DELETE' && url.pathname.startsWith('/token/')) {
return Response.json({ error: 'registry exploded' }, { status: 500 });
}
return Response.json({ status: 'healthy' });
},
});
try {
writeStateFile(stateFile, process.pid, stub.port);
const result = await runCli(['tunnel', 'revoke', 'mallory'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('Revoke failed: registry exploded');
} finally {
stub.stop(true);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('tunnel agents with a broken /agents → "Could not read the agent list", exit 1', async () => {
// Regression pin: a 500 from /agents must not render as "No paired
// agents." — an unreadable list is not an empty list.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-agents500-'));
const stateFile = path.join(tmpDir, 'browse.json');
const stub = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === '/agents') {
return new Response('boom', { status: 500 });
}
return Response.json({ status: 'healthy' });
},
});
try {
writeStateFile(stateFile, process.pid, stub.port);
const result = await runCli(['tunnel', 'agents'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('Could not read the agent list');
expect(result.stdout).not.toContain('No paired agents.');
} finally {
stub.stop(true);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('alive pid but unreachable port → "Could not reach daemon", exit 1 (NOT "no daemon")', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-tunnel-unreach-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
// Alive pid (this test process) is what routes to the fetch-failure
// branch; a dead pid + dead port would be the exit-0 "no daemon" path.
writeStateFile(stateFile, process.pid, await closedPort());
const result = await runCli(['tunnel', 'revoke', 'anyone'], baseEnv(stateFile));
expect(result.code).toBe(1);
expect(result.stderr).toContain('Could not reach daemon');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
});
+9 -8
View File
@@ -17,8 +17,8 @@ GStack Browser Server Any AI agent
├── Local listener 127.0.0.1:LOCAL │
│ (bootstrap, CLI, sidebar, cookies) │
├── Tunnel listener 127.0.0.1:TUNNEL ◄───────┤
│ (pair-agent only: /connect, /command,
│ /sidebar-chat — locked allowlist) │
│ (pair-agent only: /connect and
│ /command — locked allowlist)
├── ngrok tunnel (forwards tunnel port only) │
│ https://xxx.ngrok.dev ─────────────────┘
└── Token Registry
@@ -32,7 +32,7 @@ GStack Browser Server Any AI agent
The daemon binds two HTTP sockets. The **local listener** serves the full command surface to 127.0.0.1 only and is never forwarded. The **tunnel listener** is bound lazily on `/tunnel/start` (and torn down on `/tunnel/stop`) with a locked path allowlist. ngrok forwards only the tunnel port.
A caller who stumbles onto your ngrok URL cannot reach `/health`, `/cookie-picker`, `/inspector/*`, or `/welcome` — those paths don't exist on that TCP socket. Root tokens sent over the tunnel get 403. The tunnel listener accepts only `/connect`, `/command` (with a scoped token + the 26-command browser-driving allowlist), and `/sidebar-chat`.
A caller who stumbles onto your ngrok URL cannot reach `/health`, `/cookie-picker`, `/inspector/*`, or `/welcome` — those paths don't exist on that TCP socket. Root tokens sent over the tunnel get 403. The tunnel listener accepts only `/connect` and `/command` (with a scoped token + the 26-command browser-driving allowlist).
See [ARCHITECTURE.md](../ARCHITECTURE.md#dual-listener-tunnel-architecture-v1600) for the full endpoint table.
@@ -67,7 +67,7 @@ Exchange a setup key for a session token. No auth required. Rate-limited to 300/
```json
Request: {"setup_key": "gsk_setup_..."}
Response: {"token": "gsk_sess_...", "expires": "ISO8601", "scopes": ["read","write"], "agent": "agent-name"}
Response: {"token": "gsk_sess_...", "expires": "ISO8601", "scopes": ["read","write","admin","meta"], "agent": "agent-name"}
```
#### POST /command
@@ -146,8 +146,9 @@ CSS selectors. Always `snapshot -i` first, then use the refs.
| `write` | goto, click, fill, scroll, newtab, closetab, etc. |
| `admin` | eval, js, cookies, storage, cookie-import, useragent, etc. |
| `meta` | tab, diff, frame, responsive, watch |
| `control` | stop, restart, disconnect, state, handoff — browser-wide destructive ops |
Default tokens get `read` + `write`. Admin requires `--admin` flag when pairing.
Paired agents get `read+write+admin+meta` by default; the pairing ceremony is the trust boundary. `--restrict` narrows the list (it can never grant `control`). `--control` adds the control scope (`--admin` is a legacy alias). Over the tunnel, the `js`/`cookies`/`storage` commands are blocked by the command allowlist regardless of scope; `eval` works. Pair with `--restrict "read,write"` when the agent will read untrusted web content — scope caps the prompt-injection blast radius.
## Tab Isolation
@@ -162,7 +163,7 @@ Each agent owns the tabs it creates. Rules:
| Code | Meaning | What to do |
|------|---------|------------|
| 401 | Token invalid, expired, or revoked | Ask user to run /pair-agent again |
| 403 | Command not in scope, or tab not yours | Use newtab, or ask for --admin |
| 403 | Command not in scope, tab not yours, or not on the tunnel allowlist | Use newtab; the user can re-pair without --restrict or with --control |
| 429 | Rate limit exceeded (>10 req/s) | Wait for Retry-After header |
## Security Model
@@ -173,8 +174,8 @@ Each agent owns the tabs it creates. Rules:
- **Setup keys** expire in 5 minutes and can only be used once.
- **Session tokens** expire in 24 hours (configurable).
- The root token never appears in instruction blocks or connection strings.
- **Admin scope** (JS execution, cookie access) is denied by default.
- Tokens can be revoked instantly: `$B tunnel revoke agent-name`
- **Control scope** (stop/restart/disconnect) is denied by default and never rides in via a scopes list. Admin is granted at pairing; `js`/`cookies`/`storage` stay blocked over the tunnel by the command allowlist. Use `--restrict` for less-trusted agents.
- Tokens can be revoked instantly: `$B tunnel revoke agent-name` deletes the session plus any pending setup keys and verifies against the live agent list. `$B tunnel agents` shows who's paired (pending setup keys included). `$B stop` clears everything — tokens never survive the daemon.
- **SSE auth** uses a 30-minute HttpOnly SameSite=Strict cookie, stream-scope only (never valid against `/command`).
- **Path traversal guarded** on `/welcome``GSTACK_SLUG` must match `^[a-z0-9_-]+$` or falls back to the built-in template.
- **SSRF guards** on `goto`, `download`, and scrape paths — validates URL target against a localhost/private-range blocklist.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.68.1",
"version": "1.68.2",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+45 -11
View File
@@ -22,7 +22,8 @@ allowed-tools:
One command generates a setup key and
prints instructions the other agent can follow to connect. Works with OpenClaw,
Hermes, Codex, Cursor, or any agent that can make HTTP requests. The remote agent
gets its own tab with scoped access (read+write by default, admin on request).
gets its own tab with full page access by default (the pairing ceremony is the
trust boundary; --restrict narrows it).
Use when asked to "pair agent", "connect agent", "share browser", "remote browser",
"let another agent use my browser", or "give browser access".
@@ -1000,10 +1001,18 @@ ngrok, start the tunnel, and print the instruction block with the tunnel URL:
$B pair-agent --client TARGET_HOST
```
If the user also needs admin access (JS execution, cookies, storage):
Default access already includes JS execution. To also grant browser-wide
control (stop, restart, disconnect):
```bash
$B pair-agent --admin --client TARGET_HOST
$B pair-agent --control --client TARGET_HOST
```
For a less-trusted agent, narrow the scopes instead:
```bash
$B pair-agent --restrict read --client TARGET_HOST # read-only
$B pair-agent --restrict "read,write" --client TARGET_HOST # no JS, no cookies
```
**CRITICAL: You MUST output the full instruction block to the user.** The command
@@ -1075,15 +1084,28 @@ side panel if you have GStack Browser open."
## What the remote agent can do
With default (read+write) access:
Default access is read+write+admin+meta. The trust boundary is the pairing
ceremony, not the scope:
- Navigate to URLs, click elements, fill forms, take screenshots
- Read page content (text, HTML, snapshot)
- Create new tabs (each agent gets its own)
- Cannot execute arbitrary JavaScript, read cookies, or access storage
- Execute JavaScript via `eval`
- Cannot stop or restart the browser, or disconnect headed mode (needs --control)
With admin access (--admin flag):
- Everything above, plus JS execution, cookie access, storage access
- Use sparingly. Only for agents you fully trust.
Remote agents go through the tunnel command allowlist: `eval` works, but the
`js`, `cookies`, and `storage` commands are not dispatchable over the tunnel
even with admin scope. Agents paired with `--local` get all four.
With --restrict (`--restrict read`, `--restrict "read,write"`):
- Sandboxed sessions: read-only, or read+write with no JS, cookie, or storage
access. Pair this way when the remote agent will read untrusted web content:
a trusted agent can be prompt-injected by pages it reads, and scope caps the
blast radius (eval works over the tunnel).
- `--restrict` never grants `control`; that scope stays behind --control.
With --control (--admin is the legacy alias):
- Everything, plus browser-wide destructive ops (stop, restart, disconnect)
- Only for agents you fully trust.
## Troubleshooting
@@ -1131,9 +1153,21 @@ To disconnect a specific agent:
$B tunnel revoke AGENT_NAME
```
To disconnect all agents and rotate the root token:
The command deletes every token for that agent (the session and any pending
setup keys) and re-reads the agent list to prove it's gone.
See who's paired:
```bash
# This invalidates ALL scoped tokens immediately
$B tunnel rotate
$B tunnel agents
```
Unexchanged setup keys show as "(pending)"; `tunnel revoke` removes them too.
To disconnect ALL agents at once, stop the daemon. Scoped tokens live in
daemon memory and never survive a restart; the next command boots a fresh
daemon with a new root token:
```bash
$B stop
```
+45 -11
View File
@@ -6,7 +6,8 @@ description: |
Pair a remote AI agent with your browser. One command generates a setup key and
prints instructions the other agent can follow to connect. Works with OpenClaw,
Hermes, Codex, Cursor, or any agent that can make HTTP requests. The remote agent
gets its own tab with scoped access (read+write by default, admin on request).
gets its own tab with full page access by default (the pairing ceremony is the
trust boundary; --restrict narrows it).
Use when asked to "pair agent", "connect agent", "share browser", "remote browser",
"let another agent use my browser", or "give browser access". (gstack)
voice-triggers:
@@ -185,10 +186,18 @@ ngrok, start the tunnel, and print the instruction block with the tunnel URL:
$B pair-agent --client TARGET_HOST
```
If the user also needs admin access (JS execution, cookies, storage):
Default access already includes JS execution. To also grant browser-wide
control (stop, restart, disconnect):
```bash
$B pair-agent --admin --client TARGET_HOST
$B pair-agent --control --client TARGET_HOST
```
For a less-trusted agent, narrow the scopes instead:
```bash
$B pair-agent --restrict read --client TARGET_HOST # read-only
$B pair-agent --restrict "read,write" --client TARGET_HOST # no JS, no cookies
```
**CRITICAL: You MUST output the full instruction block to the user.** The command
@@ -260,15 +269,28 @@ side panel if you have GStack Browser open."
## What the remote agent can do
With default (read+write) access:
Default access is read+write+admin+meta. The trust boundary is the pairing
ceremony, not the scope:
- Navigate to URLs, click elements, fill forms, take screenshots
- Read page content (text, HTML, snapshot)
- Create new tabs (each agent gets its own)
- Cannot execute arbitrary JavaScript, read cookies, or access storage
- Execute JavaScript via `eval`
- Cannot stop or restart the browser, or disconnect headed mode (needs --control)
With admin access (--admin flag):
- Everything above, plus JS execution, cookie access, storage access
- Use sparingly. Only for agents you fully trust.
Remote agents go through the tunnel command allowlist: `eval` works, but the
`js`, `cookies`, and `storage` commands are not dispatchable over the tunnel
even with admin scope. Agents paired with `--local` get all four.
With --restrict (`--restrict read`, `--restrict "read,write"`):
- Sandboxed sessions: read-only, or read+write with no JS, cookie, or storage
access. Pair this way when the remote agent will read untrusted web content:
a trusted agent can be prompt-injected by pages it reads, and scope caps the
blast radius (eval works over the tunnel).
- `--restrict` never grants `control`; that scope stays behind --control.
With --control (--admin is the legacy alias):
- Everything, plus browser-wide destructive ops (stop, restart, disconnect)
- Only for agents you fully trust.
## Troubleshooting
@@ -316,9 +338,21 @@ To disconnect a specific agent:
$B tunnel revoke AGENT_NAME
```
To disconnect all agents and rotate the root token:
The command deletes every token for that agent (the session and any pending
setup keys) and re-reads the agent list to prove it's gone.
See who's paired:
```bash
# This invalidates ALL scoped tokens immediately
$B tunnel rotate
$B tunnel agents
```
Unexchanged setup keys show as "(pending)"; `tunnel revoke` removes them too.
To disconnect ALL agents at once, stop the daemon. Scoped tokens live in
daemon memory and never survive a restart; the next command boots a fresh
daemon with a new root token:
```bash
$B stop
```