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

> Discover the dual-listener tunnel architecture in gstack pair-agent. Securely share Chromium instances with multiple AI agents using separate local and public HTTP endpoints for key exchange.

- Repository: [Garry Tan/gstack](https://github.com/garrytan/gstack)
- Tags: architecture
- Published: 2026-05-15

---

**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](https://github.com/garrytan/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`](https://github.com/garrytan/gstack/blob/main/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`](https://github.com/garrytan/gstack/blob/main/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`)

```typescript
// 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.

```typescript
// 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`](https://github.com/garrytan/gstack/blob/main/browse/src/token-registry.ts) validates every command against these scopes:

```typescript
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`](https://github.com/garrytan/gstack/blob/main/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`](https://github.com/garrytan/gstack/blob/main/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:

```text
/pair-agent

```

The skill outputs:

```text

# Pair-Agent setup

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

```

**Step 2:** The remote agent exchanges the setup key:

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

```

Response:

```json
{
  "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:

```bash
$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`](https://github.com/garrytan/gstack/blob/main/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`](https://github.com/garrytan/gstack/blob/main/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`](https://github.com/garrytan/gstack/blob/main/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`](https://github.com/garrytan/gstack/blob/main/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`](https://github.com/garrytan/gstack/blob/main/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.