@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,387 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* INTERACTIVE ENROLMENT — sign in in a browser, choose an org, done.
|
|
4
|
+
*
|
|
5
|
+
* Before this, starting a runner meant obtaining an enrolment token out of band
|
|
6
|
+
* (there is no UI for it — minting one is a `curl` against an admin route) and
|
|
7
|
+
* pasting it in. That is a fine mechanism for a MANAGED sandbox, where
|
|
8
|
+
* pf-bootstrap injects the token through the environment precisely so it never
|
|
9
|
+
* appears in /proc/<pid>/cmdline. It is a poor one for a human on a laptop.
|
|
10
|
+
*
|
|
11
|
+
* THIS IS A FAITHFUL PORT OF WHAT 20x ALREADY DOES, not a new design. 20x's
|
|
12
|
+
* `enterprise:signupInBrowser` starts a loopback HTTP server, opens
|
|
13
|
+
* `<app>/login?redirect_uri=<loopback>` in the real browser, and waits. The
|
|
14
|
+
* workflow-builder login page already understands `redirect_uri`: it routes
|
|
15
|
+
* through `/auth/desktop-callback`, which validates the target is localhost and
|
|
16
|
+
* then redirects with the Supabase tokens IN THE URL FRAGMENT.
|
|
17
|
+
*
|
|
18
|
+
* THE FRAGMENT IS THE WHOLE REASON THIS FILE SERVES AN HTML PAGE. A fragment is
|
|
19
|
+
* never sent to an HTTP server — that is what keeps the tokens out of server
|
|
20
|
+
* logs and out of `Referer` headers. So the callback path serves a tiny page
|
|
21
|
+
* whose script reads `location.hash` and POSTs it back to this same loopback
|
|
22
|
+
* server. Without that hop the tokens would be visible to the browser and
|
|
23
|
+
* invisible to us.
|
|
24
|
+
*
|
|
25
|
+
* NOTHING NEW IS NEEDED ON THE SERVER. Every endpoint used here already exists
|
|
26
|
+
* and is already used by 20x:
|
|
27
|
+
* POST /api/20x/auth/verify -> the user's companies
|
|
28
|
+
* POST /api/20x/auth/select-tenant -> a pf-workflo JWT for the chosen org
|
|
29
|
+
* POST /api/coding-agents/runners/enrolment-tokens -> the token we then spend
|
|
30
|
+
*
|
|
31
|
+
* AND THE TOKEN IS STILL THE ONLY WAY IN. This flow does NOT mint a runner
|
|
32
|
+
* credential; it mints the same single-use enrolment token an administrator
|
|
33
|
+
* would have minted by hand, and hands it to the ordinary registration path.
|
|
34
|
+
* One enrolment path to reason about, one to secure.
|
|
35
|
+
*
|
|
36
|
+
* TWO DELIBERATE DEPARTURES FROM 20x's IMPLEMENTATION, both hardening:
|
|
37
|
+
*
|
|
38
|
+
* 1. A `state` NONCE. 20x's loopback accepts any POST to its receive route, so
|
|
39
|
+
* anything able to reach 127.0.0.1 — including a page in the user's own
|
|
40
|
+
* browser — can inject an attacker's tokens into the waiting app. Here the
|
|
41
|
+
* nonce goes out in the redirect query, comes back with the tokens, and is
|
|
42
|
+
* compared in constant time. A mismatch is refused.
|
|
43
|
+
* 2. NO `Access-Control-Allow-Origin: *`. 20x sets it; nothing needs it,
|
|
44
|
+
* because the POST comes from the page this server itself served, which is
|
|
45
|
+
* same-origin. Setting it invites exactly the injection the nonce blocks.
|
|
46
|
+
*
|
|
47
|
+
* ZERO RUNTIME DEPENDENCIES, like the rest of this package: node:http,
|
|
48
|
+
* node:crypto, node:readline and the global fetch. Adding a dependency here
|
|
49
|
+
* would break the sandbox install (see the package README).
|
|
50
|
+
*/
|
|
51
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
|
+
exports.deriveAppOrigin = deriveAppOrigin;
|
|
53
|
+
exports.enrolViaBrowser = enrolViaBrowser;
|
|
54
|
+
const node_child_process_1 = require("node:child_process");
|
|
55
|
+
const node_crypto_1 = require("node:crypto");
|
|
56
|
+
const node_http_1 = require("node:http");
|
|
57
|
+
const node_os_1 = require("node:os");
|
|
58
|
+
const node_readline_1 = require("node:readline");
|
|
59
|
+
/** Ports to try, deliberately NOT 20x's 48620-48640 so both can run at once. */
|
|
60
|
+
const PORT_RANGE_START = 48660;
|
|
61
|
+
const PORT_RANGE_END = 48680;
|
|
62
|
+
/** How long a human gets to finish signing in. 20x uses ten minutes. */
|
|
63
|
+
const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
64
|
+
/**
|
|
65
|
+
* Where the browser-facing app lives, derived from the API url.
|
|
66
|
+
*
|
|
67
|
+
* THE SAME RULE 20x USES, and it must stay the same rule: loopback means the
|
|
68
|
+
* Next dev server on 4000, otherwise the `api` host maps to the `app` host.
|
|
69
|
+
* Getting this wrong sends the user to a page that cannot sign them in.
|
|
70
|
+
*/
|
|
71
|
+
function deriveAppOrigin(controlPlaneUrl) {
|
|
72
|
+
const parsed = new URL(controlPlaneUrl);
|
|
73
|
+
if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
|
|
74
|
+
parsed.port = '4000';
|
|
75
|
+
return parsed.origin;
|
|
76
|
+
}
|
|
77
|
+
parsed.hostname = parsed.hostname
|
|
78
|
+
.replace('-api.', '-app.')
|
|
79
|
+
.replace(/^api\./, 'app.');
|
|
80
|
+
return parsed.origin;
|
|
81
|
+
}
|
|
82
|
+
/** Constant-time compare that cannot throw on a length mismatch. */
|
|
83
|
+
function nonceMatches(expected, actual) {
|
|
84
|
+
if (typeof actual !== 'string')
|
|
85
|
+
return false;
|
|
86
|
+
const a = Buffer.from(expected);
|
|
87
|
+
const b = Buffer.from(actual);
|
|
88
|
+
if (a.length !== b.length)
|
|
89
|
+
return false;
|
|
90
|
+
return (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The page served at the callback. Reads the fragment, posts it back, and says
|
|
94
|
+
* something truthful to the human either way.
|
|
95
|
+
*/
|
|
96
|
+
const CALLBACK_PAGE = `<!doctype html>
|
|
97
|
+
<html><head><meta charset="utf-8"><title>Peakflo runner</title>
|
|
98
|
+
<style>
|
|
99
|
+
body{font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
|
100
|
+
display:flex;align-items:center;justify-content:center;height:100vh;margin:0;
|
|
101
|
+
background:#0b0b0c;color:#e6e6e6}
|
|
102
|
+
.card{text-align:center;max-width:28rem;padding:2rem}
|
|
103
|
+
h1{font-size:1.1rem;margin:0 0 .5rem}
|
|
104
|
+
p{color:#9a9a9a;margin:0}
|
|
105
|
+
</style></head>
|
|
106
|
+
<body><div class="card" id="c">
|
|
107
|
+
<h1>Connecting…</h1><p>One moment.</p>
|
|
108
|
+
</div>
|
|
109
|
+
<script>
|
|
110
|
+
(function () {
|
|
111
|
+
var card = document.getElementById('c');
|
|
112
|
+
function show(title, detail) {
|
|
113
|
+
card.innerHTML = '<h1>' + title + '</h1><p>' + detail + '</p>';
|
|
114
|
+
}
|
|
115
|
+
var hash = new URLSearchParams((location.hash || '').replace(/^#/, ''));
|
|
116
|
+
var qs = new URLSearchParams(location.search);
|
|
117
|
+
var body = {
|
|
118
|
+
access_token: hash.get('access_token'),
|
|
119
|
+
refresh_token: hash.get('refresh_token'),
|
|
120
|
+
state: qs.get('state')
|
|
121
|
+
};
|
|
122
|
+
if (!body.access_token) {
|
|
123
|
+
show('No sign-in tokens found', 'Try starting the runner again.');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
fetch('/auth/receive-tokens', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json' },
|
|
129
|
+
body: JSON.stringify(body)
|
|
130
|
+
}).then(function (r) {
|
|
131
|
+
if (r.ok) {
|
|
132
|
+
show('Runner authorised', 'You can close this tab and return to the terminal.');
|
|
133
|
+
} else {
|
|
134
|
+
show('Authorisation refused', 'The terminal has the details.');
|
|
135
|
+
}
|
|
136
|
+
// Do not leave the tokens sitting in the address bar.
|
|
137
|
+
history.replaceState(null, '', location.pathname);
|
|
138
|
+
}).catch(function () {
|
|
139
|
+
show('Could not reach the runner', 'Is it still running?');
|
|
140
|
+
});
|
|
141
|
+
})();
|
|
142
|
+
</script></body></html>`;
|
|
143
|
+
/**
|
|
144
|
+
* The loopback server. Serves the fragment-reading page, then receives the
|
|
145
|
+
* tokens that page POSTs back.
|
|
146
|
+
*/
|
|
147
|
+
class CallbackServer {
|
|
148
|
+
server = null;
|
|
149
|
+
port = 0;
|
|
150
|
+
state = (0, node_crypto_1.randomBytes)(24).toString('hex');
|
|
151
|
+
resolve = null;
|
|
152
|
+
reject = null;
|
|
153
|
+
async start() {
|
|
154
|
+
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
|
|
155
|
+
try {
|
|
156
|
+
await this.listenOn(port);
|
|
157
|
+
this.port = port;
|
|
158
|
+
// The nonce rides in the QUERY, which `/auth/desktop-callback`
|
|
159
|
+
// preserves — it only ever replaces the fragment.
|
|
160
|
+
return `http://127.0.0.1:${port}/auth/callback?state=${this.state}`;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
throw new Error(`No free port for the login callback in ${PORT_RANGE_START}-${PORT_RANGE_END}.`);
|
|
167
|
+
}
|
|
168
|
+
listenOn(port) {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
171
|
+
this.handle(req.url ?? '/', req, res);
|
|
172
|
+
});
|
|
173
|
+
server.on('error', reject);
|
|
174
|
+
// 127.0.0.1, not 0.0.0.0: this must never be reachable off the machine.
|
|
175
|
+
server.listen(port, '127.0.0.1', () => {
|
|
176
|
+
this.server = server;
|
|
177
|
+
resolve();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
handle(url, req, res) {
|
|
182
|
+
const parsed = new URL(url, `http://127.0.0.1:${this.port}`);
|
|
183
|
+
if (parsed.pathname === '/auth/callback') {
|
|
184
|
+
// The tokens are in the FRAGMENT, which never reached us. Serve a page
|
|
185
|
+
// that reads it and posts it back.
|
|
186
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
187
|
+
res.end(CALLBACK_PAGE);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (parsed.pathname === '/auth/receive-tokens' && req.method === 'POST') {
|
|
191
|
+
let body = '';
|
|
192
|
+
req.on('data', (chunk) => {
|
|
193
|
+
body += String(chunk);
|
|
194
|
+
// A runaway body must not exhaust memory on the user's laptop.
|
|
195
|
+
if (body.length > 64_000)
|
|
196
|
+
req.destroy();
|
|
197
|
+
});
|
|
198
|
+
req.on('end', () => {
|
|
199
|
+
try {
|
|
200
|
+
const payload = JSON.parse(body);
|
|
201
|
+
if (!nonceMatches(this.state, payload.state)) {
|
|
202
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
203
|
+
res.end(JSON.stringify({ error: 'state mismatch' }));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const accessToken = payload.access_token;
|
|
207
|
+
const refreshToken = payload.refresh_token;
|
|
208
|
+
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
|
209
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
210
|
+
res.end(JSON.stringify({ error: 'no access token' }));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
214
|
+
res.end(JSON.stringify({ ok: true }));
|
|
215
|
+
this.resolve?.({
|
|
216
|
+
accessToken,
|
|
217
|
+
refreshToken: typeof refreshToken === 'string' ? refreshToken : ''
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
222
|
+
res.end(JSON.stringify({ error: 'bad json' }));
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
res.writeHead(404);
|
|
228
|
+
res.end();
|
|
229
|
+
}
|
|
230
|
+
wait(timeoutMs = LOGIN_TIMEOUT_MS) {
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
this.resolve = resolve;
|
|
233
|
+
this.reject = reject;
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
reject(new Error('Timed out waiting for the browser sign-in. Run the command again, ' +
|
|
236
|
+
'or pass --token if this machine has no browser.'));
|
|
237
|
+
}, timeoutMs);
|
|
238
|
+
// Never hold the process open on this timer alone.
|
|
239
|
+
timer.unref?.();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
stop() {
|
|
243
|
+
this.server?.close();
|
|
244
|
+
this.server = null;
|
|
245
|
+
this.reject = null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Best-effort browser open. A failure is not fatal: the url is printed too. */
|
|
249
|
+
function defaultOpenBrowser(url) {
|
|
250
|
+
const command = process.platform === 'darwin'
|
|
251
|
+
? 'open'
|
|
252
|
+
: process.platform === 'win32'
|
|
253
|
+
? 'cmd'
|
|
254
|
+
: 'xdg-open';
|
|
255
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
256
|
+
try {
|
|
257
|
+
const child = (0, node_child_process_1.spawn)(command, args, { stdio: 'ignore', detached: true });
|
|
258
|
+
// An ENOENT here must not take the process down; the printed url is the
|
|
259
|
+
// real contract and opening the browser is only a convenience.
|
|
260
|
+
child.on('error', () => undefined);
|
|
261
|
+
child.unref();
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* printed url is the fallback */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function defaultPrompt(question) {
|
|
268
|
+
const rl = (0, node_readline_1.createInterface)({ input: process.stdin, output: process.stdout });
|
|
269
|
+
return new Promise((resolve) => {
|
|
270
|
+
rl.question(question, (answer) => {
|
|
271
|
+
rl.close();
|
|
272
|
+
resolve(answer);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
async function postJson(fetchImpl, url, token, body) {
|
|
277
|
+
const response = await fetchImpl(url, {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: {
|
|
280
|
+
'Content-Type': 'application/json',
|
|
281
|
+
Authorization: `Bearer ${token}`
|
|
282
|
+
},
|
|
283
|
+
body: JSON.stringify(body ?? {})
|
|
284
|
+
});
|
|
285
|
+
const text = await response.text();
|
|
286
|
+
let json = {};
|
|
287
|
+
try {
|
|
288
|
+
json = text ? JSON.parse(text) : {};
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
json = { raw: text };
|
|
292
|
+
}
|
|
293
|
+
// 2xx, NOT an exact code. The mint route answers 201 Created, and demanding
|
|
294
|
+
// 200 turned a SUCCESSFUL enrolment into "unknown error" after the user had
|
|
295
|
+
// already signed in and chosen an organisation.
|
|
296
|
+
return { status: response.status, ok: response.ok, json };
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Run the whole flow and return a single-use ENROLMENT TOKEN.
|
|
300
|
+
*
|
|
301
|
+
* The caller then enrols with it exactly as if it had been pasted in, so this
|
|
302
|
+
* function adds a way to OBTAIN the token and changes nothing about how a
|
|
303
|
+
* runner registers.
|
|
304
|
+
*/
|
|
305
|
+
async function enrolViaBrowser(options) {
|
|
306
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
307
|
+
const log = options.log ?? ((m) => process.stdout.write(`${m}\n`));
|
|
308
|
+
const prompt = options.promptImpl ?? defaultPrompt;
|
|
309
|
+
const open = options.openBrowser ?? defaultOpenBrowser;
|
|
310
|
+
const appOrigin = options.appUrl
|
|
311
|
+
? new URL(options.appUrl).origin
|
|
312
|
+
: deriveAppOrigin(options.controlPlaneUrl);
|
|
313
|
+
const server = new CallbackServer();
|
|
314
|
+
try {
|
|
315
|
+
const redirectUri = await server.start();
|
|
316
|
+
const loginUrl = `${appOrigin}/login?redirect_uri=${encodeURIComponent(redirectUri)}`;
|
|
317
|
+
log('');
|
|
318
|
+
log(' Authorise this runner by signing in:');
|
|
319
|
+
log(` ${loginUrl}`);
|
|
320
|
+
log('');
|
|
321
|
+
log(' Waiting for the browser… (Ctrl-C to cancel)');
|
|
322
|
+
const waiting = server.wait();
|
|
323
|
+
open(loginUrl);
|
|
324
|
+
const tokens = await waiting;
|
|
325
|
+
log(' Signed in. Fetching your organisations…');
|
|
326
|
+
/* ---- Which organisations may this user use? ---------------------- */
|
|
327
|
+
const verify = await postJson(fetchImpl, `${options.controlPlaneUrl}/api/20x/auth/verify`, tokens.accessToken, {});
|
|
328
|
+
if (!verify.ok) {
|
|
329
|
+
throw new Error(`Could not read your account (${verify.status}): ${verify.json.message ??
|
|
330
|
+
verify.json.error ??
|
|
331
|
+
'unknown error'}`);
|
|
332
|
+
}
|
|
333
|
+
const companies = verify.json.companies ?? [];
|
|
334
|
+
if (companies.length === 0) {
|
|
335
|
+
throw new Error('Your account belongs to no organisation, so there is nothing to ' +
|
|
336
|
+
'enrol this runner into.');
|
|
337
|
+
}
|
|
338
|
+
/* ---- Choose one. Auto-select a sole org, exactly as 20x does. ----- */
|
|
339
|
+
let chosen;
|
|
340
|
+
if (companies.length === 1) {
|
|
341
|
+
chosen = companies[0];
|
|
342
|
+
log(` Organisation: ${chosen.name}`);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
log('');
|
|
346
|
+
log(' Choose an organisation for this runner:');
|
|
347
|
+
companies.forEach((c, i) => {
|
|
348
|
+
log(` ${i + 1}) ${c.name}${c.isPrimary ? ' (primary)' : ''}`);
|
|
349
|
+
});
|
|
350
|
+
log('');
|
|
351
|
+
const answer = (await prompt(` Number [1-${companies.length}]: `)).trim();
|
|
352
|
+
const index = Number.parseInt(answer, 10) - 1;
|
|
353
|
+
if (!Number.isInteger(index) || index < 0 || index >= companies.length) {
|
|
354
|
+
throw new Error(`Not a valid choice: "${answer}"`);
|
|
355
|
+
}
|
|
356
|
+
chosen = companies[index];
|
|
357
|
+
log(` Organisation: ${chosen.name}`);
|
|
358
|
+
}
|
|
359
|
+
/* ---- Exchange for a pf-workflo JWT scoped to that org ------------- */
|
|
360
|
+
const selected = await postJson(fetchImpl, `${options.controlPlaneUrl}/api/20x/auth/select-tenant`, tokens.accessToken, { tenantId: chosen.id });
|
|
361
|
+
if (!selected.ok || typeof selected.json.token !== 'string') {
|
|
362
|
+
throw new Error(`Could not select "${chosen.name}" (${selected.status}): ${selected.json.message ??
|
|
363
|
+
selected.json.error ??
|
|
364
|
+
'unknown error'}`);
|
|
365
|
+
}
|
|
366
|
+
/* ---- Mint the ordinary single-use enrolment token ----------------- */
|
|
367
|
+
const minted = await postJson(fetchImpl, `${options.controlPlaneUrl}/api/coding-agents/runners/enrolment-tokens`, selected.json.token, { runtime: options.runtime, label: options.label ?? (0, node_os_1.hostname)() });
|
|
368
|
+
if (minted.status === 403) {
|
|
369
|
+
// Worth naming precisely: this is the SAME permission that gates minting
|
|
370
|
+
// a token by hand, and the flow deliberately does not lower it.
|
|
371
|
+
throw new Error(`You do not have permission to add a runner to "${chosen.name}". ` +
|
|
372
|
+
'Adding one needs the coding-agents:manage permission — ask an ' +
|
|
373
|
+
'administrator, or have them mint a token and pass --token.');
|
|
374
|
+
}
|
|
375
|
+
if (!minted.ok || typeof minted.json.enrolmentToken !== 'string') {
|
|
376
|
+
throw new Error(`Could not mint an enrolment token (${minted.status}): ${minted.json.message ??
|
|
377
|
+
minted.json.error ??
|
|
378
|
+
'unknown error'}`);
|
|
379
|
+
}
|
|
380
|
+
log(' Authorised.');
|
|
381
|
+
log('');
|
|
382
|
+
return minted.json.enrolmentToken;
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
server.stop();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const MERGE_REFUSAL = "GitHub merge refused: agents may open pull requests but must not merge them.";
|
|
2
|
+
interface GuardPathLease {
|
|
3
|
+
bin: string;
|
|
4
|
+
release(): void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Install friendly command-line refusals before backend processes start.
|
|
8
|
+
* These launchers are UX only. The TLS proxy is the security boundary and
|
|
9
|
+
* applies the same policy even when an agent calls the real binary directly.
|
|
10
|
+
*/
|
|
11
|
+
export declare function installGhGuard(root: string): Promise<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Lease one session-neutral guard directory for the whole process. Generated
|
|
14
|
+
* launchers read policy from the target worktree, so sharing the launcher is
|
|
15
|
+
* safe; stacking per-Harness directories in global PATH is not.
|
|
16
|
+
*/
|
|
17
|
+
export declare function acquireGhGuardPath(root: string): Promise<GuardPathLease>;
|
|
18
|
+
/** Git supplies resolved destination refs, covering force, wildcard and implicit pushes. */
|
|
19
|
+
export declare function installPushGuard(directory: string, sessionId: string, defaultBranch: string, remoteUrl: string): Promise<void>;
|
|
20
|
+
export {};
|