@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/README.md +12 -5
- package/dist/cli.js +309 -107
- package/package.json +1 -1
- package/src/cli.ts +199 -35
- package/src/config/policy.ts +1 -1
- package/src/discovery.ts +28 -18
- package/src/loop.ts +20 -3
- package/src/protocol/presence.ts +83 -0
- package/src/run/execute.ts +1 -1
- package/src/run/sanitise.ts +36 -14
- package/src/ui/output.ts +2 -1
- package/src/version.ts +1 -1
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(`
|
|
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
|
-
|
|
8168
|
-
const
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
await route.
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
`
|
|
9193
|
+
`Private product discovery wants ${job.allowedHosts.join(", ")}, but this machine allows ${local.allowedHosts.join(", ") || "nothing"}.`
|
|
9100
9194
|
);
|
|
9101
9195
|
}
|
|
9102
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
9254
|
+
async function assertTarget(url, allowedDomains, allowPrivateNetwork) {
|
|
9160
9255
|
const verdict = await evaluateTargetUrl(url, {
|
|
9161
9256
|
allowedDomains,
|
|
9162
|
-
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,
|
|
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
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
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,
|
|
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:
|
|
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>
|
|
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(
|
|
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)
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
|
11127
|
+
return 1;
|
|
10951
11128
|
}
|
|
10952
11129
|
async function signedInState(credentials) {
|
|
10953
11130
|
if (!credentials) return { state: "none" };
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
|
|
10957
|
-
|
|
10958
|
-
|
|
10959
|
-
|
|
10960
|
-
|
|
10961
|
-
|
|
10962
|
-
|
|
10963
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
11359
|
+
Private/local access approved for: ${next.join(", ")}
|
|
11176
11360
|
`);
|
|
11177
|
-
process.stdout.write("
|
|
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) {
|