@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1
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/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* LEASE RENEWAL FOR A SLOW, SILENT TURN — the fix for "a turn is requeued
|
|
4
|
+
* underneath itself".
|
|
5
|
+
*
|
|
6
|
+
* THE PROBLEM. Lease extension rode ENTIRELY on `/ingest` piggy-backing
|
|
7
|
+
* `leaseExpiresAt`: every successful ingest pushes the visibility timeout out by
|
|
8
|
+
* LEASE_TTL_MS (120 s). `renewLease` existed on the client but had ZERO call
|
|
9
|
+
* sites. So a turn that is slow AND produces no parts for over 120 s — one long
|
|
10
|
+
* tool call, one slow model, a big file read — has its lease expire while it is
|
|
11
|
+
* still working. The watchdog then requeues the session onto ANOTHER runner, and
|
|
12
|
+
* two runners run the same turn: two writers interleaving one transcript, double
|
|
13
|
+
* the model spend, and eventually MAX_REQUEUE_ATTEMPTS poisoning the turn.
|
|
14
|
+
*
|
|
15
|
+
* THE FIX, in two halves:
|
|
16
|
+
*
|
|
17
|
+
* 1. Renew on a TIMER for the whole duration of the turn, comfortably inside
|
|
18
|
+
* LEASE_TTL_MS, so a silent turn keeps its lease alive without waiting for
|
|
19
|
+
* output to ingest.
|
|
20
|
+
*
|
|
21
|
+
* 2. Handle `{ renewed: false }` by ABANDONING the turn. A lease that could
|
|
22
|
+
* not be renewed has already expired and been requeued elsewhere; another
|
|
23
|
+
* runner owns the session now. Continuing to spend model budget on work we
|
|
24
|
+
* no longer own is exactly the double-execution this fix exists to stop, so
|
|
25
|
+
* the turn's AbortController is fired and the runner stops.
|
|
26
|
+
*/
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.APPROVAL_RENEW_INTERVAL_MS = exports.DEFAULT_RENEW_INTERVAL_MS = void 0;
|
|
29
|
+
exports.startIsolatedLeaseRenewal = startIsolatedLeaseRenewal;
|
|
30
|
+
exports.startLeaseRenewal = startLeaseRenewal;
|
|
31
|
+
const node_worker_threads_1 = require("node:worker_threads");
|
|
32
|
+
const contract_1 = require("../contract");
|
|
33
|
+
const control_plane_1 = require("../control-plane");
|
|
34
|
+
/** The default renewal cadence: half of LEASE_TTL_MS, so one lost renewal is survivable. */
|
|
35
|
+
exports.DEFAULT_RENEW_INTERVAL_MS = 60_000;
|
|
36
|
+
/**
|
|
37
|
+
* THE CADENCE WHILE A HUMAN IS BEING WAITED ON — and why this route, not the
|
|
38
|
+
* heartbeat.
|
|
39
|
+
*
|
|
40
|
+
* A permission prompt is only useful if it appears, and is answered, at
|
|
41
|
+
* conversational speed. Measured against the channels that existed before this
|
|
42
|
+
* change:
|
|
43
|
+
*
|
|
44
|
+
* heartbeat 30 s the ONLY carrier of sessionCommands
|
|
45
|
+
* lease long-poll 25 s but NOT polled at all during a turn — the loop
|
|
46
|
+
* sees `active.size >= maxConcurrentSessions` and
|
|
47
|
+
* sleeps 500 ms without calling the route
|
|
48
|
+
* lease renew 60 s per-lease, runs for the whole turn, carried
|
|
49
|
+
* no commands
|
|
50
|
+
*
|
|
51
|
+
* So the brief's premise — "commands ride back on BOTH the heartbeat and the
|
|
52
|
+
* lease poll, and the lease poll is far tighter" — does not hold in this build.
|
|
53
|
+
* `sessionCommands` is returned by exactly one route (handlers/runners.ts:391,
|
|
54
|
+
* the heartbeat), and the lease poll is not even called while the turn that
|
|
55
|
+
* needs the answer is running. Every approval would have cost up to 30 s.
|
|
56
|
+
*
|
|
57
|
+
* THE RENEW ROUTE IS THE RIGHT CARRIER, for three reasons that all point the
|
|
58
|
+
* same way:
|
|
59
|
+
* 1. It is the only control-plane call that already runs for the whole
|
|
60
|
+
* duration of a turn, which is exactly the window an approval lives in.
|
|
61
|
+
* 2. It is already per-lease and per-runner authenticated, so it needs no new
|
|
62
|
+
* auth path and no new route — `takePendingCommands` matches on the
|
|
63
|
+
* session's runner and can simply be called there too.
|
|
64
|
+
* 3. It is the same call that keeps the turn from being scavenged. One call
|
|
65
|
+
* proves the turn is alive AND collects the answer, so the two can never
|
|
66
|
+
* disagree about whether the turn still exists.
|
|
67
|
+
*
|
|
68
|
+
* Two seconds matches the runner's own backend poll interval, so the answer and
|
|
69
|
+
* the backend's reaction to it land in the same beat. It is expensive only
|
|
70
|
+
* while a human is genuinely being waited on, which is rare, bounded by the
|
|
71
|
+
* person, and precisely when the extra call is worth making.
|
|
72
|
+
*/
|
|
73
|
+
exports.APPROVAL_RENEW_INTERVAL_MS = 2_000;
|
|
74
|
+
/*
|
|
75
|
+
* A real OS thread owns both the timer and the HTTP request. Pi can therefore
|
|
76
|
+
* spend as long as it needs in synchronous output processing without starving
|
|
77
|
+
* the lease. Keep this source self-contained: `eval` means the published
|
|
78
|
+
* CommonJS package does not need a second worker entry point or a loader.
|
|
79
|
+
*/
|
|
80
|
+
const RENEWAL_WORKER_SOURCE = String.raw `
|
|
81
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
82
|
+
let stopped = false;
|
|
83
|
+
let inFlight = false;
|
|
84
|
+
let urgent = false;
|
|
85
|
+
let credential = workerData.runnerCredential;
|
|
86
|
+
let githubTokenExpiresAt = workerData.githubTokenExpiresAt;
|
|
87
|
+
let modelTokenExpiresAt = workerData.modelTokenExpiresAt;
|
|
88
|
+
let timer = null;
|
|
89
|
+
|
|
90
|
+
function arm() {
|
|
91
|
+
if (timer) clearInterval(timer);
|
|
92
|
+
const interval = urgent ? workerData.urgentIntervalMs : workerData.baseIntervalMs;
|
|
93
|
+
timer = setInterval(renew, interval);
|
|
94
|
+
timer.unref();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function renew() {
|
|
98
|
+
if (stopped || inFlight) return;
|
|
99
|
+
inFlight = true;
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
102
|
+
timeout.unref();
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(workerData.url, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: 'Bearer ' + credential,
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
[workerData.protocolHeader]: String(workerData.protocolVersion)
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
protocolVersion: workerData.protocolVersion,
|
|
113
|
+
...(githubTokenExpiresAt ? { githubTokenExpiresAt } : {}),
|
|
114
|
+
...(modelTokenExpiresAt ? { modelTokenExpiresAt } : {})
|
|
115
|
+
}),
|
|
116
|
+
signal: controller.signal
|
|
117
|
+
});
|
|
118
|
+
const text = await response.text();
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const reason = workerData.path + ' returned ' + response.status + ': ' + text.slice(0, 400);
|
|
121
|
+
if (workerData.finalStatuses.includes(response.status)) {
|
|
122
|
+
stopped = true;
|
|
123
|
+
clearInterval(timer);
|
|
124
|
+
parentPort.postMessage({ type: 'final-error', status: response.status, reason });
|
|
125
|
+
} else {
|
|
126
|
+
parentPort.postMessage({ type: 'transient-error', reason });
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const result = text ? JSON.parse(text) : {};
|
|
131
|
+
if (result.githubCredential?.expiresAt) {
|
|
132
|
+
githubTokenExpiresAt = result.githubCredential.expiresAt;
|
|
133
|
+
}
|
|
134
|
+
if (result.modelTokenExpiresAt) {
|
|
135
|
+
modelTokenExpiresAt = result.modelTokenExpiresAt;
|
|
136
|
+
}
|
|
137
|
+
parentPort.postMessage({ type: 'result', result });
|
|
138
|
+
if (result.renewed === false) {
|
|
139
|
+
stopped = true;
|
|
140
|
+
clearInterval(timer);
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
parentPort.postMessage({
|
|
144
|
+
type: 'transient-error',
|
|
145
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
146
|
+
});
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timeout);
|
|
149
|
+
inFlight = false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
parentPort.on('message', (message) => {
|
|
154
|
+
if (message.type === 'stop') {
|
|
155
|
+
stopped = true;
|
|
156
|
+
if (timer) clearInterval(timer);
|
|
157
|
+
parentPort.close();
|
|
158
|
+
} else if (message.type === 'urgent' && urgent !== message.value) {
|
|
159
|
+
urgent = message.value;
|
|
160
|
+
arm();
|
|
161
|
+
} else if (message.type === 'credential') {
|
|
162
|
+
credential = message.value;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
arm();
|
|
166
|
+
`;
|
|
167
|
+
/**
|
|
168
|
+
* Renew a lease outside the adapter's event loop.
|
|
169
|
+
*
|
|
170
|
+
* The callbacks still run on the main thread, but the lease extension does
|
|
171
|
+
* not depend on them. Messages wait safely in the worker port until Pi yields.
|
|
172
|
+
*/
|
|
173
|
+
function startIsolatedLeaseRenewal(options) {
|
|
174
|
+
const log = options.log ?? (() => undefined);
|
|
175
|
+
const path = new URL(options.url).pathname;
|
|
176
|
+
const worker = new node_worker_threads_1.Worker(RENEWAL_WORKER_SOURCE, {
|
|
177
|
+
eval: true,
|
|
178
|
+
workerData: {
|
|
179
|
+
url: options.url.replace('__LEASE_ID__', encodeURIComponent(options.leaseId)),
|
|
180
|
+
path,
|
|
181
|
+
runnerCredential: options.runnerCredential,
|
|
182
|
+
githubTokenExpiresAt: options.githubTokenExpiresAt,
|
|
183
|
+
modelTokenExpiresAt: options.modelTokenExpiresAt,
|
|
184
|
+
baseIntervalMs: options.intervalMs ?? exports.DEFAULT_RENEW_INTERVAL_MS,
|
|
185
|
+
urgentIntervalMs: options.urgentIntervalMs ?? exports.APPROVAL_RENEW_INTERVAL_MS,
|
|
186
|
+
protocolVersion: contract_1.PROTOCOL_VERSION,
|
|
187
|
+
protocolHeader: contract_1.PROTOCOL_VERSION_HEADER,
|
|
188
|
+
finalStatuses: contract_1.FINAL_HTTP_STATUSES
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
worker.unref();
|
|
192
|
+
let stopped = false;
|
|
193
|
+
const stop = () => {
|
|
194
|
+
if (stopped)
|
|
195
|
+
return;
|
|
196
|
+
stopped = true;
|
|
197
|
+
try {
|
|
198
|
+
worker.postMessage({ type: 'stop' });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// The error event can arrive after the thread is already gone. Stopping
|
|
202
|
+
// a dead renewal worker is complete, not a new harness-level failure.
|
|
203
|
+
}
|
|
204
|
+
const forcedStop = setTimeout(() => {
|
|
205
|
+
void worker.terminate();
|
|
206
|
+
}, 1_000);
|
|
207
|
+
forcedStop.unref();
|
|
208
|
+
};
|
|
209
|
+
worker.on('message', (message) => {
|
|
210
|
+
if (stopped)
|
|
211
|
+
return;
|
|
212
|
+
if (message.type === 'result') {
|
|
213
|
+
const result = message.result;
|
|
214
|
+
if (result.sessionCommands?.length) {
|
|
215
|
+
try {
|
|
216
|
+
options.onCommands?.(result.sessionCommands);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
log('Command handler threw on a renewal', {
|
|
220
|
+
leaseId: options.leaseId,
|
|
221
|
+
error: error instanceof Error ? error.message : String(error)
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (result.githubCredential) {
|
|
226
|
+
options.onGithubCredential?.(result.githubCredential);
|
|
227
|
+
}
|
|
228
|
+
if (result.modelToken && result.modelTokenExpiresAt) {
|
|
229
|
+
options.onModelCredential?.({
|
|
230
|
+
token: result.modelToken,
|
|
231
|
+
expiresAt: result.modelTokenExpiresAt
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (result.renewed === false) {
|
|
235
|
+
const reason = result.reason ?? 'lease expired or belongs to another runner';
|
|
236
|
+
log('Lease could not be renewed; abandoning the turn', {
|
|
237
|
+
leaseId: options.leaseId,
|
|
238
|
+
reason
|
|
239
|
+
});
|
|
240
|
+
stop();
|
|
241
|
+
options.onLost(reason);
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (message.type === 'final-error') {
|
|
246
|
+
log('Lease renewal failed with a final status; abandoning the turn', {
|
|
247
|
+
leaseId: options.leaseId,
|
|
248
|
+
status: message.status,
|
|
249
|
+
reason: message.reason
|
|
250
|
+
});
|
|
251
|
+
stop();
|
|
252
|
+
options.onLost(message.reason);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
log('Lease renewal failed transiently; will retry', {
|
|
256
|
+
leaseId: options.leaseId,
|
|
257
|
+
reason: message.reason
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
worker.on('error', (error) => {
|
|
261
|
+
if (stopped)
|
|
262
|
+
return;
|
|
263
|
+
const reason = `lease renewal worker failed: ${error.message}`;
|
|
264
|
+
log('Lease renewal worker failed; abandoning the turn', {
|
|
265
|
+
leaseId: options.leaseId,
|
|
266
|
+
reason
|
|
267
|
+
});
|
|
268
|
+
stop();
|
|
269
|
+
options.onLost(reason);
|
|
270
|
+
});
|
|
271
|
+
worker.on('exit', (code) => {
|
|
272
|
+
if (stopped)
|
|
273
|
+
return;
|
|
274
|
+
const reason = `lease renewal worker exited unexpectedly with code ${code}`;
|
|
275
|
+
log('Lease renewal worker exited; abandoning the turn', {
|
|
276
|
+
leaseId: options.leaseId,
|
|
277
|
+
reason
|
|
278
|
+
});
|
|
279
|
+
stopped = true;
|
|
280
|
+
options.onLost(reason);
|
|
281
|
+
});
|
|
282
|
+
return {
|
|
283
|
+
stop,
|
|
284
|
+
setUrgent: (value) => {
|
|
285
|
+
if (!stopped)
|
|
286
|
+
worker.postMessage({ type: 'urgent', value });
|
|
287
|
+
},
|
|
288
|
+
setCredential: (value) => {
|
|
289
|
+
if (!stopped)
|
|
290
|
+
worker.postMessage({ type: 'credential', value });
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Keep a lease alive for the duration of a turn. Returns a `stop` function the
|
|
296
|
+
* caller MUST call when the turn ends, so a completed turn does not keep
|
|
297
|
+
* renewing a lease that has already been released.
|
|
298
|
+
*
|
|
299
|
+
* A renewal that fails with a TRANSIENT error (a network blip, a 500) is logged
|
|
300
|
+
* and retried on the next tick — losing one renewal is why the cadence is half
|
|
301
|
+
* the TTL. Only a definitive `renewed: false`, or a FINAL HTTP status, means the
|
|
302
|
+
* lease is truly gone and the turn must be abandoned.
|
|
303
|
+
*/
|
|
304
|
+
function startLeaseRenewal(options) {
|
|
305
|
+
const baseIntervalMs = options.intervalMs ?? exports.DEFAULT_RENEW_INTERVAL_MS;
|
|
306
|
+
const urgentIntervalMs = options.urgentIntervalMs ?? exports.APPROVAL_RENEW_INTERVAL_MS;
|
|
307
|
+
const setIntervalFn = options.setIntervalImpl ?? setInterval;
|
|
308
|
+
const clearIntervalFn = options.clearIntervalImpl ?? clearInterval;
|
|
309
|
+
const log = options.log ?? (() => undefined);
|
|
310
|
+
let stopped = false;
|
|
311
|
+
let inFlight = false;
|
|
312
|
+
let currentIntervalMs = baseIntervalMs;
|
|
313
|
+
let githubTokenExpiresAt = options.githubTokenExpiresAt;
|
|
314
|
+
let modelTokenExpiresAt = options.modelTokenExpiresAt;
|
|
315
|
+
// A holder rather than a bare `let`, so `stop` can reference the handle that
|
|
316
|
+
// is assigned just below without a use-before-assign or a prefer-const fight.
|
|
317
|
+
const timer = { handle: null };
|
|
318
|
+
const stop = () => {
|
|
319
|
+
if (stopped)
|
|
320
|
+
return;
|
|
321
|
+
stopped = true;
|
|
322
|
+
if (timer.handle)
|
|
323
|
+
clearIntervalFn(timer.handle);
|
|
324
|
+
};
|
|
325
|
+
const onTick = () => {
|
|
326
|
+
// Never let two renewals overlap: a renew that is slow must not have a
|
|
327
|
+
// second one queued behind it, which on a struggling control plane would
|
|
328
|
+
// only add load at the worst moment.
|
|
329
|
+
if (stopped || inFlight)
|
|
330
|
+
return;
|
|
331
|
+
inFlight = true;
|
|
332
|
+
const renewalRequest = githubTokenExpiresAt || modelTokenExpiresAt
|
|
333
|
+
? options.client.renewLease(options.leaseId, undefined, githubTokenExpiresAt, modelTokenExpiresAt)
|
|
334
|
+
: options.client.renewLease(options.leaseId);
|
|
335
|
+
void renewalRequest
|
|
336
|
+
.then((result) => {
|
|
337
|
+
if (stopped)
|
|
338
|
+
return;
|
|
339
|
+
/*
|
|
340
|
+
* COMMANDS FIRST, AND BEFORE THE renewed:false CHECK.
|
|
341
|
+
*
|
|
342
|
+
* A renewal that reports the lease gone can still be carrying the
|
|
343
|
+
* user's answer — the control plane hands out commands and evaluates
|
|
344
|
+
* the lease in the same response. Dropping them because the turn is
|
|
345
|
+
* ending would silently lose an answer the user has already given, and
|
|
346
|
+
* `delivered_at` is set at hand-off, so it would never be re-sent.
|
|
347
|
+
*/
|
|
348
|
+
if (result.sessionCommands?.length) {
|
|
349
|
+
try {
|
|
350
|
+
options.onCommands?.(result.sessionCommands);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
// A bad command handler must never stop the renewal loop; that
|
|
354
|
+
// would turn a rendering bug into a requeued turn.
|
|
355
|
+
log('Command handler threw on a renewal', {
|
|
356
|
+
leaseId: options.leaseId,
|
|
357
|
+
error: error instanceof Error ? error.message : String(error)
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (result.githubCredential) {
|
|
362
|
+
githubTokenExpiresAt = result.githubCredential.expiresAt;
|
|
363
|
+
options.onGithubCredential?.(result.githubCredential);
|
|
364
|
+
}
|
|
365
|
+
if (result.modelToken && result.modelTokenExpiresAt) {
|
|
366
|
+
modelTokenExpiresAt = result.modelTokenExpiresAt;
|
|
367
|
+
options.onModelCredential?.({
|
|
368
|
+
token: result.modelToken,
|
|
369
|
+
expiresAt: result.modelTokenExpiresAt
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (result.renewed === false) {
|
|
373
|
+
log('Lease could not be renewed; abandoning the turn', {
|
|
374
|
+
leaseId: options.leaseId,
|
|
375
|
+
reason: result.reason
|
|
376
|
+
});
|
|
377
|
+
stop();
|
|
378
|
+
options.onLost(result.reason ?? 'lease expired or belongs to another runner');
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
.catch((error) => {
|
|
382
|
+
if (stopped)
|
|
383
|
+
return;
|
|
384
|
+
// A FINAL status (401/403/404/409/426) means the lease or the runner is
|
|
385
|
+
// gone for good — abandon, exactly as for `renewed: false`. A transient
|
|
386
|
+
// error is left for the next tick.
|
|
387
|
+
if (error instanceof control_plane_1.ControlPlaneError && error.final) {
|
|
388
|
+
log('Lease renewal failed with a final status; abandoning the turn', {
|
|
389
|
+
leaseId: options.leaseId,
|
|
390
|
+
status: error.status,
|
|
391
|
+
code: error.code,
|
|
392
|
+
reason: error.message
|
|
393
|
+
});
|
|
394
|
+
stop();
|
|
395
|
+
options.onLost(error.message);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
log('Lease renewal failed transiently; will retry', {
|
|
399
|
+
leaseId: options.leaseId,
|
|
400
|
+
error: error instanceof Error ? error.message : String(error)
|
|
401
|
+
});
|
|
402
|
+
})
|
|
403
|
+
.finally(() => {
|
|
404
|
+
inFlight = false;
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
const arm = () => {
|
|
408
|
+
timer.handle = setIntervalFn(onTick, currentIntervalMs);
|
|
409
|
+
// Never hold the event loop open on the renewal timer alone.
|
|
410
|
+
timer.handle.unref?.();
|
|
411
|
+
};
|
|
412
|
+
const setUrgent = (urgent) => {
|
|
413
|
+
if (stopped)
|
|
414
|
+
return;
|
|
415
|
+
const wanted = urgent ? urgentIntervalMs : baseIntervalMs;
|
|
416
|
+
// Idempotent. Re-arming on every call would let a caller that repeatedly
|
|
417
|
+
// asserts the same state postpone a renewal indefinitely, which on the
|
|
418
|
+
// ordinary 60 s cadence is how a lease quietly expires.
|
|
419
|
+
if (wanted === currentIntervalMs)
|
|
420
|
+
return;
|
|
421
|
+
currentIntervalMs = wanted;
|
|
422
|
+
if (timer.handle)
|
|
423
|
+
clearIntervalFn(timer.handle);
|
|
424
|
+
arm();
|
|
425
|
+
log('Lease renewal cadence changed', {
|
|
426
|
+
leaseId: options.leaseId,
|
|
427
|
+
intervalMs: currentIntervalMs,
|
|
428
|
+
urgent
|
|
429
|
+
});
|
|
430
|
+
};
|
|
431
|
+
arm();
|
|
432
|
+
return { stop, setUrgent };
|
|
433
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP AUTH PROXY — extracted from 20x `src/main/mcp-auth-proxy.ts`.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS, unchanged from the original: ACP, OpenCode and Claude Code all
|
|
5
|
+
* bake MCP server headers at SESSION start and none of them refresh those
|
|
6
|
+
* headers mid-session. A one-hour token therefore expires during a long agent
|
|
7
|
+
* run, and every MCP tool call after that point returns 401 with no recovery
|
|
8
|
+
* path — the session is alive, the model keeps trying, and every tool fails
|
|
9
|
+
* until a human notices. This proxy decouples token lifetime from session
|
|
10
|
+
* lifetime by fetching a fresh token PER REQUEST.
|
|
11
|
+
*
|
|
12
|
+
* AND IT MATTERS EVEN MORE ON A RUNNER. The agent is pointed at
|
|
13
|
+
* `127.0.0.1:<port>/<id>`, so the real gateway URL and the real token never
|
|
14
|
+
* appear in an MCP config file, in a backend's session state, or in anything
|
|
15
|
+
* the agent can read. The token is in this process's memory and nowhere else.
|
|
16
|
+
*
|
|
17
|
+
* WHAT CHANGED IN THE EXTRACTION
|
|
18
|
+
* - `enterpriseAuth.getJwt()` became the `TokenInjector` port, so the desktop
|
|
19
|
+
* passes its enterprise JWT and a runner passes the session-scoped MCP token
|
|
20
|
+
* from the lease.
|
|
21
|
+
* - `new Notification(...)` on a token failure became the `Notifier` port, a
|
|
22
|
+
* no-op on a server.
|
|
23
|
+
* - Module-level singletons (`let server`, `let port`, three Maps and a
|
|
24
|
+
* counter) became instance state. The original could only ever have ONE proxy
|
|
25
|
+
* per process, which is correct for a desktop with one signed-in user and
|
|
26
|
+
* wrong for a runner that may hold several sessions with DIFFERENT tokens.
|
|
27
|
+
* Sharing one proxy across sessions there would let session A's agent reach
|
|
28
|
+
* session B's targets by guessing a small integer id.
|
|
29
|
+
* - Ids are random, not `nextId++`. See `registerTarget`.
|
|
30
|
+
*
|
|
31
|
+
* ON "THE HARNESS MUST NEVER OPEN A LISTENING PORT": this binds 127.0.0.1 ONLY,
|
|
32
|
+
* exactly as the health check does. The property that rule protects is that
|
|
33
|
+
* nothing outside the host can initiate a connection to the harness, so a
|
|
34
|
+
* customer VM behind NAT needs no tunnel. A loopback socket is not reachable
|
|
35
|
+
* from the network and does not weaken it. Binding 0.0.0.0 here WOULD, and the
|
|
36
|
+
* test suite asserts the bind address.
|
|
37
|
+
*/
|
|
38
|
+
import { Notifier, TokenInjector } from '../ports';
|
|
39
|
+
export interface McpAuthProxyOptions {
|
|
40
|
+
tokens: TokenInjector;
|
|
41
|
+
notifier?: Notifier;
|
|
42
|
+
/** Injected in tests. Defaults to console. */
|
|
43
|
+
logger?: Pick<Console, 'warn' | 'error' | 'log'>;
|
|
44
|
+
}
|
|
45
|
+
export declare class McpAuthProxy {
|
|
46
|
+
private readonly options;
|
|
47
|
+
private server;
|
|
48
|
+
private port;
|
|
49
|
+
private readonly targets;
|
|
50
|
+
/** Reverse lookup, so re-registering the same URL does not leak ids. */
|
|
51
|
+
private readonly idsByUrl;
|
|
52
|
+
/** One notification per integration per process, not one per failed call. */
|
|
53
|
+
private readonly notifiedFailures;
|
|
54
|
+
private readonly logger;
|
|
55
|
+
constructor(options: McpAuthProxyOptions);
|
|
56
|
+
getPort(): number | null;
|
|
57
|
+
/**
|
|
58
|
+
* Register a real MCP URL and get back a loopback URL that injects a fresh
|
|
59
|
+
* token on every forwarded request. Returns null when the proxy is not running.
|
|
60
|
+
*
|
|
61
|
+
* IDS ARE RANDOM, where the 20x original used `String(nextId++)`. A
|
|
62
|
+
* monotonic counter is fine for a desktop with a single user, but on a runner
|
|
63
|
+
* holding several sessions it means session A can reach session B's targets by
|
|
64
|
+
* requesting `/1`, `/2`, `/3` — and the proxy would dutifully attach B's
|
|
65
|
+
* token. 16 random bytes makes that unguessable. It costs nothing.
|
|
66
|
+
*/
|
|
67
|
+
registerTarget(targetUrl: string, label?: string): string | null;
|
|
68
|
+
unregisterTarget(proxyUrl: string): void;
|
|
69
|
+
start(): Promise<number>;
|
|
70
|
+
stop(): Promise<void>;
|
|
71
|
+
private handleRequest;
|
|
72
|
+
/**
|
|
73
|
+
* One notification per TARGET per process — keyed on the target id, not on
|
|
74
|
+
* the human-readable label.
|
|
75
|
+
*
|
|
76
|
+
* Two different servers can be registered under the same label (a tenant with
|
|
77
|
+
* two Jira instances, say). Keyed on the label, the second one's auth failure
|
|
78
|
+
* would be silently swallowed for ever because the first had already "used
|
|
79
|
+
* up" the notification. The id is unique by construction.
|
|
80
|
+
*/
|
|
81
|
+
private reportTokenFailure;
|
|
82
|
+
}
|