@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. 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
+ }