@aloud/runner 0.2.5 → 0.3.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/README.md +15 -2
- package/dist/cli.js +3645 -657
- package/package.json +2 -2
- package/src/cli.ts +377 -55
- 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 +56 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aloud/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Run Aloud usability studies in a real browser on your own machine, so a study can reach localhost and anything else behind your network.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"repository": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
".": "./src/index.ts",
|
|
18
18
|
"./*": "./src/*.ts"
|
|
19
19
|
},
|
|
20
|
-
"bin": { "aloud": "
|
|
20
|
+
"bin": { "aloud": "dist/cli.js" },
|
|
21
21
|
"files": ["dist/cli.js", "src", "README.md"],
|
|
22
22
|
"engines": { "node": ">=20" },
|
|
23
23
|
"scripts": {
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { createInterface } from "node:readline/promises";
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
|
+
import { startApproval, waitForApproval, type ApprovalStart } from "./protocol/approval";
|
|
12
|
+
import {
|
|
13
|
+
clearMcpCredentials,
|
|
14
|
+
mcpCredentialsPath,
|
|
15
|
+
readMcpCredentials,
|
|
16
|
+
writeMcpCredentials,
|
|
17
|
+
} from "./config/mcp-credentials";
|
|
11
18
|
import { mkdir } from "node:fs/promises";
|
|
12
19
|
import { accessSync, constants, existsSync, openSync, readFileSync, unlinkSync } from "node:fs";
|
|
13
20
|
import { hostname } from "node:os";
|
|
@@ -24,7 +31,14 @@ import {
|
|
|
24
31
|
} from "./config/credentials";
|
|
25
32
|
import { policyFrom, type LocalPolicy } from "./config/policy";
|
|
26
33
|
import { clearRunning, readRunning, runningPath, writeRunning, type RunningState } from "./config/running";
|
|
27
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
RUNNER_VERSION,
|
|
36
|
+
compareRunnerVersions,
|
|
37
|
+
runnerUpdateFor,
|
|
38
|
+
runnerVersionPolicyFrom,
|
|
39
|
+
type RunnerUpdate,
|
|
40
|
+
type RunnerVersionPolicy,
|
|
41
|
+
} from "./version";
|
|
28
42
|
import { RunnerClient } from "./protocol/client";
|
|
29
43
|
import { installChromium, preflight } from "./preflight";
|
|
30
44
|
import { TerminalReporter } from "./ui/output";
|
|
@@ -50,8 +64,17 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
50
64
|
return allow(rest);
|
|
51
65
|
case "setup":
|
|
52
66
|
return setup();
|
|
67
|
+
case "version":
|
|
68
|
+
case "--version":
|
|
69
|
+
case "-v":
|
|
70
|
+
process.stdout.write(RUNNER_VERSION + "\n");
|
|
71
|
+
return 0;
|
|
53
72
|
case "mcp":
|
|
54
|
-
|
|
73
|
+
// `aloud mcp` is the server an MCP host launches; `aloud mcp connect` is how it gets a
|
|
74
|
+
// credential in the first place. Same word, because from the outside they are one feature.
|
|
75
|
+
if (rest[0] === "connect") return connectMcp(rest.slice(1));
|
|
76
|
+
if (rest[0] === "disconnect") return disconnectMcp();
|
|
77
|
+
await startStdioServer((await mcpOptions()) ?? undefined);
|
|
55
78
|
return 0;
|
|
56
79
|
case "help":
|
|
57
80
|
case "--help":
|
|
@@ -74,12 +97,15 @@ function printHelp(): void {
|
|
|
74
97
|
"aloud - run usability studies on this machine",
|
|
75
98
|
"",
|
|
76
99
|
" aloud setup What to do next, for a person or an agent",
|
|
77
|
-
" aloud login [--token <token>] Connect this machine
|
|
78
|
-
" aloud start [--once] [--quiet]
|
|
100
|
+
" aloud login [--token <token>] Connect this machine, approving it in your browser",
|
|
101
|
+
" aloud start [--once] [--quiet] [--no-update]",
|
|
102
|
+
" Update, then wait for studies and run them here",
|
|
79
103
|
" aloud status What is set up, and whether it is running",
|
|
80
104
|
" aloud allow <host> Let studies open this host from this machine",
|
|
81
|
-
" aloud mcp
|
|
105
|
+
" aloud mcp Serve MCP to an editor, using the saved credential",
|
|
106
|
+
" aloud mcp connect Connect an editor, approving it in your browser",
|
|
82
107
|
" aloud logout Forget the token on this machine",
|
|
108
|
+
" aloud --version Print the installed runner version",
|
|
83
109
|
"",
|
|
84
110
|
`Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
|
|
85
111
|
"",
|
|
@@ -96,6 +122,7 @@ function policyOf(credentials: Credentials, argv: readonly string[]): LocalPolic
|
|
|
96
122
|
return policyFrom({
|
|
97
123
|
allowedHosts: credentials.allowedHosts,
|
|
98
124
|
allowPrivateNetwork: credentials.allowPrivateNetwork,
|
|
125
|
+
syntheticEmailTemplate: process.env.UTA_SYNTHETIC_EMAIL_TEMPLATE ?? null,
|
|
99
126
|
...(concurrency !== null ? { maxConcurrentSessions: concurrency } : {}),
|
|
100
127
|
});
|
|
101
128
|
}
|
|
@@ -104,44 +131,63 @@ function policyOf(credentials: Credentials, argv: readonly string[]): LocalPolic
|
|
|
104
131
|
|
|
105
132
|
async function login(argv: readonly string[]): Promise<number> {
|
|
106
133
|
const server = stringOption(argv, "--server") ?? DEFAULT_SERVER;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (!token) {
|
|
110
|
-
// An agent runs commands in a shell with nothing attached to stdin, so the prompt below would
|
|
111
|
-
// read end-of-file and the token would come back empty. That used to surface as "that does not
|
|
112
|
-
// look like a runner token", which blames the wrong thing and tells nobody what to do. This is
|
|
113
|
-
// the single step of setup that a person has to perform, so it is worth saying so precisely.
|
|
114
|
-
if (!process.stdin.isTTY) {
|
|
115
|
-
process.stderr.write(
|
|
116
|
-
[
|
|
117
|
-
"",
|
|
118
|
-
"There is no terminal attached here, so there is nowhere to paste a token.",
|
|
119
|
-
"",
|
|
120
|
-
"If you are an agent: stop and hand this back. The person runs `aloud login` in their",
|
|
121
|
-
"own terminal and pastes the token at the prompt. Do not ask them to paste it to you.",
|
|
122
|
-
"",
|
|
123
|
-
"To connect without a terminal, set both of these instead and skip login entirely:",
|
|
124
|
-
` export ALOUD_SERVER=${server}`,
|
|
125
|
-
" export ALOUD_RUNNER_TOKEN=utar_...",
|
|
126
|
-
"",
|
|
127
|
-
`A token comes from ${server}/app/settings/runners and is shown once.`,
|
|
128
|
-
"",
|
|
129
|
-
].join("\n"),
|
|
130
|
-
);
|
|
131
|
-
return 1;
|
|
132
|
-
}
|
|
134
|
+
const token = stringOption(argv, "--token");
|
|
133
135
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
// A token on the command line is for CI, where there is nobody to click anything. Everything else
|
|
137
|
+
// goes through an approval, including a terminal: it is fewer steps even when someone is watching.
|
|
138
|
+
if (token) return connectWith(server, token);
|
|
139
|
+
|
|
140
|
+
let started: ApprovalStart;
|
|
141
|
+
try {
|
|
142
|
+
started = await startApproval({ server, kind: "runner", name: hostname() });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
process.stderr.write(`\n${(error as Error).message}\n\n`);
|
|
145
|
+
return 1;
|
|
142
146
|
}
|
|
143
147
|
|
|
144
|
-
|
|
148
|
+
process.stdout.write("\nTo connect this machine, open this page and approve it:\n\n");
|
|
149
|
+
process.stdout.write(` ${started.approveUrl}\n\n`);
|
|
150
|
+
process.stdout.write("Then type this code on that page:\n\n");
|
|
151
|
+
process.stdout.write(` ${started.userCode}\n\n`);
|
|
152
|
+
// Said explicitly because the thing running this is often not a person, and the old flow trained
|
|
153
|
+
// agents to go looking for a token to paste. There is nothing to paste any more.
|
|
154
|
+
process.stdout.write("Waiting for approval. Nothing here needs a terminal, and there is no token\n");
|
|
155
|
+
process.stdout.write("to paste: if you are an agent, give the person the link and the code above.\n\n");
|
|
156
|
+
|
|
157
|
+
// Opening a browser is a courtesy for whoever is sitting here. It is never attempted when nobody
|
|
158
|
+
// is, and a failure is ignored, because the printed URL is the thing that actually matters.
|
|
159
|
+
if (process.stdout.isTTY) openInBrowser(started.approveUrl);
|
|
160
|
+
|
|
161
|
+
const outcome = await waitForApproval(started, {
|
|
162
|
+
server,
|
|
163
|
+
onWaiting: (seconds) => process.stdout.write(` still waiting (${seconds}s)\n`),
|
|
164
|
+
}).catch((error: Error) => {
|
|
165
|
+
process.stderr.write(`\n${error.message}\n`);
|
|
166
|
+
return null;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (!outcome) return 1;
|
|
170
|
+
if (outcome.status === "denied") {
|
|
171
|
+
process.stderr.write("\nThat request was refused. Nothing was connected.\n\n");
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
if (outcome.status === "expired") {
|
|
175
|
+
process.stderr.write("\nThat request expired before anyone approved it. Run `aloud login` again.\n\n");
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return connectWith(server, outcome.secret);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Writes the credential and says what this machine may now do.
|
|
184
|
+
*
|
|
185
|
+
* Shared by both paths on purpose: a token that arrived through an approval and one passed on the
|
|
186
|
+
* command line have to end up in exactly the same state on disk, or the two ways of connecting
|
|
187
|
+
* diverge in ways nobody notices until one of them breaks.
|
|
188
|
+
*/
|
|
189
|
+
async function connectWith(server: string, token: string): Promise<number> {
|
|
190
|
+
if (!token.startsWith("utar_")) {
|
|
145
191
|
process.stderr.write("That does not look like a runner token. They start with utar_.\n");
|
|
146
192
|
return 1;
|
|
147
193
|
}
|
|
@@ -184,6 +230,93 @@ async function login(argv: readonly string[]): Promise<number> {
|
|
|
184
230
|
return 0;
|
|
185
231
|
}
|
|
186
232
|
|
|
233
|
+
/** Best effort, and deliberately silent about failing. The URL was already printed. */
|
|
234
|
+
function openInBrowser(url: string): void {
|
|
235
|
+
const command =
|
|
236
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
237
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
238
|
+
try {
|
|
239
|
+
spawn(command, args, { stdio: "ignore", detached: true }).unref();
|
|
240
|
+
} catch {
|
|
241
|
+
// No browser here. That is what the printed link is for.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Connects an MCP host, through the same approval as a machine.
|
|
247
|
+
*
|
|
248
|
+
* The difference from a runner is only what the approval mints. Everything a person experiences,
|
|
249
|
+
* and everything an agent has to do, is identical: a link, a code, and a wait. Nobody edits a
|
|
250
|
+
* config file to hold a token, which was the last place a credential still had to be carried by
|
|
251
|
+
* hand after `aloud login` stopped needing one.
|
|
252
|
+
*/
|
|
253
|
+
async function connectMcp(argv: readonly string[]): Promise<number> {
|
|
254
|
+
const server = stringOption(argv, "--server") ?? DEFAULT_SERVER;
|
|
255
|
+
|
|
256
|
+
let started: ApprovalStart;
|
|
257
|
+
try {
|
|
258
|
+
started = await startApproval({ server, kind: "mcp", name: hostname() });
|
|
259
|
+
} catch (error) {
|
|
260
|
+
process.stderr.write(`\n${(error as Error).message}\n\n`);
|
|
261
|
+
return 1;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
process.stdout.write("\nTo connect this editor to your workspace, open this page and approve it:\n\n");
|
|
265
|
+
process.stdout.write(` ${started.approveUrl}\n\n`);
|
|
266
|
+
process.stdout.write("Then type this code on that page:\n\n");
|
|
267
|
+
process.stdout.write(` ${started.userCode}\n\n`);
|
|
268
|
+
process.stdout.write("Waiting for approval. There is no token to paste anywhere.\n\n");
|
|
269
|
+
if (process.stdout.isTTY) openInBrowser(started.approveUrl);
|
|
270
|
+
|
|
271
|
+
const outcome = await waitForApproval(started, {
|
|
272
|
+
server,
|
|
273
|
+
onWaiting: (seconds) => process.stdout.write(` still waiting (${seconds}s)\n`),
|
|
274
|
+
}).catch((error: Error) => {
|
|
275
|
+
process.stderr.write(`\n${error.message}\n`);
|
|
276
|
+
return null;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (!outcome) return 1;
|
|
280
|
+
if (outcome.status === "denied") {
|
|
281
|
+
process.stderr.write("\nThat request was refused. Nothing was connected.\n\n");
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
if (outcome.status === "expired") {
|
|
285
|
+
process.stderr.write("\nThat request expired. Run `aloud mcp connect` again.\n\n");
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await writeMcpCredentials({ server, token: outcome.secret });
|
|
290
|
+
|
|
291
|
+
process.stdout.write(`\nConnected. The credential is in ${mcpCredentialsPath()}, not in any config file.\n`);
|
|
292
|
+
process.stdout.write("Point your MCP host at `aloud mcp` and it will find it.\n\n");
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function disconnectMcp(): Promise<number> {
|
|
297
|
+
const removed = await clearMcpCredentials();
|
|
298
|
+
process.stdout.write(
|
|
299
|
+
removed
|
|
300
|
+
? `Forgot the MCP credential at ${mcpCredentialsPath()}.\nRevoke it in the web app too, if you want it dead everywhere.\n`
|
|
301
|
+
: "There was no MCP credential here to forget.\n",
|
|
302
|
+
);
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The credential `aloud mcp` runs with, if there is one.
|
|
308
|
+
*
|
|
309
|
+
* The environment wins, so CI and self-hosting keep working exactly as they did. A missing file is
|
|
310
|
+
* not an error here: `startStdioServer` falls back to the environment and produces the message that
|
|
311
|
+
* explains what to set.
|
|
312
|
+
*/
|
|
313
|
+
async function mcpOptions(): Promise<{ server: string; token: string } | null> {
|
|
314
|
+
const credentials = await readMcpCredentials().catch(() => null);
|
|
315
|
+
if (!credentials) return null;
|
|
316
|
+
if (process.env.ALOUD_MCP_TOKEN?.trim()) return null;
|
|
317
|
+
return { server: credentials.server, token: credentials.token };
|
|
318
|
+
}
|
|
319
|
+
|
|
187
320
|
async function logout(): Promise<number> {
|
|
188
321
|
const removed = await clearCredentials();
|
|
189
322
|
process.stdout.write(removed ? `Forgot the token at ${credentialsPath()}.\n` : "There was nothing to forget.\n");
|
|
@@ -210,7 +343,7 @@ async function setup(): Promise<number> {
|
|
|
210
343
|
const running = await readRunning();
|
|
211
344
|
const installed = onPath("aloud");
|
|
212
345
|
const latest = await latestVersion();
|
|
213
|
-
const stale = latest !== null && latest
|
|
346
|
+
const stale = latest !== null && compareRunnerVersions(latest, RUNNER_VERSION) === 1;
|
|
214
347
|
const signedIn = await signedInState(credentials);
|
|
215
348
|
|
|
216
349
|
const out = (line = "") => process.stdout.write(line + "\n");
|
|
@@ -250,15 +383,15 @@ async function setup(): Promise<number> {
|
|
|
250
383
|
}
|
|
251
384
|
if (signedIn.state === "revoked") {
|
|
252
385
|
steps.push([
|
|
253
|
-
|
|
254
|
-
"The saved
|
|
386
|
+
"aloud login",
|
|
387
|
+
"The saved connection was revoked. This prints a new browser-approval link and code.",
|
|
255
388
|
]);
|
|
256
389
|
}
|
|
257
|
-
if (signedIn.state === "none"
|
|
390
|
+
if (signedIn.state === "none") {
|
|
258
391
|
steps.push([
|
|
259
392
|
"aloud login",
|
|
260
|
-
"
|
|
261
|
-
"
|
|
393
|
+
"Prints a link and a short code, then waits. Give both to the person; they approve in",
|
|
394
|
+
"their browser. Nothing to paste, and no terminal needed. It finishes on its own.",
|
|
262
395
|
]);
|
|
263
396
|
}
|
|
264
397
|
if (!running) {
|
|
@@ -299,9 +432,9 @@ async function setup(): Promise<number> {
|
|
|
299
432
|
);
|
|
300
433
|
}
|
|
301
434
|
out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
|
|
302
|
-
out(" Do not install a similarly named package
|
|
303
|
-
out(" -
|
|
304
|
-
out("
|
|
435
|
+
out(" Do not install a similarly named package.");
|
|
436
|
+
out(" - There is nothing secret for you to handle. `aloud login` prints a link and a code,");
|
|
437
|
+
out(" and the credential goes straight to this machine. Never ask anyone for a token.");
|
|
305
438
|
out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
|
|
306
439
|
out();
|
|
307
440
|
if (!credentials) {
|
|
@@ -350,14 +483,12 @@ async function interactiveSetup(state: {
|
|
|
350
483
|
if (state.signedIn.state !== "ok") {
|
|
351
484
|
out("");
|
|
352
485
|
if (state.signedIn.state === "revoked") {
|
|
353
|
-
out(
|
|
486
|
+
out("The credential saved here was revoked, so this machine needs connecting again.");
|
|
354
487
|
}
|
|
355
|
-
out(`Create a token at ${server}/app/settings/runners`);
|
|
356
|
-
out("It is shown once. Copy it, then paste it below.");
|
|
357
|
-
out("");
|
|
358
|
-
const token = (await rl.question("Token: ")).trim();
|
|
359
488
|
rl.close();
|
|
360
|
-
|
|
489
|
+
// Straight into the approval. There is no token to ask anybody for any more, so there is
|
|
490
|
+
// nothing to prompt for either.
|
|
491
|
+
const code = await login(["--server", server]);
|
|
361
492
|
if (code !== 0) return code;
|
|
362
493
|
} else {
|
|
363
494
|
rl.close();
|
|
@@ -507,6 +638,182 @@ async function latestVersion(): Promise<string | null> {
|
|
|
507
638
|
}
|
|
508
639
|
}
|
|
509
640
|
|
|
641
|
+
/**
|
|
642
|
+
* The release policy belongs to the control plane this machine is connected to, not to npm.
|
|
643
|
+
*
|
|
644
|
+
* That distinction lets a self-hosted deployment deliberately lag the hosted service and lets the
|
|
645
|
+
* hosted service publish a runner before recommending it. The response is validated before any
|
|
646
|
+
* part of it reaches an install command; even a compromised control plane can select only an exact
|
|
647
|
+
* numeric release of the official scoped package.
|
|
648
|
+
*/
|
|
649
|
+
async function serverVersionPolicy(credentials: Credentials): Promise<RunnerVersionPolicy | null> {
|
|
650
|
+
try {
|
|
651
|
+
const client = new RunnerClient({ server: credentials.server, token: credentials.token });
|
|
652
|
+
const response = await client.request<{ runnerVersionPolicy?: unknown }>("api/runner/me", {
|
|
653
|
+
retry: false,
|
|
654
|
+
});
|
|
655
|
+
return runnerVersionPolicyFrom(response.body?.runnerVersionPolicy);
|
|
656
|
+
} catch {
|
|
657
|
+
// Startup still reaches the ordinary authenticated claim below. A revoked token, unreachable
|
|
658
|
+
// server, or old self-hosted control plane will be explained there; an update convenience must
|
|
659
|
+
// not replace the runner's established connection behavior.
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Updates before any browser or lease exists, then starts the newly installed bundle.
|
|
666
|
+
*
|
|
667
|
+
* `null` means the current process should continue. A number means startup has been handed to the
|
|
668
|
+
* replacement process, or could not safely continue because the server requires that replacement.
|
|
669
|
+
*/
|
|
670
|
+
export interface StartUpdateDependencies {
|
|
671
|
+
versionPolicy(credentials: Credentials): Promise<RunnerVersionPolicy | null>;
|
|
672
|
+
npmPrefix(): Promise<{ path: string; writable: boolean | null } | null>;
|
|
673
|
+
install(target: string, write: (text: string) => void): Promise<boolean>;
|
|
674
|
+
installedEntry(target: string): Promise<string | null>;
|
|
675
|
+
relaunch(entry: string, argv: readonly string[]): Promise<number>;
|
|
676
|
+
stdout(text: string): void;
|
|
677
|
+
stderr(text: string): void;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export async function updateBeforeStart(
|
|
681
|
+
credentials: Credentials,
|
|
682
|
+
argv: readonly string[],
|
|
683
|
+
overrides: Partial<StartUpdateDependencies> = {},
|
|
684
|
+
): Promise<number | null> {
|
|
685
|
+
const stdout = overrides.stdout ?? ((text: string) => process.stdout.write(text));
|
|
686
|
+
const stderr = overrides.stderr ?? ((text: string) => process.stderr.write(text));
|
|
687
|
+
const policy = await (overrides.versionPolicy ?? serverVersionPolicy)(credentials);
|
|
688
|
+
if (!policy) return null;
|
|
689
|
+
const update = runnerUpdateFor(policy);
|
|
690
|
+
if (!update) return null;
|
|
691
|
+
|
|
692
|
+
if (argv.includes("--no-update")) {
|
|
693
|
+
if (!update.required) return null;
|
|
694
|
+
stderr(
|
|
695
|
+
`Runner ${RUNNER_VERSION} cannot start studies on this server; ${policy.minimum} or newer is required.\n` +
|
|
696
|
+
`Automatic updates were disabled. Remove --no-update or run npm install -g @aloud/runner@${update.target}.\n`,
|
|
697
|
+
);
|
|
698
|
+
return 1;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
stdout(`\nUpdating Aloud runner ${RUNNER_VERSION} → ${update.target} before it starts.\n`);
|
|
702
|
+
const prefix = await (overrides.npmPrefix ?? npmPrefix)();
|
|
703
|
+
if (prefix?.writable === false) {
|
|
704
|
+
return failedAutomaticUpdate(
|
|
705
|
+
update,
|
|
706
|
+
policy,
|
|
707
|
+
`npm's global install directory (${prefix.path}) is not writable by this user.`,
|
|
708
|
+
stderr,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const installed = overrides.install
|
|
713
|
+
? await overrides.install(update.target, stdout)
|
|
714
|
+
: await run(
|
|
715
|
+
"npm",
|
|
716
|
+
["install", "-g", `@aloud/runner@${update.target}`],
|
|
717
|
+
(line = "") => stdout(line + "\n"),
|
|
718
|
+
);
|
|
719
|
+
if (!installed) {
|
|
720
|
+
return failedAutomaticUpdate(update, policy, "npm did not complete the update.", stderr);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Do not relaunch `process.argv[1]`: a runner started through npx or a project-local shim can
|
|
724
|
+
// live somewhere entirely different from the global package npm just replaced. Resolve npm's
|
|
725
|
+
// canonical global root and verify the exact installed version before handing it any work.
|
|
726
|
+
const entry = await (overrides.installedEntry ?? installedRunnerEntry)(update.target);
|
|
727
|
+
if (!entry) {
|
|
728
|
+
return failedAutomaticUpdate(
|
|
729
|
+
update,
|
|
730
|
+
policy,
|
|
731
|
+
`npm completed, but the installed @aloud/runner@${update.target} could not be verified.`,
|
|
732
|
+
stderr,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
stdout(`\nUpdated to ${update.target}. Starting it now.\n\n`);
|
|
737
|
+
return (overrides.relaunch ?? relaunchUpdatedRunner)(entry, argv);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function failedAutomaticUpdate(
|
|
741
|
+
update: RunnerUpdate,
|
|
742
|
+
policy: RunnerVersionPolicy,
|
|
743
|
+
reason: string,
|
|
744
|
+
stderr: (text: string) => void,
|
|
745
|
+
): number | null {
|
|
746
|
+
const command = `npm install -g @aloud/runner@${update.target}`;
|
|
747
|
+
if (update.required) {
|
|
748
|
+
stderr(
|
|
749
|
+
`\n${reason}\nRunner ${RUNNER_VERSION} is below this server's minimum ${policy.minimum}, so nothing was started.\n` +
|
|
750
|
+
`Do not use sudo. Fix npm's global prefix, then run: ${command}\n`,
|
|
751
|
+
);
|
|
752
|
+
return 1;
|
|
753
|
+
}
|
|
754
|
+
stderr(
|
|
755
|
+
`\n${reason}\nRunner ${RUNNER_VERSION} is still compatible, so it will start without updating.\n` +
|
|
756
|
+
`To update it later, run: ${command}\n\n`,
|
|
757
|
+
);
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Finds the exact global bundle npm just installed, independently of how this process was run. */
|
|
762
|
+
async function installedRunnerEntry(target: string): Promise<string | null> {
|
|
763
|
+
const root = await commandOutput("npm", ["root", "--global"]);
|
|
764
|
+
if (!root) return null;
|
|
765
|
+
|
|
766
|
+
const directory = join(root, "@aloud", "runner");
|
|
767
|
+
const entry = join(directory, "dist", "cli.js");
|
|
768
|
+
try {
|
|
769
|
+
const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")) as {
|
|
770
|
+
version?: unknown;
|
|
771
|
+
};
|
|
772
|
+
return manifest.version === target && existsSync(entry) ? entry : null;
|
|
773
|
+
} catch {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Captures one short command result without involving a shell. */
|
|
779
|
+
async function commandOutput(command: string, args: readonly string[]): Promise<string | null> {
|
|
780
|
+
return new Promise((resolve) => {
|
|
781
|
+
const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "ignore"] });
|
|
782
|
+
let output = "";
|
|
783
|
+
let settled = false;
|
|
784
|
+
const finish = (value: string | null) => {
|
|
785
|
+
if (settled) return;
|
|
786
|
+
settled = true;
|
|
787
|
+
resolve(value);
|
|
788
|
+
};
|
|
789
|
+
child.stdout?.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
|
|
790
|
+
child.on("error", () => finish(null));
|
|
791
|
+
child.on("close", (code: number | null) => finish(code === 0 && output.trim() ? output.trim() : null));
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** The old bundle stays only as a transparent parent while the verified new bundle runs. */
|
|
796
|
+
async function relaunchUpdatedRunner(entry: string, argv: readonly string[]): Promise<number> {
|
|
797
|
+
return new Promise((resolve) => {
|
|
798
|
+
let settled = false;
|
|
799
|
+
const finish = (code: number) => {
|
|
800
|
+
if (settled) return;
|
|
801
|
+
settled = true;
|
|
802
|
+
resolve(code);
|
|
803
|
+
};
|
|
804
|
+
const child = spawn(process.execPath, [entry, "start", ...argv, "--no-update"], {
|
|
805
|
+
stdio: "inherit",
|
|
806
|
+
});
|
|
807
|
+
child.on("error", (error: Error) => {
|
|
808
|
+
process.stderr.write(`The runner updated but could not relaunch: ${error.message}\nRun \`aloud start\` again.\n`);
|
|
809
|
+
finish(1);
|
|
810
|
+
});
|
|
811
|
+
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
812
|
+
finish(code ?? (signal ? 130 : 1));
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
510
817
|
/* --------------------------------- status --------------------------------- */
|
|
511
818
|
|
|
512
819
|
async function status(): Promise<number> {
|
|
@@ -572,6 +879,21 @@ async function start(argv: readonly string[]): Promise<number> {
|
|
|
572
879
|
return 1;
|
|
573
880
|
}
|
|
574
881
|
|
|
882
|
+
// Two starts using one runner identity can claim two studies while status reports only the last
|
|
883
|
+
// pid written. Refuse before updating or polling; a restart has to stop the existing process
|
|
884
|
+
// first, which also guarantees we never replace its package while it is inside an active study.
|
|
885
|
+
const existing = await readRunning();
|
|
886
|
+
if (existing && existing.pid !== process.pid) {
|
|
887
|
+
process.stderr.write(
|
|
888
|
+
`Aloud runner is already running as pid ${existing.pid}. Stop that process before starting another.\n` +
|
|
889
|
+
`To restart it: kill ${existing.pid} && aloud start\n`,
|
|
890
|
+
);
|
|
891
|
+
return 1;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const updated = await updateBeforeStart(credentials, argv);
|
|
895
|
+
if (updated !== null) return updated;
|
|
896
|
+
|
|
575
897
|
const reporter = new TerminalReporter(process.stdout, credentials.token, !argv.includes("--quiet"));
|
|
576
898
|
|
|
577
899
|
// Preflight runs before the first claim, deliberately. A missing Chromium makes
|
|
@@ -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,
|