@aloud/runner 0.2.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.2.0",
3
+ "version": "0.2.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": {
@@ -21,14 +21,17 @@
21
21
  "files": ["dist/cli.js", "src", "README.md"],
22
22
  "engines": { "node": ">=20" },
23
23
  "scripts": {
24
- "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --external:playwright --external:sharp --outfile=dist/cli.js",
24
+ "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --external:playwright --external:sharp --external:@modelcontextprotocol/sdk --external:zod --outfile=dist/cli.js",
25
25
  "prepack": "npm run build"
26
26
  },
27
27
  "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.30.0",
28
29
  "playwright": "^1.62.1",
29
- "sharp": "^0.35.3"
30
+ "sharp": "^0.35.3",
31
+ "zod": "^4.4.3"
30
32
  },
31
33
  "devDependencies": {
34
+ "@aloud/mcp": "*",
32
35
  "@aloud/core": "*",
33
36
  "@aloud/engine": "*",
34
37
  "@aloud/eval": "*",
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import { RunnerClient } from "./protocol/client";
25
25
  import { installChromium, preflight } from "./preflight";
26
26
  import { TerminalReporter } from "./ui/output";
27
27
  import { runLoop } from "./loop";
28
+ import { startStdioServer } from "@aloud/mcp";
28
29
 
29
30
  /** Where a runner reaches the hosted control plane. Overridable for self-hosting and for tests. */
30
31
  const DEFAULT_SERVER = process.env.ALOUD_SERVER?.replace(/\/+$/, "") || "https://usealoud.com";
@@ -43,6 +44,9 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
43
44
  return status();
44
45
  case "allow":
45
46
  return allow(rest);
47
+ case "mcp":
48
+ await startStdioServer();
49
+ return 0;
46
50
  case "help":
47
51
  case "--help":
48
52
  case "-h":
@@ -67,6 +71,7 @@ function printHelp(): void {
67
71
  " aloud start [--once] [--quiet] Wait for studies and run them here",
68
72
  " aloud status What is set up, and whether it is running",
69
73
  " aloud allow <host> Let studies open this host from this machine",
74
+ " aloud mcp Connect an MCP host to the Aloud web workspace",
70
75
  " aloud logout Forget the token on this machine",
71
76
  "",
72
77
  `Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
@@ -0,0 +1,231 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chromium, type BrowserContext } from "playwright";
3
+ import {
4
+ VIEWPORT_PRESETS,
5
+ evaluateTargetUrl,
6
+ hostPermitted,
7
+ intersectHosts,
8
+ type DeviceContext,
9
+ type DiscoveryLink,
10
+ type DiscoveryPage,
11
+ type ProductDiscoveryEvidence,
12
+ type StudySetupJob,
13
+ } from "@aloud/core";
14
+ import type { LocalPolicy } from "./config/policy";
15
+
16
+ const MAX_PAGES = 6;
17
+ const MAX_LINKS_PER_PAGE = 200;
18
+ const PAGE_TIMEOUT_MS = 15_000;
19
+
20
+ /**
21
+ * Reads a small, public-facing corpus without clicking controls, submitting forms, or carrying a
22
+ * signed-in browser profile. The server can narrow this job, but only the on-disk policy can grant
23
+ * this machine access to a host.
24
+ */
25
+ export async function discoverProduct(
26
+ job: StudySetupJob,
27
+ local: LocalPolicy,
28
+ ): Promise<ProductDiscoveryEvidence> {
29
+ const effectiveHosts = intersectHosts(job.allowedHosts, local.allowedHosts);
30
+ if (effectiveHosts.length === 0) {
31
+ throw new Error(
32
+ `Product discovery wants ${job.allowedHosts.join(", ")}, but this machine allows ` +
33
+ `${local.allowedHosts.join(", ") || "nothing"}.`,
34
+ );
35
+ }
36
+ await assertTarget(job.url, effectiveHosts, local);
37
+
38
+ const browser = await chromium.launch({
39
+ args: ["--disable-dev-shm-usage"],
40
+ handleSIGINT: false,
41
+ handleSIGTERM: false,
42
+ handleSIGHUP: false,
43
+ });
44
+ const viewport = VIEWPORT_PRESETS[job.device as DeviceContext];
45
+ const context = await browser.newContext({
46
+ viewport: { width: viewport.width, height: viewport.height },
47
+ deviceScaleFactor: viewport.deviceScaleFactor,
48
+ isMobile: viewport.isMobile,
49
+ hasTouch: viewport.isMobile,
50
+ serviceWorkers: "block",
51
+ });
52
+
53
+ try {
54
+ await guardRequests(context, effectiveHosts, local);
55
+ const start = canonicalUrl(job.url);
56
+ const origin = new URL(start).origin;
57
+ const queued = [start];
58
+ const seen = new Set<string>();
59
+ const pages: DiscoveryPage[] = [];
60
+
61
+ while (queued.length > 0 && pages.length < MAX_PAGES) {
62
+ const url = queued.shift()!;
63
+ if (seen.has(url)) continue;
64
+ seen.add(url);
65
+ try {
66
+ await assertTarget(url, effectiveHosts, local);
67
+ const page = await context.newPage();
68
+ try {
69
+ page.setDefaultTimeout(PAGE_TIMEOUT_MS);
70
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: PAGE_TIMEOUT_MS });
71
+ try {
72
+ await page.waitForLoadState("networkidle", { timeout: 1_500 });
73
+ } catch {
74
+ // Some products poll forever. The DOM is still useful after DOMContentLoaded.
75
+ }
76
+ await assertTarget(page.url(), effectiveHosts, local);
77
+ const captured = await extractPage(page);
78
+ pages.push(captured);
79
+
80
+ const candidates = prioritiseDiscoveryLinks(captured.links, origin)
81
+ .map((link) => canonicalUrl(link.href))
82
+ .filter((href) => !seen.has(href) && !queued.includes(href));
83
+ queued.push(...candidates.slice(0, MAX_PAGES - pages.length));
84
+ } finally {
85
+ await page.close();
86
+ }
87
+ } catch (error) {
88
+ if (pages.length === 0) throw error;
89
+ // A stale marketing link should not erase the useful pages already captured.
90
+ }
91
+ }
92
+
93
+ if (pages.length === 0) throw new Error("Product discovery could not read the starting page.");
94
+ const capturedAt = new Date().toISOString();
95
+ const sourceFingerprint = createHash("sha256")
96
+ .update(JSON.stringify(pages.map(({ url, title, headings, visibleText }) => ({ url, title, headings, visibleText }))))
97
+ .digest("hex");
98
+ return { pages, sourceFingerprint, capturedAt };
99
+ } finally {
100
+ await context.close();
101
+ await browser.close();
102
+ }
103
+ }
104
+
105
+ async function assertTarget(url: string, allowedDomains: string[], local: LocalPolicy): Promise<void> {
106
+ const verdict = await evaluateTargetUrl(url, {
107
+ allowedDomains,
108
+ allowPrivateNetwork: local.allowPrivateNetwork,
109
+ });
110
+ if (!verdict.allowed) throw new Error(`Refusing product discovery: ${verdict.reason}`);
111
+ }
112
+
113
+ async function guardRequests(
114
+ context: BrowserContext,
115
+ allowedDomains: string[],
116
+ local: LocalPolicy,
117
+ ): Promise<void> {
118
+ await context.route("**/*", async (route) => {
119
+ const request = route.request();
120
+ let url: URL;
121
+ try {
122
+ url = new URL(request.url());
123
+ } catch {
124
+ await route.abort("blockedbyclient");
125
+ return;
126
+ }
127
+ if (url.protocol === "data:" || url.protocol === "blob:") {
128
+ await route.continue();
129
+ return;
130
+ }
131
+ if (
132
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
133
+ !hostPermitted(url.hostname, allowedDomains)
134
+ ) {
135
+ await route.abort("blockedbyclient");
136
+ return;
137
+ }
138
+ if (request.resourceType() === "document") {
139
+ const verdict = await evaluateTargetUrl(url.toString(), {
140
+ allowedDomains,
141
+ allowPrivateNetwork: local.allowPrivateNetwork,
142
+ });
143
+ if (!verdict.allowed) {
144
+ await route.abort("blockedbyclient");
145
+ return;
146
+ }
147
+ }
148
+ await route.continue();
149
+ });
150
+ }
151
+
152
+ async function extractPage(page: import("playwright").Page): Promise<DiscoveryPage> {
153
+ // Pass source text rather than a closure here. TypeScript runners may decorate nested
154
+ // functions with module-local helpers that do not exist inside the browser realm.
155
+ const extracted = await page.evaluate(`(() => {
156
+ const isVisible = (element) => {
157
+ const rect = element.getBoundingClientRect();
158
+ const style = getComputedStyle(element);
159
+ return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden";
160
+ };
161
+ const headings = [];
162
+ for (const element of document.querySelectorAll("h1,h2,h3")) {
163
+ if (!isVisible(element)) continue;
164
+ const text = (element.innerText || "").replace(/\\s+/g, " ").trim();
165
+ if (text) headings.push(text);
166
+ }
167
+ const links = [];
168
+ for (const element of document.querySelectorAll("a[href]")) {
169
+ if (!isVisible(element)) continue;
170
+ const href = element.href;
171
+ if (!href.startsWith("http://") && !href.startsWith("https://")) continue;
172
+ links.push({ href, text: (element.innerText || "").replace(/\\s+/g, " ").trim() });
173
+ }
174
+ return {
175
+ title: document.title || "",
176
+ headings,
177
+ visibleText: (document.body?.innerText || "").replace(/\\s+/g, " ").trim(),
178
+ links,
179
+ };
180
+ })()`) as {
181
+ title: string;
182
+ headings: string[];
183
+ visibleText: string;
184
+ links: DiscoveryLink[];
185
+ };
186
+ return {
187
+ url: canonicalUrl(page.url()),
188
+ title: extracted.title.slice(0, 300),
189
+ headings: [...new Set(extracted.headings)].slice(0, 80).map((heading) => heading.slice(0, 300)),
190
+ visibleText: extracted.visibleText.slice(0, 30_000),
191
+ links: extracted.links.slice(0, MAX_LINKS_PER_PAGE).map((link) => ({
192
+ href: link.href,
193
+ text: link.text.slice(0, 240),
194
+ })),
195
+ };
196
+ }
197
+
198
+ /** Stable and exported so corpus selection can be unit-tested without launching Chromium. */
199
+ export function prioritiseDiscoveryLinks(links: DiscoveryLink[], origin: string): DiscoveryLink[] {
200
+ const scores = [
201
+ [/\b(pricing|plans?)\b/i, 100],
202
+ [/\b(features?|product)\b/i, 95],
203
+ [/\b(use[- ]?cases?|solutions?)\b/i, 90],
204
+ [/\b(customers?|stories|case[- ]?studies)\b/i, 80],
205
+ [/\b(docs?|guides?|help)\b/i, 70],
206
+ [/\b(about|why)\b/i, 60],
207
+ ] as const;
208
+ const destructive = /\b(log[- ]?out|sign[- ]?out|delete|remove|unsubscribe)\b/i;
209
+ const unique = new Map<string, { link: DiscoveryLink; score: number; index: number }>();
210
+ links.forEach((link, index) => {
211
+ try {
212
+ const url = new URL(link.href);
213
+ if (url.origin !== origin || destructive.test(`${link.text} ${url.pathname}`)) return;
214
+ const key = canonicalUrl(url.toString());
215
+ const haystack = `${link.text} ${url.pathname.replace(/[-_/]+/g, " ")}`;
216
+ const score = scores.find(([pattern]) => pattern.test(haystack))?.[1] ?? 0;
217
+ if (!unique.has(key)) unique.set(key, { link: { ...link, href: key }, score, index });
218
+ } catch {
219
+ // Invalid page links are not evidence and do not stop discovery.
220
+ }
221
+ });
222
+ return [...unique.values()]
223
+ .sort((a, b) => b.score - a.score || a.index - b.index)
224
+ .map(({ link }) => link);
225
+ }
226
+
227
+ function canonicalUrl(raw: string): string {
228
+ const url = new URL(raw);
229
+ url.hash = "";
230
+ return url.toString();
231
+ }
package/src/index.ts CHANGED
@@ -22,3 +22,4 @@ export * from "./run/execute";
22
22
  export * from "./ui/output";
23
23
  export * from "./preflight";
24
24
  export * from "./loop";
25
+ export * from "./discovery";
package/src/loop.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { systemClock, type JobLease, type StudyRun } from "@aloud/core";
1
+ import { systemClock, type JobLease, type StudyRun, type StudySetupJob } from "@aloud/core";
2
2
  import { OfflineError, RunnerClient, LeaseLostError, ServerError } from "./protocol/client";
3
3
  import { BlobSpool } from "./protocol/blob-spool";
4
4
  import { executeLease, type ExecuteResult, type RunReporter } from "./run/execute";
5
5
  import type { StageRouting } from "./model/proxy-adapter";
6
6
  import type { LocalPolicy } from "./config/policy";
7
+ import { discoverProduct } from "./discovery";
7
8
 
8
9
  /**
9
10
  * Claim, run, repeat.
@@ -21,8 +22,10 @@ const FAST_WINDOW_MS = 30_000;
21
22
  const IDLE_AFTER_MS = 5 * 60_000;
22
23
 
23
24
  interface ClaimResponse {
25
+ kind?: "study_run" | "product_discovery";
24
26
  lease?: JobLease;
25
27
  run?: StudyRun;
28
+ setupJob?: StudySetupJob;
26
29
  productId?: string;
27
30
  routing?: Partial<Record<string, StageRouting>>;
28
31
  }
@@ -75,6 +78,36 @@ export async function runLoop(deps: LoopDeps): Promise<ExecuteResult[]> {
75
78
  throw error;
76
79
  }
77
80
 
81
+ if (claim?.kind === "product_discovery" && claim.setupJob) {
82
+ lastActivityAt = now();
83
+ deps.ui.note(`Learning what ${new URL(claim.setupJob.url).hostname} does from its public pages.`);
84
+ try {
85
+ const evidence = await discoverProduct(claim.setupJob, deps.local);
86
+ await deps.client.request("api/runner/discovery", {
87
+ method: "POST",
88
+ body: { setupJobId: claim.setupJob.id, evidence },
89
+ ...(deps.signal ? { signal: deps.signal } : {}),
90
+ });
91
+ deps.ui.note(`Product context captured from ${evidence.pages.length} page${evidence.pages.length === 1 ? "" : "s"}.`);
92
+ } catch (error) {
93
+ const reason = error instanceof Error ? error.message : "Product discovery failed.";
94
+ deps.ui.failed(reason);
95
+ try {
96
+ await deps.client.request("api/runner/discovery", {
97
+ method: "POST",
98
+ body: { setupJobId: claim.setupJob.id, failureReason: reason },
99
+ ...(deps.signal ? { signal: deps.signal } : {}),
100
+ });
101
+ } catch {
102
+ // The control plane will expire the job; the original browser error is the useful one.
103
+ }
104
+ }
105
+ lastActivityAt = now();
106
+ if (deps.once) break;
107
+ deps.ui.waiting?.(deps.webUrl);
108
+ continue;
109
+ }
110
+
78
111
  if (claim?.lease && !claim.run) {
79
112
  // The server offered work without saying what it is. Better to say so than to invent a run
80
113
  // row and write a report whose methodology section is fiction.
@@ -1,6 +1,6 @@
1
1
  import { ModelError, type AdapterRequest, type AdapterResponse, type ModelAdapter } from "@aloud/engine";
2
2
  import type { ProviderName, PromptStage } from "@aloud/core";
3
- import { LeaseLostError, ServerError, type RunnerClient } from "../protocol/client";
3
+ import { LeaseLostError, ServerError, type ModelProxyRequest, type RunnerClient } from "../protocol/client";
4
4
  import type { BlobSpool } from "../protocol/blob-spool";
5
5
 
6
6
  /**
@@ -89,24 +89,35 @@ export class ProxyModelAdapter implements ModelAdapter {
89
89
  }
90
90
 
91
91
  private async post(request: AdapterRequest, images: unknown[]): Promise<ModelProxyResponse> {
92
- const { body } = await this.deps.client.request<ModelProxyResponse>("api/runner/model", {
92
+ const body: ModelProxyRequest = {
93
+ leaseId: this.deps.leaseId,
94
+ stage: this.deps.stage,
95
+ // Sent so the server can reject a mismatch loudly rather than silently billing for
96
+ // something the runner did not ask for. The server's own table is the authority.
97
+ modelId: this.modelId,
98
+ // BILLING_PLAN 1.6: the proxy writes the usage row and can derive the workspace and the run
99
+ // from the lease, but not the participant or the moment. Only this side knows them, so only
100
+ // this side can send them, and the server checks the session against the lease before it
101
+ // believes any of it.
102
+ //
103
+ // Read off the request rather than off `this.deps`, because one adapter serves every session
104
+ // in a run at once: a field on the adapter would name whichever session called last.
105
+ sessionId: request.sessionId ?? null,
106
+ momentId: request.momentId ?? null,
107
+ system: request.system,
108
+ prompt: request.prompt,
109
+ responseShape: request.responseShape,
110
+ maxOutputTokens: request.maxOutputTokens,
111
+ temperature: request.temperature,
112
+ images,
113
+ };
114
+
115
+ const response = await this.deps.client.request<ModelProxyResponse>("api/runner/model", {
93
116
  method: "POST",
94
- body: {
95
- leaseId: this.deps.leaseId,
96
- stage: this.deps.stage,
97
- // Sent so the server can reject a mismatch loudly rather than silently billing for
98
- // something the runner did not ask for. The server's own table is the authority.
99
- modelId: this.modelId,
100
- system: request.system,
101
- prompt: request.prompt,
102
- responseShape: request.responseShape,
103
- maxOutputTokens: request.maxOutputTokens,
104
- temperature: request.temperature,
105
- images,
106
- },
117
+ body,
107
118
  ...(request.signal ? { signal: request.signal } : {}),
108
119
  });
109
- return body ?? {};
120
+ return response.body ?? {};
110
121
  }
111
122
 
112
123
  /** A hash if the server already has the bytes, the bytes if it does not. */
@@ -1,4 +1,5 @@
1
1
  import { scrubToken } from "../config/credentials";
2
+ import { RUNNER_VERSION, RUNNER_VERSION_HEADER } from "../version";
2
3
 
3
4
  /**
4
5
  * The runner's only route to the server.
@@ -50,6 +51,36 @@ export interface RunnerClientOptions {
50
51
 
51
52
  export const DEFAULT_OFFLINE_HORIZON_MS = 60_000;
52
53
 
54
+ /**
55
+ * The body of a call to `api/runner/model`, which is the one request that spends money.
56
+ *
57
+ * Written down as a type because both halves of it are checked server-side and neither is believed:
58
+ * `stage` and `modelId` are validated against the server's own routing table, and `sessionId` is
59
+ * validated against the lease's run (SPEC 20.1). A runner cannot widen what it may spend on, or
60
+ * whose ledger the spend lands in, by editing this object.
61
+ */
62
+ export interface ModelProxyRequest {
63
+ leaseId: string;
64
+ stage: string;
65
+ modelId: string;
66
+ /**
67
+ * BILLING_PLAN 1.6: which participant this call is for, and which moment it is paying for.
68
+ *
69
+ * The proxy knows the lease and therefore the run, but has no way to know either of these. Before
70
+ * they were sent, every study run through a local runner was invisible to per-session cost
71
+ * analysis, and cost per moment could not be measured at all. Null for stages outside the
72
+ * participant loop, such as synthesis.
73
+ */
74
+ sessionId: string | null;
75
+ momentId: string | null;
76
+ system: string | null;
77
+ prompt: string;
78
+ responseShape: string;
79
+ maxOutputTokens: number;
80
+ temperature: number;
81
+ images: unknown[];
82
+ }
83
+
53
84
  export interface RequestOptions {
54
85
  method?: "GET" | "POST" | "PUT";
55
86
  body?: unknown;
@@ -100,6 +131,9 @@ export class RunnerClient {
100
131
  headers: {
101
132
  authorization: `Bearer ${this.options.token}`,
102
133
  accept: "application/json",
134
+ // Cheap on every request rather than negotiated once: a runner that upgrades mid-poll
135
+ // should be recognised on its next word, not after a reconnect.
136
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION,
103
137
  ...(options.raw
104
138
  ? { "content-type": options.raw.contentType }
105
139
  : options.body !== undefined
package/src/version.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * What this runner is, when it introduces itself.
3
+ *
4
+ * Sent as a header on every request, and recorded by the server on each claim, so the server can
5
+ * refuse to queue work a machine is too old to understand. The alternative, which is what happened
6
+ * before this existed, is a job nothing claims and a page that waits and then gives up without ever
7
+ * saying the word "upgrade".
8
+ *
9
+ * Written out rather than read from package.json at runtime. The bundle that ships to npm has no
10
+ * package.json beside it to read, and importing one into the source trips the composite build's
11
+ * rootDir. `version.test.ts` asserts this matches, so the drift this invites cannot survive CI.
12
+ */
13
+ export const RUNNER_VERSION = "0.2.1";
14
+
15
+ /** The header the server reads it from. */
16
+ export const RUNNER_VERSION_HEADER = "x-aloud-runner-version";