@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,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>;
|