@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.
@@ -0,0 +1,93 @@
1
+ import {
2
+ evaluateTargetUrl,
3
+ intersectHosts,
4
+ leasePermits,
5
+ normaliseHosts,
6
+ type JobLease,
7
+ type StudySnapshot,
8
+ } from "@aloud/core";
9
+ import type { LocalPolicy } from "../config/policy";
10
+
11
+ /**
12
+ * The security core of the runner.
13
+ *
14
+ * `RunCoordinator` passes `snapshot.environment.allowedDomains`,
15
+ * `snapshot.environment.actionPolicy.blockOffDomainNavigation` and `snapshot.environment.type`
16
+ * straight into `workers.create`. All three arrive from the server. Handing the raw snapshot to the
17
+ * coordinator would therefore let a compromised control plane point a browser on this machine at
18
+ * anything it liked, which is the one thing this whole architecture exists to prevent.
19
+ *
20
+ * So nothing from the wire decides what may be opened. The effective allowlist is the intersection
21
+ * of what the lease granted and what this machine's own policy permits, and off-domain navigation
22
+ * is forced on whatever the snapshot said.
23
+ */
24
+ export class SnapshotRefused extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "SnapshotRefused";
28
+ }
29
+ }
30
+
31
+ export interface SanitisedSnapshot {
32
+ snapshot: StudySnapshot;
33
+ allowedHosts: string[];
34
+ }
35
+
36
+ export async function sanitiseSnapshot(
37
+ snapshot: StudySnapshot,
38
+ lease: JobLease,
39
+ local: LocalPolicy,
40
+ nowIso: string,
41
+ /** Injected only by tests. DNS is re-resolved on every check, which closes the rebinding window. */
42
+ resolver?: (hostname: string) => Promise<string[]>,
43
+ ): Promise<SanitisedSnapshot> {
44
+ const granted = normaliseHosts(lease.allowedHosts);
45
+ const permitted = normaliseHosts(local.allowedHosts);
46
+
47
+ // The server already did this intersection. Doing it again here is the point: a server that got
48
+ // it wrong, or lied about it, changes nothing on this machine.
49
+ const effective = intersectHosts(granted, permitted);
50
+ if (effective.length === 0) {
51
+ throw new SnapshotRefused(
52
+ `This study wants ${granted.join(", ") || "nothing"}, and this machine allows ` +
53
+ `${permitted.join(", ") || "nothing"}. Nothing in common, so nothing will be opened. ` +
54
+ "Add a host with `aloud allow <host>` if that is wrong.",
55
+ );
56
+ }
57
+
58
+ const startUrl = snapshot.study?.startUrl;
59
+ if (typeof startUrl !== "string" || startUrl.length === 0) {
60
+ throw new SnapshotRefused("This study has no start URL.");
61
+ }
62
+
63
+ // Checked against the *effective* list, not the one the lease claimed.
64
+ const verdict = leasePermits({ ...lease, allowedHosts: effective }, startUrl, nowIso);
65
+ if (!verdict.allowed) throw new SnapshotRefused(verdict.reason);
66
+
67
+ // And then the same check the browser worker will make, so a refusal happens before anything is
68
+ // launched rather than three browsers in. Cloud metadata endpoints are refused unconditionally
69
+ // in here, regardless of the private-network setting.
70
+ const target = await evaluateTargetUrl(startUrl, {
71
+ allowedDomains: effective,
72
+ allowPrivateNetwork: local.allowPrivateNetwork,
73
+ ...(resolver ? { resolver } : {}),
74
+ });
75
+ if (!target.allowed) throw new SnapshotRefused(target.reason);
76
+
77
+ return {
78
+ allowedHosts: effective,
79
+ snapshot: {
80
+ ...snapshot,
81
+ environment: {
82
+ ...snapshot.environment,
83
+ allowedDomains: effective,
84
+ actionPolicy: {
85
+ ...snapshot.environment.actionPolicy,
86
+ // Forced on. A snapshot that asked for it to be off is exactly the snapshot that must
87
+ // not get it: without this a single redirect walks the browser off the allowlist.
88
+ blockOffDomainNavigation: true,
89
+ },
90
+ },
91
+ } as StudySnapshot,
92
+ };
93
+ }
@@ -0,0 +1,185 @@
1
+ import { scrubToken } from "../config/credentials";
2
+ import type { RunReporter } from "../run/execute";
3
+
4
+ const CSI = String.fromCharCode(27) + "[";
5
+ const RESET = CSI + "0m";
6
+ const BOLD = CSI + "1m";
7
+ const DIM = CSI + "2m";
8
+ const GREEN = CSI + "32m";
9
+ const YELLOW = CSI + "33m";
10
+
11
+ export interface Writer {
12
+ write(text: string): void;
13
+ }
14
+
15
+ /**
16
+ * Terminal output, in the same plain register the rest of the product uses.
17
+ *
18
+ * Every write goes through `scrubToken`, so a transcript someone pastes into a bug report cannot
19
+ * carry a working runner credential.
20
+ */
21
+ export class TerminalReporter implements RunReporter {
22
+ private readonly lines = new Map<string, string>();
23
+ private readonly order: string[] = [];
24
+ private rendered = 0;
25
+
26
+ constructor(
27
+ private readonly out: Writer = process.stdout,
28
+ private readonly token: string | null = null,
29
+ private readonly interactive = Boolean(process.stdout.isTTY),
30
+ ) {}
31
+
32
+ private say(text: string): void {
33
+ this.out.write(scrubToken(text, this.token) + "\n");
34
+ }
35
+
36
+ header(input: { runnerName: string; workspace: string; server: string; allowedHosts: string[]; chromium: boolean }): void {
37
+ this.say("");
38
+ this.say(`${BOLD}Aloud runner${RESET} ${DIM}${input.runnerName} - ${input.workspace}${RESET}`);
39
+ this.say("");
40
+ this.say(` Chromium ${input.chromium ? GREEN + "ready" + RESET : YELLOW + "not installed yet" + RESET}`);
41
+ this.say(` Connection ${input.server}`);
42
+ this.say(` Allowed here ${input.allowedHosts.join(", ") || "nothing yet - run `aloud allow <host>`"}`);
43
+ this.say("");
44
+ }
45
+
46
+ waiting(url: string): void {
47
+ this.say(`Waiting for a study. Start one at ${url}`);
48
+ this.say(`${DIM}Press Ctrl-C to stop.${RESET}`);
49
+ }
50
+
51
+ studyStarted(input: { name: string; goal: string; startUrl: string; participants: number; runId: string }): void {
52
+ this.lines.clear();
53
+ this.order.length = 0;
54
+ this.rendered = 0;
55
+ this.say("");
56
+ this.say(`${BOLD}Study: ${input.name}${RESET}`);
57
+ this.say(`${DIM}${input.goal}${RESET}`);
58
+ this.say(`Target: ${input.startUrl}`);
59
+ this.say(`${input.participants} participants, running here on this machine.`);
60
+ this.say("");
61
+ }
62
+
63
+ sessionProgress(personaName: string, detail: string): void {
64
+ this.set(personaName, detail);
65
+ }
66
+
67
+ sessionFinished(personaName: string, detail: string): void {
68
+ this.set(personaName, detail, true);
69
+ }
70
+
71
+ private set(name: string, detail: string, done = false): void {
72
+ if (!this.lines.has(name)) this.order.push(name);
73
+ this.lines.set(name, ` ${name.padEnd(12)}${done ? GREEN + detail + RESET : DIM + detail + RESET}`);
74
+ this.repaint();
75
+ }
76
+
77
+ /**
78
+ * Rewrites the progress block in place on a terminal, and appends on anything else.
79
+ *
80
+ * Appending into a CI log or a pipe is the right behaviour there; cursor movement would turn it
81
+ * into a wall of escape codes.
82
+ */
83
+ private repaint(): void {
84
+ if (!this.interactive) {
85
+ const last = this.order[this.order.length - 1];
86
+ if (last) this.say(this.lines.get(last)!);
87
+ return;
88
+ }
89
+ if (this.rendered > 0) this.out.write(CSI + this.rendered + "A" + CSI + "0J");
90
+ for (const name of this.order) this.say(this.lines.get(name)!);
91
+ this.rendered = this.order.length;
92
+ }
93
+
94
+ synthesising(): void {
95
+ this.rendered = 0;
96
+ this.say("");
97
+ this.say("Writing up what happened...");
98
+ }
99
+
100
+ finished(summary: { participants: number; succeeded: number; costCents: number; runId: string }): void {
101
+ this.rendered = 0;
102
+ this.say("");
103
+ this.say(
104
+ `${GREEN}Done.${RESET} ${summary.succeeded} of ${summary.participants} participants reached the goal.`,
105
+ );
106
+ this.say(`Spent ${summary.costCents} cents.`);
107
+ this.say("");
108
+ }
109
+
110
+ failed(reason: string): void {
111
+ this.rendered = 0;
112
+ this.say("");
113
+ this.say(`${YELLOW}This study did not finish.${RESET} ${reason}`);
114
+ this.say(`${DIM}Anything the participants did before this has been saved.${RESET}`);
115
+ this.say("");
116
+ }
117
+
118
+ note(message: string): void {
119
+ this.rendered = 0;
120
+ this.say(`${DIM} ${message}${RESET}`);
121
+ }
122
+
123
+ connection(state: "lost" | "restored", detail: string): void {
124
+ this.rendered = 0;
125
+ if (state === "lost") {
126
+ this.say("");
127
+ this.say(`${YELLOW} waiting - no connection to the server${RESET}`);
128
+ this.say(`${DIM} The browsers are paused, not lost. I will pick up where I left off when the`);
129
+ this.say(` connection comes back, or stop after a minute.${RESET}`);
130
+ return;
131
+ }
132
+ this.say(`${GREEN} Connection is back. Carrying on.${RESET}`);
133
+ void detail;
134
+ }
135
+
136
+ /**
137
+ * The one thing a security-minded person deserves to be told, once.
138
+ *
139
+ * What genuinely never happens is our servers dialling into their network. What does happen is
140
+ * that screenshots leave this machine, because that is what the report is made of. Saying only
141
+ * the first half would be true and misleading.
142
+ */
143
+ privacyNote(): void {
144
+ this.say("");
145
+ this.say(`${BOLD}One thing worth knowing.${RESET}`);
146
+ this.say("");
147
+ this.say("The browsers run here, on this machine, so we can test localhost and");
148
+ this.say("anything behind your firewall. Our servers never connect to you.");
149
+ this.say("");
150
+ this.say("But the participants' screenshots do get sent to us, because that is");
151
+ this.say("what the report is built from. If you point this at something you would");
152
+ this.say("not screenshot and email, don't.");
153
+ this.say("");
154
+ }
155
+ }
156
+
157
+ /** For tests and for `--quiet`: records everything, prints nothing. */
158
+ export class SilentReporter implements RunReporter {
159
+ readonly messages: string[] = [];
160
+ studyStarted(input: { name: string }): void {
161
+ this.messages.push(`study:${input.name}`);
162
+ }
163
+ sessionProgress(name: string, detail: string): void {
164
+ this.messages.push(`progress:${name}:${detail}`);
165
+ }
166
+ sessionFinished(name: string, detail: string): void {
167
+ this.messages.push(`finished:${name}:${detail}`);
168
+ }
169
+ synthesising(): void {
170
+ this.messages.push("synthesising");
171
+ }
172
+ finished(summary: { succeeded: number; participants: number }): void {
173
+ this.messages.push(`done:${summary.succeeded}/${summary.participants}`);
174
+ }
175
+ failed(reason: string): void {
176
+ this.messages.push(`failed:${reason}`);
177
+ }
178
+ note(message: string): void {
179
+ this.messages.push(`note:${message}`);
180
+ }
181
+ connection(state: string, detail: string): void {
182
+ this.messages.push(`connection:${state}`);
183
+ void detail;
184
+ }
185
+ }