@aloud/runner 0.3.2 → 0.3.3

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 CHANGED
@@ -23,7 +23,7 @@ The first `aloud start` downloads Chromium, about 350 MB, once.
23
23
  | `aloud login [--token <token>]` | Connect this machine to your workspace |
24
24
  | `aloud start [--once] [--quiet] [--no-update]` | Update, then wait for studies and run them here |
25
25
  | `aloud status` | What is set up, what is not, and whether it is running |
26
- | `aloud allow <host>` | Let studies open this host from this machine |
26
+ | `aloud allow <host>` | Approve a private or local host on this machine |
27
27
  | `aloud mcp` | Connect an MCP host to the web workspace selected by its MCP access token |
28
28
  | `aloud logout` | Forget the token on this machine |
29
29
  | `aloud --version` | Print the installed runner version |
@@ -34,6 +34,9 @@ Starting with 0.3.1, `aloud start` asks the connected Aloud server which officia
34
34
  it recommends. If a newer one is available, it installs that exact `@aloud/runner` version and
35
35
  relaunches it before claiming work. It never replaces code during an active study.
36
36
 
37
+ Starting with 0.3.3, a runner that is already waiting does the same update automatically while it
38
+ is idle. A customer does not have to notice a release, stop the process, or start it again.
39
+
37
40
  If an optional update cannot be installed, the terminal says why and a still-compatible runner may
38
41
  continue. If the server requires the update, nothing starts and the command exits non-zero with the
39
42
  manual repair command. `--no-update` is available for managed environments, but it cannot make an
@@ -53,10 +56,14 @@ the web app without deleting existing study history.
53
56
 
54
57
  ## What it will open
55
58
 
56
- A machine starts allowed to open `localhost` only. Anything else has to be added here, with
57
- `aloud allow <host>`, and that list lives on this machine in `~/.aloud/credentials.json`. Nothing
58
- the server sends can add to it, so a study offered for a host you never allowed is refused rather
59
- than run.
59
+ Public websites work automatically. The runner grants only the public domain named by the current
60
+ lease, checks DNS again immediately before launch and on every later network request, and forces
61
+ private-network access off for that study. The grant disappears with the lease and is never written
62
+ to `~/.aloud/credentials.json`.
63
+
64
+ Localhost, private IPs, and names that resolve inside a private network remain explicit. Add those
65
+ once with `aloud allow <host>`; that list lives only on this machine. Nothing the server sends can
66
+ widen it, and cloud metadata and link-local targets are always refused.
60
67
 
61
68
  The token is stored in that same file at mode 0600, and `aloud logout` deletes it. Revoking a
62
69
  machine from the web app takes effect immediately, including for a study already running.
package/dist/cli.js CHANGED
@@ -866,6 +866,22 @@ async function evaluateTargetUrl(rawUrl, options = {}) {
866
866
  }
867
867
  return { allowed: true, hostname: hostname2, addresses };
868
868
  }
869
+ async function targetNetworkAccess(rawUrl, resolver) {
870
+ const verdict = await evaluateTargetUrl(rawUrl, {
871
+ allowPrivateNetwork: false,
872
+ ...resolver ? { resolver } : {}
873
+ });
874
+ if (verdict.allowed) return { kind: "public", hostname: verdict.hostname };
875
+ let hostname2 = null;
876
+ try {
877
+ hostname2 = new URL(rawUrl).hostname.toLowerCase().replace(/^\[|\]$/g, "");
878
+ } catch {
879
+ }
880
+ if ((verdict.code === "loopback" || verdict.code === "private_network") && hostname2) {
881
+ return { kind: "private", hostname: hostname2, code: verdict.code, reason: verdict.reason };
882
+ }
883
+ return { kind: "blocked", hostname: hostname2, code: verdict.code, reason: verdict.reason };
884
+ }
869
885
  function normaliseHosts(hosts) {
870
886
  return [...new Set(hosts.map((host) => host.toLowerCase().replace(/^\*\./, "").trim()).filter(Boolean))];
871
887
  }
@@ -3153,7 +3169,7 @@ async function waitForApproval(start2, deps) {
3153
3169
  }
3154
3170
 
3155
3171
  // src/version.ts
3156
- var RUNNER_VERSION = "0.3.2";
3172
+ var RUNNER_VERSION = "0.3.3";
3157
3173
  var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
3158
3174
  function versionParts(value) {
3159
3175
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
@@ -3661,7 +3677,8 @@ var TerminalReporter = class {
3661
3677
  this.say("");
3662
3678
  this.say(` Chromium ${input.chromium ? GREEN + "ready" + RESET : YELLOW + "not installed yet" + RESET}`);
3663
3679
  this.say(` Connection ${input.server}`);
3664
- this.say(` Allowed here ${input.allowedHosts.join(", ") || "nothing yet - run `aloud allow <host>`"}`);
3680
+ this.say(` Public sites ${GREEN}automatic per study${RESET}`);
3681
+ this.say(` Private/local ${input.allowedHosts.join(", ") || "nothing approved"}`);
3665
3682
  this.say("");
3666
3683
  }
3667
3684
  waiting(url) {
@@ -8146,6 +8163,26 @@ var VISUAL_SIGNATURE_STYLE = `
8146
8163
  box-shadow: none !important;
8147
8164
  }
8148
8165
  `;
8166
+ async function browserRequestRefusal(rawUrl, options, resourceType, resolver) {
8167
+ let url;
8168
+ try {
8169
+ url = new URL(rawUrl);
8170
+ } catch {
8171
+ return `Not a valid request URL: ${rawUrl}`;
8172
+ }
8173
+ if (url.protocol === "data:" || url.protocol === "blob:") return null;
8174
+ if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "ws:" && url.protocol !== "wss:") {
8175
+ return `Only HTTP(S) and WebSocket network requests are supported, got ${url.protocol}`;
8176
+ }
8177
+ const evaluatedUrl = url.protocol === "ws:" || url.protocol === "wss:" ? `${url.protocol === "wss:" ? "https:" : "http:"}//${url.host}${url.pathname}${url.search}` : rawUrl;
8178
+ const restrictDocument = resourceType === "document" && options.blockOffDomainNavigation && options.allowedDomains.length > 0;
8179
+ const verdict = await evaluateTargetUrl(evaluatedUrl, {
8180
+ ...restrictDocument ? { allowedDomains: options.allowedDomains } : {},
8181
+ allowPrivateNetwork: options.allowPrivateNetwork ?? false,
8182
+ ...resolver ? { resolver } : {}
8183
+ });
8184
+ return verdict.allowed ? null : verdict.reason;
8185
+ }
8149
8186
  function digestOf(payload) {
8150
8187
  return createHash4("sha256").update(payload.title + " " + payload.controls + " " + payload.text + " " + payload.scrollY).digest("hex").slice(0, 24);
8151
8188
  }
@@ -8206,17 +8243,19 @@ var PlaywrightBrowserWorker = class {
8206
8243
  if (typeof window.__aloudRecordSubmit === "function") window.__aloudRecordSubmit();
8207
8244
  }, true);
8208
8245
  `);
8209
- if (this.options.blockOffDomainNavigation && this.options.allowedDomains.length > 0) {
8210
- const allowed = this.options.allowedDomains;
8211
- await this.context.route("**/*", async (route) => {
8212
- const url = route.request().url();
8213
- if (route.request().resourceType() === "document" && !isDomainAllowed(url, allowed)) {
8214
- await route.abort("blockedbyclient");
8215
- return;
8216
- }
8217
- await route.continue();
8218
- });
8219
- }
8246
+ await this.context.route("**/*", async (route) => {
8247
+ const request = route.request();
8248
+ const refusal = await browserRequestRefusal(
8249
+ request.url(),
8250
+ this.options,
8251
+ request.resourceType()
8252
+ );
8253
+ if (refusal) {
8254
+ await route.abort("blockedbyclient");
8255
+ return;
8256
+ }
8257
+ await route.continue();
8258
+ });
8220
8259
  await this.page.goto(startUrl, { waitUntil: "domcontentloaded" });
8221
8260
  await this.settle();
8222
8261
  const capture = await this.capture();
@@ -8831,26 +8870,37 @@ var SnapshotRefused = class extends Error {
8831
8870
  async function sanitiseSnapshot(snapshot, lease, local, nowIso, resolver) {
8832
8871
  const granted = normaliseHosts(lease.allowedHosts);
8833
8872
  const permitted = normaliseHosts(local.allowedHosts);
8834
- const effective = intersectHosts(granted, permitted);
8835
- if (effective.length === 0) {
8836
- throw new SnapshotRefused(
8837
- `This study wants ${granted.join(", ") || "nothing"}, and this machine allows ${permitted.join(", ") || "nothing"}. Nothing in common, so nothing will be opened. Add a host with \`aloud allow <host>\` if that is wrong.`
8838
- );
8839
- }
8840
8873
  const startUrl = snapshot.study?.startUrl;
8841
8874
  if (typeof startUrl !== "string" || startUrl.length === 0) {
8842
8875
  throw new SnapshotRefused("This study has no start URL.");
8843
8876
  }
8844
- const verdict = leasePermits({ ...lease, allowedHosts: effective }, startUrl, nowIso);
8877
+ const verdict = leasePermits({ ...lease, allowedHosts: granted }, startUrl, nowIso);
8845
8878
  if (!verdict.allowed) throw new SnapshotRefused(verdict.reason);
8879
+ const access = await targetNetworkAccess(startUrl, resolver);
8880
+ if (access.kind === "blocked") throw new SnapshotRefused(access.reason);
8881
+ let effective;
8882
+ let allowPrivateNetwork;
8883
+ if (access.kind === "public") {
8884
+ effective = granted.some((entry2) => hostPermitted(access.hostname, [entry2])) ? [access.hostname] : [];
8885
+ allowPrivateNetwork = false;
8886
+ } else {
8887
+ effective = intersectHosts(granted, permitted);
8888
+ allowPrivateNetwork = local.allowPrivateNetwork;
8889
+ }
8890
+ if (effective.length === 0) {
8891
+ throw new SnapshotRefused(
8892
+ `This private or local study wants ${granted.join(", ") || "nothing"}, and this machine allows ${permitted.join(", ") || "nothing"}. Nothing in common, so nothing will be opened. Add the private host with \`aloud allow <host>\` if that is intentional.`
8893
+ );
8894
+ }
8846
8895
  const target = await evaluateTargetUrl(startUrl, {
8847
8896
  allowedDomains: effective,
8848
- allowPrivateNetwork: local.allowPrivateNetwork,
8897
+ allowPrivateNetwork,
8849
8898
  ...resolver ? { resolver } : {}
8850
8899
  });
8851
8900
  if (!target.allowed) throw new SnapshotRefused(target.reason);
8852
8901
  return {
8853
8902
  allowedHosts: effective,
8903
+ allowPrivateNetwork,
8854
8904
  snapshot: {
8855
8905
  ...snapshot,
8856
8906
  environment: {
@@ -8965,7 +9015,7 @@ async function executeLease(deps) {
8965
9015
  const workers = new GuardedWorkerFactory(
8966
9016
  deps.workers ?? new PlaywrightWorkerFactory(),
8967
9017
  effective.allowedHosts,
8968
- deps.local
9018
+ { ...deps.local, allowPrivateNetwork: effective.allowPrivateNetwork }
8969
9019
  );
8970
9020
  coordinator = new RunCoordinator(
8971
9021
  {
@@ -9135,13 +9185,16 @@ var MAX_PAGES = 6;
9135
9185
  var MAX_LINKS_PER_PAGE = 200;
9136
9186
  var PAGE_TIMEOUT_MS = 15e3;
9137
9187
  async function discoverProduct(job, local) {
9138
- const effectiveHosts = intersectHosts(job.allowedHosts, local.allowedHosts);
9188
+ const access = await targetNetworkAccess(job.url);
9189
+ if (access.kind === "blocked") throw new Error(`Refusing product discovery: ${access.reason}`);
9190
+ const effectiveHosts = access.kind === "public" ? job.allowedHosts.some((host) => hostPermitted(access.hostname, [host])) ? [access.hostname] : [] : intersectHosts(job.allowedHosts, local.allowedHosts);
9139
9191
  if (effectiveHosts.length === 0) {
9140
9192
  throw new Error(
9141
- `Product discovery wants ${job.allowedHosts.join(", ")}, but this machine allows ${local.allowedHosts.join(", ") || "nothing"}.`
9193
+ `Private product discovery wants ${job.allowedHosts.join(", ")}, but this machine allows ${local.allowedHosts.join(", ") || "nothing"}.`
9142
9194
  );
9143
9195
  }
9144
- await assertTarget(job.url, effectiveHosts, local);
9196
+ const allowPrivateNetwork = access.kind === "private" && local.allowPrivateNetwork;
9197
+ await assertTarget(job.url, effectiveHosts, allowPrivateNetwork);
9145
9198
  const browser = await chromium2.launch({
9146
9199
  args: ["--disable-dev-shm-usage"],
9147
9200
  handleSIGINT: false,
@@ -9157,7 +9210,7 @@ async function discoverProduct(job, local) {
9157
9210
  serviceWorkers: "block"
9158
9211
  });
9159
9212
  try {
9160
- await guardRequests(context, effectiveHosts, local);
9213
+ await guardRequests(context, effectiveHosts, allowPrivateNetwork);
9161
9214
  const start2 = canonicalUrl(job.url);
9162
9215
  const origin = new URL(start2).origin;
9163
9216
  const queued = [start2];
@@ -9168,7 +9221,7 @@ async function discoverProduct(job, local) {
9168
9221
  if (seen.has(url)) continue;
9169
9222
  seen.add(url);
9170
9223
  try {
9171
- await assertTarget(url, effectiveHosts, local);
9224
+ await assertTarget(url, effectiveHosts, allowPrivateNetwork);
9172
9225
  const page = await context.newPage();
9173
9226
  try {
9174
9227
  page.setDefaultTimeout(PAGE_TIMEOUT_MS);
@@ -9177,7 +9230,7 @@ async function discoverProduct(job, local) {
9177
9230
  await page.waitForLoadState("networkidle", { timeout: 1500 });
9178
9231
  } catch {
9179
9232
  }
9180
- await assertTarget(page.url(), effectiveHosts, local);
9233
+ await assertTarget(page.url(), effectiveHosts, allowPrivateNetwork);
9181
9234
  const captured = await extractPage(page);
9182
9235
  pages.push(captured);
9183
9236
  const candidates = prioritiseDiscoveryLinks(captured.links, origin).map((link) => canonicalUrl(link.href)).filter((href) => !seen.has(href) && !queued.includes(href));
@@ -9198,14 +9251,14 @@ async function discoverProduct(job, local) {
9198
9251
  await browser.close();
9199
9252
  }
9200
9253
  }
9201
- async function assertTarget(url, allowedDomains, local) {
9254
+ async function assertTarget(url, allowedDomains, allowPrivateNetwork) {
9202
9255
  const verdict = await evaluateTargetUrl(url, {
9203
9256
  allowedDomains,
9204
- allowPrivateNetwork: local.allowPrivateNetwork
9257
+ allowPrivateNetwork
9205
9258
  });
9206
9259
  if (!verdict.allowed) throw new Error(`Refusing product discovery: ${verdict.reason}`);
9207
9260
  }
9208
- async function guardRequests(context, allowedDomains, local) {
9261
+ async function guardRequests(context, allowedDomains, allowPrivateNetwork) {
9209
9262
  await context.route("**/*", async (route) => {
9210
9263
  const request = route.request();
9211
9264
  let url;
@@ -9223,15 +9276,13 @@ async function guardRequests(context, allowedDomains, local) {
9223
9276
  await route.abort("blockedbyclient");
9224
9277
  return;
9225
9278
  }
9226
- if (request.resourceType() === "document") {
9227
- const verdict = await evaluateTargetUrl(url.toString(), {
9228
- allowedDomains,
9229
- allowPrivateNetwork: local.allowPrivateNetwork
9230
- });
9231
- if (!verdict.allowed) {
9232
- await route.abort("blockedbyclient");
9233
- return;
9234
- }
9279
+ const verdict = await evaluateTargetUrl(url.toString(), {
9280
+ allowedDomains,
9281
+ allowPrivateNetwork
9282
+ });
9283
+ if (!verdict.allowed) {
9284
+ await route.abort("blockedbyclient");
9285
+ return;
9235
9286
  }
9236
9287
  await route.continue();
9237
9288
  });
@@ -9320,9 +9371,13 @@ async function runLoop(deps) {
9320
9371
  deps.ui.waiting?.(deps.webUrl);
9321
9372
  while (!deps.signal?.aborted) {
9322
9373
  let claim = null;
9374
+ const pollLocal = deps.loadLocalPolicy ? await deps.loadLocalPolicy() : deps.local;
9323
9375
  try {
9324
9376
  const response = await deps.client.request("api/runner/claim", {
9325
9377
  method: "POST",
9378
+ // The machine owns private-network policy. Mirroring it on every outbound poll makes one
9379
+ // `aloud allow` command sufficient and repairs a missed sync without human intervention.
9380
+ body: { localAllowedHosts: pollLocal.allowedHosts },
9326
9381
  // A poll should fail fast and come back, not block for a minute holding the loop.
9327
9382
  retry: false,
9328
9383
  ...deps.signal ? { signal: deps.signal } : {}
@@ -9340,11 +9395,17 @@ async function runLoop(deps) {
9340
9395
  }
9341
9396
  throw error;
9342
9397
  }
9398
+ if (claim?.kind === "runner_update") {
9399
+ const policy = runnerVersionPolicyFrom(claim.runnerVersionPolicy);
9400
+ if (policy && deps.onUpdateRequested && await deps.onUpdateRequested(policy)) break;
9401
+ await sleep(POLL_NORMAL_MS);
9402
+ continue;
9403
+ }
9343
9404
  if (claim?.kind === "product_discovery" && claim.setupJob) {
9344
9405
  lastActivityAt = now();
9345
9406
  deps.ui.note(`Learning what ${new URL(claim.setupJob.url).hostname} does from its public pages.`);
9346
9407
  try {
9347
- const evidence = await discoverProduct(claim.setupJob, deps.local);
9408
+ const evidence = await discoverProduct(claim.setupJob, pollLocal);
9348
9409
  await deps.client.request("api/runner/discovery", {
9349
9410
  method: "POST",
9350
9411
  body: { setupJobId: claim.setupJob.id, evidence },
@@ -9387,7 +9448,7 @@ async function runLoop(deps) {
9387
9448
  run: claim.run,
9388
9449
  productId: claim.productId ?? "",
9389
9450
  routing: claim.routing ?? {},
9390
- local: deps.local,
9451
+ local: pollLocal,
9391
9452
  spool,
9392
9453
  ui: deps.ui,
9393
9454
  ...deps.workers ? { workers: deps.workers } : {},
@@ -10642,7 +10703,7 @@ function printHelp() {
10642
10703
  " aloud start [--once] [--quiet] [--no-update]",
10643
10704
  " Update, then wait for studies and run them here",
10644
10705
  " aloud status What is set up, and whether it is running",
10645
- " aloud allow <host> Let studies open this host from this machine",
10706
+ " aloud allow <host> Approve a private or local host on this machine",
10646
10707
  " aloud mcp Serve MCP to an editor, using the saved credential",
10647
10708
  " aloud mcp connect Connect an editor, approving it in your browser",
10648
10709
  " aloud logout Forget the token on this machine",
@@ -10742,7 +10803,8 @@ async function connectWith(server, token) {
10742
10803
  process.stdout.write(`
10743
10804
  Connected as ${credentials.runnerName}.
10744
10805
  `);
10745
- process.stdout.write(`Allowed here: ${credentials.allowedHosts.join(", ")}
10806
+ process.stdout.write("Public sites: automatic for the study that names them.\n");
10807
+ process.stdout.write(`Private/local: ${credentials.allowedHosts.join(", ") || "nothing approved"}
10746
10808
  `);
10747
10809
  reporter.privacyNote();
10748
10810
  process.stdout.write("Run `aloud start` and leave it running.\n\n");
@@ -11259,8 +11321,11 @@ async function status() {
11259
11321
  `);
11260
11322
  process.stdout.write(`Server ${credentials.server}
11261
11323
  `);
11262
- process.stdout.write(`Allowed here ${credentials.allowedHosts.join(", ") || "nothing yet"}
11263
- `);
11324
+ process.stdout.write("Public sites automatic per study\n");
11325
+ process.stdout.write(
11326
+ `Private/local ${credentials.allowedHosts.join(", ") || "nothing approved"}
11327
+ `
11328
+ );
11264
11329
  }
11265
11330
  process.stdout.write(
11266
11331
  `Chromium ${checks.chromiumInstalled ? `ready (${checks.chromiumPath})` : "not installed yet"}
@@ -11280,7 +11345,7 @@ async function status() {
11280
11345
  async function allow(argv) {
11281
11346
  const host = argv.find((arg) => !arg.startsWith("-"));
11282
11347
  if (!host) {
11283
- process.stderr.write("Which host? For example: aloud allow staging.acme.com\n");
11348
+ process.stderr.write("Which private or local host? For example: aloud allow internal.acme.test\n");
11284
11349
  return 1;
11285
11350
  }
11286
11351
  const credentials = await readCredentials();
@@ -11291,9 +11356,10 @@ async function allow(argv) {
11291
11356
  const next = normaliseHosts([...credentials.allowedHosts, host]);
11292
11357
  await writeCredentials({ ...credentials, allowedHosts: next });
11293
11358
  process.stdout.write(`
11294
- Studies on this machine may now open: ${next.join(", ")}
11359
+ Private/local access approved for: ${next.join(", ")}
11295
11360
  `);
11296
- process.stdout.write("Nothing the server sends can change that list. Only this command can.\n\n");
11361
+ process.stdout.write("A running runner syncs this on its next poll; otherwise the next start does.\n");
11362
+ process.stdout.write("Public sites work automatically per study. Nothing the server sends can widen this private list.\n\n");
11297
11363
  return 0;
11298
11364
  }
11299
11365
  async function start(argv) {
@@ -11326,10 +11392,6 @@ To restart it: kill ${existing.pid} && aloud start
11326
11392
  }
11327
11393
  }
11328
11394
  const local = policyOf(credentials, argv);
11329
- if (local.allowedHosts.length === 0) {
11330
- process.stderr.write("This machine is not allowed to open anything. Try `aloud allow localhost`.\n");
11331
- return 1;
11332
- }
11333
11395
  const client = new RunnerClient({
11334
11396
  server: credentials.server,
11335
11397
  token: credentials.token,
@@ -11346,14 +11408,35 @@ To restart it: kill ${existing.pid} && aloud start
11346
11408
  installSignalHandlers(controller, reporter);
11347
11409
  await writeRunning({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString(), server: credentials.server });
11348
11410
  process.on("exit", () => clearRunningSync());
11411
+ let attemptedIdleUpdate = null;
11349
11412
  try {
11350
11413
  await runLoop({
11351
11414
  client,
11352
11415
  local,
11416
+ loadLocalPolicy: async () => {
11417
+ const current = await readCredentials();
11418
+ return current && current.runnerId === credentials.runnerId ? policyOf(current, argv) : policyFrom({ allowedHosts: [], allowPrivateNetwork: false });
11419
+ },
11353
11420
  ui: reporter,
11354
11421
  webUrl: `${credentials.server}/app`,
11355
11422
  once: argv.includes("--once"),
11356
- signal: controller.signal
11423
+ signal: controller.signal,
11424
+ onUpdateRequested: async (policy) => {
11425
+ if (attemptedIdleUpdate === policy.recommended) return false;
11426
+ attemptedIdleUpdate = policy.recommended;
11427
+ reporter.note(`Runner ${policy.recommended} is ready. Updating now while this machine is idle.`);
11428
+ await clearRunning();
11429
+ const result = await updateBeforeStart(credentials, argv, {
11430
+ versionPolicy: async () => policy
11431
+ });
11432
+ if (result !== null) return true;
11433
+ await writeRunning({
11434
+ pid: process.pid,
11435
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
11436
+ server: credentials.server
11437
+ });
11438
+ return false;
11439
+ }
11357
11440
  });
11358
11441
  return 0;
11359
11442
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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
@@ -102,7 +102,7 @@ function printHelp(): void {
102
102
  " aloud start [--once] [--quiet] [--no-update]",
103
103
  " Update, then wait for studies and run them here",
104
104
  " aloud status What is set up, and whether it is running",
105
- " aloud allow <host> Let studies open this host from this machine",
105
+ " aloud allow <host> Approve a private or local host on this machine",
106
106
  " aloud mcp Serve MCP to an editor, using the saved credential",
107
107
  " aloud mcp connect Connect an editor, approving it in your browser",
108
108
  " aloud logout Forget the token on this machine",
@@ -225,7 +225,8 @@ async function connectWith(server: string, token: string): Promise<number> {
225
225
 
226
226
  const reporter = new TerminalReporter(process.stdout, token);
227
227
  process.stdout.write(`\nConnected as ${credentials.runnerName}.\n`);
228
- process.stdout.write(`Allowed here: ${credentials.allowedHosts.join(", ")}\n`);
228
+ process.stdout.write("Public sites: automatic for the study that names them.\n");
229
+ process.stdout.write(`Private/local: ${credentials.allowedHosts.join(", ") || "nothing approved"}\n`);
229
230
  reporter.privacyNote();
230
231
  process.stdout.write("Run `aloud start` and leave it running.\n\n");
231
232
  return 0;
@@ -949,7 +950,10 @@ async function status(): Promise<number> {
949
950
  } else {
950
951
  process.stdout.write(`Signed in ${credentials.runnerName}\n`);
951
952
  process.stdout.write(`Server ${credentials.server}\n`);
952
- process.stdout.write(`Allowed here ${credentials.allowedHosts.join(", ") || "nothing yet"}\n`);
953
+ process.stdout.write("Public sites automatic per study\n");
954
+ process.stdout.write(
955
+ `Private/local ${credentials.allowedHosts.join(", ") || "nothing approved"}\n`,
956
+ );
953
957
  }
954
958
  process.stdout.write(
955
959
  `Chromium ${checks.chromiumInstalled ? `ready (${checks.chromiumPath})` : "not installed yet"}\n`,
@@ -985,7 +989,7 @@ async function status(): Promise<number> {
985
989
  async function allow(argv: readonly string[]): Promise<number> {
986
990
  const host = argv.find((arg) => !arg.startsWith("-"));
987
991
  if (!host) {
988
- process.stderr.write("Which host? For example: aloud allow staging.acme.com\n");
992
+ process.stderr.write("Which private or local host? For example: aloud allow internal.acme.test\n");
989
993
  return 1;
990
994
  }
991
995
 
@@ -997,8 +1001,9 @@ async function allow(argv: readonly string[]): Promise<number> {
997
1001
 
998
1002
  const next = normaliseHosts([...credentials.allowedHosts, host]);
999
1003
  await writeCredentials({ ...credentials, allowedHosts: next });
1000
- process.stdout.write(`\nStudies on this machine may now open: ${next.join(", ")}\n`);
1001
- process.stdout.write("Nothing the server sends can change that list. Only this command can.\n\n");
1004
+ process.stdout.write(`\nPrivate/local access approved for: ${next.join(", ")}\n`);
1005
+ process.stdout.write("A running runner syncs this on its next poll; otherwise the next start does.\n");
1006
+ process.stdout.write("Public sites work automatically per study. Nothing the server sends can widen this private list.\n\n");
1002
1007
  return 0;
1003
1008
  }
1004
1009
 
@@ -1044,11 +1049,6 @@ async function start(argv: readonly string[]): Promise<number> {
1044
1049
  }
1045
1050
 
1046
1051
  const local = policyOf(credentials, argv);
1047
- if (local.allowedHosts.length === 0) {
1048
- process.stderr.write("This machine is not allowed to open anything. Try `aloud allow localhost`.\n");
1049
- return 1;
1050
- }
1051
-
1052
1052
  const client = new RunnerClient({
1053
1053
  server: credentials.server,
1054
1054
  token: credentials.token,
@@ -1070,15 +1070,44 @@ async function start(argv: readonly string[]): Promise<number> {
1070
1070
  // The `finally` below covers every ordinary ending. This covers the second Ctrl-C, which calls
1071
1071
  // `process.exit` and unwinds nothing. Synchronous, because an exit handler cannot await.
1072
1072
  process.on("exit", () => clearRunningSync());
1073
+ let attemptedIdleUpdate: string | null = null;
1073
1074
 
1074
1075
  try {
1075
1076
  await runLoop({
1076
1077
  client,
1077
1078
  local,
1079
+ loadLocalPolicy: async () => {
1080
+ const current = await readCredentials();
1081
+ return current && current.runnerId === credentials.runnerId
1082
+ ? policyOf(current, argv)
1083
+ : policyFrom({ allowedHosts: [], allowPrivateNetwork: false });
1084
+ },
1078
1085
  ui: reporter,
1079
1086
  webUrl: `${credentials.server}/app`,
1080
1087
  once: argv.includes("--once"),
1081
1088
  signal: controller.signal,
1089
+ onUpdateRequested: async (policy) => {
1090
+ if (attemptedIdleUpdate === policy.recommended) return false;
1091
+ attemptedIdleUpdate = policy.recommended;
1092
+ reporter.note(`Runner ${policy.recommended} is ready. Updating now while this machine is idle.`);
1093
+ // The replacement must be able to write its own PID before it starts polling. Clearing this
1094
+ // process's marker is the handoff; the exit cleanup checks the PID and cannot erase the
1095
+ // replacement's record afterwards.
1096
+ await clearRunning();
1097
+ const result = await updateBeforeStart(credentials, argv, {
1098
+ versionPolicy: async () => policy,
1099
+ });
1100
+ if (result !== null) return true;
1101
+
1102
+ // An optional update can fail while this release remains compatible. Resume this exact
1103
+ // process and make status truthful again rather than silently leaving no runner behind.
1104
+ await writeRunning({
1105
+ pid: process.pid,
1106
+ startedAt: new Date().toISOString(),
1107
+ server: credentials.server,
1108
+ });
1109
+ return false;
1110
+ },
1082
1111
  });
1083
1112
  return 0;
1084
1113
  } catch (error) {
@@ -13,7 +13,7 @@ import { normaliseHosts } from "@aloud/core";
13
13
  * ever edits this file.
14
14
  */
15
15
  export interface LocalPolicy {
16
- /** Hosts this machine will open. Written at login, changed only by `aloud allow`. */
16
+ /** Private/local hosts this machine will open. Public access is ephemeral and lease-scoped. */
17
17
  allowedHosts: string[];
18
18
  /**
19
19
  * Whether private and loopback addresses may be opened at all.
package/src/discovery.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  evaluateTargetUrl,
6
6
  hostPermitted,
7
7
  intersectHosts,
8
+ targetNetworkAccess,
8
9
  type DeviceContext,
9
10
  type DiscoveryLink,
10
11
  type DiscoveryPage,
@@ -26,14 +27,22 @@ export async function discoverProduct(
26
27
  job: StudySetupJob,
27
28
  local: LocalPolicy,
28
29
  ): Promise<ProductDiscoveryEvidence> {
29
- const effectiveHosts = intersectHosts(job.allowedHosts, local.allowedHosts);
30
+ const access = await targetNetworkAccess(job.url);
31
+ if (access.kind === "blocked") throw new Error(`Refusing product discovery: ${access.reason}`);
32
+ const effectiveHosts =
33
+ access.kind === "public"
34
+ ? job.allowedHosts.some((host) => hostPermitted(access.hostname, [host]))
35
+ ? [access.hostname]
36
+ : []
37
+ : intersectHosts(job.allowedHosts, local.allowedHosts);
30
38
  if (effectiveHosts.length === 0) {
31
39
  throw new Error(
32
- `Product discovery wants ${job.allowedHosts.join(", ")}, but this machine allows ` +
40
+ `Private product discovery wants ${job.allowedHosts.join(", ")}, but this machine allows ` +
33
41
  `${local.allowedHosts.join(", ") || "nothing"}.`,
34
42
  );
35
43
  }
36
- await assertTarget(job.url, effectiveHosts, local);
44
+ const allowPrivateNetwork = access.kind === "private" && local.allowPrivateNetwork;
45
+ await assertTarget(job.url, effectiveHosts, allowPrivateNetwork);
37
46
 
38
47
  const browser = await chromium.launch({
39
48
  args: ["--disable-dev-shm-usage"],
@@ -51,7 +60,7 @@ export async function discoverProduct(
51
60
  });
52
61
 
53
62
  try {
54
- await guardRequests(context, effectiveHosts, local);
63
+ await guardRequests(context, effectiveHosts, allowPrivateNetwork);
55
64
  const start = canonicalUrl(job.url);
56
65
  const origin = new URL(start).origin;
57
66
  const queued = [start];
@@ -63,7 +72,7 @@ export async function discoverProduct(
63
72
  if (seen.has(url)) continue;
64
73
  seen.add(url);
65
74
  try {
66
- await assertTarget(url, effectiveHosts, local);
75
+ await assertTarget(url, effectiveHosts, allowPrivateNetwork);
67
76
  const page = await context.newPage();
68
77
  try {
69
78
  page.setDefaultTimeout(PAGE_TIMEOUT_MS);
@@ -73,7 +82,7 @@ export async function discoverProduct(
73
82
  } catch {
74
83
  // Some products poll forever. The DOM is still useful after DOMContentLoaded.
75
84
  }
76
- await assertTarget(page.url(), effectiveHosts, local);
85
+ await assertTarget(page.url(), effectiveHosts, allowPrivateNetwork);
77
86
  const captured = await extractPage(page);
78
87
  pages.push(captured);
79
88
 
@@ -102,10 +111,10 @@ export async function discoverProduct(
102
111
  }
103
112
  }
104
113
 
105
- async function assertTarget(url: string, allowedDomains: string[], local: LocalPolicy): Promise<void> {
114
+ async function assertTarget(url: string, allowedDomains: string[], allowPrivateNetwork: boolean): Promise<void> {
106
115
  const verdict = await evaluateTargetUrl(url, {
107
116
  allowedDomains,
108
- allowPrivateNetwork: local.allowPrivateNetwork,
117
+ allowPrivateNetwork,
109
118
  });
110
119
  if (!verdict.allowed) throw new Error(`Refusing product discovery: ${verdict.reason}`);
111
120
  }
@@ -113,7 +122,7 @@ async function assertTarget(url: string, allowedDomains: string[], local: LocalP
113
122
  async function guardRequests(
114
123
  context: BrowserContext,
115
124
  allowedDomains: string[],
116
- local: LocalPolicy,
125
+ allowPrivateNetwork: boolean,
117
126
  ): Promise<void> {
118
127
  await context.route("**/*", async (route) => {
119
128
  const request = route.request();
@@ -135,15 +144,16 @@ async function guardRequests(
135
144
  await route.abort("blockedbyclient");
136
145
  return;
137
146
  }
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
+ // Discovery is narrower than a participant browser and keeps every request on the product
148
+ // domain. Resolve all of them, not only documents: a same-host private subresource or DNS
149
+ // rebind must not turn automatic public discovery into local-network access.
150
+ const verdict = await evaluateTargetUrl(url.toString(), {
151
+ allowedDomains,
152
+ allowPrivateNetwork,
153
+ });
154
+ if (!verdict.allowed) {
155
+ await route.abort("blockedbyclient");
156
+ return;
147
157
  }
148
158
  await route.continue();
149
159
  });
package/src/loop.ts CHANGED
@@ -5,6 +5,7 @@ import { executeLease, type ExecuteResult, type RunReporter } from "./run/execut
5
5
  import type { StageRouting } from "./model/proxy-adapter";
6
6
  import type { LocalPolicy } from "./config/policy";
7
7
  import { discoverProduct } from "./discovery";
8
+ import { runnerVersionPolicyFrom, type RunnerVersionPolicy } from "./version";
8
9
 
9
10
  /**
10
11
  * Claim, run, repeat.
@@ -22,17 +23,20 @@ const FAST_WINDOW_MS = 30_000;
22
23
  const IDLE_AFTER_MS = 5 * 60_000;
23
24
 
24
25
  interface ClaimResponse {
25
- kind?: "study_run" | "product_discovery";
26
+ kind?: "study_run" | "product_discovery" | "runner_update";
26
27
  lease?: JobLease;
27
28
  run?: StudyRun;
28
29
  setupJob?: StudySetupJob;
29
30
  productId?: string;
30
31
  routing?: Partial<Record<string, StageRouting>>;
32
+ runnerVersionPolicy?: unknown;
31
33
  }
32
34
 
33
35
  export interface LoopDeps {
34
36
  client: RunnerClient;
35
37
  local: LocalPolicy;
38
+ /** Reloads durable private routes before a job, so `aloud allow` needs no process restart. */
39
+ loadLocalPolicy?: () => Promise<LocalPolicy>;
36
40
  ui: RunReporter & { waiting?(url: string): void };
37
41
  webUrl: string;
38
42
  /** Claim one lease, run it, and stop. For CI, and for `aloud start --once`. */
@@ -42,6 +46,8 @@ export interface LoopDeps {
42
46
  now?: () => number;
43
47
  signal?: AbortSignal;
44
48
  workers?: Parameters<typeof executeLease>[0]["workers"];
49
+ /** Install and relaunch an update only while no study is claimed. True stops this old loop. */
50
+ onUpdateRequested?: (policy: RunnerVersionPolicy) => Promise<boolean>;
45
51
  }
46
52
 
47
53
  export async function runLoop(deps: LoopDeps): Promise<ExecuteResult[]> {
@@ -56,9 +62,13 @@ export async function runLoop(deps: LoopDeps): Promise<ExecuteResult[]> {
56
62
 
57
63
  while (!deps.signal?.aborted) {
58
64
  let claim: ClaimResponse | null = null;
65
+ const pollLocal = deps.loadLocalPolicy ? await deps.loadLocalPolicy() : deps.local;
59
66
  try {
60
67
  const response = await deps.client.request<ClaimResponse>("api/runner/claim", {
61
68
  method: "POST",
69
+ // The machine owns private-network policy. Mirroring it on every outbound poll makes one
70
+ // `aloud allow` command sufficient and repairs a missed sync without human intervention.
71
+ body: { localAllowedHosts: pollLocal.allowedHosts },
62
72
  // A poll should fail fast and come back, not block for a minute holding the loop.
63
73
  retry: false,
64
74
  ...(deps.signal ? { signal: deps.signal } : {}),
@@ -78,11 +88,18 @@ export async function runLoop(deps: LoopDeps): Promise<ExecuteResult[]> {
78
88
  throw error;
79
89
  }
80
90
 
91
+ if (claim?.kind === "runner_update") {
92
+ const policy = runnerVersionPolicyFrom(claim.runnerVersionPolicy);
93
+ if (policy && deps.onUpdateRequested && (await deps.onUpdateRequested(policy))) break;
94
+ await sleep(POLL_NORMAL_MS);
95
+ continue;
96
+ }
97
+
81
98
  if (claim?.kind === "product_discovery" && claim.setupJob) {
82
99
  lastActivityAt = now();
83
100
  deps.ui.note(`Learning what ${new URL(claim.setupJob.url).hostname} does from its public pages.`);
84
101
  try {
85
- const evidence = await discoverProduct(claim.setupJob, deps.local);
102
+ const evidence = await discoverProduct(claim.setupJob, pollLocal);
86
103
  await deps.client.request("api/runner/discovery", {
87
104
  method: "POST",
88
105
  body: { setupJobId: claim.setupJob.id, evidence },
@@ -132,7 +149,7 @@ export async function runLoop(deps: LoopDeps): Promise<ExecuteResult[]> {
132
149
  run: claim.run,
133
150
  productId: claim.productId ?? "",
134
151
  routing: (claim.routing ?? {}) as never,
135
- local: deps.local,
152
+ local: pollLocal,
136
153
  spool,
137
154
  ui: deps.ui,
138
155
  ...(deps.workers ? { workers: deps.workers } : {}),
@@ -145,7 +145,7 @@ export async function executeLease(deps: ExecuteDeps): Promise<ExecuteResult> {
145
145
  const workers = new GuardedWorkerFactory(
146
146
  deps.workers ?? new PlaywrightWorkerFactory(),
147
147
  effective.allowedHosts,
148
- deps.local,
148
+ { ...deps.local, allowPrivateNetwork: effective.allowPrivateNetwork },
149
149
  );
150
150
 
151
151
  coordinator = new RunCoordinator(
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  evaluateTargetUrl,
3
+ hostPermitted,
3
4
  intersectHosts,
4
5
  leasePermits,
5
6
  normaliseHosts,
7
+ targetNetworkAccess,
6
8
  type JobLease,
7
9
  type StudySnapshot,
8
10
  } from "@aloud/core";
@@ -31,6 +33,8 @@ export class SnapshotRefused extends Error {
31
33
  export interface SanitisedSnapshot {
32
34
  snapshot: StudySnapshot;
33
35
  allowedHosts: string[];
36
+ /** False for an automatic public grant, which keeps DNS rebinding out of the local network. */
37
+ allowPrivateNetwork: boolean;
34
38
  }
35
39
 
36
40
  export async function sanitiseSnapshot(
@@ -44,38 +48,56 @@ export async function sanitiseSnapshot(
44
48
  const granted = normaliseHosts(lease.allowedHosts);
45
49
  const permitted = normaliseHosts(local.allowedHosts);
46
50
 
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
51
  const startUrl = snapshot.study?.startUrl;
59
52
  if (typeof startUrl !== "string" || startUrl.length === 0) {
60
53
  throw new SnapshotRefused("This study has no start URL.");
61
54
  }
62
55
 
63
- // Checked against the *effective* list, not the one the lease claimed.
64
- const verdict = leasePermits({ ...lease, allowedHosts: effective }, startUrl, nowIso);
56
+ // The lease is still the outer boundary. Automatic access never invents a host the lease did not
57
+ // name; it only decides whether that lease host needs a durable local permission as well.
58
+ const verdict = leasePermits({ ...lease, allowedHosts: granted }, startUrl, nowIso);
65
59
  if (!verdict.allowed) throw new SnapshotRefused(verdict.reason);
66
60
 
61
+ const access = await targetNetworkAccess(startUrl, resolver);
62
+ if (access.kind === "blocked") throw new SnapshotRefused(access.reason);
63
+
64
+ let effective: string[];
65
+ let allowPrivateNetwork: boolean;
66
+ if (access.kind === "public") {
67
+ // A covering lease entry proves the start host was in scope, but the browser receives only the
68
+ // exact start hostname. A hostile lease cannot smuggle an unrelated domain—or a broad parent
69
+ // that grants sibling subdomains—into the automatic browser policy.
70
+ effective = granted.some((entry) => hostPermitted(access.hostname, [entry]))
71
+ ? [access.hostname]
72
+ : [];
73
+ allowPrivateNetwork = false;
74
+ } else {
75
+ // Private and loopback access still needs both halves of the durable permission. The server can
76
+ // offer it, but cannot make this intersection non-empty.
77
+ effective = intersectHosts(granted, permitted);
78
+ allowPrivateNetwork = local.allowPrivateNetwork;
79
+ }
80
+ if (effective.length === 0) {
81
+ throw new SnapshotRefused(
82
+ `This private or local study wants ${granted.join(", ") || "nothing"}, and this machine allows ` +
83
+ `${permitted.join(", ") || "nothing"}. Nothing in common, so nothing will be opened. ` +
84
+ "Add the private host with `aloud allow <host>` if that is intentional.",
85
+ );
86
+ }
87
+
67
88
  // And then the same check the browser worker will make, so a refusal happens before anything is
68
89
  // launched rather than three browsers in. Cloud metadata endpoints are refused unconditionally
69
90
  // in here, regardless of the private-network setting.
70
91
  const target = await evaluateTargetUrl(startUrl, {
71
92
  allowedDomains: effective,
72
- allowPrivateNetwork: local.allowPrivateNetwork,
93
+ allowPrivateNetwork,
73
94
  ...(resolver ? { resolver } : {}),
74
95
  });
75
96
  if (!target.allowed) throw new SnapshotRefused(target.reason);
76
97
 
77
98
  return {
78
99
  allowedHosts: effective,
100
+ allowPrivateNetwork,
79
101
  snapshot: {
80
102
  ...snapshot,
81
103
  environment: {
package/src/ui/output.ts CHANGED
@@ -39,7 +39,8 @@ export class TerminalReporter implements RunReporter {
39
39
  this.say("");
40
40
  this.say(` Chromium ${input.chromium ? GREEN + "ready" + RESET : YELLOW + "not installed yet" + RESET}`);
41
41
  this.say(` Connection ${input.server}`);
42
- this.say(` Allowed here ${input.allowedHosts.join(", ") || "nothing yet - run `aloud allow <host>`"}`);
42
+ this.say(` Public sites ${GREEN}automatic per study${RESET}`);
43
+ this.say(` Private/local ${input.allowedHosts.join(", ") || "nothing approved"}`);
43
44
  this.say("");
44
45
  }
45
46
 
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.3.2";
13
+ export const RUNNER_VERSION = "0.3.3";
14
14
 
15
15
  /** The header the server reads it from. */
16
16
  export const RUNNER_VERSION_HEADER = "x-aloud-runner-version";