Understanding the Dual-Listener Tunnel Architecture in gstack pair-agent

The dual-listener tunnel architecture in gstack's /pair-agent skill enables multiple AI agents to securely share a single Chromium instance by running separate local and public HTTP endpoints that exchange one-time setup keys for scoped session tokens.

The /pair-agent skill in the gstack repository allows several AI agents to collaboratively control one browser instance without exposing internal credentials. This is made possible by a sophisticated dual-listener tunnel architecture that separates local CLI access from remote agent connections through distinct authentication flows.

How the Dual-Listener Architecture Works

The architecture runs two simultaneous HTTP listeners inside the gstack browse server to isolate privilege levels. According to the implementation in browse/src/server.ts, this separation ensures that remote agents never receive privileged tokens while maintaining direct access for the local operator.

The Local Listener (127.0.0.1)

The local listener binds to 127.0.0.1 and serves the CLI that executes the /pair-agent command inside Claude Code. This listener can maintain a direct session token without requiring a setup-key exchange, providing fast, low-latency access for the primary operator. Because it only accepts localhost connections, it remains inaccessible to remote networks.

The Tunnel Listener (0.0.0.0 and ngrok)

The tunnel listener binds to 0.0.0.0 and exposes an ngrok URL to the internet. This endpoint receives connections from remote agents running on different machines. When the user runs /pair-agent, the CLI prints a block containing the ngrok URL and a one-time setup key, which is the only credential transmitted to the remote side.

Token Lifecycle and Security Boundaries

The token system implements a zero-trust model where credentials are scoped, time-bound, and single-use where appropriate. The registry logic resides in browse/src/token-registry.ts and enforces strict isolation between the root authority and operational tokens.

Root Token Protection

When the browse daemon starts, it generates a root token that possesses minting authority for scoped sub-tokens. This root token is held entirely within the server process and is never exposed to agents or printed in logs. By keeping the root token internal, the server guarantees that only the daemon itself can issue descendant tokens.

One-Time Setup Keys (gsk_setup_*)

The /pair endpoint generates a setup key via createSetupKey() with the following constraints:

  • Prefix: gsk_setup_
  • Expiry: 5 minutes
  • Usage: Single-use (usesRemaining: 1)
  • Scopes: Inherits from parent policy (typically read and write)
// browse/src/token-registry.ts
export function createSetupKey(opts: Omit<CreateTokenOptions, 'clientId'> & { clientId?: string }): TokenInfo {
  const token = generateToken('gsk_setup_');
  const now = new Date();
  const expiresAt = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 min
  const info: TokenInfo = {
    token,
    clientId: opts.clientId || `remote-${Date.now()}`,
    type: 'setup',
    scopes: opts.scopes || ['read', 'write'],
    tabPolicy: opts.tabPolicy || 'own-only',
    rateLimit: opts.rateLimit || 10,
    expiresAt,
    createdAt: now.toISOString(),
    usesRemaining: 1,
    commandCount: 0,
  };
  tokens.set(token, info);
  return info;
}

Session Token Exchange

Remote agents POST the setup key to /connect, which triggers exchangeSetupKey(). The server validates the key, consumes it (setting usesRemaining to 0), and returns a session token (gsk_sess_*) valid for 24 hours.

// browse/src/token-registry.ts
export function exchangeSetupKey(setupKey: string, sessionExpiresSeconds?: number | null): TokenInfo | null {
  const setup = tokens.get(setupKey);
  if (!setup || setup.type !== 'setup') return null;
  if (setup.expiresAt && new Date(setup.expiresAt) < new Date()) {
    tokens.delete(setupKey);
    return null;
  }
  if (setup.usesRemaining === 0) return null; // already used
  setup.usesRemaining = 0;
  const session = createToken({
    clientId: setup.clientId,
    scopes: setup.scopes,
    domains: setup.domains,
    tabPolicy: setup.tabPolicy,
    rateLimit: setup.rateLimit,
    expiresSeconds: sessionExpiresSeconds ?? 86400,
  });
  setup.issuedSessionToken = session.token;
  return session;
}

Authorization and Isolation Mechanisms

The token registry enforces fine-grained access controls through scope categories and tab policies, preventing agents from interfering with each other's sessions.

Scope Categories

Each token carries an array of scopes that map to permitted commands:

  • read: View page content and state
  • write: Execute navigation and interaction commands
  • admin: Manage tokens and server configuration
  • meta: Access chain-level operations
  • control: Browser-level control commands

The checkScope() function in browse/src/token-registry.ts validates every command against these scopes:

export function checkScope(info: TokenInfo, command: string): boolean {
  if (info.clientId === 'root') return true;
  if (command === 'chain' && info.scopes.includes('meta')) return true;
  for (const scope of info.scopes) {
    if (SCOPE_MAP[scope]?.has(command)) return true;
  }
  return false;
}

Tab Policy Enforcement

The tabPolicy field supports two modes:

  • own-only: The agent receives a dedicated tab and cannot target other tabs by ID. This is the default for remote agents to prevent cross-contamination.
  • shared: Allows access to all tabs (restricted to local/elevated contexts).

When a token has tabPolicy: 'own-only', the server creates a unique tab for that agent's client ID and blocks any command targeting a different tab.

Rate Limiting and Domain Restrictions

Each token can specify:

  • rateLimit: Maximum requests per second (default 10)
  • domains: Glob patterns restricting reachable URLs

These constraints in browse/src/content-security.ts prevent compromised remote agents from hammering the server or browsing arbitrary sites.

Process Lifetime and Daemon Persistence

Normally, the browse server monitors its parent PID and exits when the spawning process terminates. For pair-agent sessions, browse/src/cli.ts sets BROWSE_PARENT_PID=0 to disable this monitoring. This ensures the tunnel stays alive after the CLI exits, which is essential when the remote agent runs on a different machine and may connect after the local command completes.

Practical Usage Example

Step 1: Start the pair-agent session in Claude Code:

/pair-agent

The skill outputs:


# Pair-Agent setup

CONNECT_URL=https://abcd1234.ngrok.io/connect
SETUP_KEY=gsk_setup_7f3b9e…

Step 2: The remote agent exchanges the setup key:

curl -X POST -H "Content-Type: application/json" \
  -d '{"setupKey":"gsk_setup_7f3b9e…"}' \
  https://abcd1234.ngrok.io/connect

Response:

{
  "token": "gsk_sess_4a1c2d…",
  "clientId": "remote-1623456789",
  "scopes": ["read","write"],
  "tabPolicy": "own-only",
  "expiresAt": "2026-05-16T12:34:56.000Z"
}

Step 3: Both agents issue commands simultaneously. The local CLI uses its direct session token, while the remote agent includes the token in the Authorization header:

$B goto https://example.com --token gsk_sess_4a1c2d…

Summary

  • Dual-listener design: The architecture runs a local listener (127.0.0.1) for the CLI and a tunnel listener (0.0.0.0 + ngrok) for remote agents, both sharing the token registry in browse/src/token-registry.ts.
  • Hierarchical tokens: A protected root token mints short-lived setup keys (gsk_setup_*), which are exchanged for session tokens (gsk_sess_*) without exposing root authority.
  • Strict isolation: Remote agents receive own-only tab policies and scoped permissions (read/write), enforced by checkScope() and tab validation logic.
  • Ephemeral credentials: Setup keys expire after 5 minutes and single-use; session tokens default to 24 hours with configurable lifetimes.
  • Process independence: Setting BROWSE_PARENT_PID=0 in browse/src/cli.ts keeps the daemon alive after the local CLI exits, supporting asynchronous remote connections.

Frequently Asked Questions

What is the purpose of having two separate HTTP listeners?

The dual-listener design creates a security boundary between the privileged local operator and untrusted remote agents. The local listener (127.0.0.1) maintains direct access with full session tokens, while the tunnel listener (0.0.0.0) only accepts setup keys and returns limited-scope session tokens. According to the source code in browse/src/server.ts, this separation ensures that remote agents cannot access the internal token registry or elevate privileges even if the tunnel is compromised.

How does the setup key prevent exposure of the root token?

The setup key acts as a single-use, short-lived voucher. Generated by createSetupKey() in browse/src/token-registry.ts, it expires in 5 minutes and can only be exchanged once via exchangeSetupKey(). The remote agent never sees the root token; it only receives a derived session token (gsk_sess_*) with restricted scopes and own-only tab access. Once consumed, the setup key is invalidated, preventing replay attacks.

Can remote agents access all browser tabs or only specific ones?

Remote agents are restricted to own-only tab access by default. When the session token is created with tabPolicy: 'own-only', the server allocates a dedicated tab for that agent's clientId and rejects any command targeting other tabs. This prevents two agents from interfering with each other's browsing sessions. Local CLI tokens may use shared policy for full access, but remote agents never receive this privilege.

What happens to the tunnel when the local CLI process exits?

The tunnel persists because the /pair-agent command in browse/src/cli.ts disables parent-PID monitoring by setting BROWSE_PARENT_PID=0. Without this flag, the browse daemon would terminate when the CLI process ended, severing the connection for remote agents running on different machines. By disabling PID monitoring, the daemon continues serving the tunnel endpoint until the browser process exits or the session tokens expire.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →