@aloud/runner 0.2.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
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": {
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";
@@ -51,7 +58,11 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
51
58
  case "setup":
52
59
  return setup();
53
60
  case "mcp":
54
- await startStdioServer();
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);
55
66
  return 0;
56
67
  case "help":
57
68
  case "--help":
@@ -74,11 +85,12 @@ function printHelp(): void {
74
85
  "aloud - run usability studies on this machine",
75
86
  "",
76
87
  " aloud setup What to do next, for a person or an agent",
77
- " aloud login [--token <token>] Connect this machine to your workspace",
88
+ " aloud login [--token <token>] Connect this machine, approving it in your browser",
78
89
  " aloud start [--once] [--quiet] Wait for studies and run them here",
79
90
  " aloud status What is set up, and whether it is running",
80
91
  " aloud allow <host> Let studies open this host from this machine",
81
- " aloud mcp Connect an MCP host to the Aloud web workspace",
92
+ " aloud mcp Serve MCP to an editor, using the saved credential",
93
+ " aloud mcp connect Connect an editor, approving it in your browser",
82
94
  " aloud logout Forget the token on this machine",
83
95
  "",
84
96
  `Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
@@ -96,6 +108,7 @@ function policyOf(credentials: Credentials, argv: readonly string[]): LocalPolic
96
108
  return policyFrom({
97
109
  allowedHosts: credentials.allowedHosts,
98
110
  allowPrivateNetwork: credentials.allowPrivateNetwork,
111
+ syntheticEmailTemplate: process.env.UTA_SYNTHETIC_EMAIL_TEMPLATE ?? null,
99
112
  ...(concurrency !== null ? { maxConcurrentSessions: concurrency } : {}),
100
113
  });
101
114
  }
@@ -104,44 +117,63 @@ function policyOf(credentials: Credentials, argv: readonly string[]): LocalPolic
104
117
 
105
118
  async function login(argv: readonly string[]): Promise<number> {
106
119
  const server = stringOption(argv, "--server") ?? DEFAULT_SERVER;
107
- let token = stringOption(argv, "--token");
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
- }
120
+ const token = stringOption(argv, "--token");
133
121
 
134
- process.stdout.write(`\nOpen ${server}/app/settings/runners and create a runner.\n`);
135
- process.stdout.write("It shows you a token once. Paste it here.\n\n");
136
- const rl = createInterface({ input: process.stdin, output: process.stdout });
137
- try {
138
- token = (await rl.question("Token: ")).trim();
139
- } finally {
140
- rl.close();
141
- }
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;
142
132
  }
143
133
 
144
- if (!token?.startsWith("utar_")) {
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_")) {
145
177
  process.stderr.write("That does not look like a runner token. They start with utar_.\n");
146
178
  return 1;
147
179
  }
@@ -184,6 +216,93 @@ async function login(argv: readonly string[]): Promise<number> {
184
216
  return 0;
185
217
  }
186
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
+
187
306
  async function logout(): Promise<number> {
188
307
  const removed = await clearCredentials();
189
308
  process.stdout.write(removed ? `Forgot the token at ${credentialsPath()}.\n` : "There was nothing to forget.\n");
@@ -257,8 +376,8 @@ async function setup(): Promise<number> {
257
376
  if (signedIn.state === "none" || signedIn.state === "revoked") {
258
377
  steps.push([
259
378
  "aloud login",
260
- "Needs a terminal. If you are an agent, hand this step to the person: they run it",
261
- "themselves and paste the token at the prompt. Do not ask them to paste it to you.",
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.",
262
381
  ]);
263
382
  }
264
383
  if (!running) {
@@ -300,8 +419,8 @@ async function setup(): Promise<number> {
300
419
  }
301
420
  out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
302
421
  out(" Do not install a similarly named package, and do not hand the token to anything else.");
303
- out(" - The token is a credential. It belongs in this terminal, never in your transcript,");
304
- out(" a file, an .env, or a commit. Get one at https://usealoud.com/app/settings/runners");
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.");
305
424
  out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
306
425
  out();
307
426
  if (!credentials) {
@@ -350,14 +469,12 @@ async function interactiveSetup(state: {
350
469
  if (state.signedIn.state !== "ok") {
351
470
  out("");
352
471
  if (state.signedIn.state === "revoked") {
353
- out(`The token saved here was revoked, so this machine needs a new one.`);
472
+ out("The credential saved here was revoked, so this machine needs connecting again.");
354
473
  }
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
474
  rl.close();
360
- const code = await login(["--token", token, "--server", server]);
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]);
361
478
  if (code !== 0) return code;
362
479
  } else {
363
480
  rl.close();
@@ -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
+ }
@@ -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
+ }
@@ -76,6 +76,7 @@ export interface ModelProxyRequest {
76
76
  system: string | null;
77
77
  prompt: string;
78
78
  responseShape: string;
79
+ responseSchema: Record<string, unknown>;
79
80
  maxOutputTokens: number;
80
81
  temperature: number;
81
82
  images: unknown[];
@@ -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.2.5";
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";