@aloud/runner 0.3.1 → 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/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
  }
@@ -3152,6 +3168,83 @@ async function waitForApproval(start2, deps) {
3152
3168
  }
3153
3169
  }
3154
3170
 
3171
+ // src/version.ts
3172
+ var RUNNER_VERSION = "0.3.3";
3173
+ var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
3174
+ function versionParts(value) {
3175
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
3176
+ if (!match) return null;
3177
+ const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
3178
+ return parts.every(Number.isSafeInteger) ? parts : null;
3179
+ }
3180
+ function compareRunnerVersions(left, right) {
3181
+ const a = versionParts(left);
3182
+ const b = versionParts(right);
3183
+ if (!a || !b) return null;
3184
+ for (let index = 0; index < a.length; index += 1) {
3185
+ if (a[index] > b[index]) return 1;
3186
+ if (a[index] < b[index]) return -1;
3187
+ }
3188
+ return 0;
3189
+ }
3190
+ function runnerVersionPolicyFrom(value) {
3191
+ if (!value || typeof value !== "object") return null;
3192
+ const policy = value;
3193
+ if (typeof policy.minimum !== "string" || typeof policy.recommended !== "string") return null;
3194
+ const order = compareRunnerVersions(policy.recommended, policy.minimum);
3195
+ if (order === null || order < 0) return null;
3196
+ return { minimum: policy.minimum, recommended: policy.recommended };
3197
+ }
3198
+ function runnerUpdateFor(policy, current = RUNNER_VERSION) {
3199
+ const recommendedOrder = compareRunnerVersions(policy.recommended, current);
3200
+ const minimumOrder = compareRunnerVersions(policy.minimum, current);
3201
+ if (recommendedOrder === null || minimumOrder === null) return null;
3202
+ if (recommendedOrder <= 0) return null;
3203
+ return { target: policy.recommended, required: minimumOrder > 0 };
3204
+ }
3205
+
3206
+ // src/protocol/presence.ts
3207
+ async function readRunnerPresence(input) {
3208
+ const fetchImpl = input.fetchImpl ?? fetch;
3209
+ try {
3210
+ const response = await fetchImpl(new URL("api/runner/me", input.server + "/"), {
3211
+ headers: {
3212
+ authorization: `Bearer ${input.token}`,
3213
+ accept: "application/json",
3214
+ [RUNNER_VERSION_HEADER]: RUNNER_VERSION
3215
+ },
3216
+ signal: AbortSignal.timeout(input.timeoutMs ?? 5e3)
3217
+ });
3218
+ if (response.status === 401 || response.status === 403) return { state: "revoked" };
3219
+ if (!response.ok) return { state: "unreachable" };
3220
+ const body = await response.json().catch(() => ({}));
3221
+ return {
3222
+ state: "ok",
3223
+ presence: {
3224
+ online: body.online === true,
3225
+ lastSeenAt: typeof body.lastSeenAt === "string" ? body.lastSeenAt : null,
3226
+ lastVersion: typeof body.lastVersion === "string" ? body.lastVersion : null
3227
+ }
3228
+ };
3229
+ } catch {
3230
+ return { state: "unreachable" };
3231
+ }
3232
+ }
3233
+ async function waitForRunnerCheckIn(input) {
3234
+ const now = input.now ?? Date.now;
3235
+ const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
3236
+ const deadline = now() + (input.timeoutMs ?? 15e3);
3237
+ for (; ; ) {
3238
+ const result = await readRunnerPresence(input);
3239
+ if (result.state === "revoked") return "revoked";
3240
+ if (result.state === "ok" && result.presence.online && result.presence.lastSeenAt !== null && result.presence.lastSeenAt !== input.previousLastSeenAt && result.presence.lastVersion === (input.expectedVersion ?? RUNNER_VERSION)) {
3241
+ return "confirmed";
3242
+ }
3243
+ if (now() >= deadline) return "timeout";
3244
+ await sleep(Math.min(input.pollMs ?? 750, Math.max(0, deadline - now())));
3245
+ }
3246
+ }
3247
+
3155
3248
  // src/config/mcp-credentials.ts
3156
3249
  import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3157
3250
  import { constants } from "node:fs";
@@ -3384,41 +3477,6 @@ function alive(pid) {
3384
3477
  }
3385
3478
  }
3386
3479
 
3387
- // src/version.ts
3388
- var RUNNER_VERSION = "0.3.1";
3389
- var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
3390
- function versionParts(value) {
3391
- const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
3392
- if (!match) return null;
3393
- const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
3394
- return parts.every(Number.isSafeInteger) ? parts : null;
3395
- }
3396
- function compareRunnerVersions(left, right) {
3397
- const a = versionParts(left);
3398
- const b = versionParts(right);
3399
- if (!a || !b) return null;
3400
- for (let index = 0; index < a.length; index += 1) {
3401
- if (a[index] > b[index]) return 1;
3402
- if (a[index] < b[index]) return -1;
3403
- }
3404
- return 0;
3405
- }
3406
- function runnerVersionPolicyFrom(value) {
3407
- if (!value || typeof value !== "object") return null;
3408
- const policy = value;
3409
- if (typeof policy.minimum !== "string" || typeof policy.recommended !== "string") return null;
3410
- const order = compareRunnerVersions(policy.recommended, policy.minimum);
3411
- if (order === null || order < 0) return null;
3412
- return { minimum: policy.minimum, recommended: policy.recommended };
3413
- }
3414
- function runnerUpdateFor(policy, current = RUNNER_VERSION) {
3415
- const recommendedOrder = compareRunnerVersions(policy.recommended, current);
3416
- const minimumOrder = compareRunnerVersions(policy.minimum, current);
3417
- if (recommendedOrder === null || minimumOrder === null) return null;
3418
- if (recommendedOrder <= 0) return null;
3419
- return { target: policy.recommended, required: minimumOrder > 0 };
3420
- }
3421
-
3422
3480
  // src/protocol/client.ts
3423
3481
  var LeaseLostError = class extends Error {
3424
3482
  constructor(message) {
@@ -3619,7 +3677,8 @@ var TerminalReporter = class {
3619
3677
  this.say("");
3620
3678
  this.say(` Chromium ${input.chromium ? GREEN + "ready" + RESET : YELLOW + "not installed yet" + RESET}`);
3621
3679
  this.say(` Connection ${input.server}`);
3622
- 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"}`);
3623
3682
  this.say("");
3624
3683
  }
3625
3684
  waiting(url) {
@@ -8104,6 +8163,26 @@ var VISUAL_SIGNATURE_STYLE = `
8104
8163
  box-shadow: none !important;
8105
8164
  }
8106
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
+ }
8107
8186
  function digestOf(payload) {
8108
8187
  return createHash4("sha256").update(payload.title + " " + payload.controls + " " + payload.text + " " + payload.scrollY).digest("hex").slice(0, 24);
8109
8188
  }
@@ -8164,17 +8243,19 @@ var PlaywrightBrowserWorker = class {
8164
8243
  if (typeof window.__aloudRecordSubmit === "function") window.__aloudRecordSubmit();
8165
8244
  }, true);
8166
8245
  `);
8167
- if (this.options.blockOffDomainNavigation && this.options.allowedDomains.length > 0) {
8168
- const allowed = this.options.allowedDomains;
8169
- await this.context.route("**/*", async (route) => {
8170
- const url = route.request().url();
8171
- if (route.request().resourceType() === "document" && !isDomainAllowed(url, allowed)) {
8172
- await route.abort("blockedbyclient");
8173
- return;
8174
- }
8175
- await route.continue();
8176
- });
8177
- }
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
+ });
8178
8259
  await this.page.goto(startUrl, { waitUntil: "domcontentloaded" });
8179
8260
  await this.settle();
8180
8261
  const capture = await this.capture();
@@ -8789,26 +8870,37 @@ var SnapshotRefused = class extends Error {
8789
8870
  async function sanitiseSnapshot(snapshot, lease, local, nowIso, resolver) {
8790
8871
  const granted = normaliseHosts(lease.allowedHosts);
8791
8872
  const permitted = normaliseHosts(local.allowedHosts);
8792
- const effective = intersectHosts(granted, permitted);
8793
- if (effective.length === 0) {
8794
- throw new SnapshotRefused(
8795
- `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.`
8796
- );
8797
- }
8798
8873
  const startUrl = snapshot.study?.startUrl;
8799
8874
  if (typeof startUrl !== "string" || startUrl.length === 0) {
8800
8875
  throw new SnapshotRefused("This study has no start URL.");
8801
8876
  }
8802
- const verdict = leasePermits({ ...lease, allowedHosts: effective }, startUrl, nowIso);
8877
+ const verdict = leasePermits({ ...lease, allowedHosts: granted }, startUrl, nowIso);
8803
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
+ }
8804
8895
  const target = await evaluateTargetUrl(startUrl, {
8805
8896
  allowedDomains: effective,
8806
- allowPrivateNetwork: local.allowPrivateNetwork,
8897
+ allowPrivateNetwork,
8807
8898
  ...resolver ? { resolver } : {}
8808
8899
  });
8809
8900
  if (!target.allowed) throw new SnapshotRefused(target.reason);
8810
8901
  return {
8811
8902
  allowedHosts: effective,
8903
+ allowPrivateNetwork,
8812
8904
  snapshot: {
8813
8905
  ...snapshot,
8814
8906
  environment: {
@@ -8923,7 +9015,7 @@ async function executeLease(deps) {
8923
9015
  const workers = new GuardedWorkerFactory(
8924
9016
  deps.workers ?? new PlaywrightWorkerFactory(),
8925
9017
  effective.allowedHosts,
8926
- deps.local
9018
+ { ...deps.local, allowPrivateNetwork: effective.allowPrivateNetwork }
8927
9019
  );
8928
9020
  coordinator = new RunCoordinator(
8929
9021
  {
@@ -9093,13 +9185,16 @@ var MAX_PAGES = 6;
9093
9185
  var MAX_LINKS_PER_PAGE = 200;
9094
9186
  var PAGE_TIMEOUT_MS = 15e3;
9095
9187
  async function discoverProduct(job, local) {
9096
- 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);
9097
9191
  if (effectiveHosts.length === 0) {
9098
9192
  throw new Error(
9099
- `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"}.`
9100
9194
  );
9101
9195
  }
9102
- await assertTarget(job.url, effectiveHosts, local);
9196
+ const allowPrivateNetwork = access.kind === "private" && local.allowPrivateNetwork;
9197
+ await assertTarget(job.url, effectiveHosts, allowPrivateNetwork);
9103
9198
  const browser = await chromium2.launch({
9104
9199
  args: ["--disable-dev-shm-usage"],
9105
9200
  handleSIGINT: false,
@@ -9115,7 +9210,7 @@ async function discoverProduct(job, local) {
9115
9210
  serviceWorkers: "block"
9116
9211
  });
9117
9212
  try {
9118
- await guardRequests(context, effectiveHosts, local);
9213
+ await guardRequests(context, effectiveHosts, allowPrivateNetwork);
9119
9214
  const start2 = canonicalUrl(job.url);
9120
9215
  const origin = new URL(start2).origin;
9121
9216
  const queued = [start2];
@@ -9126,7 +9221,7 @@ async function discoverProduct(job, local) {
9126
9221
  if (seen.has(url)) continue;
9127
9222
  seen.add(url);
9128
9223
  try {
9129
- await assertTarget(url, effectiveHosts, local);
9224
+ await assertTarget(url, effectiveHosts, allowPrivateNetwork);
9130
9225
  const page = await context.newPage();
9131
9226
  try {
9132
9227
  page.setDefaultTimeout(PAGE_TIMEOUT_MS);
@@ -9135,7 +9230,7 @@ async function discoverProduct(job, local) {
9135
9230
  await page.waitForLoadState("networkidle", { timeout: 1500 });
9136
9231
  } catch {
9137
9232
  }
9138
- await assertTarget(page.url(), effectiveHosts, local);
9233
+ await assertTarget(page.url(), effectiveHosts, allowPrivateNetwork);
9139
9234
  const captured = await extractPage(page);
9140
9235
  pages.push(captured);
9141
9236
  const candidates = prioritiseDiscoveryLinks(captured.links, origin).map((link) => canonicalUrl(link.href)).filter((href) => !seen.has(href) && !queued.includes(href));
@@ -9156,14 +9251,14 @@ async function discoverProduct(job, local) {
9156
9251
  await browser.close();
9157
9252
  }
9158
9253
  }
9159
- async function assertTarget(url, allowedDomains, local) {
9254
+ async function assertTarget(url, allowedDomains, allowPrivateNetwork) {
9160
9255
  const verdict = await evaluateTargetUrl(url, {
9161
9256
  allowedDomains,
9162
- allowPrivateNetwork: local.allowPrivateNetwork
9257
+ allowPrivateNetwork
9163
9258
  });
9164
9259
  if (!verdict.allowed) throw new Error(`Refusing product discovery: ${verdict.reason}`);
9165
9260
  }
9166
- async function guardRequests(context, allowedDomains, local) {
9261
+ async function guardRequests(context, allowedDomains, allowPrivateNetwork) {
9167
9262
  await context.route("**/*", async (route) => {
9168
9263
  const request = route.request();
9169
9264
  let url;
@@ -9181,15 +9276,13 @@ async function guardRequests(context, allowedDomains, local) {
9181
9276
  await route.abort("blockedbyclient");
9182
9277
  return;
9183
9278
  }
9184
- if (request.resourceType() === "document") {
9185
- const verdict = await evaluateTargetUrl(url.toString(), {
9186
- allowedDomains,
9187
- allowPrivateNetwork: local.allowPrivateNetwork
9188
- });
9189
- if (!verdict.allowed) {
9190
- await route.abort("blockedbyclient");
9191
- return;
9192
- }
9279
+ const verdict = await evaluateTargetUrl(url.toString(), {
9280
+ allowedDomains,
9281
+ allowPrivateNetwork
9282
+ });
9283
+ if (!verdict.allowed) {
9284
+ await route.abort("blockedbyclient");
9285
+ return;
9193
9286
  }
9194
9287
  await route.continue();
9195
9288
  });
@@ -9278,9 +9371,13 @@ async function runLoop(deps) {
9278
9371
  deps.ui.waiting?.(deps.webUrl);
9279
9372
  while (!deps.signal?.aborted) {
9280
9373
  let claim = null;
9374
+ const pollLocal = deps.loadLocalPolicy ? await deps.loadLocalPolicy() : deps.local;
9281
9375
  try {
9282
9376
  const response = await deps.client.request("api/runner/claim", {
9283
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 },
9284
9381
  // A poll should fail fast and come back, not block for a minute holding the loop.
9285
9382
  retry: false,
9286
9383
  ...deps.signal ? { signal: deps.signal } : {}
@@ -9298,11 +9395,17 @@ async function runLoop(deps) {
9298
9395
  }
9299
9396
  throw error;
9300
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
+ }
9301
9404
  if (claim?.kind === "product_discovery" && claim.setupJob) {
9302
9405
  lastActivityAt = now();
9303
9406
  deps.ui.note(`Learning what ${new URL(claim.setupJob.url).hostname} does from its public pages.`);
9304
9407
  try {
9305
- const evidence = await discoverProduct(claim.setupJob, deps.local);
9408
+ const evidence = await discoverProduct(claim.setupJob, pollLocal);
9306
9409
  await deps.client.request("api/runner/discovery", {
9307
9410
  method: "POST",
9308
9411
  body: { setupJobId: claim.setupJob.id, evidence },
@@ -9345,7 +9448,7 @@ async function runLoop(deps) {
9345
9448
  run: claim.run,
9346
9449
  productId: claim.productId ?? "",
9347
9450
  routing: claim.routing ?? {},
9348
- local: deps.local,
9451
+ local: pollLocal,
9349
9452
  spool,
9350
9453
  ui: deps.ui,
9351
9454
  ...deps.workers ? { workers: deps.workers } : {},
@@ -10600,7 +10703,7 @@ function printHelp() {
10600
10703
  " aloud start [--once] [--quiet] [--no-update]",
10601
10704
  " Update, then wait for studies and run them here",
10602
10705
  " aloud status What is set up, and whether it is running",
10603
- " aloud allow <host> Let studies open this host from this machine",
10706
+ " aloud allow <host> Approve a private or local host on this machine",
10604
10707
  " aloud mcp Serve MCP to an editor, using the saved credential",
10605
10708
  " aloud mcp connect Connect an editor, approving it in your browser",
10606
10709
  " aloud logout Forget the token on this machine",
@@ -10700,7 +10803,8 @@ async function connectWith(server, token) {
10700
10803
  process.stdout.write(`
10701
10804
  Connected as ${credentials.runnerName}.
10702
10805
  `);
10703
- 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"}
10704
10808
  `);
10705
10809
  reporter.privacyNote();
10706
10810
  process.stdout.write("Run `aloud start` and leave it running.\n\n");
@@ -10802,8 +10906,21 @@ async function setup() {
10802
10906
  );
10803
10907
  out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
10804
10908
  out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
10909
+ out(
10910
+ ` Aloud sees ${signedIn.state === "ok" ? signedIn.online ? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""}` : running ? "not yet. The local process exists, but no current check-in is confirmed" : "no" : signedIn.state === "unreachable" ? "unknown, the server did not answer" : "no"}`
10911
+ );
10805
10912
  out();
10806
- if (process.stdin.isTTY) return interactiveSetup({ installed, stale, latest, signedIn, running, credentials });
10913
+ if (process.stdin.isTTY) {
10914
+ return interactiveSetup({
10915
+ installed,
10916
+ stale,
10917
+ latest,
10918
+ signedIn,
10919
+ running,
10920
+ credentials,
10921
+ chromiumInstalled: checks.chromiumInstalled
10922
+ });
10923
+ }
10807
10924
  const steps = [];
10808
10925
  if (!installed) {
10809
10926
  steps.push(["npm install -g @aloud/runner"]);
@@ -10832,6 +10949,20 @@ async function setup() {
10832
10949
  out();
10833
10950
  return 1;
10834
10951
  }
10952
+ if (steps.length === 0 && running && signedIn.state === "ok" && !signedIn.online) {
10953
+ out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
10954
+ out(`Read ${join6(dirname5(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
10955
+ out("Do not report this machine ready until `Aloud sees` says yes.");
10956
+ out();
10957
+ return 1;
10958
+ }
10959
+ if (steps.length === 0 && running && signedIn.state === "ok" && signedIn.online && signedIn.lastVersion !== RUNNER_VERSION) {
10960
+ out(`The live process is checking in as v${signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`);
10961
+ out(`Restart it with: kill ${running.pid} && aloud start`);
10962
+ out("Do not report the update complete until `aloud status` shows the expected version.");
10963
+ out();
10964
+ return 1;
10965
+ }
10835
10966
  if (steps.length === 0) {
10836
10967
  out("Nothing to do. This machine is set up and waiting for studies.");
10837
10968
  out();
@@ -10905,12 +11036,28 @@ async function interactiveSetup(state) {
10905
11036
  out("Start it when you are ready, and leave it running: aloud start");
10906
11037
  return 1;
10907
11038
  }
10908
- return startDetached(out);
11039
+ return startDetached(out, { waitForConfirmation: state.chromiumInstalled });
11040
+ }
11041
+ if (state.signedIn.state === "ok" && state.signedIn.online && state.signedIn.lastVersion === RUNNER_VERSION) {
11042
+ out("");
11043
+ out("Set up and confirmed by Aloud. This machine is waiting for eligible studies.");
11044
+ out("");
11045
+ return 0;
11046
+ }
11047
+ if (state.signedIn.state === "ok" && state.signedIn.online) {
11048
+ out("");
11049
+ out(
11050
+ `The live process is checking in as v${state.signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`
11051
+ );
11052
+ out(`Restart it with: kill ${state.running.pid} && aloud start`);
11053
+ out("");
11054
+ return 1;
10909
11055
  }
10910
11056
  out("");
10911
- out("Set up. This machine is waiting for studies.");
11057
+ out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
11058
+ out(`Read ${join6(dirname5(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
10912
11059
  out("");
10913
- return 0;
11060
+ return 1;
10914
11061
  } finally {
10915
11062
  rl.close();
10916
11063
  }
@@ -10930,9 +11077,11 @@ async function run(command, args, out) {
10930
11077
  child.on("close", (code) => resolve(code === 0));
10931
11078
  });
10932
11079
  }
10933
- async function startDetached(out) {
11080
+ async function startDetached(out, options) {
10934
11081
  const log = join6(dirname5(credentialsPath()), "runner.log");
10935
11082
  await mkdir5(dirname5(log), { recursive: true, mode: 448 });
11083
+ const credentials = await readCredentials();
11084
+ const before = credentials ? await readRunnerPresence({ server: credentials.server, token: credentials.token }) : null;
10936
11085
  const handle = openSync(log, "a");
10937
11086
  const child = spawn2(process.execPath, [process.argv[1] ?? "", "start"], {
10938
11087
  detached: true,
@@ -10945,23 +11094,50 @@ async function startDetached(out) {
10945
11094
  out(" Check it aloud status");
10946
11095
  out(` Stop it kill ${child.pid}`);
10947
11096
  out("");
10948
- out("The first start downloads Chromium, about 350 MB, once. Studies will wait until it is done.");
11097
+ if (!options.waitForConfirmation) {
11098
+ out("The runner is preparing Chromium in the background. It is started locally, but not yet");
11099
+ out("confirmed by Aloud. The Machines page updates automatically after its first check-in.");
11100
+ out("");
11101
+ return 0;
11102
+ }
11103
+ if (!credentials || before?.state !== "ok") {
11104
+ out("Started locally, but Aloud could not establish a before-start status to confirm this launch.");
11105
+ out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
11106
+ out("");
11107
+ return 1;
11108
+ }
11109
+ out("Waiting for Aloud to confirm the first check-in\u2026");
11110
+ const confirmation = await waitForRunnerCheckIn({
11111
+ server: credentials.server,
11112
+ token: credentials.token,
11113
+ previousLastSeenAt: before.presence.lastSeenAt
11114
+ });
11115
+ if (confirmation === "confirmed") {
11116
+ out("Confirmed by Aloud. This machine is online and waiting for eligible studies.");
11117
+ out("");
11118
+ return 0;
11119
+ }
11120
+ if (confirmation === "revoked") {
11121
+ out("Aloud refused the saved connection. Run `aloud login` to approve this machine again.");
11122
+ } else {
11123
+ out("The process started, but Aloud did not confirm a check-in within 15 seconds.");
11124
+ out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
11125
+ }
10949
11126
  out("");
10950
- return 0;
11127
+ return 1;
10951
11128
  }
10952
11129
  async function signedInState(credentials) {
10953
11130
  if (!credentials) return { state: "none" };
10954
- try {
10955
- const response = await fetch(new URL("api/runner/me", credentials.server + "/"), {
10956
- headers: { authorization: `Bearer ${credentials.token}` },
10957
- signal: AbortSignal.timeout(5e3)
10958
- });
10959
- if (response.status === 401 || response.status === 403) return { state: "revoked" };
10960
- if (!response.ok) return { state: "unreachable", server: credentials.server };
10961
- return { state: "ok", name: credentials.runnerName };
10962
- } catch {
10963
- return { state: "unreachable", server: credentials.server };
10964
- }
11131
+ const result = await readRunnerPresence({ server: credentials.server, token: credentials.token });
11132
+ if (result.state === "revoked") return { state: "revoked" };
11133
+ if (result.state === "unreachable") return { state: "unreachable", server: credentials.server };
11134
+ return {
11135
+ state: "ok",
11136
+ name: credentials.runnerName,
11137
+ online: result.presence.online,
11138
+ lastSeenAt: result.presence.lastSeenAt,
11139
+ lastVersion: result.presence.lastVersion
11140
+ };
10965
11141
  }
10966
11142
  async function npmPrefix() {
10967
11143
  const path = await new Promise((resolve) => {
@@ -11136,6 +11312,7 @@ async function status() {
11136
11312
  });
11137
11313
  const checks = await preflight();
11138
11314
  const running = await readRunning();
11315
+ const signedIn = await signedInState(credentials);
11139
11316
  process.stdout.write("\n");
11140
11317
  if (!credentials) {
11141
11318
  process.stdout.write("Signed in no. Run `aloud login`.\n");
@@ -11144,8 +11321,11 @@ async function status() {
11144
11321
  `);
11145
11322
  process.stdout.write(`Server ${credentials.server}
11146
11323
  `);
11147
- process.stdout.write(`Allowed here ${credentials.allowedHosts.join(", ") || "nothing yet"}
11148
- `);
11324
+ process.stdout.write("Public sites automatic per study\n");
11325
+ process.stdout.write(
11326
+ `Private/local ${credentials.allowedHosts.join(", ") || "nothing approved"}
11327
+ `
11328
+ );
11149
11329
  }
11150
11330
  process.stdout.write(
11151
11331
  `Chromium ${checks.chromiumInstalled ? `ready (${checks.chromiumPath})` : "not installed yet"}
@@ -11153,15 +11333,19 @@ async function status() {
11153
11333
  );
11154
11334
  process.stdout.write(
11155
11335
  `Running ${running ? `yes, since ${when(running.startedAt)} (pid ${running.pid})` : "no. Run `aloud start`."}
11336
+ `
11337
+ );
11338
+ process.stdout.write(
11339
+ `Aloud sees ${signedIn.state === "ok" ? signedIn.online ? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""}` : "no current check-in" : signedIn.state === "unreachable" ? "unknown, server did not answer" : "no"}
11156
11340
  `
11157
11341
  );
11158
11342
  process.stdout.write("\n");
11159
- return credentials && checks.chromiumInstalled && running ? 0 : 1;
11343
+ return credentials && checks.chromiumInstalled && running && signedIn.state === "ok" && signedIn.online ? 0 : 1;
11160
11344
  }
11161
11345
  async function allow(argv) {
11162
11346
  const host = argv.find((arg) => !arg.startsWith("-"));
11163
11347
  if (!host) {
11164
- 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");
11165
11349
  return 1;
11166
11350
  }
11167
11351
  const credentials = await readCredentials();
@@ -11172,9 +11356,10 @@ async function allow(argv) {
11172
11356
  const next = normaliseHosts([...credentials.allowedHosts, host]);
11173
11357
  await writeCredentials({ ...credentials, allowedHosts: next });
11174
11358
  process.stdout.write(`
11175
- Studies on this machine may now open: ${next.join(", ")}
11359
+ Private/local access approved for: ${next.join(", ")}
11176
11360
  `);
11177
- 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");
11178
11363
  return 0;
11179
11364
  }
11180
11365
  async function start(argv) {
@@ -11207,10 +11392,6 @@ To restart it: kill ${existing.pid} && aloud start
11207
11392
  }
11208
11393
  }
11209
11394
  const local = policyOf(credentials, argv);
11210
- if (local.allowedHosts.length === 0) {
11211
- process.stderr.write("This machine is not allowed to open anything. Try `aloud allow localhost`.\n");
11212
- return 1;
11213
- }
11214
11395
  const client = new RunnerClient({
11215
11396
  server: credentials.server,
11216
11397
  token: credentials.token,
@@ -11227,14 +11408,35 @@ To restart it: kill ${existing.pid} && aloud start
11227
11408
  installSignalHandlers(controller, reporter);
11228
11409
  await writeRunning({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString(), server: credentials.server });
11229
11410
  process.on("exit", () => clearRunningSync());
11411
+ let attemptedIdleUpdate = null;
11230
11412
  try {
11231
11413
  await runLoop({
11232
11414
  client,
11233
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
+ },
11234
11420
  ui: reporter,
11235
11421
  webUrl: `${credentials.server}/app`,
11236
11422
  once: argv.includes("--once"),
11237
- 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
+ }
11238
11440
  });
11239
11441
  return 0;
11240
11442
  } catch (error) {