Merge origin/main into garrytan/browserharness

Resolves three conflicts:

- VERSION: kept 1.19.0.0 (this branch's consolidated Phase 1 + 2a ship);
  main brought 1.17.0.0 (setup-gbrain wireup) on top of 1.16.0.0 (tunnel
  allowlist), this branch leaps past with a gap (CLAUDE.md allows version
  gaps).
- package.json: synced to 1.19.0.0 to match VERSION (HEAD had 1.16.0.0
  stale from the prior release commit; this fixes the drift).
- CHANGELOG.md: kept the v1.19.0.0 entry on top, then main's v1.17.0.0
  + v1.16.0.0 entries below.

browse/src/server.ts auto-merged: main's TUNNEL_COMMANDS expanded from
17→26 (export const) with the canDispatchOverTunnel pure gate is intact
alongside this branch's LOCAL_LISTEN_PORT addition for $B skill run.

Verification: 858/0 pass across browser-skill-write + browser-skill-commands
+ browser-skills-storage + skill-token + dual-listener + tunnel-gate-unit
+ skill-validation + gen-skill-docs (covers both this branch's Phase 2a
and main's tunnel allowlist work).

Regenerated all SKILL.md files (--host all) to absorb main's setup-gbrain
template changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-28 01:47:26 -07:00
21 changed files with 1646 additions and 128 deletions

View File

@@ -116,13 +116,31 @@ const TUNNEL_PATHS = new Set<string>([
* extension-inspector state. This allowlist maps to the eng-review decision
* logged in the CEO plan for sec-wave v1.6.0.0.
*/
const TUNNEL_COMMANDS = new Set<string>([
export const TUNNEL_COMMANDS = new Set<string>([
// Original 17
'goto', 'click', 'text', 'screenshot',
'html', 'links', 'forms', 'accessibility',
'attrs', 'media', 'data',
'scroll', 'press', 'type', 'select', 'wait', 'eval',
// Tab + navigation primitives operator docs and CLI hints already promised
'newtab', 'tabs', 'back', 'forward', 'reload',
// Read/inspect/write operators paired agents need to be useful
'snapshot', 'fill', 'url', 'closetab',
]);
/**
* Pure gate: returns true iff the command is reachable over the tunnel surface.
* Extracted from the inline /command handler so the gate logic is unit-testable
* without standing up an HTTP listener. Behavior is identical to the inline
* check; the function canonicalizes the command (so aliases hit the same set)
* and returns false for null/undefined input.
*/
export function canDispatchOverTunnel(command: string | undefined | null): boolean {
if (typeof command !== 'string' || command.length === 0) return false;
const cmd = canonicalizeCommand(command);
return TUNNEL_COMMANDS.has(cmd);
}
/**
* Read ngrok authtoken from env var, ~/.gstack/ngrok.env, or ngrok's native
* config files. Returns null if nothing found. Shared between the
@@ -1782,8 +1800,7 @@ async function start() {
// Paired remote agents drive the browser but cannot configure the
// daemon, launch new browsers, import cookies, or rotate tokens.
if (surface === 'tunnel') {
const cmd = canonicalizeCommand(body?.command);
if (!cmd || !TUNNEL_COMMANDS.has(cmd)) {
if (!canDispatchOverTunnel(body?.command)) {
logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
return new Response(JSON.stringify({
error: `Command '${body?.command}' is not allowed over the tunnel surface`,
@@ -2070,6 +2087,29 @@ async function start() {
tunnelListener = null;
}
}
} else if (process.env.BROWSE_TUNNEL_LOCAL_ONLY === '1') {
// Test-only: bind the dual-listener tunnel surface on 127.0.0.1 with NO
// ngrok forwarding. Lets paid evals exercise the surface==='tunnel' gate
// without an ngrok authtoken or live network. Production tunneling still
// requires BROWSE_TUNNEL=1 + a valid authtoken above.
try {
const boundTunnel = Bun.serve({
port: 0,
hostname: '127.0.0.1',
fetch: makeFetchHandler('tunnel'),
});
tunnelServer = boundTunnel;
tunnelActive = true;
const tunnelPort = boundTunnel.port;
console.log(`[browse] Tunnel listener bound (local-only test mode) on 127.0.0.1:${tunnelPort}`);
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
stateContent.tunnelLocalPort = tunnelPort;
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] BROWSE_TUNNEL_LOCAL_ONLY=1 listener bind failed: ${err.message}`);
}
}
}