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

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,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.