@peakflo/agent-harness 0.0.0-stage → 0.2.1-rc.3

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 +38 -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,110 @@
1
+ /**
2
+ * DURABLE RUNNER CREDENTIAL STORAGE — the fix for "a crashed harness can never
3
+ * rejoin".
4
+ *
5
+ * THE PROBLEM. The enrolment token is single-use and burns on the first
6
+ * `register`. The runner credential it is exchanged for is long-lived
7
+ * (RUNNER_CREDENTIAL_TTL_SECONDS = 86400) but Track B held it IN MEMORY ONLY,
8
+ * inside `ControlPlaneClient`. So a harness that crashed — SIGKILL, OOM, a host
9
+ * reboot — lost the one credential it had and could not get another: `register`
10
+ * answers `401 enrolment_token_spent`, which the harness correctly treats as
11
+ * final and exits. On a Daytona sandbox that is harmless, because a fresh
12
+ * sandbox is minted with a fresh single-use token. On a SELF-HOSTED runner it
13
+ * bricks the host until a human issues a new token.
14
+ *
15
+ * "Long-lived credential" and "memory only" are in direct tension: a credential
16
+ * is long-lived precisely so it can outlive a process. So it is persisted here —
17
+ * but ONLY for the runtimes where that reasoning holds.
18
+ *
19
+ * WHICH RUNTIMES, AND WHY. The parent research says the sandbox token is "MEMORY
20
+ * ONLY, never on disk". That reasoning was written for the SANDBOX, where
21
+ * session users share a host and a credential on disk is a credential a session
22
+ * user might read. It does NOT extend to a self-hosted VM or the desktop, which
23
+ * are single-tenant machines the customer owns, and where every comparable
24
+ * runner installer (GitHub Actions, GitLab, Buildkite) persists exactly this.
25
+ *
26
+ * - `sandbox` -> NoCredentialStore. Memory only. A fresh token always
27
+ * exists, and the host is shared.
28
+ * - `self-hosted` -> FileCredentialStore at 0600 under the workspace root.
29
+ * - `desktop` -> FileCredentialStore. The user's own machine.
30
+ *
31
+ * The file is written 0600 so that even on a multi-user VM it is readable only
32
+ * by the harness user — matching how the sandbox already protects the bootstrap
33
+ * .npmrc.
34
+ */
35
+ import { RunnerRuntime } from './types';
36
+ /** The file name under the workspace root. */
37
+ export declare const CREDENTIAL_FILE_NAME = "runner-credential.json";
38
+ /**
39
+ * What is persisted. Deliberately the minimum a harness needs to re-authenticate
40
+ * without re-enrolling: the credential, who it belongs to, and when it expires.
41
+ * No enrolment token is ever stored — it is single-use and already spent by the
42
+ * time a credential exists.
43
+ */
44
+ export interface StoredCredential {
45
+ runnerId: string;
46
+ tenantId: string | null;
47
+ credential: string;
48
+ /** ISO 8601, or null when the control plane returned no expiry. */
49
+ credentialExpiresAt: string | null;
50
+ /** ISO 8601. When this row was written, for operator diagnostics. */
51
+ savedAt: string;
52
+ }
53
+ export interface CredentialStore {
54
+ /** The stored credential, or null when there is none or it is unreadable. */
55
+ load(): StoredCredential | null;
56
+ /** Persist a credential, overwriting any previous one. */
57
+ save(credential: StoredCredential): void;
58
+ /** Remove the stored credential. Idempotent. */
59
+ clear(): void;
60
+ }
61
+ /**
62
+ * The sandbox implementation: hold nothing.
63
+ *
64
+ * This is a REAL decision, not a stub. A sandbox is ephemeral and its host is
65
+ * shared between session users, so persisting the credential would both be
66
+ * pointless (the disk goes away with the sandbox) and a small exposure (a
67
+ * session user could read it). Memory only is the correct security position
68
+ * there — the same one Track B shipped.
69
+ */
70
+ export declare class NoCredentialStore implements CredentialStore {
71
+ load(): StoredCredential | null;
72
+ save(): void;
73
+ clear(): void;
74
+ }
75
+ /** One-process credential handoff from pf-bootstrap. Nothing is written. */
76
+ export declare class MemoryCredentialStore implements CredentialStore {
77
+ private credential;
78
+ constructor(credential: StoredCredential | null);
79
+ load(): StoredCredential | null;
80
+ save(credential: StoredCredential): void;
81
+ clear(): void;
82
+ }
83
+ /**
84
+ * Take the private bootstrap handoff and remove every credential-bearing
85
+ * variable before an adapter can copy the process environment to a child.
86
+ */
87
+ export declare function takeHandedOffCredential(env: NodeJS.ProcessEnv): StoredCredential | null;
88
+ /**
89
+ * The durable implementation: a single JSON file at 0600.
90
+ *
91
+ * Synchronous fs on purpose. This is touched at most a handful of times per
92
+ * process — once on start, once per registration, once per rotation — and never
93
+ * on the hot path of a turn, so there is nothing to gain from async and a great
94
+ * deal of clarity to lose. A partial or corrupt file is treated as "no
95
+ * credential" rather than a crash: the fallback is enrolment, which is exactly
96
+ * what an absent credential should trigger.
97
+ */
98
+ export declare class FileCredentialStore implements CredentialStore {
99
+ private readonly path;
100
+ constructor(workspaceRoot: string);
101
+ load(): StoredCredential | null;
102
+ save(credential: StoredCredential): void;
103
+ clear(): void;
104
+ }
105
+ /**
106
+ * Pick the store for a runtime. This is the ONE place that encodes the
107
+ * sandbox-vs-durable decision, so a future runtime has exactly one line to
108
+ * change and cannot silently inherit the wrong policy.
109
+ */
110
+ export declare function createCredentialStore(runtime: RunnerRuntime, workspaceRoot: string): CredentialStore;
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+ /**
3
+ * DURABLE RUNNER CREDENTIAL STORAGE — the fix for "a crashed harness can never
4
+ * rejoin".
5
+ *
6
+ * THE PROBLEM. The enrolment token is single-use and burns on the first
7
+ * `register`. The runner credential it is exchanged for is long-lived
8
+ * (RUNNER_CREDENTIAL_TTL_SECONDS = 86400) but Track B held it IN MEMORY ONLY,
9
+ * inside `ControlPlaneClient`. So a harness that crashed — SIGKILL, OOM, a host
10
+ * reboot — lost the one credential it had and could not get another: `register`
11
+ * answers `401 enrolment_token_spent`, which the harness correctly treats as
12
+ * final and exits. On a Daytona sandbox that is harmless, because a fresh
13
+ * sandbox is minted with a fresh single-use token. On a SELF-HOSTED runner it
14
+ * bricks the host until a human issues a new token.
15
+ *
16
+ * "Long-lived credential" and "memory only" are in direct tension: a credential
17
+ * is long-lived precisely so it can outlive a process. So it is persisted here —
18
+ * but ONLY for the runtimes where that reasoning holds.
19
+ *
20
+ * WHICH RUNTIMES, AND WHY. The parent research says the sandbox token is "MEMORY
21
+ * ONLY, never on disk". That reasoning was written for the SANDBOX, where
22
+ * session users share a host and a credential on disk is a credential a session
23
+ * user might read. It does NOT extend to a self-hosted VM or the desktop, which
24
+ * are single-tenant machines the customer owns, and where every comparable
25
+ * runner installer (GitHub Actions, GitLab, Buildkite) persists exactly this.
26
+ *
27
+ * - `sandbox` -> NoCredentialStore. Memory only. A fresh token always
28
+ * exists, and the host is shared.
29
+ * - `self-hosted` -> FileCredentialStore at 0600 under the workspace root.
30
+ * - `desktop` -> FileCredentialStore. The user's own machine.
31
+ *
32
+ * The file is written 0600 so that even on a multi-user VM it is readable only
33
+ * by the harness user — matching how the sandbox already protects the bootstrap
34
+ * .npmrc.
35
+ */
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.FileCredentialStore = exports.MemoryCredentialStore = exports.NoCredentialStore = exports.CREDENTIAL_FILE_NAME = void 0;
38
+ exports.takeHandedOffCredential = takeHandedOffCredential;
39
+ exports.createCredentialStore = createCredentialStore;
40
+ const node_fs_1 = require("node:fs");
41
+ const node_path_1 = require("node:path");
42
+ /** The file name under the workspace root. */
43
+ exports.CREDENTIAL_FILE_NAME = 'runner-credential.json';
44
+ /**
45
+ * The sandbox implementation: hold nothing.
46
+ *
47
+ * This is a REAL decision, not a stub. A sandbox is ephemeral and its host is
48
+ * shared between session users, so persisting the credential would both be
49
+ * pointless (the disk goes away with the sandbox) and a small exposure (a
50
+ * session user could read it). Memory only is the correct security position
51
+ * there — the same one Track B shipped.
52
+ */
53
+ class NoCredentialStore {
54
+ load() {
55
+ return null;
56
+ }
57
+ save() {
58
+ // Deliberately empty. See the class comment.
59
+ }
60
+ clear() {
61
+ // Deliberately empty.
62
+ }
63
+ }
64
+ exports.NoCredentialStore = NoCredentialStore;
65
+ /** One-process credential handoff from pf-bootstrap. Nothing is written. */
66
+ class MemoryCredentialStore {
67
+ credential;
68
+ constructor(credential) {
69
+ this.credential = credential;
70
+ }
71
+ load() {
72
+ return this.credential;
73
+ }
74
+ save(credential) {
75
+ this.credential = credential;
76
+ }
77
+ clear() {
78
+ this.credential = null;
79
+ }
80
+ }
81
+ exports.MemoryCredentialStore = MemoryCredentialStore;
82
+ /**
83
+ * Take the private bootstrap handoff and remove every credential-bearing
84
+ * variable before an adapter can copy the process environment to a child.
85
+ */
86
+ function takeHandedOffCredential(env) {
87
+ const runnerId = env.PF_RUNNER_ID;
88
+ const credential = env.PF_RUNNER_CREDENTIAL;
89
+ const stored = runnerId && credential
90
+ ? {
91
+ runnerId,
92
+ tenantId: env.PF_RUNNER_TENANT_ID || null,
93
+ credential,
94
+ credentialExpiresAt: env.PF_RUNNER_CREDENTIAL_EXPIRES_AT || null,
95
+ savedAt: new Date().toISOString()
96
+ }
97
+ : null;
98
+ // On warm upgrade PF_ENROLMENT_TOKEN contains the live runner credential.
99
+ // On cold boot it contains a single-use enrolment token. Neither belongs in
100
+ // an agent subprocess environment after parseOptions() has consumed it.
101
+ delete env.PF_ENROLMENT_TOKEN;
102
+ delete env.PF_RUNNER_CREDENTIAL;
103
+ delete env.PF_RUNNER_ID;
104
+ delete env.PF_RUNNER_TENANT_ID;
105
+ delete env.PF_RUNNER_CREDENTIAL_EXPIRES_AT;
106
+ return stored;
107
+ }
108
+ function isStoredCredential(value) {
109
+ if (typeof value !== 'object' || value === null)
110
+ return false;
111
+ const v = value;
112
+ return (typeof v.runnerId === 'string' &&
113
+ v.runnerId.length > 0 &&
114
+ typeof v.credential === 'string' &&
115
+ v.credential.length > 0 &&
116
+ (v.tenantId === null || typeof v.tenantId === 'string') &&
117
+ (v.credentialExpiresAt === null ||
118
+ typeof v.credentialExpiresAt === 'string'));
119
+ }
120
+ /**
121
+ * The durable implementation: a single JSON file at 0600.
122
+ *
123
+ * Synchronous fs on purpose. This is touched at most a handful of times per
124
+ * process — once on start, once per registration, once per rotation — and never
125
+ * on the hot path of a turn, so there is nothing to gain from async and a great
126
+ * deal of clarity to lose. A partial or corrupt file is treated as "no
127
+ * credential" rather than a crash: the fallback is enrolment, which is exactly
128
+ * what an absent credential should trigger.
129
+ */
130
+ class FileCredentialStore {
131
+ path;
132
+ constructor(workspaceRoot) {
133
+ this.path = (0, node_path_1.join)(workspaceRoot, exports.CREDENTIAL_FILE_NAME);
134
+ }
135
+ load() {
136
+ let raw;
137
+ try {
138
+ raw = (0, node_fs_1.readFileSync)(this.path, 'utf8');
139
+ }
140
+ catch {
141
+ // Missing file is the ordinary first-boot case, not an error.
142
+ return null;
143
+ }
144
+ try {
145
+ const parsed = JSON.parse(raw);
146
+ return isStoredCredential(parsed) ? parsed : null;
147
+ }
148
+ catch {
149
+ // A truncated write from a crash between open and flush. Treat it as
150
+ // absent so the harness re-enrols rather than dying on a parse error.
151
+ return null;
152
+ }
153
+ }
154
+ save(credential) {
155
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(this.path), { recursive: true, mode: 0o700 });
156
+ // Write with the restrictive mode up front, then chmod as well: `mode` on
157
+ // writeFileSync is masked by the process umask, so an unusual umask could
158
+ // otherwise leave the file group- or world-readable. The explicit chmod is
159
+ // not subject to the umask.
160
+ (0, node_fs_1.writeFileSync)(this.path, JSON.stringify(credential), {
161
+ encoding: 'utf8',
162
+ mode: 0o600
163
+ });
164
+ (0, node_fs_1.chmodSync)(this.path, 0o600);
165
+ }
166
+ clear() {
167
+ (0, node_fs_1.rmSync)(this.path, { force: true });
168
+ }
169
+ }
170
+ exports.FileCredentialStore = FileCredentialStore;
171
+ /**
172
+ * Pick the store for a runtime. This is the ONE place that encodes the
173
+ * sandbox-vs-durable decision, so a future runtime has exactly one line to
174
+ * change and cannot silently inherit the wrong policy.
175
+ */
176
+ function createCredentialStore(runtime, workspaceRoot) {
177
+ if (runtime === 'sandbox') {
178
+ return new NoCredentialStore();
179
+ }
180
+ return new FileCredentialStore(workspaceRoot);
181
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * INTERACTIVE ENROLMENT — sign in in a browser, choose an org, done.
3
+ *
4
+ * Before this, starting a runner meant obtaining an enrolment token out of band
5
+ * (there is no UI for it — minting one is a `curl` against an admin route) and
6
+ * pasting it in. That is a fine mechanism for a MANAGED sandbox, where
7
+ * pf-bootstrap injects the token through the environment precisely so it never
8
+ * appears in /proc/<pid>/cmdline. It is a poor one for a human on a laptop.
9
+ *
10
+ * THIS IS A FAITHFUL PORT OF WHAT 20x ALREADY DOES, not a new design. 20x's
11
+ * `enterprise:signupInBrowser` starts a loopback HTTP server, opens
12
+ * `<app>/login?redirect_uri=<loopback>` in the real browser, and waits. The
13
+ * workflow-builder login page already understands `redirect_uri`: it routes
14
+ * through `/auth/desktop-callback`, which validates the target is localhost and
15
+ * then redirects with the Supabase tokens IN THE URL FRAGMENT.
16
+ *
17
+ * THE FRAGMENT IS THE WHOLE REASON THIS FILE SERVES AN HTML PAGE. A fragment is
18
+ * never sent to an HTTP server — that is what keeps the tokens out of server
19
+ * logs and out of `Referer` headers. So the callback path serves a tiny page
20
+ * whose script reads `location.hash` and POSTs it back to this same loopback
21
+ * server. Without that hop the tokens would be visible to the browser and
22
+ * invisible to us.
23
+ *
24
+ * NOTHING NEW IS NEEDED ON THE SERVER. Every endpoint used here already exists
25
+ * and is already used by 20x:
26
+ * POST /api/20x/auth/verify -> the user's companies
27
+ * POST /api/20x/auth/select-tenant -> a pf-workflo JWT for the chosen org
28
+ * POST /api/coding-agents/runners/enrolment-tokens -> the token we then spend
29
+ *
30
+ * AND THE TOKEN IS STILL THE ONLY WAY IN. This flow does NOT mint a runner
31
+ * credential; it mints the same single-use enrolment token an administrator
32
+ * would have minted by hand, and hands it to the ordinary registration path.
33
+ * One enrolment path to reason about, one to secure.
34
+ *
35
+ * TWO DELIBERATE DEPARTURES FROM 20x's IMPLEMENTATION, both hardening:
36
+ *
37
+ * 1. A `state` NONCE. 20x's loopback accepts any POST to its receive route, so
38
+ * anything able to reach 127.0.0.1 — including a page in the user's own
39
+ * browser — can inject an attacker's tokens into the waiting app. Here the
40
+ * nonce goes out in the redirect query, comes back with the tokens, and is
41
+ * compared in constant time. A mismatch is refused.
42
+ * 2. NO `Access-Control-Allow-Origin: *`. 20x sets it; nothing needs it,
43
+ * because the POST comes from the page this server itself served, which is
44
+ * same-origin. Setting it invites exactly the injection the nonce blocks.
45
+ *
46
+ * ZERO RUNTIME DEPENDENCIES, like the rest of this package: node:http,
47
+ * node:crypto, node:readline and the global fetch. Adding a dependency here
48
+ * would break the sandbox install (see the package README).
49
+ */
50
+ export interface BrowserEnrolOptions {
51
+ controlPlaneUrl: string;
52
+ runtime: string;
53
+ /** Overrides the origin derived from the control plane url. */
54
+ appUrl?: string;
55
+ /** Runner label; defaults to this machine's hostname. */
56
+ label?: string;
57
+ /** Injected in tests. */
58
+ fetchImpl?: typeof fetch;
59
+ /** Injected in tests; returns the chosen index. */
60
+ promptImpl?: (question: string) => Promise<string>;
61
+ /** Injected in tests, and skipped when a browser cannot be opened. */
62
+ openBrowser?: (url: string) => void;
63
+ log?: (message: string) => void;
64
+ }
65
+ export interface Company {
66
+ id: string;
67
+ name: string;
68
+ isPrimary?: boolean;
69
+ }
70
+ /**
71
+ * Where the browser-facing app lives, derived from the API url.
72
+ *
73
+ * THE SAME RULE 20x USES, and it must stay the same rule: loopback means the
74
+ * Next dev server on 4000, otherwise the `api` host maps to the `app` host.
75
+ * Getting this wrong sends the user to a page that cannot sign them in.
76
+ */
77
+ export declare function deriveAppOrigin(controlPlaneUrl: string): string;
78
+ /**
79
+ * Run the whole flow and return a single-use ENROLMENT TOKEN.
80
+ *
81
+ * The caller then enrols with it exactly as if it had been pasted in, so this
82
+ * function adds a way to OBTAIN the token and changes nothing about how a
83
+ * runner registers.
84
+ */
85
+ export declare function enrolViaBrowser(options: BrowserEnrolOptions): Promise<string>;