mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-17 01:31:26 +08:00
Three layers of regression coverage for the tunnel allowlist: 1. dual-listener.test.ts: replaces must-include/must-exclude with exact-set equality on the 26-command literal (the prior intersection-only style let new commands sneak into the source without test updates). Adds a regex assertion that the `command !== 'newtab'` ownership exemption at server.ts:613 still exists — catches refactors that re-introduce the catch-22 from the other side. Updates the /command handler test to look for canDispatchOverTunnel(body?.command) instead of the inline check. 2. tunnel-gate-unit.test.ts (new): 53 expects covering all 26 allowed, 20 blocked, null/undefined/empty/non-string defensive handling, and alias canonicalization (e.g. 'set-content' resolves to 'load-html' which is correctly rejected since 'load-html' isn't tunnel-allowed). 3. pair-agent-tunnel-eval.test.ts (new): 4 behavioral tests that spawn the daemon under BROWSE_HEADLESS_SKIP=1 BROWSE_TUNNEL_LOCAL_ONLY=1, bind both listeners on 127.0.0.1, mint a scoped token via /pair → /connect, and assert: (a) newtab over tunnel passes the gate; (b) pair over tunnel 403s with disallowed_command:pair AND writes a denial-log entry; (c) pair over local does NOT trigger the tunnel gate (proves the gate is surface-scoped); (d) regression for the catch-22 — newtab + goto on the resulting tab does not 403 with "Tab not owned by your agent". All four tests run free under bun test (no API spend, no ngrok). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
98 lines
4.0 KiB
TypeScript
98 lines
4.0 KiB
TypeScript
/**
|
|
* Unit-test the pure tunnel-gate function extracted from the /command handler.
|
|
*
|
|
* The gate decides whether a paired remote agent's request to `/command` over
|
|
* the tunnel surface is allowed (returns true) or 403'd (returns false). Pure,
|
|
* synchronous, no HTTP — testable without standing up a Bun.serve listener.
|
|
*
|
|
* The behavioral coverage of the gate firing on the right surface (and only
|
|
* the right surface) lives in `pair-agent-tunnel-eval.test.ts` (paid eval,
|
|
* gate-tier).
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { canDispatchOverTunnel, TUNNEL_COMMANDS } from '../src/server';
|
|
|
|
describe('canDispatchOverTunnel — closed allowlist', () => {
|
|
test('every command in TUNNEL_COMMANDS dispatches over tunnel', () => {
|
|
for (const cmd of TUNNEL_COMMANDS) {
|
|
expect(canDispatchOverTunnel(cmd)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('TUNNEL_COMMANDS contains the 26-command closed set', () => {
|
|
// Mirror the source-level guard in dual-listener.test.ts. If this ever
|
|
// disagrees with the literal in server.ts, one of them is wrong.
|
|
const expected = new Set([
|
|
'goto', 'click', 'text', 'screenshot',
|
|
'html', 'links', 'forms', 'accessibility',
|
|
'attrs', 'media', 'data',
|
|
'scroll', 'press', 'type', 'select', 'wait', 'eval',
|
|
'newtab', 'tabs', 'back', 'forward', 'reload',
|
|
'snapshot', 'fill', 'url', 'closetab',
|
|
]);
|
|
expect(TUNNEL_COMMANDS.size).toBe(expected.size);
|
|
for (const c of expected) expect(TUNNEL_COMMANDS.has(c)).toBe(true);
|
|
for (const c of TUNNEL_COMMANDS) expect(expected.has(c)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('canDispatchOverTunnel — daemon-config + bootstrap commands rejected', () => {
|
|
const blocked = [
|
|
'pair', 'unpair', 'cookies', 'setup',
|
|
'launch', 'launch-browser', 'connect', 'disconnect',
|
|
'restart', 'stop', 'tunnel-start', 'tunnel-stop',
|
|
'token-mint', 'token-revoke', 'cookie-picker', 'cookie-import',
|
|
'inspector-pick', 'extension-inspect',
|
|
'invalid-command-xyz', 'totally-made-up',
|
|
];
|
|
for (const cmd of blocked) {
|
|
test(`rejects '${cmd}'`, () => {
|
|
expect(canDispatchOverTunnel(cmd)).toBe(false);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('canDispatchOverTunnel — null/undefined/empty input', () => {
|
|
test('returns false for empty string', () => {
|
|
expect(canDispatchOverTunnel('')).toBe(false);
|
|
});
|
|
|
|
test('returns false for undefined', () => {
|
|
expect(canDispatchOverTunnel(undefined)).toBe(false);
|
|
});
|
|
|
|
test('returns false for null', () => {
|
|
expect(canDispatchOverTunnel(null)).toBe(false);
|
|
});
|
|
|
|
test('returns false for non-string input (defensive)', () => {
|
|
// The body parser may hand the gate a number or object if a malicious
|
|
// client sends `{"command": 42}`. The pure gate must treat anything
|
|
// non-string as not-allowed rather than throw.
|
|
expect(canDispatchOverTunnel(42 as unknown as string)).toBe(false);
|
|
expect(canDispatchOverTunnel({} as unknown as string)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('canDispatchOverTunnel — alias canonicalization', () => {
|
|
// canonicalizeCommand resolves aliases (e.g. 'set-content' → 'load-html').
|
|
// Any aliased form of an allowlisted canonical command should also pass the
|
|
// gate; aliases that resolve to a non-allowlisted canonical command should
|
|
// not. We don't hardcode alias names here — we read from the source registry
|
|
// by importing what we need from commands.ts.
|
|
test('aliases that resolve to allowlisted commands pass the gate', () => {
|
|
// 'set-content' canonicalizes to 'load-html'. 'load-html' is NOT in
|
|
// TUNNEL_COMMANDS, so 'set-content' must also be rejected. This guards
|
|
// against a future alias that accidentally maps a tunnel-allowed name to
|
|
// a non-tunnel-allowed canonical (e.g. 'goto' → 'navigate' would break).
|
|
expect(canDispatchOverTunnel('set-content')).toBe(false);
|
|
});
|
|
|
|
test('canonical commands pass directly without alias lookup', () => {
|
|
expect(canDispatchOverTunnel('goto')).toBe(true);
|
|
expect(canDispatchOverTunnel('newtab')).toBe(true);
|
|
expect(canDispatchOverTunnel('closetab')).toBe(true);
|
|
});
|
|
});
|