@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 +12 -5
- package/dist/cli.js +137 -54
- package/package.json +1 -1
- package/src/cli.ts +40 -11
- package/src/config/policy.ts +1 -1
- package/src/discovery.ts +28 -18
- package/src/loop.ts +20 -3
- 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/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>` |
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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(`
|
|
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
|
-
|
|
8210
|
-
const
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
8217
|
-
await route.
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
`
|
|
9193
|
+
`Private product discovery wants ${job.allowedHosts.join(", ")}, but this machine allows ${local.allowedHosts.join(", ") || "nothing"}.`
|
|
9142
9194
|
);
|
|
9143
9195
|
}
|
|
9144
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
9254
|
+
async function assertTarget(url, allowedDomains, allowPrivateNetwork) {
|
|
9202
9255
|
const verdict = await evaluateTargetUrl(url, {
|
|
9203
9256
|
allowedDomains,
|
|
9204
|
-
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,
|
|
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
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
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,
|
|
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:
|
|
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>
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
11359
|
+
Private/local access approved for: ${next.join(", ")}
|
|
11295
11360
|
`);
|
|
11296
|
-
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");
|
|
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
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>
|
|
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(
|
|
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(
|
|
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
|
|
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(`\
|
|
1001
|
-
process.stdout.write("
|
|
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) {
|
package/src/config/policy.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { normaliseHosts } from "@aloud/core";
|
|
|
13
13
|
* ever edits this file.
|
|
14
14
|
*/
|
|
15
15
|
export interface LocalPolicy {
|
|
16
|
-
/**
|
|
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
|
|
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
|
-
`
|
|
40
|
+
`Private product discovery wants ${job.allowedHosts.join(", ")}, but this machine allows ` +
|
|
33
41
|
`${local.allowedHosts.join(", ") || "nothing"}.`,
|
|
34
42
|
);
|
|
35
43
|
}
|
|
36
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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[],
|
|
114
|
+
async function assertTarget(url: string, allowedDomains: string[], allowPrivateNetwork: boolean): Promise<void> {
|
|
106
115
|
const verdict = await evaluateTargetUrl(url, {
|
|
107
116
|
allowedDomains,
|
|
108
|
-
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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,
|
|
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:
|
|
152
|
+
local: pollLocal,
|
|
136
153
|
spool,
|
|
137
154
|
ui: deps.ui,
|
|
138
155
|
...(deps.workers ? { workers: deps.workers } : {}),
|
package/src/run/execute.ts
CHANGED
|
@@ -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(
|
package/src/run/sanitise.ts
CHANGED
|
@@ -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
|
-
//
|
|
64
|
-
|
|
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
|
|
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(`
|
|
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.
|
|
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";
|