mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 21:49:45 +08:00
feat: extend tunnel allowlist to 26 commands + extract canDispatchOverTunnel
Adds newtab, tabs, back, forward, reload, snapshot, fill, url, closetab to
TUNNEL_COMMANDS (matching what cli.ts and REMOTE_BROWSER_ACCESS.md already
documented). Each new command is bounded by the existing per-tab ownership
check at server.ts:613-624 — scoped tokens default to tabPolicy: 'own-only'
so paired agents still can't operate on tabs they don't own.
Refactors the inline gate check at server.ts:1771-1783 into a pure exported
function canDispatchOverTunnel(command). Same behavior as the inline check;
the difference is unit-testability without HTTP.
Adds BROWSE_TUNNEL_LOCAL_ONLY=1 test-mode flag that binds the second Bun.serve
listener with makeFetchHandler('tunnel') on 127.0.0.1 — no ngrok needed.
Production tunnel still requires BROWSE_TUNNEL=1 + valid NGROK_AUTHTOKEN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -108,13 +108,31 @@ const TUNNEL_PATHS = new Set<string>([
|
|||||||
* extension-inspector state. This allowlist maps to the eng-review decision
|
* extension-inspector state. This allowlist maps to the eng-review decision
|
||||||
* logged in the CEO plan for sec-wave v1.6.0.0.
|
* 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',
|
'goto', 'click', 'text', 'screenshot',
|
||||||
'html', 'links', 'forms', 'accessibility',
|
'html', 'links', 'forms', 'accessibility',
|
||||||
'attrs', 'media', 'data',
|
'attrs', 'media', 'data',
|
||||||
'scroll', 'press', 'type', 'select', 'wait', 'eval',
|
'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
|
* Read ngrok authtoken from env var, ~/.gstack/ngrok.env, or ngrok's native
|
||||||
* config files. Returns null if nothing found. Shared between the
|
* config files. Returns null if nothing found. Shared between the
|
||||||
@@ -1772,8 +1790,7 @@ async function start() {
|
|||||||
// Paired remote agents drive the browser but cannot configure the
|
// Paired remote agents drive the browser but cannot configure the
|
||||||
// daemon, launch new browsers, import cookies, or rotate tokens.
|
// daemon, launch new browsers, import cookies, or rotate tokens.
|
||||||
if (surface === 'tunnel') {
|
if (surface === 'tunnel') {
|
||||||
const cmd = canonicalizeCommand(body?.command);
|
if (!canDispatchOverTunnel(body?.command)) {
|
||||||
if (!cmd || !TUNNEL_COMMANDS.has(cmd)) {
|
|
||||||
logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
|
logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
error: `Command '${body?.command}' is not allowed over the tunnel surface`,
|
error: `Command '${body?.command}' is not allowed over the tunnel surface`,
|
||||||
@@ -2060,6 +2077,29 @@ async function start() {
|
|||||||
tunnelListener = null;
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user