@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/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { createInterface } from "node:readline/promises";
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { startApproval, waitForApproval, type ApprovalStart } from "./protocol/approval";
|
|
12
|
+
import { readRunnerPresence, waitForRunnerCheckIn } from "./protocol/presence";
|
|
12
13
|
import {
|
|
13
14
|
clearMcpCredentials,
|
|
14
15
|
mcpCredentialsPath,
|
|
@@ -101,7 +102,7 @@ function printHelp(): void {
|
|
|
101
102
|
" aloud start [--once] [--quiet] [--no-update]",
|
|
102
103
|
" Update, then wait for studies and run them here",
|
|
103
104
|
" aloud status What is set up, and whether it is running",
|
|
104
|
-
" aloud allow <host>
|
|
105
|
+
" aloud allow <host> Approve a private or local host on this machine",
|
|
105
106
|
" aloud mcp Serve MCP to an editor, using the saved credential",
|
|
106
107
|
" aloud mcp connect Connect an editor, approving it in your browser",
|
|
107
108
|
" aloud logout Forget the token on this machine",
|
|
@@ -224,7 +225,8 @@ async function connectWith(server: string, token: string): Promise<number> {
|
|
|
224
225
|
|
|
225
226
|
const reporter = new TerminalReporter(process.stdout, token);
|
|
226
227
|
process.stdout.write(`\nConnected as ${credentials.runnerName}.\n`);
|
|
227
|
-
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`);
|
|
228
230
|
reporter.privacyNote();
|
|
229
231
|
process.stdout.write("Run `aloud start` and leave it running.\n\n");
|
|
230
232
|
return 0;
|
|
@@ -366,6 +368,21 @@ async function setup(): Promise<number> {
|
|
|
366
368
|
);
|
|
367
369
|
out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
|
|
368
370
|
out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
|
|
371
|
+
out(
|
|
372
|
+
` Aloud sees ${
|
|
373
|
+
signedIn.state === "ok"
|
|
374
|
+
? signedIn.online
|
|
375
|
+
? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${
|
|
376
|
+
signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""
|
|
377
|
+
}`
|
|
378
|
+
: running
|
|
379
|
+
? "not yet. The local process exists, but no current check-in is confirmed"
|
|
380
|
+
: "no"
|
|
381
|
+
: signedIn.state === "unreachable"
|
|
382
|
+
? "unknown, the server did not answer"
|
|
383
|
+
: "no"
|
|
384
|
+
}`,
|
|
385
|
+
);
|
|
369
386
|
out();
|
|
370
387
|
|
|
371
388
|
// Each step is a command plus the lines that qualify it. Only the command gets a number, or a
|
|
@@ -373,7 +390,17 @@ async function setup(): Promise<number> {
|
|
|
373
390
|
// into a shell.
|
|
374
391
|
// A person in a terminal gets setup done, not a list of things to go and do. An agent, which has
|
|
375
392
|
// no terminal, gets the list. Same command, and the difference is who can answer a prompt.
|
|
376
|
-
if (process.stdin.isTTY)
|
|
393
|
+
if (process.stdin.isTTY) {
|
|
394
|
+
return interactiveSetup({
|
|
395
|
+
installed,
|
|
396
|
+
stale,
|
|
397
|
+
latest,
|
|
398
|
+
signedIn,
|
|
399
|
+
running,
|
|
400
|
+
credentials,
|
|
401
|
+
chromiumInstalled: checks.chromiumInstalled,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
377
404
|
|
|
378
405
|
const steps: string[][] = [];
|
|
379
406
|
if (!installed) {
|
|
@@ -405,6 +432,28 @@ async function setup(): Promise<number> {
|
|
|
405
432
|
return 1;
|
|
406
433
|
}
|
|
407
434
|
|
|
435
|
+
if (steps.length === 0 && running && signedIn.state === "ok" && !signedIn.online) {
|
|
436
|
+
out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
|
|
437
|
+
out(`Read ${join(dirname(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
|
|
438
|
+
out("Do not report this machine ready until `Aloud sees` says yes.");
|
|
439
|
+
out();
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (
|
|
444
|
+
steps.length === 0 &&
|
|
445
|
+
running &&
|
|
446
|
+
signedIn.state === "ok" &&
|
|
447
|
+
signedIn.online &&
|
|
448
|
+
signedIn.lastVersion !== RUNNER_VERSION
|
|
449
|
+
) {
|
|
450
|
+
out(`The live process is checking in as v${signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`);
|
|
451
|
+
out(`Restart it with: kill ${running.pid} && aloud start`);
|
|
452
|
+
out("Do not report the update complete until `aloud status` shows the expected version.");
|
|
453
|
+
out();
|
|
454
|
+
return 1;
|
|
455
|
+
}
|
|
456
|
+
|
|
408
457
|
if (steps.length === 0) {
|
|
409
458
|
out("Nothing to do. This machine is set up and waiting for studies.");
|
|
410
459
|
out();
|
|
@@ -458,9 +507,10 @@ async function interactiveSetup(state: {
|
|
|
458
507
|
installed: boolean;
|
|
459
508
|
stale: boolean;
|
|
460
509
|
latest: string | null;
|
|
461
|
-
signedIn:
|
|
510
|
+
signedIn: SignedInState;
|
|
462
511
|
running: RunningState | null;
|
|
463
512
|
credentials: Credentials | null;
|
|
513
|
+
chromiumInstalled: boolean;
|
|
464
514
|
}): Promise<number> {
|
|
465
515
|
const out = (line = "") => process.stdout.write(line + "\n");
|
|
466
516
|
const server = state.credentials?.server ?? DEFAULT_SERVER;
|
|
@@ -503,13 +553,35 @@ async function interactiveSetup(state: {
|
|
|
503
553
|
out("Start it when you are ready, and leave it running: aloud start");
|
|
504
554
|
return 1;
|
|
505
555
|
}
|
|
506
|
-
return startDetached(out);
|
|
556
|
+
return startDetached(out, { waitForConfirmation: state.chromiumInstalled });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (
|
|
560
|
+
state.signedIn.state === "ok" &&
|
|
561
|
+
state.signedIn.online &&
|
|
562
|
+
state.signedIn.lastVersion === RUNNER_VERSION
|
|
563
|
+
) {
|
|
564
|
+
out("");
|
|
565
|
+
out("Set up and confirmed by Aloud. This machine is waiting for eligible studies.");
|
|
566
|
+
out("");
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (state.signedIn.state === "ok" && state.signedIn.online) {
|
|
571
|
+
out("");
|
|
572
|
+
out(
|
|
573
|
+
`The live process is checking in as v${state.signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`,
|
|
574
|
+
);
|
|
575
|
+
out(`Restart it with: kill ${state.running.pid} && aloud start`);
|
|
576
|
+
out("");
|
|
577
|
+
return 1;
|
|
507
578
|
}
|
|
508
579
|
|
|
509
580
|
out("");
|
|
510
|
-
out("
|
|
581
|
+
out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
|
|
582
|
+
out(`Read ${join(dirname(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
|
|
511
583
|
out("");
|
|
512
|
-
return
|
|
584
|
+
return 1;
|
|
513
585
|
} finally {
|
|
514
586
|
rl.close();
|
|
515
587
|
}
|
|
@@ -540,9 +612,16 @@ async function run(command: string, args: readonly string[], out: (line?: string
|
|
|
540
612
|
* Output goes to a file rather than nowhere, because the first start downloads Chromium and a
|
|
541
613
|
* silent five minutes is indistinguishable from a hang.
|
|
542
614
|
*/
|
|
543
|
-
async function startDetached(
|
|
615
|
+
async function startDetached(
|
|
616
|
+
out: (line?: string) => void,
|
|
617
|
+
options: { waitForConfirmation: boolean },
|
|
618
|
+
): Promise<number> {
|
|
544
619
|
const log = join(dirname(credentialsPath()), "runner.log");
|
|
545
620
|
await mkdir(dirname(log), { recursive: true, mode: 0o700 });
|
|
621
|
+
const credentials = await readCredentials();
|
|
622
|
+
const before = credentials
|
|
623
|
+
? await readRunnerPresence({ server: credentials.server, token: credentials.token })
|
|
624
|
+
: null;
|
|
546
625
|
const handle = openSync(log, "a");
|
|
547
626
|
const child = spawn(process.execPath, [process.argv[1] ?? "", "start"], {
|
|
548
627
|
detached: true,
|
|
@@ -556,9 +635,40 @@ async function startDetached(out: (line?: string) => void): Promise<number> {
|
|
|
556
635
|
out(" Check it aloud status");
|
|
557
636
|
out(` Stop it kill ${child.pid}`);
|
|
558
637
|
out("");
|
|
559
|
-
|
|
638
|
+
if (!options.waitForConfirmation) {
|
|
639
|
+
out("The runner is preparing Chromium in the background. It is started locally, but not yet");
|
|
640
|
+
out("confirmed by Aloud. The Machines page updates automatically after its first check-in.");
|
|
641
|
+
out("");
|
|
642
|
+
return 0;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (!credentials || before?.state !== "ok") {
|
|
646
|
+
out("Started locally, but Aloud could not establish a before-start status to confirm this launch.");
|
|
647
|
+
out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
|
|
648
|
+
out("");
|
|
649
|
+
return 1;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
out("Waiting for Aloud to confirm the first check-in…");
|
|
653
|
+
const confirmation = await waitForRunnerCheckIn({
|
|
654
|
+
server: credentials.server,
|
|
655
|
+
token: credentials.token,
|
|
656
|
+
previousLastSeenAt: before.presence.lastSeenAt,
|
|
657
|
+
});
|
|
658
|
+
if (confirmation === "confirmed") {
|
|
659
|
+
out("Confirmed by Aloud. This machine is online and waiting for eligible studies.");
|
|
660
|
+
out("");
|
|
661
|
+
return 0;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (confirmation === "revoked") {
|
|
665
|
+
out("Aloud refused the saved connection. Run `aloud login` to approve this machine again.");
|
|
666
|
+
} else {
|
|
667
|
+
out("The process started, but Aloud did not confirm a check-in within 15 seconds.");
|
|
668
|
+
out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
|
|
669
|
+
}
|
|
560
670
|
out("");
|
|
561
|
-
return
|
|
671
|
+
return 1;
|
|
562
672
|
}
|
|
563
673
|
|
|
564
674
|
/**
|
|
@@ -569,21 +679,30 @@ async function startDetached(out: (line?: string) => void): Promise<number> {
|
|
|
569
679
|
* presence sent an agent off to `aloud start`, which died on its first poll with a 401 and no
|
|
570
680
|
* explanation of what to do. This is the same endpoint `login` checks a token against.
|
|
571
681
|
*/
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
682
|
+
type SignedInState =
|
|
683
|
+
| { state: "none" }
|
|
684
|
+
| {
|
|
685
|
+
state: "ok";
|
|
686
|
+
name: string;
|
|
687
|
+
online: boolean;
|
|
688
|
+
lastSeenAt: string | null;
|
|
689
|
+
lastVersion: string | null;
|
|
690
|
+
}
|
|
691
|
+
| { state: "revoked" }
|
|
692
|
+
| { state: "unreachable"; server: string };
|
|
693
|
+
|
|
694
|
+
async function signedInState(credentials: Credentials | null): Promise<SignedInState> {
|
|
575
695
|
if (!credentials) return { state: "none" };
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
696
|
+
const result = await readRunnerPresence({ server: credentials.server, token: credentials.token });
|
|
697
|
+
if (result.state === "revoked") return { state: "revoked" };
|
|
698
|
+
if (result.state === "unreachable") return { state: "unreachable", server: credentials.server };
|
|
699
|
+
return {
|
|
700
|
+
state: "ok",
|
|
701
|
+
name: credentials.runnerName,
|
|
702
|
+
online: result.presence.online,
|
|
703
|
+
lastSeenAt: result.presence.lastSeenAt,
|
|
704
|
+
lastVersion: result.presence.lastVersion,
|
|
705
|
+
};
|
|
587
706
|
}
|
|
588
707
|
|
|
589
708
|
/**
|
|
@@ -823,6 +942,7 @@ async function status(): Promise<number> {
|
|
|
823
942
|
});
|
|
824
943
|
const checks = await preflight();
|
|
825
944
|
const running = await readRunning();
|
|
945
|
+
const signedIn = await signedInState(credentials);
|
|
826
946
|
|
|
827
947
|
process.stdout.write("\n");
|
|
828
948
|
if (!credentials) {
|
|
@@ -830,7 +950,10 @@ async function status(): Promise<number> {
|
|
|
830
950
|
} else {
|
|
831
951
|
process.stdout.write(`Signed in ${credentials.runnerName}\n`);
|
|
832
952
|
process.stdout.write(`Server ${credentials.server}\n`);
|
|
833
|
-
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
|
+
);
|
|
834
957
|
}
|
|
835
958
|
process.stdout.write(
|
|
836
959
|
`Chromium ${checks.chromiumInstalled ? `ready (${checks.chromiumPath})` : "not installed yet"}\n`,
|
|
@@ -838,11 +961,27 @@ async function status(): Promise<number> {
|
|
|
838
961
|
process.stdout.write(
|
|
839
962
|
`Running ${running ? `yes, since ${when(running.startedAt)} (pid ${running.pid})` : "no. Run `aloud start`."}\n`,
|
|
840
963
|
);
|
|
964
|
+
process.stdout.write(
|
|
965
|
+
`Aloud sees ${
|
|
966
|
+
signedIn.state === "ok"
|
|
967
|
+
? signedIn.online
|
|
968
|
+
? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${
|
|
969
|
+
signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""
|
|
970
|
+
}`
|
|
971
|
+
: "no current check-in"
|
|
972
|
+
: signedIn.state === "unreachable"
|
|
973
|
+
? "unknown, server did not answer"
|
|
974
|
+
: "no"
|
|
975
|
+
}\n`,
|
|
976
|
+
);
|
|
841
977
|
process.stdout.write("\n");
|
|
842
978
|
|
|
843
979
|
// Zero means "a study started now would run here", which is the only question worth asking of
|
|
844
|
-
// this command.
|
|
845
|
-
|
|
980
|
+
// this command. A local PID without a server-confirmed claim poll is not ready either: that exact
|
|
981
|
+
// disagreement is what previously made setup and the Machines page contradict one another.
|
|
982
|
+
return credentials && checks.chromiumInstalled && running && signedIn.state === "ok" && signedIn.online
|
|
983
|
+
? 0
|
|
984
|
+
: 1;
|
|
846
985
|
}
|
|
847
986
|
|
|
848
987
|
/* --------------------------------- allow --------------------------------- */
|
|
@@ -850,7 +989,7 @@ async function status(): Promise<number> {
|
|
|
850
989
|
async function allow(argv: readonly string[]): Promise<number> {
|
|
851
990
|
const host = argv.find((arg) => !arg.startsWith("-"));
|
|
852
991
|
if (!host) {
|
|
853
|
-
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");
|
|
854
993
|
return 1;
|
|
855
994
|
}
|
|
856
995
|
|
|
@@ -862,8 +1001,9 @@ async function allow(argv: readonly string[]): Promise<number> {
|
|
|
862
1001
|
|
|
863
1002
|
const next = normaliseHosts([...credentials.allowedHosts, host]);
|
|
864
1003
|
await writeCredentials({ ...credentials, allowedHosts: next });
|
|
865
|
-
process.stdout.write(`\
|
|
866
|
-
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");
|
|
867
1007
|
return 0;
|
|
868
1008
|
}
|
|
869
1009
|
|
|
@@ -909,11 +1049,6 @@ async function start(argv: readonly string[]): Promise<number> {
|
|
|
909
1049
|
}
|
|
910
1050
|
|
|
911
1051
|
const local = policyOf(credentials, argv);
|
|
912
|
-
if (local.allowedHosts.length === 0) {
|
|
913
|
-
process.stderr.write("This machine is not allowed to open anything. Try `aloud allow localhost`.\n");
|
|
914
|
-
return 1;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
1052
|
const client = new RunnerClient({
|
|
918
1053
|
server: credentials.server,
|
|
919
1054
|
token: credentials.token,
|
|
@@ -935,15 +1070,44 @@ async function start(argv: readonly string[]): Promise<number> {
|
|
|
935
1070
|
// The `finally` below covers every ordinary ending. This covers the second Ctrl-C, which calls
|
|
936
1071
|
// `process.exit` and unwinds nothing. Synchronous, because an exit handler cannot await.
|
|
937
1072
|
process.on("exit", () => clearRunningSync());
|
|
1073
|
+
let attemptedIdleUpdate: string | null = null;
|
|
938
1074
|
|
|
939
1075
|
try {
|
|
940
1076
|
await runLoop({
|
|
941
1077
|
client,
|
|
942
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
|
+
},
|
|
943
1085
|
ui: reporter,
|
|
944
1086
|
webUrl: `${credentials.server}/app`,
|
|
945
1087
|
once: argv.includes("--once"),
|
|
946
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
|
+
},
|
|
947
1111
|
});
|
|
948
1112
|
return 0;
|
|
949
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 } : {}),
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { RUNNER_VERSION, RUNNER_VERSION_HEADER } from "../version";
|
|
2
|
+
|
|
3
|
+
export interface RunnerPresence {
|
|
4
|
+
online: boolean;
|
|
5
|
+
lastSeenAt: string | null;
|
|
6
|
+
lastVersion: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type PresenceRead =
|
|
10
|
+
| { state: "ok"; presence: RunnerPresence }
|
|
11
|
+
| { state: "revoked" }
|
|
12
|
+
| { state: "unreachable" };
|
|
13
|
+
|
|
14
|
+
/** Reads server-confirmed presence without itself updating the runner's last-seen clock. */
|
|
15
|
+
export async function readRunnerPresence(input: {
|
|
16
|
+
server: string;
|
|
17
|
+
token: string;
|
|
18
|
+
fetchImpl?: typeof fetch;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}): Promise<PresenceRead> {
|
|
21
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetchImpl(new URL("api/runner/me", input.server + "/"), {
|
|
24
|
+
headers: {
|
|
25
|
+
authorization: `Bearer ${input.token}`,
|
|
26
|
+
accept: "application/json",
|
|
27
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION,
|
|
28
|
+
},
|
|
29
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? 5_000),
|
|
30
|
+
});
|
|
31
|
+
if (response.status === 401 || response.status === 403) return { state: "revoked" };
|
|
32
|
+
if (!response.ok) return { state: "unreachable" };
|
|
33
|
+
const body = (await response.json().catch(() => ({}))) as Partial<RunnerPresence>;
|
|
34
|
+
return {
|
|
35
|
+
state: "ok",
|
|
36
|
+
presence: {
|
|
37
|
+
online: body.online === true,
|
|
38
|
+
lastSeenAt: typeof body.lastSeenAt === "string" ? body.lastSeenAt : null,
|
|
39
|
+
lastVersion: typeof body.lastVersion === "string" ? body.lastVersion : null,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
return { state: "unreachable" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Waits for a newly launched process to make a claim poll the control plane has actually recorded.
|
|
49
|
+
*
|
|
50
|
+
* A PID is local evidence only. Requiring `lastSeenAt` to change prevents a recent heartbeat from a
|
|
51
|
+
* previous process being mistaken for confirmation of this launch.
|
|
52
|
+
*/
|
|
53
|
+
export async function waitForRunnerCheckIn(input: {
|
|
54
|
+
server: string;
|
|
55
|
+
token: string;
|
|
56
|
+
previousLastSeenAt: string | null;
|
|
57
|
+
expectedVersion?: string;
|
|
58
|
+
fetchImpl?: typeof fetch;
|
|
59
|
+
sleep?: (ms: number) => Promise<void>;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
pollMs?: number;
|
|
63
|
+
}): Promise<"confirmed" | "revoked" | "timeout"> {
|
|
64
|
+
const now = input.now ?? Date.now;
|
|
65
|
+
const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
66
|
+
const deadline = now() + (input.timeoutMs ?? 15_000);
|
|
67
|
+
|
|
68
|
+
for (;;) {
|
|
69
|
+
const result = await readRunnerPresence(input);
|
|
70
|
+
if (result.state === "revoked") return "revoked";
|
|
71
|
+
if (
|
|
72
|
+
result.state === "ok" &&
|
|
73
|
+
result.presence.online &&
|
|
74
|
+
result.presence.lastSeenAt !== null &&
|
|
75
|
+
result.presence.lastSeenAt !== input.previousLastSeenAt &&
|
|
76
|
+
result.presence.lastVersion === (input.expectedVersion ?? RUNNER_VERSION)
|
|
77
|
+
) {
|
|
78
|
+
return "confirmed";
|
|
79
|
+
}
|
|
80
|
+
if (now() >= deadline) return "timeout";
|
|
81
|
+
await sleep(Math.min(input.pollMs ?? 750, Math.max(0, deadline - now())));
|
|
82
|
+
}
|
|
83
|
+
}
|