@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.
@@ -22,6 +22,7 @@ const util_1 = require("util");
22
22
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
23
23
  const model_gateway_1 = require("./model-gateway");
24
24
  const vendor_environment_1 = require("./vendor-environment");
25
+ const browser_takeover_1 = require("../runtime/browser-takeover");
25
26
  const cold_start_steps_1 = require("../runtime/cold-start-steps");
26
27
  const dev_server_guard_1 = require("../runtime/dev-server-guard");
27
28
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
@@ -167,7 +168,13 @@ function buildPiMcpConfigDocument(servers, onRename) {
167
168
  // The managed integrations gateway can wait for a cold pfMCP
168
169
  // instance. Its own upstream deadline is 270 seconds, so Pi must
169
170
  // wait long enough to receive the gateway's result or clear error.
170
- ...(name === 'integrations' ? { requestTimeoutMs: 300_000 } : {})
171
+ ...(name === 'integrations' ? { requestTimeoutMs: 300_000 } : {}),
172
+ // A browser call is HELD by the loopback proxy while a person
173
+ // controls the chat browser, for as long as they need. Pi's
174
+ // default 60 s timeout would fail the call mid-takeover.
175
+ ...(name === browser_takeover_1.BROWSER_SERVER_NAME
176
+ ? { requestTimeoutMs: browser_takeover_1.BROWSER_MCP_TIMEOUT_MS }
177
+ : {})
171
178
  }
172
179
  ];
173
180
  }));
@@ -30,8 +30,11 @@ import { RunnerCapabilities } from './types';
30
30
  *
31
31
  * - `spaceFiles`: syncs `space-files/` (read-only) and serves
32
32
  * `knowledge_files_list` / `knowledge_file_download` (P9).
33
+ * - `browserTakeover`: holds a `browser` MCP call while a person controls the
34
+ * chat browser (409 + `x-pf-browser-takeover`) and gives Pi a 24 h timeout
35
+ * for that server. The API leases the `browser` server only with this.
33
36
  */
34
- export declare const HARNESS_FEATURES: readonly ["spaceFiles"];
37
+ export declare const HARNESS_FEATURES: readonly ["spaceFiles", "browserTakeover"];
35
38
  /** Steering mode was verified with Pi 0.84.3. Older Pi keeps the queue path. */
36
39
  export declare function supportsPiSteering(version: string | null): boolean;
37
40
  export interface ProbeResult {
@@ -33,6 +33,7 @@ const node_child_process_1 = require("node:child_process");
33
33
  const node_fs_1 = require("node:fs");
34
34
  const node_os_1 = require("node:os");
35
35
  const node_path_1 = require("node:path");
36
+ const browser_takeover_1 = require("./runtime/browser-takeover");
36
37
  const version_1 = require("./version");
37
38
  const PROBE_TIMEOUT_MS = 10_000;
38
39
  /**
@@ -41,8 +42,14 @@ const PROBE_TIMEOUT_MS = 10_000;
41
42
  *
42
43
  * - `spaceFiles`: syncs `space-files/` (read-only) and serves
43
44
  * `knowledge_files_list` / `knowledge_file_download` (P9).
45
+ * - `browserTakeover`: holds a `browser` MCP call while a person controls the
46
+ * chat browser (409 + `x-pf-browser-takeover`) and gives Pi a 24 h timeout
47
+ * for that server. The API leases the `browser` server only with this.
44
48
  */
45
- exports.HARNESS_FEATURES = ['spaceFiles'];
49
+ exports.HARNESS_FEATURES = [
50
+ 'spaceFiles',
51
+ browser_takeover_1.BROWSER_HARNESS_FEATURE
52
+ ];
46
53
  /** Steering mode was verified with Pi 0.84.3. Older Pi keeps the queue path. */
47
54
  function supportsPiSteering(version) {
48
55
  if (!version)
@@ -1,7 +1,8 @@
1
- import { type Server } from 'node:http';
1
+ import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
2
2
  import { type RequestOptions as HttpsRequestOptions } from 'node:https';
3
3
  import { type Socket } from 'node:net';
4
4
  import type { GitHubProxyCertificates } from './github-proxy-certificates';
5
+ import { type PeerUidResolver } from './runtime/loopback-peer';
5
6
  import type { LeasedGitHubCredential } from './types';
6
7
  export interface GitHubProxySession {
7
8
  sessionId: string;
@@ -15,6 +16,12 @@ export interface GitHubProxyOptions {
15
16
  logger?: Pick<Console, 'warn'>;
16
17
  connectDirect?: (hostname: string, port: number) => Socket;
17
18
  resolveUpstream?: (hostname: string) => Partial<HttpsRequestOptions>;
19
+ /**
20
+ * Injected in tests. Defaults to the /proc/net/tcp lookup. A chat browser
21
+ * uid gets its CONNECT socket closed at once, and a plain HTTP request 403
22
+ * (see `refuseBrowserPeer`). Unknown uids are unchanged (fail open).
23
+ */
24
+ peerUid?: PeerUidResolver;
18
25
  }
19
26
  export interface ReceivePackCommand {
20
27
  oldOid: string;
@@ -27,7 +34,19 @@ export declare class GitHubTlsProxy {
27
34
  private readonly listener;
28
35
  private readonly options;
29
36
  private readonly tunneled;
37
+ /**
38
+ * Chat browsers run as their own uids (20000-29999) with --no-sandbox; a
39
+ * compromised renderer must not get a GitHub tunnel carrying a session's
40
+ * credential. Looked up once per accepted connection.
41
+ */
42
+ private readonly peerGuard;
30
43
  constructor(listener: Server, options: GitHubProxyOptions);
44
+ /**
45
+ * For the listener's plain-HTTP handler (which the owner of the listener
46
+ * installs): answers a browser peer 403 `{"error":"forbidden"}` and returns
47
+ * true; otherwise returns false and the caller answers as before.
48
+ */
49
+ refuseBrowserPeer(request: IncomingMessage, response: ServerResponse): boolean;
31
50
  private refused;
32
51
  private logRefusal;
33
52
  private connect;
@@ -9,6 +9,7 @@ const node_https_1 = require("node:https");
9
9
  const node_net_1 = require("node:net");
10
10
  const node_tls_1 = require("node:tls");
11
11
  const child_stream_guards_1 = require("./runtime/child-stream-guards");
12
+ const loopback_peer_1 = require("./runtime/loopback-peer");
12
13
  const INTERCEPTED_HOSTS = new Set(['github.com', 'api.github.com']);
13
14
  const PASS_THROUGH_HOSTS = new Set([
14
15
  'objects.githubusercontent.com',
@@ -263,9 +264,17 @@ class GitHubTlsProxy {
263
264
  tunneled = (0, node_http_1.createServer)((request, response) => {
264
265
  void this.handle(request, response);
265
266
  });
267
+ /**
268
+ * Chat browsers run as their own uids (20000-29999) with --no-sandbox; a
269
+ * compromised renderer must not get a GitHub tunnel carrying a session's
270
+ * credential. Looked up once per accepted connection.
271
+ */
272
+ peerGuard;
266
273
  constructor(listener, options) {
267
274
  this.listener = listener;
268
275
  this.options = options;
276
+ this.peerGuard = new loopback_peer_1.BrowserPeerGuard(options.peerUid);
277
+ this.peerGuard.attach(listener);
269
278
  listener.on('connect', (request, socket, head) => {
270
279
  // A refused CONNECT commonly makes the client reset the TCP connection
271
280
  // after it receives our 403. Without an error listener Node promotes that
@@ -273,9 +282,22 @@ class GitHubTlsProxy {
273
282
  // harness. Install the guard before the first await/write in connect(),
274
283
  // including for targets outside the GitHub allowlist.
275
284
  (0, child_stream_guards_1.guardStream)(socket, 'GitHubProxy/connect');
285
+ if (this.peerGuard.isBrowser(socket)) {
286
+ // No response at all: nothing about the proxy is told to a browser.
287
+ socket.destroy();
288
+ return;
289
+ }
276
290
  void this.connect(request, socket, head);
277
291
  });
278
292
  }
293
+ /**
294
+ * For the listener's plain-HTTP handler (which the owner of the listener
295
+ * installs): answers a browser peer 403 `{"error":"forbidden"}` and returns
296
+ * true; otherwise returns false and the caller answers as before.
297
+ */
298
+ refuseBrowserPeer(request, response) {
299
+ return this.peerGuard.refuse(request.socket, response);
300
+ }
279
301
  refused(sessionId, kind, path, response, reason) {
280
302
  this.logRefusal(sessionId, kind, path);
281
303
  reply(response, 403, reason);
@@ -1,5 +1,6 @@
1
1
  import { type RequestListener, type Server } from 'node:http';
2
2
  import type { WorkspaceProvider, WorkspaceRequest } from './ports';
3
+ import type { PeerUidResolver } from './runtime/loopback-peer';
3
4
  import type { LeasedGitHubCredential } from './types';
4
5
  export interface GitHubWorkspaceOptions {
5
6
  root: string;
@@ -7,6 +8,8 @@ export interface GitHubWorkspaceOptions {
7
8
  createServer?: (handler: RequestListener) => Server;
8
9
  logger?: Pick<Console, 'warn'>;
9
10
  directUrls?: string[];
11
+ /** Injected in tests: the proxy's peer-uid lookup. See `GitHubTlsProxy`. */
12
+ peerUid?: PeerUidResolver;
10
13
  }
11
14
  /** Bare clone plus one worktree per session, adapted from 20x. */
12
15
  export declare class GitHubWorkspaceProvider implements WorkspaceProvider {
@@ -248,14 +248,19 @@ class GitHubWorkspaceProvider {
248
248
  }
249
249
  }
250
250
  async startServer() {
251
- const server = (this.options.createServer ?? node_http_1.createServer)((_request, response) => {
251
+ // Assigned below, before the server listens, so every request sees it.
252
+ let tlsProxy = null;
253
+ const server = (this.options.createServer ?? node_http_1.createServer)((request, response) => {
254
+ if (tlsProxy?.refuseBrowserPeer(request, response))
255
+ return;
252
256
  response.writeHead(400, { 'content-type': 'text/plain' });
253
257
  response.end('This endpoint is an HTTPS CONNECT proxy.\n');
254
258
  });
255
259
  const certificates = await (0, github_proxy_certificates_1.createGitHubProxyCertificates)(this.options.root);
256
260
  this.caCertificatePath = certificates.caCertificatePath;
257
- new github_proxy_1.GitHubTlsProxy(server, {
261
+ tlsProxy = new github_proxy_1.GitHubTlsProxy(server, {
258
262
  certificates,
263
+ peerUid: this.options.peerUid,
259
264
  logger: this.options.logger,
260
265
  isDirectHostAllowed: (hostname) => this.directHosts.has(hostname),
261
266
  findSession: (sentinel) => [...this.sessions.values()].find((entry) => entry.sentinel === sentinel)
package/dist/harness.d.ts CHANGED
@@ -164,6 +164,11 @@ export declare class Harness {
164
164
  private readonly health;
165
165
  private readonly proxy;
166
166
  /** One loopback host for every session this process runs (P9 knowledge tools). */
167
+ /**
168
+ * PF_KNOWLEDGE_PORT / PF_MCP_PROXY_PORT: fixed loopback ports, set by
169
+ * pf-bootstrap in a sandbox so the chat browser's Landlock rule can deny
170
+ * them by number. Unset (desktop) = ephemeral, as before.
171
+ */
167
172
  private readonly knowledgeHost;
168
173
  private readonly store;
169
174
  /** The model-facing conversation log. See `ports.ts#ConversationStore`. */
@@ -269,6 +274,14 @@ export declare class Harness {
269
274
  /** Same idea, for the INDEPENDENT sandbox-tools pin (gh, node deps, ...). */
270
275
  private pendingSandboxToolsVersion;
271
276
  constructor(options: HarnessOptions, deps?: HarnessDeps);
277
+ /**
278
+ * BROWSER TAKEOVER, proxy → runner. The loopback proxy is holding (or has
279
+ * stopped holding) a `browser` call because a person controls the chat
280
+ * browser. The gateway URL names the session; its runner counts the hold as
281
+ * an intentional pause. No live runner → refuse, so the call is not parked
282
+ * for a turn that is gone.
283
+ */
284
+ private onBrowserPause;
272
285
  /** The actual bound health port. Used by tests to prove the socket closed. */
273
286
  getHealthPort(): number | null;
274
287
  snapshot(): HealthReport;
package/dist/harness.js CHANGED
@@ -17,6 +17,7 @@ const harness_discovery_1 = require("./harness-discovery");
17
17
  const health_1 = require("./health");
18
18
  const model_probe_1 = require("./model-probe");
19
19
  const ports_default_1 = require("./ports-default");
20
+ const browser_takeover_1 = require("./runtime/browser-takeover");
20
21
  const knowledge_tools_1 = require("./runtime/knowledge-tools");
21
22
  const lease_renewal_1 = require("./runtime/lease-renewal");
22
23
  const mcp_auth_proxy_1 = require("./runtime/mcp-auth-proxy");
@@ -172,7 +173,15 @@ class Harness {
172
173
  health;
173
174
  proxy;
174
175
  /** One loopback host for every session this process runs (P9 knowledge tools). */
175
- knowledgeHost = new knowledge_tools_1.KnowledgeToolHost({ logger: console });
176
+ /**
177
+ * PF_KNOWLEDGE_PORT / PF_MCP_PROXY_PORT: fixed loopback ports, set by
178
+ * pf-bootstrap in a sandbox so the chat browser's Landlock rule can deny
179
+ * them by number. Unset (desktop) = ephemeral, as before.
180
+ */
181
+ knowledgeHost = new knowledge_tools_1.KnowledgeToolHost({
182
+ logger: console,
183
+ port: (0, mcp_auth_proxy_1.loopbackPortFromEnv)(process.env.PF_KNOWLEDGE_PORT)
184
+ });
176
185
  store;
177
186
  /** The model-facing conversation log. See `ports.ts#ConversationStore`. */
178
187
  conversation;
@@ -324,9 +333,31 @@ class Harness {
324
333
  deps.credentialStore ??
325
334
  (0, credential_store_1.createCredentialStore)(options.runtime, options.workspaceRoot);
326
335
  this.proxy = new mcp_auth_proxy_1.McpAuthProxy({
336
+ port: (0, mcp_auth_proxy_1.loopbackPortFromEnv)(process.env.PF_MCP_PROXY_PORT),
327
337
  tokens: this.tokens,
328
- notifier: this.notifier
338
+ notifier: this.notifier,
339
+ onBrowserPause: (targetUrl, event) => this.onBrowserPause(targetUrl, event)
340
+ });
341
+ }
342
+ /**
343
+ * BROWSER TAKEOVER, proxy → runner. The loopback proxy is holding (or has
344
+ * stopped holding) a `browser` call because a person controls the chat
345
+ * browser. The gateway URL names the session; its runner counts the hold as
346
+ * an intentional pause. No live runner → refuse, so the call is not parked
347
+ * for a turn that is gone.
348
+ */
349
+ onBrowserPause(targetUrl, event) {
350
+ const sessionId = (0, browser_takeover_1.sessionIdFromGatewayUrl)(targetUrl);
351
+ const runner = sessionId ? this.runners.get(sessionId) : undefined;
352
+ if (!sessionId || !runner)
353
+ return event.state !== 'start';
354
+ const accepted = runner.setBrowserPaused(sessionId, event.state === 'start');
355
+ log('Browser takeover hold', {
356
+ sessionId,
357
+ state: event.state,
358
+ ...(event.state === 'end' ? { reason: event.reason } : { accepted })
329
359
  });
360
+ return accepted;
330
361
  }
331
362
  /** The actual bound health port. Used by tests to prove the socket closed. */
332
363
  getHealthPort() {
@@ -1794,6 +1825,9 @@ class Harness {
1794
1825
  * passed for the wrong reason.
1795
1826
  */
1796
1827
  const modelGateway = new model_gateway_1.SessionModelGateway();
1828
+ // Two independent reasons to renew urgently; neither may clear the other.
1829
+ let approvalWaiting = false;
1830
+ let browserPaused = false;
1797
1831
  const runner = new session_runner_1.SessionRunner({
1798
1832
  mcpCheckState: this.mcpCheckState,
1799
1833
  getAdapter: (backend) => {
@@ -1841,6 +1875,13 @@ class Harness {
1841
1875
  * hard to poll the control plane for the answer. Nothing else in the
1842
1876
  * process knows a permission is outstanding.
1843
1877
  */
1878
+ onBrowserPauseChange: (id, paused) => {
1879
+ // Same tight renewal cadence as an approval, so a handback command
1880
+ // (or abort) rides back quickly. Lease renewal keeps running either way.
1881
+ browserPaused = paused;
1882
+ renewal.setUrgent(paused || approvalWaiting);
1883
+ log('Browser pause changed', { sessionId: id, paused });
1884
+ },
1844
1885
  onApprovalStateChange: (id, waiting) => {
1845
1886
  /*
1846
1887
  * NO SET IS KEPT HERE. An earlier version also maintained an
@@ -1852,7 +1893,8 @@ class Harness {
1852
1893
  // Event-driven, not polled: the cadence changes at the instant the
1853
1894
  // prompt opens, so the first tight renewal happens ~2s later rather
1854
1895
  // than up to a minute later.
1855
- renewal.setUrgent(waiting);
1896
+ approvalWaiting = waiting;
1897
+ renewal.setUrgent(waiting || browserPaused);
1856
1898
  log('Approval state changed', { sessionId: id, waiting });
1857
1899
  },
1858
1900
  ingest: (id, parts, turn, final, modelMessages) => this.client
@@ -1928,6 +1970,11 @@ class Harness {
1928
1970
  }
1929
1971
  // Revoke ONLY this session's tokens. A process-wide revoke here blanked
1930
1972
  // the credential of every concurrent turn still running.
1973
+ // A browser call held for a person cannot outlive its turn.
1974
+ for (const url of mcpTargetUrls) {
1975
+ if ((0, browser_takeover_1.isBrowserTargetUrl)(url))
1976
+ this.proxy.cancelBrowserHolds(url);
1977
+ }
1931
1978
  this.tokens.revokeTargets(mcpTargetUrls);
1932
1979
  modelGateway.revoke();
1933
1980
  githubWorkspace.clearCredential?.(sessionId);
package/dist/health.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Socket } from 'node:net';
1
2
  import { HealthReport } from './types';
2
3
  /**
3
4
  * Health check for pf-bootstrap and for the Daytona liveness probe.
@@ -6,6 +7,17 @@ import { HealthReport } from './types';
6
7
  * from outside the sandbox, and session users get no useful secret from it —
7
8
  * the report deliberately carries no token.
8
9
  *
10
+ * BROWSER USERS GET NOTHING. A chat's Chromium runs as its own uid (20000-
11
+ * 29999) with --no-sandbox and uses the sandbox network directly (no egress
12
+ * proxy, no firewall), so a page or a compromised renderer could connect here
13
+ * and learn the tenant id, runner id, versions and heartbeat errors. On Linux each
14
+ * request's peer uid is looked up in /proc/net/tcp{,6}; a browser uid gets a
15
+ * bare 403. The guard FAILS OPEN when the uid cannot be determined (non-Linux,
16
+ * /proc unreadable, no matching row): the probe and pf-bootstrap run as root
17
+ * or pfharness, the report holds no secret, and failing closed would turn a
18
+ * /proc hiccup into a dead liveness probe. Only a CONFIRMED browser uid is
19
+ * refused.
20
+ *
9
21
  * STOP MUST ACTUALLY CLOSE, and this is the reason `boundPort` and the socket
10
22
  * tracking below exist. `server.close()` stops ACCEPTING connections but leaves
11
23
  * any keep-alive connection open, and an open connection keeps the Node event
@@ -18,6 +30,8 @@ import { HealthReport } from './types';
18
30
  export declare class HealthServer {
19
31
  private readonly port;
20
32
  private readonly report;
33
+ /** Injected by tests; defaults to the /proc/net/tcp lookup. */
34
+ private readonly lookupPeerUid;
21
35
  private server;
22
36
  /**
23
37
  * The port the server actually bound to, RETAINED after `stop()`.
@@ -29,7 +43,9 @@ export declare class HealthServer {
29
43
  private bound;
30
44
  /** Live connections, tracked so `stop()` can destroy them. */
31
45
  private readonly sockets;
32
- constructor(port: number, report: () => HealthReport);
46
+ constructor(port: number, report: () => HealthReport,
47
+ /** Injected by tests; defaults to the /proc/net/tcp lookup. */
48
+ lookupPeerUid?: (socket: Socket) => number | null);
33
49
  /** The actual bound port, or null before `start()`. Survives `stop()`. */
34
50
  get boundPort(): number | null;
35
51
  start(): Promise<void>;
@@ -41,5 +57,7 @@ export declare class HealthServer {
41
57
  * process exits, without weakening enrolment or running two ready harnesses.
42
58
  */
43
59
  private listenOnce;
60
+ /** A lookup that throws counts as "unknown" — fail open, see the class doc. */
61
+ private safePeerUid;
44
62
  stop(): Promise<void>;
45
63
  }
package/dist/health.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HealthServer = void 0;
4
4
  const node_http_1 = require("node:http");
5
+ const loopback_peer_1 = require("./runtime/loopback-peer");
5
6
  const BIND_RETRY_INTERVAL_MS = 100;
6
7
  const BIND_RETRY_TIMEOUT_MS = 10_000;
7
8
  /**
@@ -11,6 +12,17 @@ const BIND_RETRY_TIMEOUT_MS = 10_000;
11
12
  * from outside the sandbox, and session users get no useful secret from it —
12
13
  * the report deliberately carries no token.
13
14
  *
15
+ * BROWSER USERS GET NOTHING. A chat's Chromium runs as its own uid (20000-
16
+ * 29999) with --no-sandbox and uses the sandbox network directly (no egress
17
+ * proxy, no firewall), so a page or a compromised renderer could connect here
18
+ * and learn the tenant id, runner id, versions and heartbeat errors. On Linux each
19
+ * request's peer uid is looked up in /proc/net/tcp{,6}; a browser uid gets a
20
+ * bare 403. The guard FAILS OPEN when the uid cannot be determined (non-Linux,
21
+ * /proc unreadable, no matching row): the probe and pf-bootstrap run as root
22
+ * or pfharness, the report holds no secret, and failing closed would turn a
23
+ * /proc hiccup into a dead liveness probe. Only a CONFIRMED browser uid is
24
+ * refused.
25
+ *
14
26
  * STOP MUST ACTUALLY CLOSE, and this is the reason `boundPort` and the socket
15
27
  * tracking below exist. `server.close()` stops ACCEPTING connections but leaves
16
28
  * any keep-alive connection open, and an open connection keeps the Node event
@@ -23,6 +35,7 @@ const BIND_RETRY_TIMEOUT_MS = 10_000;
23
35
  class HealthServer {
24
36
  port;
25
37
  report;
38
+ lookupPeerUid;
26
39
  server = null;
27
40
  /**
28
41
  * The port the server actually bound to, RETAINED after `stop()`.
@@ -34,9 +47,12 @@ class HealthServer {
34
47
  bound = null;
35
48
  /** Live connections, tracked so `stop()` can destroy them. */
36
49
  sockets = new Set();
37
- constructor(port, report) {
50
+ constructor(port, report,
51
+ /** Injected by tests; defaults to the /proc/net/tcp lookup. */
52
+ lookupPeerUid = (socket) => (0, loopback_peer_1.peerUid)(socket)) {
38
53
  this.port = port;
39
54
  this.report = report;
55
+ this.lookupPeerUid = lookupPeerUid;
40
56
  }
41
57
  /** The actual bound port, or null before `start()`. Survives `stop()`. */
42
58
  get boundPort() {
@@ -73,6 +89,11 @@ class HealthServer {
73
89
  res.end(JSON.stringify({ error: 'Not found' }));
74
90
  return;
75
91
  }
92
+ if ((0, loopback_peer_1.isBrowserUid)(this.safePeerUid(req.socket))) {
93
+ res.writeHead(403, { 'Content-Type': 'application/json' });
94
+ res.end(JSON.stringify({ error: 'forbidden' }));
95
+ return;
96
+ }
76
97
  const body = this.report();
77
98
  // 'starting' is not yet healthy, so a probe waits rather than
78
99
  // declaring the sandbox ready too early.
@@ -96,6 +117,15 @@ class HealthServer {
96
117
  });
97
118
  });
98
119
  }
120
+ /** A lookup that throws counts as "unknown" — fail open, see the class doc. */
121
+ safePeerUid(socket) {
122
+ try {
123
+ return this.lookupPeerUid(socket);
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
99
129
  async stop() {
100
130
  const server = this.server;
101
131
  if (!server) {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * BROWSER TAKEOVER — the harness half of the sandbox-browser wire.
3
+ *
4
+ * COPIED, NOT IMPORTED. The source of truth is the core contract
5
+ * `packages/core/src/services/coding-agents/browser-protocol.ts`. This package
6
+ * is published to npm on its own and must not depend on `@pf-workflo/core`, so
7
+ * the few values the harness needs are repeated here.
8
+ * `__tests__/browser-takeover-contract.test.ts` reads the core source text and
9
+ * fails if any of them drift.
10
+ */
11
+ /** Built-in gateway server id; the gateway URL ends with `/<this id>`. */
12
+ export declare const BROWSER_BUILTIN_SERVER_ID = "pf-browser";
13
+ /** The MCP server name the agent's backend sees. */
14
+ export declare const BROWSER_SERVER_NAME = "browser";
15
+ /** 409 + this header = a person holds the browser; hold the agent's call. */
16
+ export declare const BROWSER_TAKEOVER_HEADER = "x-pf-browser-takeover";
17
+ /** Sent on re-tries during a hold; the gateway then waits up to 20 s. */
18
+ export declare const BROWSER_AWAIT_HANDBACK_HEADER = "x-pf-browser-await-handback";
19
+ export declare const BROWSER_TAKEOVER_STATUS = 409;
20
+ /** How long the gateway holds one await-handback request. */
21
+ export declare const BROWSER_AWAIT_HANDBACK_HOLD_MS = 20000;
22
+ /** Pi's MCP request timeout for the `browser` server (24 h). */
23
+ export declare const BROWSER_MCP_TIMEOUT_MS: number;
24
+ /** Advertised in `capabilities.features`; the API leases `browser` only then. */
25
+ export declare const BROWSER_HARNESS_FEATURE = "browserTakeover";
26
+ /** True for a gateway target URL of the built-in browser server. */
27
+ export declare function isBrowserTargetUrl(targetUrl: string): boolean;
28
+ /**
29
+ * The session id in a gateway URL `.../api/mcp/agent/<sessionId>/<serverId>`,
30
+ * or null when the URL has another shape.
31
+ */
32
+ export declare function sessionIdFromGatewayUrl(targetUrl: string): string | null;
@@ -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
  }