@peakflo/agent-harness 0.2.11-rc.2 → 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.
- package/dist/adapters/pi-adapter.js +8 -1
- package/dist/capabilities.d.ts +4 -1
- package/dist/capabilities.js +8 -1
- 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 +53 -3
- 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/package.json +1 -1
|
@@ -37,10 +37,205 @@
|
|
|
37
37
|
* test suite asserts the bind address.
|
|
38
38
|
*/
|
|
39
39
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
-
exports.McpAuthProxy = void 0;
|
|
40
|
+
exports.McpAuthProxy = exports.BROWSER_HOLD_ATTEMPT_TIMEOUT_MS = exports.BROWSER_HOLD_MAX_FAILURES = exports.BROWSER_HOLD_MAX_BODY_BYTES = void 0;
|
|
41
|
+
exports.listenLoopback = listenLoopback;
|
|
42
|
+
exports.loopbackPortFromEnv = loopbackPortFromEnv;
|
|
43
|
+
const node_crypto_1 = require("node:crypto");
|
|
41
44
|
const node_http_1 = require("node:http");
|
|
42
45
|
const node_https_1 = require("node:https");
|
|
43
|
-
const
|
|
46
|
+
const browser_takeover_1 = require("./browser-takeover");
|
|
47
|
+
const loopback_peer_1 = require("./loopback-peer");
|
|
48
|
+
/** Largest agent request body the browser hold buffers to re-send. */
|
|
49
|
+
exports.BROWSER_HOLD_MAX_BODY_BYTES = 1024 * 1024;
|
|
50
|
+
/** Consecutive upstream failures (network/5xx) a hold tolerates. */
|
|
51
|
+
exports.BROWSER_HOLD_MAX_FAILURES = 10;
|
|
52
|
+
/** A takeover 409 faster than this was not held by the gateway: back off. */
|
|
53
|
+
const BROWSER_HOLD_FAST_ANSWER_MS = 5_000;
|
|
54
|
+
/**
|
|
55
|
+
* Longest a held re-send may wait for response headers before it counts as a
|
|
56
|
+
* transient upstream failure. The gateway holds one await-handback request
|
|
57
|
+
* for 20 s; after a handback it runs the call (a broker command is at most
|
|
58
|
+
* ~65 s), so this only fires for a gateway that has silently gone away — a
|
|
59
|
+
* hung socket would otherwise park Pi until its 24 h MCP timeout.
|
|
60
|
+
*/
|
|
61
|
+
exports.BROWSER_HOLD_ATTEMPT_TIMEOUT_MS = 180_000;
|
|
62
|
+
/** Largest 409 takeover body read (it is a small JSON document). */
|
|
63
|
+
const BROWSER_TAKEOVER_BODY_MAX_BYTES = 64 * 1024;
|
|
64
|
+
/**
|
|
65
|
+
* Listen on 127.0.0.1:`port` (0 = ephemeral). When a FIXED port is taken the
|
|
66
|
+
* listener falls back to an ephemeral one and says so loudly, rather than
|
|
67
|
+
* failing the harness start: the per-uid `BrowserPeerGuard` still refuses a
|
|
68
|
+
* chat browser on any port, and the fixed number only feeds a defence-in-depth
|
|
69
|
+
* deny rule. Resolves with the bound port.
|
|
70
|
+
*/
|
|
71
|
+
function listenLoopback(server, port, logger, label) {
|
|
72
|
+
const attempt = (wanted) => new Promise((resolvePort, reject) => {
|
|
73
|
+
const onError = (error) => reject(error);
|
|
74
|
+
server.once('error', onError);
|
|
75
|
+
server.listen(wanted, '127.0.0.1', () => {
|
|
76
|
+
server.removeListener('error', onError);
|
|
77
|
+
const address = server.address();
|
|
78
|
+
if (typeof address === 'object' && address)
|
|
79
|
+
resolvePort(address.port);
|
|
80
|
+
else
|
|
81
|
+
reject(new Error(`${label} could not determine its address`));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
if (!port)
|
|
85
|
+
return attempt(0);
|
|
86
|
+
return attempt(port).catch((error) => {
|
|
87
|
+
if (error && error.code === 'EADDRINUSE') {
|
|
88
|
+
logger.warn(`[${label}] fixed port ${port} is in use; falling back to an ephemeral port`);
|
|
89
|
+
return attempt(0);
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** A fixed loopback port from the environment, or 0 (ephemeral) when unset or invalid. */
|
|
95
|
+
function loopbackPortFromEnv(value) {
|
|
96
|
+
if (!value)
|
|
97
|
+
return 0;
|
|
98
|
+
const port = Number(value);
|
|
99
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : 0;
|
|
100
|
+
}
|
|
101
|
+
/* -------------------------------------------------------------------------- */
|
|
102
|
+
/* Browser hold helpers */
|
|
103
|
+
/* -------------------------------------------------------------------------- */
|
|
104
|
+
function isTakeover(response) {
|
|
105
|
+
return (response.statusCode === browser_takeover_1.BROWSER_TAKEOVER_STATUS &&
|
|
106
|
+
response.headers[browser_takeover_1.BROWSER_TAKEOVER_HEADER] !== undefined);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Read a stream into a buffer. Over `limit`: null, or (with `truncate`) the
|
|
110
|
+
* first `limit` bytes. Never rejects; a broken stream yields what was read.
|
|
111
|
+
*/
|
|
112
|
+
function readBounded(stream, limit, truncate = false) {
|
|
113
|
+
return new Promise((resolveBody) => {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let size = 0;
|
|
116
|
+
let over = false;
|
|
117
|
+
stream.on('data', (chunk) => {
|
|
118
|
+
if (over)
|
|
119
|
+
return;
|
|
120
|
+
size += chunk.length;
|
|
121
|
+
if (size > limit) {
|
|
122
|
+
over = true;
|
|
123
|
+
if (truncate) {
|
|
124
|
+
chunks.push(chunk.subarray(0, chunk.length - (size - limit)));
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
chunks.push(chunk);
|
|
129
|
+
});
|
|
130
|
+
const done = () => resolveBody(over && !truncate ? null : Buffer.concat(chunks));
|
|
131
|
+
stream.once('end', done);
|
|
132
|
+
stream.once('error', done);
|
|
133
|
+
stream.once('close', done);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function parseJsonOrText(buffer) {
|
|
137
|
+
const text = buffer ? buffer.toString('utf8') : '';
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(text);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Stream an upstream response back unchanged (SSE flushed per chunk). */
|
|
146
|
+
function pipeUpstreamResponse(proxyRes, res) {
|
|
147
|
+
if (res.destroyed) {
|
|
148
|
+
proxyRes.resume();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const responseHeaders = { ...proxyRes.headers };
|
|
152
|
+
const contentType = String(proxyRes.headers['content-type'] || '');
|
|
153
|
+
const isSSE = contentType.includes('text/event-stream');
|
|
154
|
+
if (!isSSE)
|
|
155
|
+
delete responseHeaders['transfer-encoding'];
|
|
156
|
+
res.writeHead(proxyRes.statusCode || 502, responseHeaders);
|
|
157
|
+
if (isSSE) {
|
|
158
|
+
proxyRes.on('data', (chunk) => {
|
|
159
|
+
res.write(chunk);
|
|
160
|
+
});
|
|
161
|
+
proxyRes.on('end', () => res.end());
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
proxyRes.pipe(res);
|
|
165
|
+
}
|
|
166
|
+
// An upstream reset mid-body (a Cloud Run instance going away) must not
|
|
167
|
+
// leave Pi waiting on a half-written answer: `pipe` never ends `res` then,
|
|
168
|
+
// and a clean `end()` would pass a truncated body off as complete.
|
|
169
|
+
proxyRes.once('close', () => {
|
|
170
|
+
if (!proxyRes.complete && !res.writableEnded)
|
|
171
|
+
res.destroy();
|
|
172
|
+
});
|
|
173
|
+
res.once('close', () => {
|
|
174
|
+
if (!proxyRes.complete)
|
|
175
|
+
proxyRes.destroy();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function writeJson(res, status, value) {
|
|
179
|
+
if (res.destroyed || res.headersSent) {
|
|
180
|
+
if (!res.writableEnded)
|
|
181
|
+
res.end();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
185
|
+
res.end(JSON.stringify(value));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Answer the agent's held call with a JSON-RPC error for ITS request id, so
|
|
189
|
+
* the tool call fails visibly instead of the turn hanging. A body without a
|
|
190
|
+
* single request id (batch, notification, unparsable) gets HTTP 502 JSON.
|
|
191
|
+
*/
|
|
192
|
+
function writeJsonRpcError(res, body, message) {
|
|
193
|
+
const request = parseJsonOrText(body);
|
|
194
|
+
const id = request && typeof request === 'object' && !Array.isArray(request)
|
|
195
|
+
? request.id
|
|
196
|
+
: undefined;
|
|
197
|
+
if (typeof id === 'string' || typeof id === 'number') {
|
|
198
|
+
writeJson(res, 200, {
|
|
199
|
+
jsonrpc: '2.0',
|
|
200
|
+
id,
|
|
201
|
+
error: { code: -32603, message }
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
writeJson(res, 502, { error: message });
|
|
206
|
+
}
|
|
207
|
+
/** `promise`, or `onAbort` as soon as `signal` aborts (whichever is first). */
|
|
208
|
+
function untilAborted(promise, signal, onAbort) {
|
|
209
|
+
if (signal.aborted)
|
|
210
|
+
return Promise.resolve(onAbort);
|
|
211
|
+
return new Promise((resolveValue, rejectValue) => {
|
|
212
|
+
const abort = () => resolveValue(onAbort);
|
|
213
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
214
|
+
promise.then((value) => {
|
|
215
|
+
signal.removeEventListener('abort', abort);
|
|
216
|
+
resolveValue(value);
|
|
217
|
+
}, (error) => {
|
|
218
|
+
signal.removeEventListener('abort', abort);
|
|
219
|
+
rejectValue(error);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function delay(ms, signal) {
|
|
224
|
+
return new Promise((resolveDelay) => {
|
|
225
|
+
if (signal.aborted || ms <= 0) {
|
|
226
|
+
resolveDelay();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const pending = {};
|
|
230
|
+
const done = () => {
|
|
231
|
+
clearTimeout(pending.timer);
|
|
232
|
+
signal.removeEventListener('abort', done);
|
|
233
|
+
resolveDelay();
|
|
234
|
+
};
|
|
235
|
+
pending.timer = setTimeout(done, ms);
|
|
236
|
+
signal.addEventListener('abort', done, { once: true });
|
|
237
|
+
});
|
|
238
|
+
}
|
|
44
239
|
class McpAuthProxy {
|
|
45
240
|
options;
|
|
46
241
|
server = null;
|
|
@@ -51,9 +246,14 @@ class McpAuthProxy {
|
|
|
51
246
|
/** One notification per integration per process, not one per failed call. */
|
|
52
247
|
notifiedFailures = new Set();
|
|
53
248
|
logger;
|
|
249
|
+
/** Browser calls held during a takeover. Cancelled on `stop()`. */
|
|
250
|
+
browserHolds = new Set();
|
|
251
|
+
/** Chat browsers run as their own uids; they get nothing from this proxy. */
|
|
252
|
+
peerGuard;
|
|
54
253
|
constructor(options) {
|
|
55
254
|
this.options = options;
|
|
56
255
|
this.logger = options.logger ?? console;
|
|
256
|
+
this.peerGuard = new loopback_peer_1.BrowserPeerGuard(options.peerUid);
|
|
57
257
|
}
|
|
58
258
|
getPort() {
|
|
59
259
|
return this.port;
|
|
@@ -106,24 +306,45 @@ class McpAuthProxy {
|
|
|
106
306
|
}
|
|
107
307
|
return new Promise((resolvePort, reject) => {
|
|
108
308
|
const server = (0, node_http_1.createServer)((req, res) => {
|
|
309
|
+
if (this.peerGuard.refuse(req.socket, res)) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
109
312
|
void this.handleRequest(req, res);
|
|
110
313
|
});
|
|
314
|
+
this.peerGuard.attach(server);
|
|
111
315
|
this.server = server;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
reject(new Error('MCP auth proxy could not determine its address'));
|
|
122
|
-
}
|
|
123
|
-
});
|
|
316
|
+
// LOOPBACK ONLY. Never 0.0.0.0. Ephemeral unless options.port pins it.
|
|
317
|
+
listenLoopback(server, this.options.port ?? 0, this.logger, 'McpAuthProxy')
|
|
318
|
+
.then((port) => {
|
|
319
|
+
this.port = port;
|
|
320
|
+
resolvePort(port);
|
|
321
|
+
})
|
|
322
|
+
.catch(reject);
|
|
124
323
|
});
|
|
125
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Cancel every held browser call whose gateway URL starts with
|
|
327
|
+
* `targetUrlPrefix` (a full target URL, or `.../api/mcp/agent/<sessionId>/`
|
|
328
|
+
* for a whole session). Held calls are answered with a JSON-RPC error and
|
|
329
|
+
* the hook gets `end` / `cancelled`. Returns how many were cancelled.
|
|
330
|
+
*/
|
|
331
|
+
cancelBrowserHolds(targetUrlPrefix) {
|
|
332
|
+
let count = 0;
|
|
333
|
+
for (const hold of this.browserHolds) {
|
|
334
|
+
if (hold.targetUrl.startsWith(targetUrlPrefix)) {
|
|
335
|
+
hold.controller.abort();
|
|
336
|
+
count += 1;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return count;
|
|
340
|
+
}
|
|
341
|
+
/** Number of browser calls currently held. For tests and diagnostics. */
|
|
342
|
+
activeBrowserHolds() {
|
|
343
|
+
return this.browserHolds.size;
|
|
344
|
+
}
|
|
126
345
|
async stop() {
|
|
346
|
+
for (const hold of this.browserHolds)
|
|
347
|
+
hold.controller.abort();
|
|
127
348
|
const server = this.server;
|
|
128
349
|
this.server = null;
|
|
129
350
|
this.port = null;
|
|
@@ -195,6 +416,10 @@ class McpAuthProxy {
|
|
|
195
416
|
targetUrl.pathname.replace(/\/$/, '') + '/' + subPath.join('/');
|
|
196
417
|
}
|
|
197
418
|
targetUrl.search = reqUrl.search;
|
|
419
|
+
if ((0, browser_takeover_1.isBrowserTargetUrl)(target.url)) {
|
|
420
|
+
await this.handleBrowserRequest(req, res, id, target, targetUrl);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
198
423
|
let token;
|
|
199
424
|
try {
|
|
200
425
|
token = await this.options.tokens.getToken(target.url);
|
|
@@ -288,6 +513,249 @@ class McpAuthProxy {
|
|
|
288
513
|
}
|
|
289
514
|
}
|
|
290
515
|
}
|
|
516
|
+
/**
|
|
517
|
+
* THE BROWSER TAKEOVER HOLD (sandbox browser). Only for the built-in
|
|
518
|
+
* `pf-browser` gateway server; every other target takes the plain streaming
|
|
519
|
+
* path above, unchanged.
|
|
520
|
+
*
|
|
521
|
+
* While a person controls the chat browser, the gateway answers a waiting
|
|
522
|
+
* browser tool with 409 + `x-pf-browser-takeover`. That answer is NOT handed
|
|
523
|
+
* to Pi: the agent's call stays open (Pi's timeout for this server is 24 h)
|
|
524
|
+
* and the SAME body is re-sent with `x-pf-browser-await-handback: 1` — each
|
|
525
|
+
* with a fresh token — until the gateway answers anything else (it holds
|
|
526
|
+
* each such request up to 20 s and, after a handback, rewrites the call into
|
|
527
|
+
* a fresh snapshot). That answer is streamed back to Pi unchanged. The turn
|
|
528
|
+
* is never aborted; the SessionRunner counts the time as an intentional
|
|
529
|
+
* pause through `onBrowserPause`.
|
|
530
|
+
*/
|
|
531
|
+
async handleBrowserRequest(req, res, id, target, targetUrl) {
|
|
532
|
+
const body = await readBounded(req, exports.BROWSER_HOLD_MAX_BODY_BYTES);
|
|
533
|
+
if (res.destroyed)
|
|
534
|
+
return;
|
|
535
|
+
if (body === null) {
|
|
536
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
537
|
+
res.end(JSON.stringify({ error: 'Browser MCP request body too large' }));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
let token;
|
|
541
|
+
try {
|
|
542
|
+
token = await this.options.tokens.getToken(target.url);
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
this.reportTokenFailure(id, target.label, error);
|
|
546
|
+
if (res.destroyed)
|
|
547
|
+
return;
|
|
548
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
549
|
+
res.end(JSON.stringify({
|
|
550
|
+
error: `Failed to obtain auth token for "${target.label}"`
|
|
551
|
+
}));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (res.destroyed)
|
|
555
|
+
return;
|
|
556
|
+
// One controller for the request's whole life: Pi closing it, a cancel,
|
|
557
|
+
// or `stop()` all abort the in-flight upstream request and the backoff.
|
|
558
|
+
const hold = {
|
|
559
|
+
targetUrl: target.url,
|
|
560
|
+
controller: new AbortController()
|
|
561
|
+
};
|
|
562
|
+
const signal = hold.controller.signal;
|
|
563
|
+
let clientClosed = false;
|
|
564
|
+
const onClose = () => {
|
|
565
|
+
if (!res.writableEnded) {
|
|
566
|
+
clientClosed = true;
|
|
567
|
+
hold.controller.abort();
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
res.once('close', onClose);
|
|
571
|
+
this.browserHolds.add(hold);
|
|
572
|
+
let holding = false;
|
|
573
|
+
const endHold = (reason) => {
|
|
574
|
+
if (!holding)
|
|
575
|
+
return;
|
|
576
|
+
holding = false;
|
|
577
|
+
this.callBrowserHook(target.url, { state: 'end', reason });
|
|
578
|
+
};
|
|
579
|
+
try {
|
|
580
|
+
let outcome = await this.sendBuffered(req, targetUrl, body, token, false, signal);
|
|
581
|
+
if (outcome.kind === 'error') {
|
|
582
|
+
if (signal.aborted)
|
|
583
|
+
return;
|
|
584
|
+
this.logger.error(`[McpAuthProxy] Upstream error for "${target.label}": ${outcome.error.message}`);
|
|
585
|
+
writeJson(res, 502, {
|
|
586
|
+
error: `Upstream error: ${outcome.error.message}`
|
|
587
|
+
});
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (!isTakeover(outcome.response)) {
|
|
591
|
+
pipeUpstreamResponse(outcome.response, res);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const takeoverText = await readBounded(outcome.response, BROWSER_TAKEOVER_BODY_MAX_BYTES, true);
|
|
595
|
+
if (signal.aborted)
|
|
596
|
+
return;
|
|
597
|
+
holding = true;
|
|
598
|
+
const accepted = this.callBrowserHook(target.url, {
|
|
599
|
+
state: 'start',
|
|
600
|
+
body: parseJsonOrText(takeoverText)
|
|
601
|
+
});
|
|
602
|
+
if (accepted === false) {
|
|
603
|
+
hold.controller.abort();
|
|
604
|
+
}
|
|
605
|
+
let attempt = 0;
|
|
606
|
+
let failures = 0;
|
|
607
|
+
while (!signal.aborted) {
|
|
608
|
+
attempt += 1;
|
|
609
|
+
const startedAt = Date.now();
|
|
610
|
+
try {
|
|
611
|
+
// A FRESH token per attempt: a takeover can outlast the token. A
|
|
612
|
+
// token lookup that hangs must not keep a cancelled hold alive.
|
|
613
|
+
const attemptToken = await untilAborted(this.options.tokens.getToken(target.url), signal, null);
|
|
614
|
+
outcome = signal.aborted
|
|
615
|
+
? { kind: 'error', error: new Error('aborted') }
|
|
616
|
+
: await this.sendBuffered(req, targetUrl, body, attemptToken, true, signal, this.options.browserHoldAttemptTimeoutMs ??
|
|
617
|
+
exports.BROWSER_HOLD_ATTEMPT_TIMEOUT_MS);
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
outcome = {
|
|
621
|
+
kind: 'error',
|
|
622
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
if (signal.aborted) {
|
|
626
|
+
if (outcome.kind === 'response')
|
|
627
|
+
outcome.response.resume();
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
let transient = false;
|
|
631
|
+
if (outcome.kind === 'error') {
|
|
632
|
+
transient = true;
|
|
633
|
+
}
|
|
634
|
+
else if (isTakeover(outcome.response)) {
|
|
635
|
+
outcome.response.resume();
|
|
636
|
+
failures = 0;
|
|
637
|
+
}
|
|
638
|
+
else if ((outcome.response.statusCode ?? 502) >= 500) {
|
|
639
|
+
outcome.response.resume();
|
|
640
|
+
transient = true;
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
endHold('resolved');
|
|
644
|
+
pipeUpstreamResponse(outcome.response, res);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (transient) {
|
|
648
|
+
failures += 1;
|
|
649
|
+
const reason = outcome.kind === 'error'
|
|
650
|
+
? outcome.error.message
|
|
651
|
+
: `HTTP ${outcome.response.statusCode}`;
|
|
652
|
+
this.logger.warn(`[McpAuthProxy] Browser hold for "${target.label}": upstream ` +
|
|
653
|
+
`failure ${failures}/${exports.BROWSER_HOLD_MAX_FAILURES}: ${reason}`);
|
|
654
|
+
if (failures >= exports.BROWSER_HOLD_MAX_FAILURES) {
|
|
655
|
+
endHold('failed');
|
|
656
|
+
writeJsonRpcError(res, body, `The browser call could not be completed: the gateway failed ` +
|
|
657
|
+
`${failures} times in a row while a person controlled the ` +
|
|
658
|
+
`browser (${reason}).`);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const fast = Date.now() - startedAt < BROWSER_HOLD_FAST_ANSWER_MS;
|
|
663
|
+
if (transient || fast) {
|
|
664
|
+
await delay(this.holdBackoffMs(attempt, failures), signal);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// Aborted: Pi closed, cancelled, or the proxy is stopping.
|
|
668
|
+
endHold(clientClosed ? 'client_closed' : 'cancelled');
|
|
669
|
+
if (!clientClosed) {
|
|
670
|
+
writeJsonRpcError(res, body, 'The browser call was cancelled while a person controlled the ' +
|
|
671
|
+
'browser (the turn ended).');
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
endHold(clientClosed ? 'client_closed' : 'cancelled');
|
|
676
|
+
this.browserHolds.delete(hold);
|
|
677
|
+
res.off('close', onClose);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
holdBackoffMs(attempt, failures) {
|
|
681
|
+
if (this.options.browserHoldBackoffMs) {
|
|
682
|
+
return this.options.browserHoldBackoffMs(attempt, failures);
|
|
683
|
+
}
|
|
684
|
+
if (failures > 0) {
|
|
685
|
+
const base = Math.min(500 * 2 ** (failures - 1), 10_000);
|
|
686
|
+
return base / 2 + Math.random() * (base / 2);
|
|
687
|
+
}
|
|
688
|
+
return 500 + Math.random() * 1500;
|
|
689
|
+
}
|
|
690
|
+
callBrowserHook(targetUrl, event) {
|
|
691
|
+
try {
|
|
692
|
+
return this.options.onBrowserPause?.(targetUrl, event);
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
// A hook must never break the request path.
|
|
696
|
+
this.logger.warn(`[McpAuthProxy] browser pause hook failed: ${String(error)}`);
|
|
697
|
+
return undefined;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* One upstream request with a buffered body and a fresh token. With
|
|
702
|
+
* `headersTimeoutMs`, no response headers by then is an error outcome.
|
|
703
|
+
*/
|
|
704
|
+
sendBuffered(req, targetUrl, body, token, awaitHandback, signal, headersTimeoutMs) {
|
|
705
|
+
const headers = {};
|
|
706
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
707
|
+
const lower = key.toLowerCase();
|
|
708
|
+
if (lower === 'host' ||
|
|
709
|
+
lower === 'connection' ||
|
|
710
|
+
lower === 'authorization' ||
|
|
711
|
+
lower === 'content-length' ||
|
|
712
|
+
lower === 'transfer-encoding' ||
|
|
713
|
+
lower === browser_takeover_1.BROWSER_AWAIT_HANDBACK_HEADER) {
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
headers[key] = value;
|
|
717
|
+
}
|
|
718
|
+
if (token !== null)
|
|
719
|
+
headers['authorization'] = `Bearer ${token}`;
|
|
720
|
+
headers['host'] = targetUrl.host;
|
|
721
|
+
if (body.length > 0 || (req.method !== 'GET' && req.method !== 'HEAD')) {
|
|
722
|
+
headers['content-length'] = String(body.length);
|
|
723
|
+
}
|
|
724
|
+
if (awaitHandback)
|
|
725
|
+
headers[browser_takeover_1.BROWSER_AWAIT_HANDBACK_HEADER] = '1';
|
|
726
|
+
const send = targetUrl.protocol === 'https:' ? node_https_1.request : node_http_1.request;
|
|
727
|
+
return new Promise((resolveOutcome) => {
|
|
728
|
+
let settled = false;
|
|
729
|
+
const settle = (outcome) => {
|
|
730
|
+
if (settled) {
|
|
731
|
+
if (outcome.kind === 'response')
|
|
732
|
+
outcome.response.resume();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
settled = true;
|
|
736
|
+
resolveOutcome(outcome);
|
|
737
|
+
};
|
|
738
|
+
if (signal.aborted) {
|
|
739
|
+
settle({ kind: 'error', error: new Error('aborted') });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const pending = {};
|
|
743
|
+
const upstreamReq = send(targetUrl.toString(), { method: req.method, headers, signal }, (response) => {
|
|
744
|
+
clearTimeout(pending.timer);
|
|
745
|
+
settle({ kind: 'response', response });
|
|
746
|
+
});
|
|
747
|
+
upstreamReq.on('error', (error) => {
|
|
748
|
+
clearTimeout(pending.timer);
|
|
749
|
+
settle({ kind: 'error', error });
|
|
750
|
+
});
|
|
751
|
+
if (headersTimeoutMs !== undefined && headersTimeoutMs > 0) {
|
|
752
|
+
pending.timer = setTimeout(() => {
|
|
753
|
+
upstreamReq.destroy(new Error(`no upstream answer within ${headersTimeoutMs} ms`));
|
|
754
|
+
}, headersTimeoutMs);
|
|
755
|
+
}
|
|
756
|
+
upstreamReq.end(body);
|
|
757
|
+
});
|
|
758
|
+
}
|
|
291
759
|
/**
|
|
292
760
|
* One notification per TARGET per process — keyed on the target id, not on
|
|
293
761
|
* the human-readable label.
|
|
@@ -79,6 +79,14 @@ export interface SessionRunnerDeps {
|
|
|
79
79
|
* Optional, so a runner constructed without one behaves exactly as before.
|
|
80
80
|
*/
|
|
81
81
|
onApprovalStateChange?(sessionId: string, waiting: boolean): void;
|
|
82
|
+
/**
|
|
83
|
+
* Called when the turn starts or stops an intentional BROWSER pause: a
|
|
84
|
+
* person took control of the chat browser and the agent's browser call is
|
|
85
|
+
* held by the loopback MCP proxy until they hand back. Separate from
|
|
86
|
+
* `onApprovalStateChange` so an approval closing cannot relax polling while
|
|
87
|
+
* the browser is still held (and vice versa). Optional.
|
|
88
|
+
*/
|
|
89
|
+
onBrowserPauseChange?(sessionId: string, paused: boolean): void;
|
|
82
90
|
/**
|
|
83
91
|
* Read the session's history from the CONTROL PLANE.
|
|
84
92
|
*
|
|
@@ -379,12 +387,36 @@ export declare class SessionRunner {
|
|
|
379
387
|
*/
|
|
380
388
|
private approvalWaitMs;
|
|
381
389
|
private approvalWaitStartedAt;
|
|
390
|
+
/**
|
|
391
|
+
* Total wall-clock this turn has spent with its browser call HELD because a
|
|
392
|
+
* person controls the chat browser (takeover). Excluded from the idle guard
|
|
393
|
+
* and added back onto the hard ceiling exactly like approval time, but it
|
|
394
|
+
* never opens an approval and the approval timeout never applies: a takeover
|
|
395
|
+
* lasts as long as the person needs (owner decision), and the turn's abort
|
|
396
|
+
* and hard ceiling (minus the pause) still end it.
|
|
397
|
+
*/
|
|
398
|
+
private browserPauseMs;
|
|
399
|
+
private browserPauseStartedAt;
|
|
400
|
+
/** When the last browser pause ended; the idle clock restarts from here. */
|
|
401
|
+
private browserPauseEndedAt;
|
|
382
402
|
constructor(deps: SessionRunnerDeps);
|
|
383
403
|
/**
|
|
384
404
|
* True while a permission prompt is outstanding. Read by the harness to
|
|
385
405
|
* decide how hard to poll the control plane for the answer.
|
|
386
406
|
*/
|
|
387
407
|
isAwaitingApproval(): boolean;
|
|
408
|
+
/** True while a browser call is held because a person controls the browser. */
|
|
409
|
+
isBrowserPaused(): boolean;
|
|
410
|
+
/**
|
|
411
|
+
* Start or stop the browser-takeover pause clock. Called by the harness from
|
|
412
|
+
* the loopback MCP proxy's hold hook. Returns false when this runner has no
|
|
413
|
+
* live turn for `sessionId` — the caller should then cancel the hold rather
|
|
414
|
+
* than keep an agent call parked for a turn that is gone.
|
|
415
|
+
*/
|
|
416
|
+
setBrowserPaused(sessionId: string, paused: boolean): boolean;
|
|
417
|
+
private closeBrowserPause;
|
|
418
|
+
/** Wall-clock spent in the browser pause we are in RIGHT NOW, if any. */
|
|
419
|
+
private liveBrowserPauseMs;
|
|
388
420
|
/**
|
|
389
421
|
* Run the leased turn to completion, or until `signal` aborts it.
|
|
390
422
|
*
|