@aloud/runner 0.2.4 → 0.3.0
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/dist/cli.js +3510 -641
- package/package.json +1 -1
- package/src/cli.ts +283 -44
- package/src/config/mcp-credentials.ts +83 -0
- package/src/config/policy.ts +4 -0
- package/src/model/proxy-adapter.ts +1 -0
- package/src/protocol/approval.ts +99 -0
- package/src/protocol/client.ts +1 -0
- package/src/run/execute.ts +4 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { createInterface } from "node:readline/promises";
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
|
-
import {
|
|
11
|
+
import { startApproval, waitForApproval, type ApprovalStart } from "./protocol/approval";
|
|
12
|
+
import {
|
|
13
|
+
clearMcpCredentials,
|
|
14
|
+
mcpCredentialsPath,
|
|
15
|
+
readMcpCredentials,
|
|
16
|
+
writeMcpCredentials,
|
|
17
|
+
} from "./config/mcp-credentials";
|
|
18
|
+
import { mkdir } from "node:fs/promises";
|
|
19
|
+
import { accessSync, constants, existsSync, openSync, readFileSync, unlinkSync } from "node:fs";
|
|
12
20
|
import { hostname } from "node:os";
|
|
13
21
|
import { delimiter, dirname, join } from "node:path";
|
|
14
22
|
import { normaliseHosts } from "@aloud/core";
|
|
@@ -22,7 +30,7 @@ import {
|
|
|
22
30
|
type Credentials,
|
|
23
31
|
} from "./config/credentials";
|
|
24
32
|
import { policyFrom, type LocalPolicy } from "./config/policy";
|
|
25
|
-
import { clearRunning, readRunning, runningPath, writeRunning } from "./config/running";
|
|
33
|
+
import { clearRunning, readRunning, runningPath, writeRunning, type RunningState } from "./config/running";
|
|
26
34
|
import { RUNNER_VERSION } from "./version";
|
|
27
35
|
import { RunnerClient } from "./protocol/client";
|
|
28
36
|
import { installChromium, preflight } from "./preflight";
|
|
@@ -50,7 +58,11 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
50
58
|
case "setup":
|
|
51
59
|
return setup();
|
|
52
60
|
case "mcp":
|
|
53
|
-
|
|
61
|
+
// `aloud mcp` is the server an MCP host launches; `aloud mcp connect` is how it gets a
|
|
62
|
+
// credential in the first place. Same word, because from the outside they are one feature.
|
|
63
|
+
if (rest[0] === "connect") return connectMcp(rest.slice(1));
|
|
64
|
+
if (rest[0] === "disconnect") return disconnectMcp();
|
|
65
|
+
await startStdioServer((await mcpOptions()) ?? undefined);
|
|
54
66
|
return 0;
|
|
55
67
|
case "help":
|
|
56
68
|
case "--help":
|
|
@@ -73,11 +85,12 @@ function printHelp(): void {
|
|
|
73
85
|
"aloud - run usability studies on this machine",
|
|
74
86
|
"",
|
|
75
87
|
" aloud setup What to do next, for a person or an agent",
|
|
76
|
-
" aloud login [--token <token>] Connect this machine
|
|
88
|
+
" aloud login [--token <token>] Connect this machine, approving it in your browser",
|
|
77
89
|
" aloud start [--once] [--quiet] Wait for studies and run them here",
|
|
78
90
|
" aloud status What is set up, and whether it is running",
|
|
79
91
|
" aloud allow <host> Let studies open this host from this machine",
|
|
80
|
-
" aloud mcp
|
|
92
|
+
" aloud mcp Serve MCP to an editor, using the saved credential",
|
|
93
|
+
" aloud mcp connect Connect an editor, approving it in your browser",
|
|
81
94
|
" aloud logout Forget the token on this machine",
|
|
82
95
|
"",
|
|
83
96
|
`Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
|
|
@@ -95,6 +108,7 @@ function policyOf(credentials: Credentials, argv: readonly string[]): LocalPolic
|
|
|
95
108
|
return policyFrom({
|
|
96
109
|
allowedHosts: credentials.allowedHosts,
|
|
97
110
|
allowPrivateNetwork: credentials.allowPrivateNetwork,
|
|
111
|
+
syntheticEmailTemplate: process.env.UTA_SYNTHETIC_EMAIL_TEMPLATE ?? null,
|
|
98
112
|
...(concurrency !== null ? { maxConcurrentSessions: concurrency } : {}),
|
|
99
113
|
});
|
|
100
114
|
}
|
|
@@ -103,44 +117,63 @@ function policyOf(credentials: Credentials, argv: readonly string[]): LocalPolic
|
|
|
103
117
|
|
|
104
118
|
async function login(argv: readonly string[]): Promise<number> {
|
|
105
119
|
const server = stringOption(argv, "--server") ?? DEFAULT_SERVER;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (!token) {
|
|
109
|
-
// An agent runs commands in a shell with nothing attached to stdin, so the prompt below would
|
|
110
|
-
// read end-of-file and the token would come back empty. That used to surface as "that does not
|
|
111
|
-
// look like a runner token", which blames the wrong thing and tells nobody what to do. This is
|
|
112
|
-
// the single step of setup that a person has to perform, so it is worth saying so precisely.
|
|
113
|
-
if (!process.stdin.isTTY) {
|
|
114
|
-
process.stderr.write(
|
|
115
|
-
[
|
|
116
|
-
"",
|
|
117
|
-
"There is no terminal attached here, so there is nowhere to paste a token.",
|
|
118
|
-
"",
|
|
119
|
-
"If you are an agent: stop and hand this back. The person runs `aloud login` in their",
|
|
120
|
-
"own terminal and pastes the token at the prompt. Do not ask them to paste it to you.",
|
|
121
|
-
"",
|
|
122
|
-
"To connect without a terminal, set both of these instead and skip login entirely:",
|
|
123
|
-
` export ALOUD_SERVER=${server}`,
|
|
124
|
-
" export ALOUD_RUNNER_TOKEN=utar_...",
|
|
125
|
-
"",
|
|
126
|
-
`A token comes from ${server}/app/settings/runners and is shown once.`,
|
|
127
|
-
"",
|
|
128
|
-
].join("\n"),
|
|
129
|
-
);
|
|
130
|
-
return 1;
|
|
131
|
-
}
|
|
120
|
+
const token = stringOption(argv, "--token");
|
|
132
121
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
122
|
+
// A token on the command line is for CI, where there is nobody to click anything. Everything else
|
|
123
|
+
// goes through an approval, including a terminal: it is fewer steps even when someone is watching.
|
|
124
|
+
if (token) return connectWith(server, token);
|
|
125
|
+
|
|
126
|
+
let started: ApprovalStart;
|
|
127
|
+
try {
|
|
128
|
+
started = await startApproval({ server, kind: "runner", name: hostname() });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
process.stderr.write(`\n${(error as Error).message}\n\n`);
|
|
131
|
+
return 1;
|
|
141
132
|
}
|
|
142
133
|
|
|
143
|
-
|
|
134
|
+
process.stdout.write("\nTo connect this machine, open this page and approve it:\n\n");
|
|
135
|
+
process.stdout.write(` ${started.approveUrl}\n\n`);
|
|
136
|
+
process.stdout.write("Then type this code on that page:\n\n");
|
|
137
|
+
process.stdout.write(` ${started.userCode}\n\n`);
|
|
138
|
+
// Said explicitly because the thing running this is often not a person, and the old flow trained
|
|
139
|
+
// agents to go looking for a token to paste. There is nothing to paste any more.
|
|
140
|
+
process.stdout.write("Waiting for approval. Nothing here needs a terminal, and there is no token\n");
|
|
141
|
+
process.stdout.write("to paste: if you are an agent, give the person the link and the code above.\n\n");
|
|
142
|
+
|
|
143
|
+
// Opening a browser is a courtesy for whoever is sitting here. It is never attempted when nobody
|
|
144
|
+
// is, and a failure is ignored, because the printed URL is the thing that actually matters.
|
|
145
|
+
if (process.stdout.isTTY) openInBrowser(started.approveUrl);
|
|
146
|
+
|
|
147
|
+
const outcome = await waitForApproval(started, {
|
|
148
|
+
server,
|
|
149
|
+
onWaiting: (seconds) => process.stdout.write(` still waiting (${seconds}s)\n`),
|
|
150
|
+
}).catch((error: Error) => {
|
|
151
|
+
process.stderr.write(`\n${error.message}\n`);
|
|
152
|
+
return null;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!outcome) return 1;
|
|
156
|
+
if (outcome.status === "denied") {
|
|
157
|
+
process.stderr.write("\nThat request was refused. Nothing was connected.\n\n");
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
if (outcome.status === "expired") {
|
|
161
|
+
process.stderr.write("\nThat request expired before anyone approved it. Run `aloud login` again.\n\n");
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return connectWith(server, outcome.secret);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Writes the credential and says what this machine may now do.
|
|
170
|
+
*
|
|
171
|
+
* Shared by both paths on purpose: a token that arrived through an approval and one passed on the
|
|
172
|
+
* command line have to end up in exactly the same state on disk, or the two ways of connecting
|
|
173
|
+
* diverge in ways nobody notices until one of them breaks.
|
|
174
|
+
*/
|
|
175
|
+
async function connectWith(server: string, token: string): Promise<number> {
|
|
176
|
+
if (!token.startsWith("utar_")) {
|
|
144
177
|
process.stderr.write("That does not look like a runner token. They start with utar_.\n");
|
|
145
178
|
return 1;
|
|
146
179
|
}
|
|
@@ -183,6 +216,93 @@ async function login(argv: readonly string[]): Promise<number> {
|
|
|
183
216
|
return 0;
|
|
184
217
|
}
|
|
185
218
|
|
|
219
|
+
/** Best effort, and deliberately silent about failing. The URL was already printed. */
|
|
220
|
+
function openInBrowser(url: string): void {
|
|
221
|
+
const command =
|
|
222
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
223
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
224
|
+
try {
|
|
225
|
+
spawn(command, args, { stdio: "ignore", detached: true }).unref();
|
|
226
|
+
} catch {
|
|
227
|
+
// No browser here. That is what the printed link is for.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Connects an MCP host, through the same approval as a machine.
|
|
233
|
+
*
|
|
234
|
+
* The difference from a runner is only what the approval mints. Everything a person experiences,
|
|
235
|
+
* and everything an agent has to do, is identical: a link, a code, and a wait. Nobody edits a
|
|
236
|
+
* config file to hold a token, which was the last place a credential still had to be carried by
|
|
237
|
+
* hand after `aloud login` stopped needing one.
|
|
238
|
+
*/
|
|
239
|
+
async function connectMcp(argv: readonly string[]): Promise<number> {
|
|
240
|
+
const server = stringOption(argv, "--server") ?? DEFAULT_SERVER;
|
|
241
|
+
|
|
242
|
+
let started: ApprovalStart;
|
|
243
|
+
try {
|
|
244
|
+
started = await startApproval({ server, kind: "mcp", name: hostname() });
|
|
245
|
+
} catch (error) {
|
|
246
|
+
process.stderr.write(`\n${(error as Error).message}\n\n`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
process.stdout.write("\nTo connect this editor to your workspace, open this page and approve it:\n\n");
|
|
251
|
+
process.stdout.write(` ${started.approveUrl}\n\n`);
|
|
252
|
+
process.stdout.write("Then type this code on that page:\n\n");
|
|
253
|
+
process.stdout.write(` ${started.userCode}\n\n`);
|
|
254
|
+
process.stdout.write("Waiting for approval. There is no token to paste anywhere.\n\n");
|
|
255
|
+
if (process.stdout.isTTY) openInBrowser(started.approveUrl);
|
|
256
|
+
|
|
257
|
+
const outcome = await waitForApproval(started, {
|
|
258
|
+
server,
|
|
259
|
+
onWaiting: (seconds) => process.stdout.write(` still waiting (${seconds}s)\n`),
|
|
260
|
+
}).catch((error: Error) => {
|
|
261
|
+
process.stderr.write(`\n${error.message}\n`);
|
|
262
|
+
return null;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (!outcome) return 1;
|
|
266
|
+
if (outcome.status === "denied") {
|
|
267
|
+
process.stderr.write("\nThat request was refused. Nothing was connected.\n\n");
|
|
268
|
+
return 1;
|
|
269
|
+
}
|
|
270
|
+
if (outcome.status === "expired") {
|
|
271
|
+
process.stderr.write("\nThat request expired. Run `aloud mcp connect` again.\n\n");
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await writeMcpCredentials({ server, token: outcome.secret });
|
|
276
|
+
|
|
277
|
+
process.stdout.write(`\nConnected. The credential is in ${mcpCredentialsPath()}, not in any config file.\n`);
|
|
278
|
+
process.stdout.write("Point your MCP host at `aloud mcp` and it will find it.\n\n");
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function disconnectMcp(): Promise<number> {
|
|
283
|
+
const removed = await clearMcpCredentials();
|
|
284
|
+
process.stdout.write(
|
|
285
|
+
removed
|
|
286
|
+
? `Forgot the MCP credential at ${mcpCredentialsPath()}.\nRevoke it in the web app too, if you want it dead everywhere.\n`
|
|
287
|
+
: "There was no MCP credential here to forget.\n",
|
|
288
|
+
);
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The credential `aloud mcp` runs with, if there is one.
|
|
294
|
+
*
|
|
295
|
+
* The environment wins, so CI and self-hosting keep working exactly as they did. A missing file is
|
|
296
|
+
* not an error here: `startStdioServer` falls back to the environment and produces the message that
|
|
297
|
+
* explains what to set.
|
|
298
|
+
*/
|
|
299
|
+
async function mcpOptions(): Promise<{ server: string; token: string } | null> {
|
|
300
|
+
const credentials = await readMcpCredentials().catch(() => null);
|
|
301
|
+
if (!credentials) return null;
|
|
302
|
+
if (process.env.ALOUD_MCP_TOKEN?.trim()) return null;
|
|
303
|
+
return { server: credentials.server, token: credentials.token };
|
|
304
|
+
}
|
|
305
|
+
|
|
186
306
|
async function logout(): Promise<number> {
|
|
187
307
|
const removed = await clearCredentials();
|
|
188
308
|
process.stdout.write(removed ? `Forgot the token at ${credentialsPath()}.\n` : "There was nothing to forget.\n");
|
|
@@ -237,6 +357,10 @@ async function setup(): Promise<number> {
|
|
|
237
357
|
// Each step is a command plus the lines that qualify it. Only the command gets a number, or a
|
|
238
358
|
// continuation reads as a step of its own, and an agent following "step 2" ends up pasting a URL
|
|
239
359
|
// into a shell.
|
|
360
|
+
// A person in a terminal gets setup done, not a list of things to go and do. An agent, which has
|
|
361
|
+
// no terminal, gets the list. Same command, and the difference is who can answer a prompt.
|
|
362
|
+
if (process.stdin.isTTY) return interactiveSetup({ installed, stale, latest, signedIn, running, credentials });
|
|
363
|
+
|
|
240
364
|
const steps: string[][] = [];
|
|
241
365
|
if (!installed) {
|
|
242
366
|
steps.push(["npm install -g @aloud/runner"]);
|
|
@@ -252,8 +376,8 @@ async function setup(): Promise<number> {
|
|
|
252
376
|
if (signedIn.state === "none" || signedIn.state === "revoked") {
|
|
253
377
|
steps.push([
|
|
254
378
|
"aloud login",
|
|
255
|
-
"
|
|
256
|
-
"
|
|
379
|
+
"Prints a link and a short code, then waits. Give both to the person; they approve in",
|
|
380
|
+
"their browser. Nothing to paste, and no terminal needed. It finishes on its own.",
|
|
257
381
|
]);
|
|
258
382
|
}
|
|
259
383
|
if (!running) {
|
|
@@ -295,8 +419,8 @@ async function setup(): Promise<number> {
|
|
|
295
419
|
}
|
|
296
420
|
out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
|
|
297
421
|
out(" Do not install a similarly named package, and do not hand the token to anything else.");
|
|
298
|
-
out(" -
|
|
299
|
-
out("
|
|
422
|
+
out(" - There is nothing secret for you to handle. `aloud login` prints a link and a code,");
|
|
423
|
+
out(" and the credential goes straight to this machine. Never ask anyone for a token.");
|
|
300
424
|
out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
|
|
301
425
|
out();
|
|
302
426
|
if (!credentials) {
|
|
@@ -308,6 +432,121 @@ async function setup(): Promise<number> {
|
|
|
308
432
|
}
|
|
309
433
|
|
|
310
434
|
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Setup, performed rather than described.
|
|
438
|
+
*
|
|
439
|
+
* Everything here is a question first. Installing globally, writing a credential and starting a
|
|
440
|
+
* long-lived process are all things someone should agree to on their own machine, and the printed
|
|
441
|
+
* version of this command exists for the case where nobody can be asked.
|
|
442
|
+
*/
|
|
443
|
+
async function interactiveSetup(state: {
|
|
444
|
+
installed: boolean;
|
|
445
|
+
stale: boolean;
|
|
446
|
+
latest: string | null;
|
|
447
|
+
signedIn: { state: string };
|
|
448
|
+
running: RunningState | null;
|
|
449
|
+
credentials: Credentials | null;
|
|
450
|
+
}): Promise<number> {
|
|
451
|
+
const out = (line = "") => process.stdout.write(line + "\n");
|
|
452
|
+
const server = state.credentials?.server ?? DEFAULT_SERVER;
|
|
453
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
if (!state.installed || state.stale) {
|
|
457
|
+
const what = state.installed ? `Update to ${state.latest}` : "Install it globally";
|
|
458
|
+
if (await confirm(rl, `${what} with npm?`)) {
|
|
459
|
+
const ok = await run("npm", ["install", "-g", "@aloud/runner@latest"], out);
|
|
460
|
+
if (!ok) {
|
|
461
|
+
out("");
|
|
462
|
+
out("That install did not work. If it asked for permissions, do not use sudo:");
|
|
463
|
+
out("npm's global prefix belongs to you or it does not, and sudo papers over the wrong one.");
|
|
464
|
+
return 1;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (state.signedIn.state !== "ok") {
|
|
470
|
+
out("");
|
|
471
|
+
if (state.signedIn.state === "revoked") {
|
|
472
|
+
out("The credential saved here was revoked, so this machine needs connecting again.");
|
|
473
|
+
}
|
|
474
|
+
rl.close();
|
|
475
|
+
// Straight into the approval. There is no token to ask anybody for any more, so there is
|
|
476
|
+
// nothing to prompt for either.
|
|
477
|
+
const code = await login(["--server", server]);
|
|
478
|
+
if (code !== 0) return code;
|
|
479
|
+
} else {
|
|
480
|
+
rl.close();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!state.running) {
|
|
484
|
+
const second = createInterface({ input: process.stdin, output: process.stdout });
|
|
485
|
+
const start = await confirm(second, "Start the runner now, in the background?");
|
|
486
|
+
second.close();
|
|
487
|
+
if (!start) {
|
|
488
|
+
out("");
|
|
489
|
+
out("Start it when you are ready, and leave it running: aloud start");
|
|
490
|
+
return 1;
|
|
491
|
+
}
|
|
492
|
+
return startDetached(out);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
out("");
|
|
496
|
+
out("Set up. This machine is waiting for studies.");
|
|
497
|
+
out("");
|
|
498
|
+
return 0;
|
|
499
|
+
} finally {
|
|
500
|
+
rl.close();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Yes unless clearly refused: the answer to every question here is the reason they ran this. */
|
|
505
|
+
async function confirm(rl: ReturnType<typeof createInterface>, question: string): Promise<boolean> {
|
|
506
|
+
const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
|
|
507
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Runs a command, showing its output, because an install that says nothing looks like a hang. */
|
|
511
|
+
async function run(command: string, args: readonly string[], out: (line?: string) => void): Promise<boolean> {
|
|
512
|
+
out("");
|
|
513
|
+
out(` ${command} ${args.join(" ")}`);
|
|
514
|
+
return new Promise((resolve) => {
|
|
515
|
+
const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
516
|
+
child.stdout?.on("data", (chunk: Buffer) => out(" " + chunk.toString("utf8").trimEnd()));
|
|
517
|
+
child.stderr?.on("data", (chunk: Buffer) => out(" " + chunk.toString("utf8").trimEnd()));
|
|
518
|
+
child.on("error", () => resolve(false));
|
|
519
|
+
child.on("close", (code: number | null) => resolve(code === 0));
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Starts the runner and lets go of it, so closing this terminal does not stop the machine.
|
|
525
|
+
*
|
|
526
|
+
* Output goes to a file rather than nowhere, because the first start downloads Chromium and a
|
|
527
|
+
* silent five minutes is indistinguishable from a hang.
|
|
528
|
+
*/
|
|
529
|
+
async function startDetached(out: (line?: string) => void): Promise<number> {
|
|
530
|
+
const log = join(dirname(credentialsPath()), "runner.log");
|
|
531
|
+
await mkdir(dirname(log), { recursive: true, mode: 0o700 });
|
|
532
|
+
const handle = openSync(log, "a");
|
|
533
|
+
const child = spawn(process.execPath, [process.argv[1] ?? "", "start"], {
|
|
534
|
+
detached: true,
|
|
535
|
+
stdio: ["ignore", handle, handle],
|
|
536
|
+
});
|
|
537
|
+
child.unref();
|
|
538
|
+
|
|
539
|
+
out("");
|
|
540
|
+
out(`Started in the background, pid ${child.pid}.`);
|
|
541
|
+
out(` Output ${log}`);
|
|
542
|
+
out(" Check it aloud status");
|
|
543
|
+
out(` Stop it kill ${child.pid}`);
|
|
544
|
+
out("");
|
|
545
|
+
out("The first start downloads Chromium, about 350 MB, once. Studies will wait until it is done.");
|
|
546
|
+
out("");
|
|
547
|
+
return 0;
|
|
548
|
+
}
|
|
549
|
+
|
|
311
550
|
/**
|
|
312
551
|
* Whether the saved credential still works, asked of the server rather than assumed from the file.
|
|
313
552
|
*
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The MCP grant, at ~/.aloud/mcp.json, mode 0600.
|
|
8
|
+
*
|
|
9
|
+
* Kept in its own file rather than beside the runner token, because they are different credentials
|
|
10
|
+
* with different lifetimes: a machine can be revoked without cutting off an editor, and an editor
|
|
11
|
+
* can be disconnected without stopping studies. Same rules as `credentials.ts` otherwise, including
|
|
12
|
+
* the refusal to read a file other users can see.
|
|
13
|
+
*
|
|
14
|
+
* This exists so nobody has to paste a token into an MCP host's config file. `aloud mcp connect`
|
|
15
|
+
* puts it here, and `aloud mcp` reads it.
|
|
16
|
+
*/
|
|
17
|
+
export interface McpCredentials {
|
|
18
|
+
server: string;
|
|
19
|
+
token: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class McpCredentialsError extends Error {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "McpCredentialsError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function mcpCredentialsPath(home = homedir()): string {
|
|
30
|
+
return join(home, ".aloud", "mcp.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readMcpCredentials(path = mcpCredentialsPath()): Promise<McpCredentials | null> {
|
|
34
|
+
let raw: string;
|
|
35
|
+
try {
|
|
36
|
+
const info = await stat(path);
|
|
37
|
+
// eslint-disable-next-line no-bitwise
|
|
38
|
+
if ((info.mode & 0o077) !== 0) {
|
|
39
|
+
throw new McpCredentialsError(
|
|
40
|
+
`${path} can be read by other users on this machine. Fix it with:\n chmod 600 ${path}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
raw = await readFile(path, "utf8");
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof McpCredentialsError) throw error;
|
|
46
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let parsed: Partial<McpCredentials>;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(raw) as Partial<McpCredentials>;
|
|
53
|
+
} catch {
|
|
54
|
+
throw new McpCredentialsError(`${path} is not valid JSON. Delete it and run \`aloud mcp connect\` again.`);
|
|
55
|
+
}
|
|
56
|
+
if (!parsed.token || !parsed.server) {
|
|
57
|
+
throw new McpCredentialsError(`${path} is missing fields. Delete it and run \`aloud mcp connect\` again.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { server: parsed.server.replace(/\/+$/, ""), token: parsed.token };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `mode` on writeFile only applies at creation, so the chmod is the part that actually holds. */
|
|
64
|
+
export async function writeMcpCredentials(
|
|
65
|
+
credentials: McpCredentials,
|
|
66
|
+
path = mcpCredentialsPath(),
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
69
|
+
await writeFile(path, JSON.stringify(credentials, null, 2) + "\n", { mode: 0o600 });
|
|
70
|
+
await chmod(path, 0o600);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function clearMcpCredentials(path = mcpCredentialsPath()): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
await writeFile(path, "", { mode: 0o600, flag: constants.O_WRONLY | constants.O_TRUNC });
|
|
76
|
+
const { unlink } = await import("node:fs/promises");
|
|
77
|
+
await unlink(path);
|
|
78
|
+
return true;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/config/policy.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface LocalPolicy {
|
|
|
25
25
|
allowPrivateNetwork: boolean;
|
|
26
26
|
/** The server does not know how much memory this machine has. This is a local decision. */
|
|
27
27
|
maxConcurrentSessions: number;
|
|
28
|
+
/** Runner-local QA inbox/catch-all; never supplied by the control plane. */
|
|
29
|
+
syntheticEmailTemplate: string | null;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export const DEFAULT_MAX_CONCURRENT_SESSIONS = 3;
|
|
@@ -33,6 +35,7 @@ export function policyFrom(input: {
|
|
|
33
35
|
allowedHosts?: readonly string[];
|
|
34
36
|
allowPrivateNetwork?: boolean;
|
|
35
37
|
maxConcurrentSessions?: number;
|
|
38
|
+
syntheticEmailTemplate?: string | null;
|
|
36
39
|
}): LocalPolicy {
|
|
37
40
|
return {
|
|
38
41
|
allowedHosts: normaliseHosts(input.allowedHosts ?? []),
|
|
@@ -40,6 +43,7 @@ export function policyFrom(input: {
|
|
|
40
43
|
// Clamped rather than trusted: three browsers is already a lot on a laptop, and a typo of 300
|
|
41
44
|
// should not take the machine down.
|
|
42
45
|
maxConcurrentSessions: clamp(input.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS, 1, 8),
|
|
46
|
+
syntheticEmailTemplate: input.syntheticEmailTemplate?.trim() || null,
|
|
43
47
|
};
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -107,6 +107,7 @@ export class ProxyModelAdapter implements ModelAdapter {
|
|
|
107
107
|
system: request.system,
|
|
108
108
|
prompt: request.prompt,
|
|
109
109
|
responseShape: request.responseShape,
|
|
110
|
+
responseSchema: request.responseSchema,
|
|
110
111
|
maxOutputTokens: request.maxOutputTokens,
|
|
111
112
|
temperature: request.temperature,
|
|
112
113
|
images,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting a credential onto this machine without anybody carrying one.
|
|
3
|
+
*
|
|
4
|
+
* The client asks the server to open an approval, prints a URL and a short code, and waits. A person
|
|
5
|
+
* opens that URL in a browser they are already signed in to, types the code this printed, and
|
|
6
|
+
* approves. The next poll returns the credential and writes it to disk.
|
|
7
|
+
*
|
|
8
|
+
* The reason this exists rather than a prompt: a prompt needs a terminal, and the thing driving
|
|
9
|
+
* setup is increasingly an agent that does not have one. Waiting on a click needs nothing but time,
|
|
10
|
+
* so the same command works for a person and for an agent, and neither of them ever sees a token.
|
|
11
|
+
*/
|
|
12
|
+
export interface ApprovalStart {
|
|
13
|
+
id: string;
|
|
14
|
+
userCode: string;
|
|
15
|
+
deviceCode: string;
|
|
16
|
+
approveUrl: string;
|
|
17
|
+
expiresAt: string;
|
|
18
|
+
pollMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ApprovalOutcome =
|
|
22
|
+
| { status: "approved"; secret: string; kind: "runner" | "mcp" }
|
|
23
|
+
| { status: "denied" }
|
|
24
|
+
| { status: "expired" };
|
|
25
|
+
|
|
26
|
+
export class ApprovalFailed extends Error {}
|
|
27
|
+
|
|
28
|
+
export async function startApproval(input: {
|
|
29
|
+
server: string;
|
|
30
|
+
kind: "runner" | "mcp";
|
|
31
|
+
name?: string | null;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
}): Promise<ApprovalStart> {
|
|
34
|
+
const response = await (input.fetchImpl ?? fetch)(new URL("api/approvals", input.server + "/"), {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
37
|
+
redirect: "error",
|
|
38
|
+
body: JSON.stringify({ kind: input.kind, name: input.name ?? null }),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
throw new ApprovalFailed(`${input.server} would not start an approval (${response.status}).`);
|
|
42
|
+
}
|
|
43
|
+
return (await response.json()) as ApprovalStart;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Polls until somebody answers, or the request expires.
|
|
48
|
+
*
|
|
49
|
+
* `204` means keep waiting, matching the run loop's claim endpoint. The interval comes from the
|
|
50
|
+
* server rather than being chosen here, because every poll is an invocation on the other end and
|
|
51
|
+
* the server is the side that knows what it can afford.
|
|
52
|
+
*/
|
|
53
|
+
export async function waitForApproval(
|
|
54
|
+
start: ApprovalStart,
|
|
55
|
+
deps: {
|
|
56
|
+
server: string;
|
|
57
|
+
fetchImpl?: typeof fetch;
|
|
58
|
+
sleep?: (ms: number) => Promise<void>;
|
|
59
|
+
now?: () => number;
|
|
60
|
+
onWaiting?: (secondsElapsed: number) => void;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
},
|
|
63
|
+
): Promise<ApprovalOutcome> {
|
|
64
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
65
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
66
|
+
const now = deps.now ?? (() => Date.now());
|
|
67
|
+
|
|
68
|
+
const startedAt = now();
|
|
69
|
+
const deadline = Date.parse(start.expiresAt);
|
|
70
|
+
let announced = 0;
|
|
71
|
+
|
|
72
|
+
for (;;) {
|
|
73
|
+
if (deps.signal?.aborted) throw new ApprovalFailed("Stopped waiting.");
|
|
74
|
+
if (now() >= deadline) return { status: "expired" };
|
|
75
|
+
|
|
76
|
+
const response = await fetchImpl(new URL("api/approvals/collect", deps.server + "/"), {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { authorization: `Bearer ${start.deviceCode}`, accept: "application/json" },
|
|
79
|
+
redirect: "error",
|
|
80
|
+
}).catch(() => null);
|
|
81
|
+
|
|
82
|
+
// A dropped poll is not an answer. Keep waiting rather than failing someone's setup because a
|
|
83
|
+
// wifi card slept: the deadline is what ends this, not one bad request.
|
|
84
|
+
if (response && response.status !== 204) {
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new ApprovalFailed(`${deps.server} refused the approval check (${response.status}).`);
|
|
87
|
+
}
|
|
88
|
+
const body = (await response.json()) as ApprovalOutcome;
|
|
89
|
+
if (body.status !== "expired" || now() >= deadline) return body;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const elapsed = Math.round((now() - startedAt) / 1000);
|
|
93
|
+
if (elapsed - announced >= 15) {
|
|
94
|
+
announced = elapsed;
|
|
95
|
+
deps.onWaiting?.(elapsed);
|
|
96
|
+
}
|
|
97
|
+
await sleep(start.pollMs);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/protocol/client.ts
CHANGED
package/src/run/execute.ts
CHANGED
|
@@ -155,6 +155,7 @@ export async function executeLease(deps: ExecuteDeps): Promise<ExecuteResult> {
|
|
|
155
155
|
run: { ...deps.run, snapshot },
|
|
156
156
|
snapshot,
|
|
157
157
|
productId: deps.productId,
|
|
158
|
+
syntheticEmailTemplate: deps.local.syntheticEmailTemplate,
|
|
158
159
|
},
|
|
159
160
|
{
|
|
160
161
|
gateway,
|
|
@@ -253,6 +254,9 @@ export async function executeLease(deps: ExecuteDeps): Promise<ExecuteResult> {
|
|
|
253
254
|
if (outcome && !leaseLost) {
|
|
254
255
|
// Same order as the server's own persistence: findings before the report, because the report
|
|
255
256
|
// holds ordered identifiers that have to resolve to something.
|
|
257
|
+
// Sessions are checkpointed before synthesis, then posted again here because integrity runs
|
|
258
|
+
// during synthesis and enriches them with the evidence exclusions the replay must disclose.
|
|
259
|
+
await postPart(deps.client, deps.lease.id, "sessions", outcome.sessions);
|
|
256
260
|
await postPart(deps.client, deps.lease.id, "judgments", outcome.judgments);
|
|
257
261
|
await postPart(deps.client, deps.lease.id, "issues", outcome.issues);
|
|
258
262
|
await postPart(deps.client, deps.lease.id, "findings", outcome.findings);
|
package/src/version.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* package.json beside it to read, and importing one into the source trips the composite build's
|
|
11
11
|
* rootDir. `version.test.ts` asserts this matches, so the drift this invites cannot survive CI.
|
|
12
12
|
*/
|
|
13
|
-
export const RUNNER_VERSION = "0.
|
|
13
|
+
export const RUNNER_VERSION = "0.3.0";
|
|
14
14
|
|
|
15
15
|
/** The header the server reads it from. */
|
|
16
16
|
export const RUNNER_VERSION_HEADER = "x-aloud-runner-version";
|