@peakflo/agent-harness 0.2.11-rc.3 → 0.2.11-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ /**
3
+ * BROWSER TAKEOVER — the harness half of the sandbox-browser wire.
4
+ *
5
+ * COPIED, NOT IMPORTED. The source of truth is the core contract
6
+ * `packages/core/src/services/coding-agents/browser-protocol.ts`. This package
7
+ * is published to npm on its own and must not depend on `@pf-workflo/core`, so
8
+ * the few values the harness needs are repeated here.
9
+ * `__tests__/browser-takeover-contract.test.ts` reads the core source text and
10
+ * fails if any of them drift.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.BROWSER_HARNESS_FEATURE = exports.BROWSER_MCP_TIMEOUT_MS = exports.BROWSER_AWAIT_HANDBACK_HOLD_MS = exports.BROWSER_TAKEOVER_STATUS = exports.BROWSER_AWAIT_HANDBACK_HEADER = exports.BROWSER_TAKEOVER_HEADER = exports.BROWSER_SERVER_NAME = exports.BROWSER_BUILTIN_SERVER_ID = void 0;
14
+ exports.isBrowserTargetUrl = isBrowserTargetUrl;
15
+ exports.sessionIdFromGatewayUrl = sessionIdFromGatewayUrl;
16
+ /** Built-in gateway server id; the gateway URL ends with `/<this id>`. */
17
+ exports.BROWSER_BUILTIN_SERVER_ID = 'pf-browser';
18
+ /** The MCP server name the agent's backend sees. */
19
+ exports.BROWSER_SERVER_NAME = 'browser';
20
+ /** 409 + this header = a person holds the browser; hold the agent's call. */
21
+ exports.BROWSER_TAKEOVER_HEADER = 'x-pf-browser-takeover';
22
+ /** Sent on re-tries during a hold; the gateway then waits up to 20 s. */
23
+ exports.BROWSER_AWAIT_HANDBACK_HEADER = 'x-pf-browser-await-handback';
24
+ exports.BROWSER_TAKEOVER_STATUS = 409;
25
+ /** How long the gateway holds one await-handback request. */
26
+ exports.BROWSER_AWAIT_HANDBACK_HOLD_MS = 20_000;
27
+ /** Pi's MCP request timeout for the `browser` server (24 h). */
28
+ exports.BROWSER_MCP_TIMEOUT_MS = 24 * 60 * 60 * 1000;
29
+ /** Advertised in `capabilities.features`; the API leases `browser` only then. */
30
+ exports.BROWSER_HARNESS_FEATURE = 'browserTakeover';
31
+ /** True for a gateway target URL of the built-in browser server. */
32
+ function isBrowserTargetUrl(targetUrl) {
33
+ try {
34
+ const path = new URL(targetUrl).pathname.replace(/\/+$/, '');
35
+ return path.endsWith(`/${exports.BROWSER_BUILTIN_SERVER_ID}`);
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /**
42
+ * The session id in a gateway URL `.../api/mcp/agent/<sessionId>/<serverId>`,
43
+ * or null when the URL has another shape.
44
+ */
45
+ function sessionIdFromGatewayUrl(targetUrl) {
46
+ try {
47
+ const parts = new URL(targetUrl).pathname.split('/').filter(Boolean);
48
+ const index = parts.lastIndexOf('agent');
49
+ if (index < 1 || parts[index - 1] !== 'mcp')
50
+ return null;
51
+ const sessionId = parts[index + 1];
52
+ return sessionId && parts.length > index + 2
53
+ ? decodeURIComponent(sessionId)
54
+ : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
@@ -1,3 +1,4 @@
1
+ import { type PeerUidResolver } from './loopback-peer';
1
2
  import type { KnowledgeDownloadResponse, KnowledgeFilesResponse } from '../types';
2
3
  /** The name the agent sees the server under. */
3
4
  export declare const KNOWLEDGE_SERVER_NAME = "pf-knowledge";
@@ -19,6 +20,13 @@ export interface KnowledgeToolHostOptions {
19
20
  fetchImpl?: typeof fetch;
20
21
  logger: Pick<Console, 'log' | 'warn' | 'error'>;
21
22
  downloadTimeoutMs?: number;
23
+ /**
24
+ * Injected in tests. Defaults to the /proc/net/tcp lookup; a chat browser
25
+ * uid gets 403 on every request of its connection. See `BrowserPeerGuard`.
26
+ */
27
+ peerUid?: PeerUidResolver;
28
+ /** Loopback port; 0 (default) is ephemeral. A sandbox pins it (PF_KNOWLEDGE_PORT). */
29
+ port?: number;
22
30
  }
23
31
  export declare class KnowledgeToolHost {
24
32
  private readonly options;
@@ -28,6 +36,8 @@ export declare class KnowledgeToolHost {
28
36
  private readonly tokenBySession;
29
37
  private readonly sessionByToken;
30
38
  private readonly turns;
39
+ /** Chat browsers run as their own uids; they get nothing from this host. */
40
+ private readonly peerGuard;
31
41
  constructor(options: KnowledgeToolHostOptions);
32
42
  /** The endpoint a session's agent is configured with. Starts the listener on first use. */
33
43
  endpointFor(sessionId: string): Promise<string>;
@@ -36,6 +36,8 @@ const promises_1 = require("node:fs/promises");
36
36
  const node_http_1 = require("node:http");
37
37
  const node_path_1 = require("node:path");
38
38
  const control_plane_1 = require("../control-plane");
39
+ const loopback_peer_1 = require("./loopback-peer");
40
+ const mcp_auth_proxy_1 = require("./mcp-auth-proxy");
39
41
  const space_files_1 = require("./space-files");
40
42
  /** The name the agent sees the server under. */
41
43
  exports.KNOWLEDGE_SERVER_NAME = 'pf-knowledge';
@@ -166,8 +168,11 @@ class KnowledgeToolHost {
166
168
  tokenBySession = new Map();
167
169
  sessionByToken = new Map();
168
170
  turns = new Map();
171
+ /** Chat browsers run as their own uids; they get nothing from this host. */
172
+ peerGuard;
169
173
  constructor(options) {
170
174
  this.options = options;
175
+ this.peerGuard = new loopback_peer_1.BrowserPeerGuard(options.peerUid);
171
176
  }
172
177
  /** The endpoint a session's agent is configured with. Starts the listener on first use. */
173
178
  async endpointFor(sessionId) {
@@ -201,6 +206,8 @@ class KnowledgeToolHost {
201
206
  if (!this.starting) {
202
207
  this.starting = new Promise((resolve, reject) => {
203
208
  const server = (0, node_http_1.createServer)((req, res) => {
209
+ if (this.peerGuard.refuse(req.socket, res))
210
+ return;
204
211
  void this.handle(req, res).catch((error) => {
205
212
  this.options.logger.error(`[knowledge] request failed: ${error instanceof Error ? error.message : String(error)}`);
206
213
  if (!res.headersSent)
@@ -208,18 +215,17 @@ class KnowledgeToolHost {
208
215
  res.end();
209
216
  });
210
217
  });
211
- server.once('error', (error) => {
212
- this.starting = null;
213
- reject(error);
214
- });
218
+ this.peerGuard.attach(server);
215
219
  // Loopback only. Nothing outside this host can open this socket.
216
- server.listen(0, '127.0.0.1', () => {
217
- const address = server.address();
218
- this.port = typeof address === 'object' && address ? address.port : 0;
220
+ (0, mcp_auth_proxy_1.listenLoopback)(server, this.options.port ?? 0, this.options.logger, 'knowledge').then((port) => {
221
+ this.port = port;
219
222
  this.server = server;
220
223
  // Never keeps the process alive on its own: a one-shot run must still exit.
221
224
  server.unref();
222
225
  resolve();
226
+ }, (error) => {
227
+ this.starting = null;
228
+ reject(error);
223
229
  });
224
230
  });
225
231
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Who is on the other end of a loopback TCP connection?
3
+ *
4
+ * Inside the sandbox every chat's Chromium runs as its own Unix user, and the
5
+ * uids are drawn from this range — keep it in step with UID_MIN / UID_MAX in
6
+ * packages/agentSandbox/browser-broker/os-users.mjs. Chromium runs with
7
+ * --no-sandbox and uses the sandbox network directly (no egress proxy, no
8
+ * firewall), so a page — or a COMPROMISED renderer — can open a socket to
9
+ * 127.0.0.1. The harness loopback services therefore refuse browser uids: a
10
+ * loopback server that should say nothing to a browser asks `peerUid()` and
11
+ * refuses uids in this range.
12
+ */
13
+ export declare const BROWSER_UID_MIN = 20000;
14
+ export declare const BROWSER_UID_MAX = 29999;
15
+ export declare function isBrowserUid(uid: number | null): boolean;
16
+ /** The parts of a `net.Socket` the lookup needs (server side of the connection). */
17
+ export interface LoopbackSocket {
18
+ remoteAddress?: string;
19
+ remotePort?: number;
20
+ localPort?: number;
21
+ }
22
+ export interface PeerUidOptions {
23
+ /** Defaults to `process.platform`; anything but 'linux' answers null. */
24
+ platform?: NodeJS.Platform;
25
+ /** Defaults to a sync read; injected by tests with fake /proc content. */
26
+ readFile?: (path: string) => string;
27
+ /** Byte order of the /proc address words. Defaults to `os.endianness()`. */
28
+ endianness?: 'LE' | 'BE';
29
+ }
30
+ export interface ProcNetTcpEntry {
31
+ localAddress: string;
32
+ localPort: number;
33
+ remoteAddress: string;
34
+ remotePort: number;
35
+ uid: number;
36
+ }
37
+ /**
38
+ * One spelling per address: dotted IPv4 for IPv4 and IPv4-mapped IPv6
39
+ * ('::ffff:127.0.0.1' -> '127.0.0.1'), 32 lowercase hex digits otherwise.
40
+ * Null for anything unparseable.
41
+ */
42
+ export declare function canonicalAddress(address: string): string | null;
43
+ /**
44
+ * Parse /proc/net/tcp or /proc/net/tcp6. Addresses come back canonical (see
45
+ * `canonicalAddress`), so an IPv4-mapped IPv6 row compares equal to its IPv4
46
+ * form. Malformed rows are skipped.
47
+ */
48
+ export declare function parseProcNetTcp(text: string, ipv6: boolean, order?: 'LE' | 'BE'): ProcNetTcpEntry[];
49
+ /**
50
+ * The owner uid of the CLIENT socket of an accepted loopback connection, or
51
+ * null when it cannot be told (not Linux, /proc unreadable, no matching row).
52
+ *
53
+ * Both ends of a loopback connection live in the same network namespace, so
54
+ * /proc/net/tcp{,6} lists the client's socket too: the row whose LOCAL
55
+ * address:port is our peer's address:port and whose REMOTE port is our own
56
+ * port. Its `uid` column is the uid that created that socket. The server's own
57
+ * accepted socket is the mirror row (local port = our port) and never matches.
58
+ *
59
+ * Synchronous and cheap enough for low-rate endpoints: two small file reads.
60
+ */
61
+ export declare function peerUid(socket: LoopbackSocket, options?: PeerUidOptions): number | null;
62
+ /** Resolves a connection's peer uid; null when unknown. Injected by tests. */
63
+ export type PeerUidResolver = (socket: LoopbackSocket) => number | null;
64
+ /** The body every guarded server sends a browser peer: nothing else. */
65
+ export declare const FORBIDDEN_BODY: string;
66
+ /** The minimum a `BrowserPeerGuard` needs from an http/net server. */
67
+ interface ConnectionEmitter {
68
+ on(event: 'connection', listener: (socket: LoopbackSocket) => void): unknown;
69
+ }
70
+ /** The minimum a `BrowserPeerGuard` needs from an http response. */
71
+ interface ForbiddableResponse {
72
+ writeHead(status: number, headers: Record<string, string>): unknown;
73
+ end(body: string): unknown;
74
+ }
75
+ /**
76
+ * Per-CONNECTION browser-uid guard for a busier loopback server (the MCP auth
77
+ * proxy, the knowledge host): the /proc lookup runs once when a connection is
78
+ * accepted and is cached on that socket, so keep-alive and streaming traffic
79
+ * pays nothing per request. A confirmed browser uid gets a bare 403 on every
80
+ * request of the connection; an unknown uid (non-Linux, /proc unreadable, a
81
+ * resolver that throws) is let through, for the same reason as the health
82
+ * check: the legitimate callers run as pfharness or root, and failing closed
83
+ * would turn a /proc hiccup into broken MCP tools.
84
+ */
85
+ export declare class BrowserPeerGuard {
86
+ private readonly resolve;
87
+ private readonly uids;
88
+ constructor(resolve?: PeerUidResolver);
89
+ /** Look up every new connection's peer uid as it is accepted. */
90
+ attach(server: ConnectionEmitter): void;
91
+ /** True for a CONFIRMED browser peer. Looks up lazily if `attach` missed it. */
92
+ isBrowser(socket: LoopbackSocket): boolean;
93
+ /** Answer a browser peer with 403 and return true; otherwise return false. */
94
+ refuse(socket: LoopbackSocket, res: ForbiddableResponse): boolean;
95
+ private lookup;
96
+ }
97
+ export {};
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BrowserPeerGuard = exports.FORBIDDEN_BODY = exports.BROWSER_UID_MAX = exports.BROWSER_UID_MIN = void 0;
4
+ exports.isBrowserUid = isBrowserUid;
5
+ exports.canonicalAddress = canonicalAddress;
6
+ exports.parseProcNetTcp = parseProcNetTcp;
7
+ exports.peerUid = peerUid;
8
+ const node_fs_1 = require("node:fs");
9
+ const node_os_1 = require("node:os");
10
+ /**
11
+ * Who is on the other end of a loopback TCP connection?
12
+ *
13
+ * Inside the sandbox every chat's Chromium runs as its own Unix user, and the
14
+ * uids are drawn from this range — keep it in step with UID_MIN / UID_MAX in
15
+ * packages/agentSandbox/browser-broker/os-users.mjs. Chromium runs with
16
+ * --no-sandbox and uses the sandbox network directly (no egress proxy, no
17
+ * firewall), so a page — or a COMPROMISED renderer — can open a socket to
18
+ * 127.0.0.1. The harness loopback services therefore refuse browser uids: a
19
+ * loopback server that should say nothing to a browser asks `peerUid()` and
20
+ * refuses uids in this range.
21
+ */
22
+ exports.BROWSER_UID_MIN = 20000;
23
+ exports.BROWSER_UID_MAX = 29999;
24
+ function isBrowserUid(uid) {
25
+ return uid !== null && uid >= exports.BROWSER_UID_MIN && uid <= exports.BROWSER_UID_MAX;
26
+ }
27
+ const PROC_FILES = [
28
+ { path: '/proc/net/tcp', ipv6: false },
29
+ { path: '/proc/net/tcp6', ipv6: true }
30
+ ];
31
+ function parseIpv4(address) {
32
+ const parts = address.split('.');
33
+ if (parts.length !== 4) {
34
+ return null;
35
+ }
36
+ const bytes = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN));
37
+ return bytes.every((b) => b >= 0 && b <= 255) ? bytes : null;
38
+ }
39
+ function parseIpv6(address) {
40
+ let text = address.split('%')[0]; // drop a zone id
41
+ const groups = [];
42
+ // A trailing dotted quad ('::ffff:1.2.3.4') is two 16-bit groups.
43
+ const lastColon = text.lastIndexOf(':');
44
+ const tail = parseIpv4(text.slice(lastColon + 1));
45
+ if (tail) {
46
+ text = `${text.slice(0, lastColon + 1)}${((tail[0] << 8) | tail[1]).toString(16)}:${((tail[2] << 8) | tail[3]).toString(16)}`;
47
+ }
48
+ const halves = text.split('::');
49
+ if (halves.length > 2) {
50
+ return null;
51
+ }
52
+ const parse = (part) => {
53
+ if (part === '') {
54
+ return [];
55
+ }
56
+ const out = [];
57
+ for (const g of part.split(':')) {
58
+ if (!/^[0-9A-Fa-f]{1,4}$/.test(g)) {
59
+ return null;
60
+ }
61
+ out.push(parseInt(g, 16));
62
+ }
63
+ return out;
64
+ };
65
+ const head = parse(halves[0]);
66
+ const rest = halves.length === 2 ? parse(halves[1]) : [];
67
+ if (!head || !rest) {
68
+ return null;
69
+ }
70
+ const missing = 8 - head.length - rest.length;
71
+ if (halves.length === 2 ? missing < 1 : missing !== 0) {
72
+ return null;
73
+ }
74
+ groups.push(...head, ...new Array(missing).fill(0), ...rest);
75
+ const bytes = Buffer.alloc(16);
76
+ groups.forEach((g, i) => bytes.writeUInt16BE(g, i * 2));
77
+ return bytes;
78
+ }
79
+ function canonicalBytes(bytes) {
80
+ if (bytes.length === 4) {
81
+ return Array.from(bytes).join('.');
82
+ }
83
+ const mapped = bytes.subarray(0, 10).every((b) => b === 0) &&
84
+ bytes[10] === 0xff &&
85
+ bytes[11] === 0xff;
86
+ return mapped
87
+ ? Array.from(bytes.subarray(12)).join('.')
88
+ : bytes.toString('hex');
89
+ }
90
+ /**
91
+ * One spelling per address: dotted IPv4 for IPv4 and IPv4-mapped IPv6
92
+ * ('::ffff:127.0.0.1' -> '127.0.0.1'), 32 lowercase hex digits otherwise.
93
+ * Null for anything unparseable.
94
+ */
95
+ function canonicalAddress(address) {
96
+ const v4 = parseIpv4(address);
97
+ if (v4) {
98
+ return v4.join('.');
99
+ }
100
+ const v6 = parseIpv6(address);
101
+ return v6 ? canonicalBytes(v6) : null;
102
+ }
103
+ /** "0100007F:1E61" -> { address: '127.0.0.1', port: 7777 } on little-endian. */
104
+ function parseProcEndpoint(value, ipv6, order) {
105
+ const [hex, portHex] = value.split(':');
106
+ const width = ipv6 ? 32 : 8;
107
+ if (!hex ||
108
+ !portHex ||
109
+ hex.length !== width ||
110
+ !/^[0-9A-Fa-f]+$/.test(hex) ||
111
+ !/^[0-9A-Fa-f]{4}$/.test(portHex)) {
112
+ return null;
113
+ }
114
+ // The kernel prints each 32-bit word of the address in host byte order.
115
+ const bytes = Buffer.from(hex, 'hex');
116
+ if (order === 'LE') {
117
+ for (let i = 0; i < bytes.length; i += 4) {
118
+ bytes.subarray(i, i + 4).reverse();
119
+ }
120
+ }
121
+ return { address: canonicalBytes(bytes), port: parseInt(portHex, 16) };
122
+ }
123
+ /**
124
+ * Parse /proc/net/tcp or /proc/net/tcp6. Addresses come back canonical (see
125
+ * `canonicalAddress`), so an IPv4-mapped IPv6 row compares equal to its IPv4
126
+ * form. Malformed rows are skipped.
127
+ */
128
+ function parseProcNetTcp(text, ipv6, order = (0, node_os_1.endianness)()) {
129
+ const entries = [];
130
+ for (const line of text.split('\n').slice(1)) {
131
+ // sl local_address rem_address st tx:rx tr:when retrnsmt uid ...
132
+ const cols = line.trim().split(/\s+/);
133
+ if (cols.length < 8) {
134
+ continue;
135
+ }
136
+ const local = parseProcEndpoint(cols[1], ipv6, order);
137
+ const remote = parseProcEndpoint(cols[2], ipv6, order);
138
+ const uid = Number(cols[7]);
139
+ if (!local || !remote || !Number.isInteger(uid)) {
140
+ continue;
141
+ }
142
+ entries.push({
143
+ localAddress: local.address,
144
+ localPort: local.port,
145
+ remoteAddress: remote.address,
146
+ remotePort: remote.port,
147
+ uid
148
+ });
149
+ }
150
+ return entries;
151
+ }
152
+ /**
153
+ * The owner uid of the CLIENT socket of an accepted loopback connection, or
154
+ * null when it cannot be told (not Linux, /proc unreadable, no matching row).
155
+ *
156
+ * Both ends of a loopback connection live in the same network namespace, so
157
+ * /proc/net/tcp{,6} lists the client's socket too: the row whose LOCAL
158
+ * address:port is our peer's address:port and whose REMOTE port is our own
159
+ * port. Its `uid` column is the uid that created that socket. The server's own
160
+ * accepted socket is the mirror row (local port = our port) and never matches.
161
+ *
162
+ * Synchronous and cheap enough for low-rate endpoints: two small file reads.
163
+ */
164
+ function peerUid(socket, options = {}) {
165
+ if ((options.platform ?? process.platform) !== 'linux') {
166
+ return null;
167
+ }
168
+ const { remoteAddress, remotePort, localPort } = socket;
169
+ if (!remoteAddress || !remotePort || !localPort) {
170
+ return null;
171
+ }
172
+ const peer = canonicalAddress(remoteAddress);
173
+ if (!peer) {
174
+ return null;
175
+ }
176
+ const read = options.readFile ?? ((path) => (0, node_fs_1.readFileSync)(path, 'utf8'));
177
+ const order = options.endianness ?? (0, node_os_1.endianness)();
178
+ for (const file of PROC_FILES) {
179
+ let text;
180
+ try {
181
+ text = read(file.path);
182
+ }
183
+ catch {
184
+ continue; // e.g. no IPv6 in the kernel: the other file may still match
185
+ }
186
+ for (const entry of parseProcNetTcp(text, file.ipv6, order)) {
187
+ if (entry.localPort === remotePort &&
188
+ entry.remotePort === localPort &&
189
+ entry.localAddress === peer) {
190
+ return entry.uid;
191
+ }
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+ /** The body every guarded server sends a browser peer: nothing else. */
197
+ exports.FORBIDDEN_BODY = JSON.stringify({ error: 'forbidden' });
198
+ /**
199
+ * Per-CONNECTION browser-uid guard for a busier loopback server (the MCP auth
200
+ * proxy, the knowledge host): the /proc lookup runs once when a connection is
201
+ * accepted and is cached on that socket, so keep-alive and streaming traffic
202
+ * pays nothing per request. A confirmed browser uid gets a bare 403 on every
203
+ * request of the connection; an unknown uid (non-Linux, /proc unreadable, a
204
+ * resolver that throws) is let through, for the same reason as the health
205
+ * check: the legitimate callers run as pfharness or root, and failing closed
206
+ * would turn a /proc hiccup into broken MCP tools.
207
+ */
208
+ class BrowserPeerGuard {
209
+ resolve;
210
+ uids = new WeakMap();
211
+ constructor(resolve = (socket) => peerUid(socket)) {
212
+ this.resolve = resolve;
213
+ }
214
+ /** Look up every new connection's peer uid as it is accepted. */
215
+ attach(server) {
216
+ server.on('connection', (socket) => {
217
+ this.uids.set(socket, this.lookup(socket));
218
+ });
219
+ }
220
+ /** True for a CONFIRMED browser peer. Looks up lazily if `attach` missed it. */
221
+ isBrowser(socket) {
222
+ if (!this.uids.has(socket)) {
223
+ this.uids.set(socket, this.lookup(socket));
224
+ }
225
+ return isBrowserUid(this.uids.get(socket) ?? null);
226
+ }
227
+ /** Answer a browser peer with 403 and return true; otherwise return false. */
228
+ refuse(socket, res) {
229
+ if (!this.isBrowser(socket)) {
230
+ return false;
231
+ }
232
+ res.writeHead(403, { 'Content-Type': 'application/json' });
233
+ res.end(exports.FORBIDDEN_BODY);
234
+ return true;
235
+ }
236
+ lookup(socket) {
237
+ try {
238
+ return this.resolve(socket);
239
+ }
240
+ catch {
241
+ return null;
242
+ }
243
+ }
244
+ }
245
+ exports.BrowserPeerGuard = BrowserPeerGuard;
@@ -35,13 +35,78 @@
35
35
  * from the network and does not weaken it. Binding 0.0.0.0 here WOULD, and the
36
36
  * test suite asserts the bind address.
37
37
  */
38
+ import { type Server as HttpServer } from 'node:http';
38
39
  import { Notifier, TokenInjector } from '../ports';
40
+ import { type PeerUidResolver } from './loopback-peer';
41
+ /** Largest agent request body the browser hold buffers to re-send. */
42
+ export declare const BROWSER_HOLD_MAX_BODY_BYTES: number;
43
+ /** Consecutive upstream failures (network/5xx) a hold tolerates. */
44
+ export declare const BROWSER_HOLD_MAX_FAILURES = 10;
45
+ /**
46
+ * Longest a held re-send may wait for response headers before it counts as a
47
+ * transient upstream failure. The gateway holds one await-handback request
48
+ * for 20 s; after a handback it runs the call (a broker command is at most
49
+ * ~65 s), so this only fires for a gateway that has silently gone away — a
50
+ * hung socket would otherwise park Pi until its 24 h MCP timeout.
51
+ */
52
+ export declare const BROWSER_HOLD_ATTEMPT_TIMEOUT_MS = 180000;
53
+ export type BrowserPauseEndReason =
54
+ /** The gateway answered something other than a takeover 409. */
55
+ 'resolved'
56
+ /** Pi closed the request. */
57
+ | 'client_closed'
58
+ /** `cancelBrowserHolds`, `stop()`, or the start hook refused the hold. */
59
+ | 'cancelled'
60
+ /** Too many consecutive upstream failures; Pi got a JSON-RPC error. */
61
+ | 'failed';
62
+ export type BrowserPauseEvent =
63
+ /** `body` is the gateway's `BrowserTakeoverBody` (parsed JSON, or text). */
64
+ {
65
+ state: 'start';
66
+ body: unknown;
67
+ } | {
68
+ state: 'end';
69
+ reason: BrowserPauseEndReason;
70
+ };
71
+ /**
72
+ * Called once when a browser call starts being held and once when the hold
73
+ * ends. Returning `false` from the `start` event refuses the hold (no live
74
+ * turn for that session): the call is answered with a JSON-RPC error instead.
75
+ */
76
+ export type BrowserPauseHook = (targetUrl: string, event: BrowserPauseEvent) => boolean | void;
39
77
  export interface McpAuthProxyOptions {
40
78
  tokens: TokenInjector;
41
79
  notifier?: Notifier;
42
80
  /** Injected in tests. Defaults to console. */
43
81
  logger?: Pick<Console, 'warn' | 'error' | 'log'>;
82
+ /** Browser takeover hold start/end. See `handleBrowserRequest`. */
83
+ onBrowserPause?: BrowserPauseHook;
84
+ /** Injected in tests: delay between hold attempts. Defaults to jittered. */
85
+ browserHoldBackoffMs?: (attempt: number, failures: number) => number;
86
+ /** Injected in tests. Defaults to `BROWSER_HOLD_ATTEMPT_TIMEOUT_MS`. */
87
+ browserHoldAttemptTimeoutMs?: number;
88
+ /**
89
+ * Injected in tests. Defaults to the /proc/net/tcp lookup; a chat browser
90
+ * uid gets 403 on every request of its connection. See `BrowserPeerGuard`.
91
+ */
92
+ peerUid?: PeerUidResolver;
93
+ /**
94
+ * Loopback port to listen on. 0 (the default, desktop) is ephemeral. A
95
+ * sandbox pins it (PF_MCP_PROXY_PORT) so the chat browser's Landlock rule
96
+ * can deny it by number. See `listenLoopback`.
97
+ */
98
+ port?: number;
44
99
  }
100
+ /**
101
+ * Listen on 127.0.0.1:`port` (0 = ephemeral). When a FIXED port is taken the
102
+ * listener falls back to an ephemeral one and says so loudly, rather than
103
+ * failing the harness start: the per-uid `BrowserPeerGuard` still refuses a
104
+ * chat browser on any port, and the fixed number only feeds a defence-in-depth
105
+ * deny rule. Resolves with the bound port.
106
+ */
107
+ export declare function listenLoopback(server: HttpServer, port: number, logger: Pick<Console, 'warn'>, label: string): Promise<number>;
108
+ /** A fixed loopback port from the environment, or 0 (ephemeral) when unset or invalid. */
109
+ export declare function loopbackPortFromEnv(value: string | undefined): number;
45
110
  export declare class McpAuthProxy {
46
111
  private readonly options;
47
112
  private server;
@@ -52,6 +117,10 @@ export declare class McpAuthProxy {
52
117
  /** One notification per integration per process, not one per failed call. */
53
118
  private readonly notifiedFailures;
54
119
  private readonly logger;
120
+ /** Browser calls held during a takeover. Cancelled on `stop()`. */
121
+ private readonly browserHolds;
122
+ /** Chat browsers run as their own uids; they get nothing from this proxy. */
123
+ private readonly peerGuard;
55
124
  constructor(options: McpAuthProxyOptions);
56
125
  getPort(): number | null;
57
126
  /**
@@ -67,8 +136,40 @@ export declare class McpAuthProxy {
67
136
  registerTarget(targetUrl: string, label?: string): string | null;
68
137
  unregisterTarget(proxyUrl: string): void;
69
138
  start(): Promise<number>;
139
+ /**
140
+ * Cancel every held browser call whose gateway URL starts with
141
+ * `targetUrlPrefix` (a full target URL, or `.../api/mcp/agent/<sessionId>/`
142
+ * for a whole session). Held calls are answered with a JSON-RPC error and
143
+ * the hook gets `end` / `cancelled`. Returns how many were cancelled.
144
+ */
145
+ cancelBrowserHolds(targetUrlPrefix: string): number;
146
+ /** Number of browser calls currently held. For tests and diagnostics. */
147
+ activeBrowserHolds(): number;
70
148
  stop(): Promise<void>;
71
149
  private handleRequest;
150
+ /**
151
+ * THE BROWSER TAKEOVER HOLD (sandbox browser). Only for the built-in
152
+ * `pf-browser` gateway server; every other target takes the plain streaming
153
+ * path above, unchanged.
154
+ *
155
+ * While a person controls the chat browser, the gateway answers a waiting
156
+ * browser tool with 409 + `x-pf-browser-takeover`. That answer is NOT handed
157
+ * to Pi: the agent's call stays open (Pi's timeout for this server is 24 h)
158
+ * and the SAME body is re-sent with `x-pf-browser-await-handback: 1` — each
159
+ * with a fresh token — until the gateway answers anything else (it holds
160
+ * each such request up to 20 s and, after a handback, rewrites the call into
161
+ * a fresh snapshot). That answer is streamed back to Pi unchanged. The turn
162
+ * is never aborted; the SessionRunner counts the time as an intentional
163
+ * pause through `onBrowserPause`.
164
+ */
165
+ private handleBrowserRequest;
166
+ private holdBackoffMs;
167
+ private callBrowserHook;
168
+ /**
169
+ * One upstream request with a buffered body and a fresh token. With
170
+ * `headersTimeoutMs`, no response headers by then is an error outcome.
171
+ */
172
+ private sendBuffered;
72
173
  /**
73
174
  * One notification per TARGET per process — keyed on the target id, not on
74
175
  * the human-readable label.