@aloud/runner 0.2.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/README.md +38 -0
- package/dist/cli.js +20513 -0
- package/package.json +37 -0
- package/src/cli.ts +348 -0
- package/src/config/credentials.ts +146 -0
- package/src/config/policy.ts +49 -0
- package/src/config/running.ts +95 -0
- package/src/evidence/uploading-store.ts +166 -0
- package/src/index.ts +24 -0
- package/src/loop.ts +117 -0
- package/src/model/proxy-adapter.ts +188 -0
- package/src/preflight.ts +96 -0
- package/src/protocol/blob-spool.ts +88 -0
- package/src/protocol/client.ts +170 -0
- package/src/run/event-shipper.ts +132 -0
- package/src/run/execute.ts +344 -0
- package/src/run/guarded-workers.ts +43 -0
- package/src/run/sanitise.ts +93 -0
- package/src/ui/output.ts +185 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aloud/runner",
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"license": "ISC",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/dfala/user-testing-ai.git",
|
|
9
|
+
"directory": "packages/runner"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://usealoud.com",
|
|
12
|
+
"keywords": ["usability", "testing", "playwright", "aloud"],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./src/index.ts",
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./*": "./src/*.ts"
|
|
19
|
+
},
|
|
20
|
+
"bin": { "aloud": "./dist/cli.js" },
|
|
21
|
+
"files": ["dist/cli.js", "src", "README.md"],
|
|
22
|
+
"engines": { "node": ">=20" },
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --external:playwright --external:sharp --outfile=dist/cli.js",
|
|
25
|
+
"prepack": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"playwright": "^1.62.1",
|
|
29
|
+
"sharp": "^0.35.3"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@aloud/core": "*",
|
|
33
|
+
"@aloud/engine": "*",
|
|
34
|
+
"@aloud/eval": "*",
|
|
35
|
+
"esbuild": "^0.28.2"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `aloud` - the local runner.
|
|
4
|
+
*
|
|
5
|
+
* The browsers run on this machine, which is what makes testing localhost and anything behind a
|
|
6
|
+
* firewall possible. The connection is outbound only: nothing here listens on a port, and the
|
|
7
|
+
* server holds no address for this machine.
|
|
8
|
+
*/
|
|
9
|
+
import { createInterface } from "node:readline/promises";
|
|
10
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
11
|
+
import { hostname } from "node:os";
|
|
12
|
+
import { normaliseHosts } from "@aloud/core";
|
|
13
|
+
import {
|
|
14
|
+
CredentialsError,
|
|
15
|
+
clearCredentials,
|
|
16
|
+
credentialsFromEnv,
|
|
17
|
+
credentialsPath,
|
|
18
|
+
readCredentials,
|
|
19
|
+
writeCredentials,
|
|
20
|
+
type Credentials,
|
|
21
|
+
} from "./config/credentials";
|
|
22
|
+
import { policyFrom, type LocalPolicy } from "./config/policy";
|
|
23
|
+
import { clearRunning, readRunning, runningPath, writeRunning } from "./config/running";
|
|
24
|
+
import { RunnerClient } from "./protocol/client";
|
|
25
|
+
import { installChromium, preflight } from "./preflight";
|
|
26
|
+
import { TerminalReporter } from "./ui/output";
|
|
27
|
+
import { runLoop } from "./loop";
|
|
28
|
+
|
|
29
|
+
/** Where a runner reaches the hosted control plane. Overridable for self-hosting and for tests. */
|
|
30
|
+
const DEFAULT_SERVER = process.env.ALOUD_SERVER?.replace(/\/+$/, "") || "https://usealoud.com";
|
|
31
|
+
|
|
32
|
+
export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<number> {
|
|
33
|
+
const [command = "help", ...rest] = argv;
|
|
34
|
+
|
|
35
|
+
switch (command) {
|
|
36
|
+
case "login":
|
|
37
|
+
return login(rest);
|
|
38
|
+
case "logout":
|
|
39
|
+
return logout();
|
|
40
|
+
case "start":
|
|
41
|
+
return start(rest);
|
|
42
|
+
case "status":
|
|
43
|
+
return status();
|
|
44
|
+
case "allow":
|
|
45
|
+
return allow(rest);
|
|
46
|
+
case "help":
|
|
47
|
+
case "--help":
|
|
48
|
+
case "-h":
|
|
49
|
+
printHelp();
|
|
50
|
+
return 0;
|
|
51
|
+
default:
|
|
52
|
+
// Refuse rather than guess. An unrecognised command that might spend money is not a thing to
|
|
53
|
+
// be forgiving about.
|
|
54
|
+
process.stderr.write(`I do not know the command "${command}".\n\n`);
|
|
55
|
+
printHelp();
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function printHelp(): void {
|
|
61
|
+
process.stdout.write(
|
|
62
|
+
[
|
|
63
|
+
"",
|
|
64
|
+
"aloud - run usability studies on this machine",
|
|
65
|
+
"",
|
|
66
|
+
" aloud login [--token <token>] Connect this machine to your workspace",
|
|
67
|
+
" aloud start [--once] [--quiet] Wait for studies and run them here",
|
|
68
|
+
" aloud status What is set up, and whether it is running",
|
|
69
|
+
" aloud allow <host> Let studies open this host from this machine",
|
|
70
|
+
" aloud logout Forget the token on this machine",
|
|
71
|
+
"",
|
|
72
|
+
`Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
|
|
73
|
+
"",
|
|
74
|
+
].join("\n"),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function loadCredentials(): Promise<Credentials | null> {
|
|
79
|
+
return credentialsFromEnv() ?? (await readCredentials());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function policyOf(credentials: Credentials, argv: readonly string[]): LocalPolicy {
|
|
83
|
+
const concurrency = numberOption(argv, "--concurrency");
|
|
84
|
+
return policyFrom({
|
|
85
|
+
allowedHosts: credentials.allowedHosts,
|
|
86
|
+
allowPrivateNetwork: credentials.allowPrivateNetwork,
|
|
87
|
+
...(concurrency !== null ? { maxConcurrentSessions: concurrency } : {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* --------------------------------- login --------------------------------- */
|
|
92
|
+
|
|
93
|
+
async function login(argv: readonly string[]): Promise<number> {
|
|
94
|
+
const server = stringOption(argv, "--server") ?? DEFAULT_SERVER;
|
|
95
|
+
let token = stringOption(argv, "--token");
|
|
96
|
+
|
|
97
|
+
if (!token) {
|
|
98
|
+
process.stdout.write(`\nOpen ${server}/app/settings/runners and create a runner.\n`);
|
|
99
|
+
process.stdout.write("It shows you a token once. Paste it here.\n\n");
|
|
100
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
101
|
+
try {
|
|
102
|
+
token = (await rl.question("Token: ")).trim();
|
|
103
|
+
} finally {
|
|
104
|
+
rl.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!token?.startsWith("utar_")) {
|
|
109
|
+
process.stderr.write("That does not look like a runner token. They start with utar_.\n");
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface Profile {
|
|
114
|
+
runnerId?: string;
|
|
115
|
+
runnerName?: string;
|
|
116
|
+
workspaceId?: string;
|
|
117
|
+
allowedHosts?: string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const client = new RunnerClient({ server, token });
|
|
121
|
+
let profile: Profile;
|
|
122
|
+
try {
|
|
123
|
+
const response = await client.request<Profile>("api/runner/me", { retry: false });
|
|
124
|
+
profile = response.body ?? {};
|
|
125
|
+
} catch (error) {
|
|
126
|
+
process.stderr.write(`That token did not work: ${(error as Error).message}\n`);
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const credentials: Credentials = {
|
|
131
|
+
server,
|
|
132
|
+
token,
|
|
133
|
+
runnerId: profile.runnerId ?? "",
|
|
134
|
+
runnerName: profile.runnerName || hostname(),
|
|
135
|
+
workspaceId: profile.workspaceId ?? "",
|
|
136
|
+
// Written from what the server says this runner was registered for, once, at login. Nothing
|
|
137
|
+
// the server sends afterwards edits this file.
|
|
138
|
+
allowedHosts: normaliseHosts(profile.allowedHosts ?? ["localhost"]),
|
|
139
|
+
allowPrivateNetwork: true,
|
|
140
|
+
};
|
|
141
|
+
await writeCredentials(credentials);
|
|
142
|
+
|
|
143
|
+
const reporter = new TerminalReporter(process.stdout, token);
|
|
144
|
+
process.stdout.write(`\nConnected as ${credentials.runnerName}.\n`);
|
|
145
|
+
process.stdout.write(`Allowed here: ${credentials.allowedHosts.join(", ")}\n`);
|
|
146
|
+
reporter.privacyNote();
|
|
147
|
+
process.stdout.write("Run `aloud start` and leave it running.\n\n");
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function logout(): Promise<number> {
|
|
152
|
+
const removed = await clearCredentials();
|
|
153
|
+
process.stdout.write(removed ? `Forgot the token at ${credentialsPath()}.\n` : "There was nothing to forget.\n");
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* --------------------------------- status --------------------------------- */
|
|
158
|
+
|
|
159
|
+
async function status(): Promise<number> {
|
|
160
|
+
const credentials = await loadCredentials().catch((error: Error) => {
|
|
161
|
+
process.stderr.write(error.message + "\n");
|
|
162
|
+
return null;
|
|
163
|
+
});
|
|
164
|
+
const checks = await preflight();
|
|
165
|
+
const running = await readRunning();
|
|
166
|
+
|
|
167
|
+
process.stdout.write("\n");
|
|
168
|
+
if (!credentials) {
|
|
169
|
+
process.stdout.write("Signed in no. Run `aloud login`.\n");
|
|
170
|
+
} else {
|
|
171
|
+
process.stdout.write(`Signed in ${credentials.runnerName}\n`);
|
|
172
|
+
process.stdout.write(`Server ${credentials.server}\n`);
|
|
173
|
+
process.stdout.write(`Allowed here ${credentials.allowedHosts.join(", ") || "nothing yet"}\n`);
|
|
174
|
+
}
|
|
175
|
+
process.stdout.write(
|
|
176
|
+
`Chromium ${checks.chromiumInstalled ? `ready (${checks.chromiumPath})` : "not installed yet"}\n`,
|
|
177
|
+
);
|
|
178
|
+
process.stdout.write(
|
|
179
|
+
`Running ${running ? `yes, since ${when(running.startedAt)} (pid ${running.pid})` : "no. Run `aloud start`."}\n`,
|
|
180
|
+
);
|
|
181
|
+
process.stdout.write("\n");
|
|
182
|
+
|
|
183
|
+
// Zero means "a study started now would run here", which is the only question worth asking of
|
|
184
|
+
// this command. Signed in and installed but stopped is not ready, and used to exit 0 anyway.
|
|
185
|
+
return credentials && checks.chromiumInstalled && running ? 0 : 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* --------------------------------- allow --------------------------------- */
|
|
189
|
+
|
|
190
|
+
async function allow(argv: readonly string[]): Promise<number> {
|
|
191
|
+
const host = argv.find((arg) => !arg.startsWith("-"));
|
|
192
|
+
if (!host) {
|
|
193
|
+
process.stderr.write("Which host? For example: aloud allow staging.acme.com\n");
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const credentials = await readCredentials();
|
|
198
|
+
if (!credentials) {
|
|
199
|
+
process.stderr.write("Not signed in. Run `aloud login` first.\n");
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const next = normaliseHosts([...credentials.allowedHosts, host]);
|
|
204
|
+
await writeCredentials({ ...credentials, allowedHosts: next });
|
|
205
|
+
process.stdout.write(`\nStudies on this machine may now open: ${next.join(", ")}\n`);
|
|
206
|
+
process.stdout.write("Nothing the server sends can change that list. Only this command can.\n\n");
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* --------------------------------- start --------------------------------- */
|
|
211
|
+
|
|
212
|
+
async function start(argv: readonly string[]): Promise<number> {
|
|
213
|
+
const credentials = await loadCredentials().catch((error: Error) => {
|
|
214
|
+
process.stderr.write(error.message + "\n");
|
|
215
|
+
return null;
|
|
216
|
+
});
|
|
217
|
+
if (!credentials) {
|
|
218
|
+
process.stderr.write("Not signed in. Run `aloud login` first.\n");
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const reporter = new TerminalReporter(process.stdout, credentials.token, !argv.includes("--quiet"));
|
|
223
|
+
|
|
224
|
+
// Preflight runs before the first claim, deliberately. A missing Chromium makes
|
|
225
|
+
// `chromium.launch()` throw outside the session's own error handling, which takes the whole run
|
|
226
|
+
// down with a generic failure. That must not be anybody's first experience of the product.
|
|
227
|
+
const checks = await preflight();
|
|
228
|
+
if (!checks.chromiumInstalled) {
|
|
229
|
+
const installed = await installChromium((line) => process.stdout.write(line + "\n"));
|
|
230
|
+
if (!installed) {
|
|
231
|
+
process.stderr.write("\nCannot run studies without Chromium. Nothing was claimed.\n");
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const local = policyOf(credentials, argv);
|
|
237
|
+
if (local.allowedHosts.length === 0) {
|
|
238
|
+
process.stderr.write("This machine is not allowed to open anything. Try `aloud allow localhost`.\n");
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const client = new RunnerClient({
|
|
243
|
+
server: credentials.server,
|
|
244
|
+
token: credentials.token,
|
|
245
|
+
onConnectionChange: (state, detail) => reporter.connection(state, detail),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
reporter.header({
|
|
249
|
+
runnerName: credentials.runnerName,
|
|
250
|
+
workspace: credentials.workspaceId || "your workspace",
|
|
251
|
+
server: credentials.server,
|
|
252
|
+
allowedHosts: local.allowedHosts,
|
|
253
|
+
chromium: true,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const controller = new AbortController();
|
|
257
|
+
installSignalHandlers(controller, reporter);
|
|
258
|
+
|
|
259
|
+
await writeRunning({ pid: process.pid, startedAt: new Date().toISOString(), server: credentials.server });
|
|
260
|
+
// The `finally` below covers every ordinary ending. This covers the second Ctrl-C, which calls
|
|
261
|
+
// `process.exit` and unwinds nothing. Synchronous, because an exit handler cannot await.
|
|
262
|
+
process.on("exit", () => clearRunningSync());
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
await runLoop({
|
|
266
|
+
client,
|
|
267
|
+
local,
|
|
268
|
+
ui: reporter,
|
|
269
|
+
webUrl: `${credentials.server}/app`,
|
|
270
|
+
once: argv.includes("--once"),
|
|
271
|
+
signal: controller.signal,
|
|
272
|
+
});
|
|
273
|
+
return 0;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
process.stderr.write(`\n${(error as Error).message}\n`);
|
|
276
|
+
return 1;
|
|
277
|
+
} finally {
|
|
278
|
+
await clearRunning();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The exit-handler twin of `clearRunning`, which cannot await anything. */
|
|
283
|
+
function clearRunningSync(): void {
|
|
284
|
+
try {
|
|
285
|
+
const path = runningPath();
|
|
286
|
+
const state = JSON.parse(readFileSync(path, "utf8")) as { pid?: number };
|
|
287
|
+
if (state.pid === process.pid) unlinkSync(path);
|
|
288
|
+
} catch {
|
|
289
|
+
// Nothing to clean up, or nothing we are allowed to clean up. Either way `status` checks the
|
|
290
|
+
// pid rather than trusting the file, so a leftover record is not a wrong answer.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Ctrl-C, twice.
|
|
296
|
+
*
|
|
297
|
+
* The first asks the run to stop, so the lease is released and nobody is left waiting ninety
|
|
298
|
+
* seconds for a runner that is already gone. The second gives up on being polite. Playwright's own
|
|
299
|
+
* SIGINT handler is disabled at launch precisely so this one gets a chance to run at all.
|
|
300
|
+
*/
|
|
301
|
+
function installSignalHandlers(controller: AbortController, reporter: TerminalReporter): void {
|
|
302
|
+
let asked = false;
|
|
303
|
+
const onSignal = () => {
|
|
304
|
+
if (asked) {
|
|
305
|
+
reporter.note("Stopping now. Some browser windows may need closing by hand.");
|
|
306
|
+
process.exit(130);
|
|
307
|
+
}
|
|
308
|
+
asked = true;
|
|
309
|
+
reporter.note("Stopping. Closing browsers and telling the server this study did not finish.");
|
|
310
|
+
controller.abort();
|
|
311
|
+
};
|
|
312
|
+
process.on("SIGINT", onSignal);
|
|
313
|
+
process.on("SIGTERM", onSignal);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A start time someone can compare against their own clock, not an ISO string. */
|
|
317
|
+
function when(iso: string): string {
|
|
318
|
+
const at = new Date(iso);
|
|
319
|
+
return Number.isNaN(at.getTime()) ? "an unknown time" : at.toLocaleTimeString();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function stringOption(argv: readonly string[], name: string): string | undefined {
|
|
323
|
+
const index = argv.indexOf(name);
|
|
324
|
+
if (index === -1) return undefined;
|
|
325
|
+
const value = argv[index + 1];
|
|
326
|
+
return value && !value.startsWith("-") ? value : undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function numberOption(argv: readonly string[], name: string): number | null {
|
|
330
|
+
const raw = stringOption(argv, name);
|
|
331
|
+
if (raw === undefined) return null;
|
|
332
|
+
const value = Number(raw);
|
|
333
|
+
return Number.isFinite(value) ? value : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export { CredentialsError };
|
|
337
|
+
|
|
338
|
+
// Only when run directly, so importing this module from a test does not start a runner.
|
|
339
|
+
const entry = process.argv[1] ?? "";
|
|
340
|
+
if (entry.endsWith("cli.ts") || entry.endsWith("cli.js") || entry.endsWith("/aloud")) {
|
|
341
|
+
main().then(
|
|
342
|
+
(code) => process.exit(code),
|
|
343
|
+
(error: Error) => {
|
|
344
|
+
process.stderr.write("\n" + error.message + "\n");
|
|
345
|
+
process.exit(1);
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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 runner's credentials, at ~/.aloud/credentials.json, mode 0600.
|
|
8
|
+
*
|
|
9
|
+
* One path on all three platforms rather than XDG on Linux, Application Support on macOS, and
|
|
10
|
+
* %APPDATA% on Windows. It is easier to explain, easier to tell someone to delete, and it is what
|
|
11
|
+
* most developer CLIs already do.
|
|
12
|
+
*/
|
|
13
|
+
export interface Credentials {
|
|
14
|
+
/** Pinned. The token is never sent anywhere else, whatever a redirect suggests. */
|
|
15
|
+
server: string;
|
|
16
|
+
token: string;
|
|
17
|
+
runnerId: string;
|
|
18
|
+
runnerName: string;
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
/** The local allowlist. Written at login from what the person chose; the server cannot edit it. */
|
|
21
|
+
allowedHosts: string[];
|
|
22
|
+
allowPrivateNetwork: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class CredentialsError extends Error {
|
|
26
|
+
constructor(message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "CredentialsError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function credentialsPath(home = homedir()): string {
|
|
33
|
+
return join(home, ".aloud", "credentials.json");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reads the credentials, or returns null if there are none.
|
|
38
|
+
*
|
|
39
|
+
* Refuses a file other users can read. A token that grants a stranger the ability to run browsers
|
|
40
|
+
* on this machine should not be sitting at 0644 because an editor rewrote it.
|
|
41
|
+
*/
|
|
42
|
+
export async function readCredentials(path = credentialsPath()): Promise<Credentials | null> {
|
|
43
|
+
let raw: string;
|
|
44
|
+
try {
|
|
45
|
+
const info = await stat(path);
|
|
46
|
+
// eslint-disable-next-line no-bitwise
|
|
47
|
+
if ((info.mode & 0o077) !== 0) {
|
|
48
|
+
throw new CredentialsError(
|
|
49
|
+
`${path} can be read by other users on this machine. Fix it with:\n chmod 600 ${path}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
raw = await readFile(path, "utf8");
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error instanceof CredentialsError) throw error;
|
|
55
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let parsed: Partial<Credentials>;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(raw) as Partial<Credentials>;
|
|
62
|
+
} catch {
|
|
63
|
+
throw new CredentialsError(`${path} is not valid JSON. Delete it and run \`aloud login\` again.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!parsed.token || !parsed.server || !parsed.workspaceId) {
|
|
67
|
+
throw new CredentialsError(`${path} is missing fields. Delete it and run \`aloud login\` again.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
server: stripTrailingSlash(parsed.server),
|
|
72
|
+
token: parsed.token,
|
|
73
|
+
runnerId: parsed.runnerId ?? "",
|
|
74
|
+
runnerName: parsed.runnerName ?? "this machine",
|
|
75
|
+
workspaceId: parsed.workspaceId,
|
|
76
|
+
allowedHosts: parsed.allowedHosts ?? [],
|
|
77
|
+
allowPrivateNetwork: parsed.allowPrivateNetwork ?? true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Writes the credentials at 0600.
|
|
83
|
+
*
|
|
84
|
+
* `mode` on writeFile applies only when the file is *created*, so an existing 0644 file would stay
|
|
85
|
+
* 0644 and the mode argument would be silently ignored. The chmod afterwards is the part that
|
|
86
|
+
* actually holds.
|
|
87
|
+
*/
|
|
88
|
+
export async function writeCredentials(credentials: Credentials, path = credentialsPath()): Promise<void> {
|
|
89
|
+
const directory = dirname(path);
|
|
90
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
91
|
+
await writeFile(path, JSON.stringify(credentials, null, 2) + "\n", { mode: 0o600 });
|
|
92
|
+
await chmod(path, 0o600);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function clearCredentials(path = credentialsPath()): Promise<boolean> {
|
|
96
|
+
try {
|
|
97
|
+
await writeFile(path, "", { mode: 0o600, flag: constants.O_WRONLY | constants.O_TRUNC });
|
|
98
|
+
const { unlink } = await import("node:fs/promises");
|
|
99
|
+
await unlink(path);
|
|
100
|
+
return true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Credentials from the environment, for CI, where writing a token to disk is the wrong shape.
|
|
109
|
+
*
|
|
110
|
+
* Requires the server too: a token with no pinned origin is a token that will go wherever it is
|
|
111
|
+
* pointed.
|
|
112
|
+
*/
|
|
113
|
+
export function credentialsFromEnv(env: NodeJS.ProcessEnv = process.env): Credentials | null {
|
|
114
|
+
const token = env.ALOUD_RUNNER_TOKEN?.trim();
|
|
115
|
+
if (!token) return null;
|
|
116
|
+
const server = env.ALOUD_SERVER?.trim();
|
|
117
|
+
if (!server) {
|
|
118
|
+
throw new CredentialsError("ALOUD_RUNNER_TOKEN is set but ALOUD_SERVER is not. Set both, or neither.");
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
server: stripTrailingSlash(server),
|
|
122
|
+
token,
|
|
123
|
+
runnerId: "",
|
|
124
|
+
runnerName: env.ALOUD_RUNNER_NAME?.trim() || "ci",
|
|
125
|
+
workspaceId: env.ALOUD_WORKSPACE_ID?.trim() || "",
|
|
126
|
+
allowedHosts: (env.ALOUD_ALLOWED_HOSTS ?? "").split(",").map((h) => h.trim()).filter(Boolean),
|
|
127
|
+
allowPrivateNetwork: env.ALOUD_ALLOW_PRIVATE_NETWORK !== "false",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* SPEC 20.4, extended to the runner's own credential.
|
|
133
|
+
*
|
|
134
|
+
* Everything the runner prints or logs goes through here, so a token cannot reach a terminal
|
|
135
|
+
* transcript that someone pastes into a bug report.
|
|
136
|
+
*/
|
|
137
|
+
export function scrubToken(text: string, token?: string | null): string {
|
|
138
|
+
let out = token && token.length >= 8 ? text.split(token).join("[redacted]") : text;
|
|
139
|
+
// Also catches a token from some other session that happens to be in the text.
|
|
140
|
+
out = out.replace(/utar_[A-Za-z0-9_-]{8,}/g, "[redacted]");
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function stripTrailingSlash(value: string): string {
|
|
145
|
+
return value.replace(/\/+$/, "");
|
|
146
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { normaliseHosts } from "@aloud/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The allowlist that lives on this machine, and that the server cannot change.
|
|
5
|
+
*
|
|
6
|
+
* This is the thing that makes the Phase 5 exit criterion true rather than merely intended. The
|
|
7
|
+
* control plane already intersects the study's hosts with the runner's registered hosts before
|
|
8
|
+
* issuing a lease, which is a good invariant against a *confused* server. It is no defence against
|
|
9
|
+
* a *compromised* one: a compromised server simply returns a lease saying `allowedHosts:
|
|
10
|
+
* ["192.168.1.1"]` and asserts it did the intersection.
|
|
11
|
+
*
|
|
12
|
+
* So the runner keeps its own copy on disk and intersects again locally. Nothing the server sends
|
|
13
|
+
* ever edits this file.
|
|
14
|
+
*/
|
|
15
|
+
export interface LocalPolicy {
|
|
16
|
+
/** Hosts this machine will open. Written at login, changed only by `aloud allow`. */
|
|
17
|
+
allowedHosts: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Whether private and loopback addresses may be opened at all.
|
|
20
|
+
*
|
|
21
|
+
* Testing localhost is the entire point of running here, so this defaults to true. It is a
|
|
22
|
+
* separate switch from the allowlist because turning it off should be one decision, not a
|
|
23
|
+
* per-host audit.
|
|
24
|
+
*/
|
|
25
|
+
allowPrivateNetwork: boolean;
|
|
26
|
+
/** The server does not know how much memory this machine has. This is a local decision. */
|
|
27
|
+
maxConcurrentSessions: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_MAX_CONCURRENT_SESSIONS = 3;
|
|
31
|
+
|
|
32
|
+
export function policyFrom(input: {
|
|
33
|
+
allowedHosts?: readonly string[];
|
|
34
|
+
allowPrivateNetwork?: boolean;
|
|
35
|
+
maxConcurrentSessions?: number;
|
|
36
|
+
}): LocalPolicy {
|
|
37
|
+
return {
|
|
38
|
+
allowedHosts: normaliseHosts(input.allowedHosts ?? []),
|
|
39
|
+
allowPrivateNetwork: input.allowPrivateNetwork ?? true,
|
|
40
|
+
// Clamped rather than trusted: three browsers is already a lot on a laptop, and a typo of 300
|
|
41
|
+
// should not take the machine down.
|
|
42
|
+
maxConcurrentSessions: clamp(input.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS, 1, 8),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clamp(value: number, low: number, high: number): number {
|
|
47
|
+
if (!Number.isFinite(value)) return low;
|
|
48
|
+
return Math.min(high, Math.max(low, Math.floor(value)));
|
|
49
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether a runner is running on this machine, recorded where `aloud status` can read it.
|
|
7
|
+
*
|
|
8
|
+
* `status` used to end with a flat "Not running." on every invocation, because it never looked:
|
|
9
|
+
* it checked the credentials file and the Chromium binary, both of which say what is *installed*,
|
|
10
|
+
* and then guessed at the one thing it was actually asked. That line had to be explained away
|
|
11
|
+
* everywhere status was recommended, which is the tell that the command was wrong rather than the
|
|
12
|
+
* documentation.
|
|
13
|
+
*
|
|
14
|
+
* A file next to the credentials is enough. The runner is a foreground process on the same machine
|
|
15
|
+
* as the person asking, so there is nothing to discover over a network: `start` writes its pid here
|
|
16
|
+
* and removes it on the way out, and `status` asks the operating system whether that pid is still
|
|
17
|
+
* alive.
|
|
18
|
+
*/
|
|
19
|
+
export interface RunningState {
|
|
20
|
+
pid: number;
|
|
21
|
+
startedAt: string;
|
|
22
|
+
server: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runningPath(home = homedir()): string {
|
|
26
|
+
return join(home, ".aloud", "running.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function writeRunning(state: RunningState, path = runningPath()): Promise<void> {
|
|
30
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
31
|
+
await writeFile(path, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What is running here, or null.
|
|
36
|
+
*
|
|
37
|
+
* A crash leaves the file behind, so the file alone is not the answer: the pid is checked with
|
|
38
|
+
* signal 0, which tests for the process without touching it. A stale record is deleted on the way
|
|
39
|
+
* past, so this heals rather than accumulating.
|
|
40
|
+
*
|
|
41
|
+
* The one thing this cannot rule out is the operating system having reused that pid for something
|
|
42
|
+
* unrelated, which would read as a runner that is not there. It is a narrow window on a number that
|
|
43
|
+
* counts to 99998 before it wraps, and the cost of being wrong is a status line, so it is not worth
|
|
44
|
+
* platform-specific process inspection to close.
|
|
45
|
+
*/
|
|
46
|
+
export async function readRunning(path = runningPath()): Promise<RunningState | null> {
|
|
47
|
+
let raw: string;
|
|
48
|
+
try {
|
|
49
|
+
raw = await readFile(path, "utf8");
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let state: Partial<RunningState>;
|
|
55
|
+
try {
|
|
56
|
+
state = JSON.parse(raw) as Partial<RunningState>;
|
|
57
|
+
} catch {
|
|
58
|
+
await unlink(path).catch(() => {});
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof state.pid !== "number" || !alive(state.pid)) {
|
|
63
|
+
await unlink(path).catch(() => {});
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
pid: state.pid,
|
|
69
|
+
startedAt: state.startedAt ?? "",
|
|
70
|
+
server: state.server ?? "",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Removes the record, but only if it is ours. A second runner's pid is not ours to forget. */
|
|
75
|
+
export async function clearRunning(pid = process.pid, path = runningPath()): Promise<void> {
|
|
76
|
+
try {
|
|
77
|
+
const raw = await readFile(path, "utf8");
|
|
78
|
+
const state = JSON.parse(raw) as Partial<RunningState>;
|
|
79
|
+
if (state.pid !== pid) return;
|
|
80
|
+
} catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await unlink(path).catch(() => {});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function alive(pid: number): boolean {
|
|
87
|
+
try {
|
|
88
|
+
// Signal 0 performs the permission and existence checks without delivering anything.
|
|
89
|
+
process.kill(pid, 0);
|
|
90
|
+
return true;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// EPERM means it exists and belongs to someone else, which still counts as alive.
|
|
93
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
94
|
+
}
|
|
95
|
+
}
|