@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.
- package/README.md +4 -2
- package/dist/adapters/pi-adapter.js +8 -1
- package/dist/capabilities.d.ts +4 -1
- package/dist/capabilities.js +8 -1
- package/dist/cli.js +2 -1
- package/dist/connect/connect.d.ts +3 -2
- package/dist/connect/connect.js +8 -6
- package/dist/github-proxy.d.ts +20 -1
- package/dist/github-proxy.js +22 -0
- package/dist/github-workspace.d.ts +3 -0
- package/dist/github-workspace.js +7 -2
- package/dist/harness.d.ts +13 -0
- package/dist/harness.js +51 -4
- package/dist/health.d.ts +19 -1
- package/dist/health.js +31 -1
- package/dist/runtime/browser-takeover.d.ts +32 -0
- package/dist/runtime/browser-takeover.js +59 -0
- package/dist/runtime/knowledge-tools.d.ts +10 -0
- package/dist/runtime/knowledge-tools.js +13 -7
- package/dist/runtime/loopback-peer.d.ts +97 -0
- package/dist/runtime/loopback-peer.js +245 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +101 -0
- package/dist/runtime/mcp-auth-proxy.js +482 -14
- package/dist/runtime/session-runner.d.ts +32 -0
- package/dist/runtime/session-runner.js +79 -2
- package/dist/service.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,7 +54,8 @@ Optional flags include `--health-port <port>`, `--runtime <desktop|self-hosted>`
|
|
|
54
54
|
|
|
55
55
|
## Connect your own agent in one command
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
Agents → "Connect your own agent" in Spaces (and the Welcome window during
|
|
58
|
+
onboarding) shows one command for Codex, Claude Code, or
|
|
58
59
|
Cursor, for example:
|
|
59
60
|
|
|
60
61
|
```sh
|
|
@@ -129,7 +130,8 @@ New service files start with `--service --stored-credential`. Older service
|
|
|
129
130
|
files with only `--stored-credential` still work after an upgrade. Run
|
|
130
131
|
`service repair` to update the file. The runner reads its saved credential
|
|
131
132
|
from the workspace. If that credential is missing, expired, or
|
|
132
|
-
rejected,
|
|
133
|
+
rejected, open Agents → "Connect your own agent" in Spaces (or "Reconnect" on
|
|
134
|
+
that machine) to get a new command, and run it on the machine. The service
|
|
133
135
|
file and the service settings contain no enrolment token or runner credential.
|
|
134
136
|
It stops after an invalid option, a refused harness version, an unusable
|
|
135
137
|
credential, or a local port conflict. Linux waits 30 seconds before it retries
|
|
@@ -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
|
}));
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -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 {
|
package/dist/capabilities.js
CHANGED
|
@@ -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 = [
|
|
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)
|
package/dist/cli.js
CHANGED
|
@@ -46,7 +46,8 @@ const USAGE = `pf-agent-harness — headless Peakflo coding-agent harness
|
|
|
46
46
|
|
|
47
47
|
Usage:
|
|
48
48
|
pf-agent-harness connect <codex|claude-code|cursor> --connection <id> [--control-plane <url>]
|
|
49
|
-
one command from
|
|
49
|
+
one command from Agents → "Connect your own
|
|
50
|
+
agent" in Spaces (or the Welcome window): approve
|
|
50
51
|
this computer in the browser, connect the agent
|
|
51
52
|
as your personal agent, run it in the background
|
|
52
53
|
pf-agent-harness start [options]
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `pf-agent-harness connect <vendor> --connection <id> [--control-plane <url>]`
|
|
3
3
|
*
|
|
4
|
-
* ONE COMMAND, copied from
|
|
4
|
+
* ONE COMMAND, copied from Agents → "Connect your own agent" (or the
|
|
5
|
+
* Welcome window), that:
|
|
5
6
|
* 1. checks the vendor CLI is installed and signed in;
|
|
6
7
|
* 2. asks the signed-in user to approve this computer in the browser
|
|
7
8
|
* (loopback + PKCE + `state`; see workflow-api `byo-connect.ts`);
|
|
@@ -34,7 +35,7 @@ export interface ConnectArgs {
|
|
|
34
35
|
appUrl?: string;
|
|
35
36
|
openBrowser: boolean;
|
|
36
37
|
}
|
|
37
|
-
export declare const CONNECT_USAGE = "Usage: pf-agent-harness connect <codex|claude-code|cursor> --connection <id> [--control-plane <url>]\n\nCopy this command from
|
|
38
|
+
export declare const CONNECT_USAGE = "Usage: pf-agent-harness connect <codex|claude-code|cursor> --connection <id> [--control-plane <url>]\n\nCopy this command from Agents \u2192 \"Connect your own agent\" in Spaces (or\n\"Reconnect\" on your machine there). It approves this computer\nin your browser, connects the agent as your personal agent, and keeps it\nrunning in the background.";
|
|
38
39
|
export declare function parseConnectArgs(argv: string[], env?: NodeJS.ProcessEnv): ConnectArgs;
|
|
39
40
|
/** The machine name the approval page shows. Must match the API's rule. */
|
|
40
41
|
export declare function machineName(raw: string): string;
|
package/dist/connect/connect.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* `pf-agent-harness connect <vendor> --connection <id> [--control-plane <url>]`
|
|
4
4
|
*
|
|
5
|
-
* ONE COMMAND, copied from
|
|
5
|
+
* ONE COMMAND, copied from Agents → "Connect your own agent" (or the
|
|
6
|
+
* Welcome window), that:
|
|
6
7
|
* 1. checks the vendor CLI is installed and signed in;
|
|
7
8
|
* 2. asks the signed-in user to approve this computer in the browser
|
|
8
9
|
* (loopback + PKCE + `state`; see workflow-api `byo-connect.ts`);
|
|
@@ -74,7 +75,8 @@ exports.VENDOR_SETUP = {
|
|
|
74
75
|
};
|
|
75
76
|
exports.CONNECT_USAGE = `Usage: pf-agent-harness connect <codex|claude-code|cursor> --connection <id> [--control-plane <url>]
|
|
76
77
|
|
|
77
|
-
Copy this command from
|
|
78
|
+
Copy this command from Agents → "Connect your own agent" in Spaces (or
|
|
79
|
+
"Reconnect" on your machine there). It approves this computer
|
|
78
80
|
in your browser, connects the agent as your personal agent, and keeps it
|
|
79
81
|
running in the background.`;
|
|
80
82
|
function flag(argv, name) {
|
|
@@ -124,7 +126,7 @@ function parseConnectArgs(argv, env = {}) {
|
|
|
124
126
|
// `--token` is refused on purpose: this command never takes a secret.
|
|
125
127
|
const connection = flag(argv, 'connection');
|
|
126
128
|
if (!connection || !UUID.test(connection))
|
|
127
|
-
throw new ConnectError(`Copy the command from
|
|
129
|
+
throw new ConnectError(`Copy the command from Agents → "Connect your own agent" in Spaces: it includes --connection <id>.\n\n${exports.CONNECT_USAGE}`);
|
|
128
130
|
const appUrl = flag(argv, 'app-url') ?? env.PF_APP_URL;
|
|
129
131
|
return {
|
|
130
132
|
vendor,
|
|
@@ -205,7 +207,7 @@ async function runConnect(args, deps) {
|
|
|
205
207
|
const setup = exports.VENDOR_SETUP[vendor];
|
|
206
208
|
const log = deps.log;
|
|
207
209
|
if (deps.platform !== 'darwin' && deps.platform !== 'linux')
|
|
208
|
-
throw new ConnectError('connect needs macOS or Linux. On Windows, use "Ask my agent to do it"
|
|
210
|
+
throw new ConnectError('connect needs macOS or Linux. On Windows, open Agents → "Connect your own agent" in Spaces and use "Ask my agent to do it instead".');
|
|
209
211
|
const machine = machineName(deps.hostname());
|
|
210
212
|
log(`Connecting ${setup.label} on ${machine}…`);
|
|
211
213
|
/* 1. The vendor CLI must be installed and signed in. */
|
|
@@ -265,7 +267,7 @@ async function runConnect(args, deps) {
|
|
|
265
267
|
exchange.json.vendor !== vendor)
|
|
266
268
|
throw new ConnectError(typeof exchange.json.error === 'string'
|
|
267
269
|
? printable(exchange.json.error)
|
|
268
|
-
: `The approval could not be used (${exchange.status}).
|
|
270
|
+
: `The approval could not be used (${exchange.status}). Open Agents → "Connect your own agent" in Spaces (or "Reconnect" on this machine) to get a new command.`);
|
|
269
271
|
const company = typeof exchange.json.companyName === 'string'
|
|
270
272
|
? exchange.json.companyName
|
|
271
273
|
: 'your company';
|
|
@@ -301,7 +303,7 @@ async function runConnect(args, deps) {
|
|
|
301
303
|
if (!healthy)
|
|
302
304
|
log(`! The service has not answered yet (it may still be starting, or a local port was taken). Check it with: npx @peakflo/agent-harness service status --vendor ${vendor}`);
|
|
303
305
|
log('');
|
|
304
|
-
log(`✓ ${setup.label} is connected to ${company} on ${machine}. Return to
|
|
306
|
+
log(`✓ ${setup.label} is connected to ${company} on ${machine}. Return to Spaces.`);
|
|
305
307
|
}
|
|
306
308
|
/* -------------------------------------------------------------------------- */
|
|
307
309
|
/* Real dependencies */
|
package/dist/github-proxy.d.ts
CHANGED
|
@@ -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;
|
package/dist/github-proxy.js
CHANGED
|
@@ -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 {
|
package/dist/github-workspace.js
CHANGED
|
@@ -248,14 +248,19 @@ class GitHubWorkspaceProvider {
|
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
async startServer() {
|
|
251
|
-
|
|
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");
|
|
@@ -115,7 +116,7 @@ exports.VersionRefusedError = VersionRefusedError;
|
|
|
115
116
|
/** A service cannot re-enrol without a new one-time token from a user. */
|
|
116
117
|
class StoredCredentialUnavailableError extends Error {
|
|
117
118
|
constructor() {
|
|
118
|
-
super('No usable stored runner credential.
|
|
119
|
+
super('No usable stored runner credential. For your own agent, open Agents in Spaces and use "Reconnect" on this machine (or "Connect your own agent") to get a new command, then run it here. For a self-hosted runner, start it once in a terminal to sign in again.');
|
|
119
120
|
this.name = 'StoredCredentialUnavailableError';
|
|
120
121
|
}
|
|
121
122
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|