@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,333 @@
1
+ "use strict";
2
+ /**
3
+ * THE SERVER-SIDE PORT IMPLEMENTATIONS — what a headless runner uses when the
4
+ * desktop is not there to supply anything.
5
+ *
6
+ * Every one of these is a REAL implementation of a capability a server genuinely
7
+ * has, or an HONEST NO-OP for one it genuinely lacks. None of them pretends: a
8
+ * server has no user to notify and never sleeps, so `ServerNotifier` does
9
+ * nothing and says so. That is why `Notifier` stayed a port instead of being
10
+ * deleted — the desktop's behaviour is preserved without the runtime having to
11
+ * ask which host it is running on.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.DirectoryWorkspaceProvider = exports.CompositeEventSink = exports.NullEventSink = exports.SessionTokenInjector = exports.StaticTaskMcpProvider = exports.NoArtifactInspector = exports.NoSecretProvider = exports.ServerNotifier = exports.NoopTelemetry = void 0;
15
+ const promises_1 = require("node:fs/promises");
16
+ const node_path_1 = require("node:path");
17
+ /* -------------------------------------------------------------------------- */
18
+ /* Telemetry */
19
+ /* -------------------------------------------------------------------------- */
20
+ /**
21
+ * A no-op, per the brief: product analytics on a server is the CONTROL PLANE's
22
+ * business, and it already sees every register, heartbeat, lease and ingest.
23
+ * Shipping a second telemetry pipe out of a customer VM would be a data-flow the
24
+ * customer never agreed to.
25
+ */
26
+ class NoopTelemetry {
27
+ record() {
28
+ // Deliberately empty.
29
+ }
30
+ }
31
+ exports.NoopTelemetry = NoopTelemetry;
32
+ /* -------------------------------------------------------------------------- */
33
+ /* Notifier */
34
+ /* -------------------------------------------------------------------------- */
35
+ /** No human at this machine, and nothing that sleeps. */
36
+ class ServerNotifier {
37
+ notify() {
38
+ // Deliberately empty.
39
+ }
40
+ keepAwake() {
41
+ return () => {
42
+ // Deliberately empty.
43
+ };
44
+ }
45
+ }
46
+ exports.ServerNotifier = ServerNotifier;
47
+ /* -------------------------------------------------------------------------- */
48
+ /* Secrets */
49
+ /* -------------------------------------------------------------------------- */
50
+ /**
51
+ * No local secret store.
52
+ *
53
+ * THIS IS A SECURITY POSITION, NOT A GAP. A customer VM is outside our security
54
+ * perimeter, so the harness deliberately holds no vendor credential to hand an
55
+ * agent: everything the agent can reach, it reaches through the MCP gateway,
56
+ * which injects the real credentials SERVER-SIDE. A runner with a secret store
57
+ * would recreate exactly the exposure the gateway exists to remove.
58
+ *
59
+ * `getBrokerPort()` returning null makes the runtime skip secret wiring
60
+ * entirely, which is why this is a working implementation and not a stub.
61
+ */
62
+ class NoSecretProvider {
63
+ getBrokerPort() {
64
+ return null;
65
+ }
66
+ registerSession() {
67
+ // Nothing to authorise.
68
+ }
69
+ unregisterSession() {
70
+ // Nothing to revoke.
71
+ }
72
+ writeShellWrapper() {
73
+ return null;
74
+ }
75
+ getSecretEnv() {
76
+ return {};
77
+ }
78
+ }
79
+ exports.NoSecretProvider = NoSecretProvider;
80
+ /* -------------------------------------------------------------------------- */
81
+ /* Artifacts */
82
+ /* -------------------------------------------------------------------------- */
83
+ /**
84
+ * Describes nothing.
85
+ *
86
+ * Artifact inspection on the desktop reads the file to build a preview. On a
87
+ * runner the file is on the runner, and the reader is a browser somewhere else,
88
+ * so the useful implementation is an upload — which belongs to the control
89
+ * plane's ingest path, not here.
90
+ */
91
+ class NoArtifactInspector {
92
+ async inspect() {
93
+ return null;
94
+ }
95
+ }
96
+ exports.NoArtifactInspector = NoArtifactInspector;
97
+ /* -------------------------------------------------------------------------- */
98
+ /* Task MCP */
99
+ /* -------------------------------------------------------------------------- */
100
+ /**
101
+ * The task-management MCP server, when the control plane supplies one.
102
+ *
103
+ * ON THE DESKTOP this port is backed by an IN-PROCESS server. On a runner there
104
+ * is no such server and there must not be one — a task MCP endpoint on the
105
+ * runner would be a second, unauthenticated door into the tenant's data. The
106
+ * runner is handed a REMOTE gateway URL in the lease instead, already scoped and
107
+ * already authenticated, and this class simply carries it.
108
+ *
109
+ * Constructed with null when the lease carried no task MCP, which is the
110
+ * ordinary case for a free-standing session (decision 6).
111
+ */
112
+ class StaticTaskMcpProvider {
113
+ url;
114
+ constructor(url) {
115
+ this.url = url;
116
+ }
117
+ async getUrl() {
118
+ return this.url;
119
+ }
120
+ }
121
+ exports.StaticTaskMcpProvider = StaticTaskMcpProvider;
122
+ /* -------------------------------------------------------------------------- */
123
+ /* Tokens */
124
+ /* -------------------------------------------------------------------------- */
125
+ /**
126
+ * Hands out the session-scoped MCP token from the lease, KEYED BY TARGET URL.
127
+ *
128
+ * The token is held in memory and never written anywhere. It is short-lived,
129
+ * bound to this runner AND this session, and carries no tool list — so the
130
+ * gateway re-reads the allowlist on every call and a revocation takes effect
131
+ * immediately rather than at the next session.
132
+ *
133
+ * WHY THE MAP, AND NOT A SINGLE FIELD — this is a concurrency correctness bug,
134
+ * not a refinement. The loopback proxy holds ONE reference to this injector for
135
+ * the whole process life, and `runLeasedTurn` runs several turns at once when
136
+ * `maxConcurrentSessions > 1`. A single shared `token` field therefore had the
137
+ * exact failure the per-turn model gateway holder was already fixed to avoid:
138
+ *
139
+ * - turn B's `setToken(tokenB)` OVERWROTE turn A's token, so A's forwarded
140
+ * MCP calls carried B's session token. The gateway's session-id gate
141
+ * (`mcpSessionAuthMiddleware`, gate 3) then answered 401 for A — a valid
142
+ * token for the wrong session — and OpenCode mis-reported that 401 as an
143
+ * OAuth challenge, so `create_artifact` failed mid-session with two agents
144
+ * running;
145
+ * - whichever turn finished FIRST called `revoke()` and blanked the token for
146
+ * every still-running turn, 401ing the survivor for the rest of its run.
147
+ *
148
+ * Each target url embeds its own session id (`/api/mcp/agent/:sessionId/...`),
149
+ * so keying the token by target url gives every concurrent session an
150
+ * independent slot. `getToken(targetUrl)` receives that url from the proxy on
151
+ * every request, so the per-request contract the proxy already relies on is
152
+ * unchanged — only the storage is now per-session instead of process-global.
153
+ *
154
+ * The constructor argument is a FALLBACK, used only for a target that was never
155
+ * explicitly registered (the desktop single-token case). It is deliberately NOT
156
+ * mutated by the per-session methods, so a fallback can never leak a session
157
+ * token to an unrelated target.
158
+ */
159
+ class SessionTokenInjector {
160
+ /** targetUrl -> the session token to attach for that url. */
161
+ byTarget = new Map();
162
+ fallback;
163
+ constructor(token) {
164
+ this.fallback = token;
165
+ }
166
+ async getToken(targetUrl) {
167
+ return this.byTarget.has(targetUrl)
168
+ ? (this.byTarget.get(targetUrl) ?? null)
169
+ : this.fallback;
170
+ }
171
+ /**
172
+ * Install one session's token for exactly the target urls that session will
173
+ * reach, for the turn that is about to run.
174
+ *
175
+ * The injector is re-pointed per turn rather than recreated, because the
176
+ * loopback proxy holds a reference to it for the whole process life. Two
177
+ * concurrent turns register DISJOINT url sets (their session ids differ), so
178
+ * neither can overwrite the other's slot.
179
+ */
180
+ registerTargets(targetUrls, token) {
181
+ for (const url of targetUrls) {
182
+ this.byTarget.set(url, token);
183
+ }
184
+ }
185
+ /**
186
+ * Revoke exactly one session's tokens when its turn ends.
187
+ *
188
+ * Scoped to the urls that turn owned, so a sibling turn still in flight keeps
189
+ * its own credential — the process-global `revoke()` blanked everyone's.
190
+ */
191
+ revokeTargets(targetUrls) {
192
+ for (const url of targetUrls) {
193
+ this.byTarget.delete(url);
194
+ }
195
+ }
196
+ /**
197
+ * Set the process-wide FALLBACK token. Retained for the desktop single-token
198
+ * path and for tests; the concurrent runner path uses `registerTargets`.
199
+ */
200
+ setToken(token) {
201
+ this.fallback = token;
202
+ }
203
+ /** Clear the fallback AND every per-target token. Used on full teardown. */
204
+ revoke() {
205
+ this.fallback = null;
206
+ this.byTarget.clear();
207
+ }
208
+ }
209
+ exports.SessionTokenInjector = SessionTokenInjector;
210
+ /* -------------------------------------------------------------------------- */
211
+ /* Events */
212
+ /* -------------------------------------------------------------------------- */
213
+ /**
214
+ * Drops everything.
215
+ *
216
+ * The runner's real event path is `POST /ingest`, driven by the session runner
217
+ * on the transcript it has already stored. This exists so a caller that supplies
218
+ * no sink does not have to null-check on the hot path of the agent loop.
219
+ */
220
+ class NullEventSink {
221
+ transcriptChanged() {
222
+ // Deliberately empty.
223
+ }
224
+ emit() {
225
+ // Deliberately empty.
226
+ }
227
+ }
228
+ exports.NullEventSink = NullEventSink;
229
+ /**
230
+ * Fans out to several sinks, and NEVER lets one failure hide another.
231
+ *
232
+ * The desktop needs exactly this once it can also run cloud sessions: the same
233
+ * transcript change goes to the renderer AND to the control plane. A sink that
234
+ * throws must not stop the sinks after it — the agent loop is the caller, and a
235
+ * broken IPC channel silently ending transcript delivery is precisely the class
236
+ * of bug this package exists to make impossible.
237
+ */
238
+ class CompositeEventSink {
239
+ sinks;
240
+ constructor(sinks) {
241
+ this.sinks = sinks;
242
+ }
243
+ transcriptChanged(sessionId, maxRev) {
244
+ for (const sink of this.sinks) {
245
+ try {
246
+ sink.transcriptChanged(sessionId, maxRev);
247
+ }
248
+ catch {
249
+ // Isolated on purpose. See the class comment.
250
+ }
251
+ }
252
+ }
253
+ emit(channel, payload) {
254
+ for (const sink of this.sinks) {
255
+ try {
256
+ sink.emit(channel, payload);
257
+ }
258
+ catch {
259
+ // Isolated on purpose.
260
+ }
261
+ }
262
+ }
263
+ }
264
+ exports.CompositeEventSink = CompositeEventSink;
265
+ /**
266
+ * One directory per session, mode 0700.
267
+ *
268
+ * DECISION 2 REMOVED THE UNIX USER PER SESSION, and `useradd` is deliberately
269
+ * absent from the sandbox image. The consequence is stated plainly rather than
270
+ * left implicit: INSIDE A TENANT, this directory is the only filesystem
271
+ * separation there is, and it is separation by path, not by kernel. Everything
272
+ * stronger is enforced by the harness and by the phase-3 MCP gateway, which is
273
+ * what makes that gateway load-bearing rather than merely useful.
274
+ *
275
+ * 'repository' mode is REFUSED here. Cloning a customer repository is an
276
+ * explicit per-agent opt-in on a runner the customer chose, so the capability
277
+ * belongs to a workspace provider that a self-hosted runner installs — not to
278
+ * the default that a shared cloud sandbox gets.
279
+ */
280
+ class DirectoryWorkspaceProvider {
281
+ options;
282
+ constructor(options) {
283
+ this.options = options;
284
+ }
285
+ async prepare(request) {
286
+ if (request.mode === 'repository') {
287
+ throw new Error('Repository workspaces are not available on this runner. ' +
288
+ 'Repository access is an explicit per-agent opt-in on a self-hosted runner.');
289
+ }
290
+ const dir = this.pathFor(request.sessionId);
291
+ // 0700, and recursive so a fresh host with no root directory still works.
292
+ await (0, promises_1.mkdir)(dir, { recursive: true, mode: 0o700 });
293
+ return dir;
294
+ }
295
+ async release(sessionId) {
296
+ // `force` so releasing a session that never got a directory — an abort
297
+ // before the first turn — is not an error on a shutdown path.
298
+ await (0, promises_1.rm)(this.pathFor(sessionId), { recursive: true, force: true });
299
+ }
300
+ /**
301
+ * Session ids come from the control plane, but a PATH is built from them, so
302
+ * they are treated as UNTRUSTED INPUT regardless.
303
+ *
304
+ * IT REJECTS RATHER THAN SANITISES, and that distinction is the whole point.
305
+ * An earlier version mapped every unsafe character to `_`, which is not
306
+ * injective: `a/b` and `a_b` both became `<root>/a_b`. Two distinct sessions
307
+ * would then SHARE one workspace — and `release()` on either would delete the
308
+ * other's files. Decision 2 removed the unix user per session, so this
309
+ * directory is the only filesystem separation two sessions of one tenant have;
310
+ * a sanitiser that can collide quietly removes even that.
311
+ *
312
+ * The id is a control-plane identifier, so refusing an unrepresentable one
313
+ * costs nothing real and fails loudly instead of silently sharing state.
314
+ */
315
+ pathFor(sessionId) {
316
+ if (!/^[a-zA-Z0-9._-]+$/.test(sessionId)) {
317
+ throw new Error(`Unusable session id for a workspace path: ${JSON.stringify(sessionId)}. Expected only [A-Za-z0-9._-].`);
318
+ }
319
+ if (sessionId === '.' || sessionId === '..') {
320
+ throw new Error(`Unusable session id for a workspace path: ${sessionId}`);
321
+ }
322
+ const root = (0, node_path_1.resolve)(this.options.root);
323
+ const dir = (0, node_path_1.resolve)((0, node_path_1.join)(root, sessionId));
324
+ // Belt and braces. The check above already excludes a separator, but a
325
+ // path that escaped here would let one session read another's workspace,
326
+ // and that is not a thing to protect with a single regular expression.
327
+ if (dir !== root && !dir.startsWith(root + '/')) {
328
+ throw new Error(`Workspace path escaped the root: ${sessionId}`);
329
+ }
330
+ return dir;
331
+ }
332
+ }
333
+ exports.DirectoryWorkspaceProvider = DirectoryWorkspaceProvider;