humanish 0.16.0 → 0.18.0
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 +54 -3
- package/dist/actor-contract.d.ts +1 -1
- package/dist/actor-contract.js.map +1 -1
- package/dist/computer-use.js +33 -5
- package/dist/computer-use.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +1 -1
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/observer-library.d.ts +19 -0
- package/dist/observer-library.js +184 -0
- package/dist/observer-library.js.map +1 -0
- package/dist/observer-serve.d.ts +99 -0
- package/dist/observer-serve.js +289 -0
- package/dist/observer-serve.js.map +1 -0
- package/dist/observer.d.ts +34 -0
- package/dist/observer.js +57 -5
- package/dist/observer.js.map +1 -1
- package/dist/program.js +433 -19
- package/dist/program.js.map +1 -1
- package/dist/serve-exposure.d.ts +62 -0
- package/dist/serve-exposure.js +129 -0
- package/dist/serve-exposure.js.map +1 -0
- package/dist/serve-http.d.ts +8 -0
- package/dist/serve-http.js +37 -0
- package/dist/serve-http.js.map +1 -0
- package/dist/serve-tunnel.d.ts +20 -0
- package/dist/serve-tunnel.js +113 -0
- package/dist/serve-tunnel.js.map +1 -0
- package/docs/architecture/actor-contract.md +29 -3
- package/docs/architecture/observer.md +37 -0
- package/docs/architecture/serve.md +196 -0
- package/docs/contracts/schemas.md +33 -2
- package/docs/goals/current.md +2 -1
- package/docs/principles/invariants-and-defaults.md +2 -0
- package/docs/ramp/README.md +1 -1
- package/package.json +1 -1
package/dist/program.js
CHANGED
|
@@ -11,8 +11,12 @@ import { runInit } from "./init.js";
|
|
|
11
11
|
import { inspectLabManifest, listLabManifests, resolveLabManifest } from "./labs.js";
|
|
12
12
|
import { runLabPreflight } from "./lab-preflight.js";
|
|
13
13
|
import { runLab, resolveLabDryRun, selectLabBackend } from "./lab-engine.js";
|
|
14
|
+
import { CUA_ACTOR_LAB_SCHEMA } from "./cua-actor-lab.js";
|
|
14
15
|
import { openTarget, renderObserver, serveObserver } from "./observer.js";
|
|
15
16
|
import { serveObserverStatic } from "./observer-static.js";
|
|
17
|
+
import { SERVE_SCHEMA, serveObserverLibrary } from "./observer-serve.js";
|
|
18
|
+
import { startExposedObserver, validateExposure } from "./serve-exposure.js";
|
|
19
|
+
import { ServeTunnelError } from "./serve-tunnel.js";
|
|
16
20
|
import { DEFAULT_OSS_REPOS, runOssLab } from "./oss-lab.js";
|
|
17
21
|
import { cleanupOssMetaLabSandboxes, cleanupStaleOssMetaLabSandboxes, runOssMetaLab, startOssMetaLabLiveRefresh } from "./oss-meta-lab.js";
|
|
18
22
|
import { cleanupRun, doctor, listRuns, readReview, runDryRun, verifyRun } from "./run.js";
|
|
@@ -163,6 +167,7 @@ export function createProgram(io = {}) {
|
|
|
163
167
|
registerRunsCommand(program, cliIo);
|
|
164
168
|
registerWatchCommand(program, cliIo);
|
|
165
169
|
registerObserveCommand(program, cliIo);
|
|
170
|
+
registerServeCommand(program, cliIo);
|
|
166
171
|
registerCodexCommands(program, cliIo);
|
|
167
172
|
registerLabCommands(program, cliIo);
|
|
168
173
|
registerFeedbackCommands(program, cliIo);
|
|
@@ -484,6 +489,14 @@ function registerWatchCommand(parent, io) {
|
|
|
484
489
|
.addOption(new Option("--follow", "Deprecated; human output follows by default.").hideHelp())
|
|
485
490
|
.option("--detach", "Render/open once and exit without attached watch server.")
|
|
486
491
|
.option("--port <port>", "Local observer server port when following.", "0")
|
|
492
|
+
.option("--expose", "CUA lab only: expose the live run through an authenticated edge so you can watch from a phone. Requires edge auth.")
|
|
493
|
+
.addOption(new Option("--tunnel <provider>", "Spawn the external tunnel binary against the loopback port.").choices(["ngrok"]))
|
|
494
|
+
.option("--tunnel-domain <domain>", "Reserved domain passed to ngrok as --url (e.g. observer.example.com). Requires --tunnel.")
|
|
495
|
+
.addOption(new Option("--oauth <provider>", "Turn on ngrok edge OAuth. Requires --tunnel.").choices(["google"]))
|
|
496
|
+
.option("--allow-email <addr>", "Edge OAuth allow rule: permit this email. Repeatable. Requires --oauth.", collectRepeated, [])
|
|
497
|
+
.option("--allow-domain <domain>", "Edge OAuth allow rule: permit this domain. Repeatable. Requires --oauth.", collectRepeated, [])
|
|
498
|
+
.option("--public-url <origin>", "Bring-your-own authed edge (Cloudflare Access/Tailscale/manual). Binds loopback and trusts your edge. Requires --expose.")
|
|
499
|
+
.option("--safe", "Not applicable to watch: a live run is never share_ready, so --safe (a `serve` library filter) is rejected here. Restrict viewers with edge auth (--allow-email/--allow-domain).")
|
|
487
500
|
.option("--json", JSON_OPTION_DESCRIPTION)
|
|
488
501
|
.addHelpText("after", [
|
|
489
502
|
"",
|
|
@@ -492,6 +505,9 @@ function registerWatchCommand(parent, io) {
|
|
|
492
505
|
" humanish watch first-run",
|
|
493
506
|
" humanish watch --lab .humanish/labs/local.yaml",
|
|
494
507
|
"",
|
|
508
|
+
"Watch a live CUA run from your phone (tunnel-edge auth):",
|
|
509
|
+
" humanish watch my-cua-lab --expose --tunnel ngrok --oauth google --allow-email you@example.com",
|
|
510
|
+
"",
|
|
495
511
|
"Agent/CI path:",
|
|
496
512
|
" humanish watch --json --no-open",
|
|
497
513
|
"",
|
|
@@ -558,11 +574,38 @@ function registerWatchCommand(parent, io) {
|
|
|
558
574
|
repo: options.repo,
|
|
559
575
|
...(options.repos === undefined ? {} : { repos: options.repos }),
|
|
560
576
|
...(options.runId === undefined ? {} : { runId: options.runId }),
|
|
561
|
-
...(options.sims === undefined ? {} : { sims: options.sims })
|
|
577
|
+
...(options.sims === undefined ? {} : { sims: options.sims }),
|
|
578
|
+
...(options.expose === undefined ? {} : { expose: options.expose }),
|
|
579
|
+
...(options.tunnel === undefined ? {} : { tunnel: options.tunnel }),
|
|
580
|
+
...(options.tunnelDomain === undefined ? {} : { tunnelDomain: options.tunnelDomain }),
|
|
581
|
+
...(options.oauth === undefined ? {} : { oauth: options.oauth }),
|
|
582
|
+
allowEmail: options.allowEmail,
|
|
583
|
+
allowDomain: options.allowDomain,
|
|
584
|
+
...(options.publicUrl === undefined ? {} : { publicUrl: options.publicUrl }),
|
|
585
|
+
...(options.safe === undefined ? {} : { safe: options.safe }),
|
|
586
|
+
...(options.json === undefined ? {} : { json: options.json })
|
|
562
587
|
}
|
|
563
588
|
});
|
|
564
589
|
return;
|
|
565
590
|
}
|
|
591
|
+
// Exposure is only meaningful for a live CUA lab run (it serves the live desktop). The
|
|
592
|
+
// non-lab watch path (existing evidence, or a fresh synthetic run) has no live desktop to
|
|
593
|
+
// stream, so exposure flags there are refused rather than silently ignored — use `serve`.
|
|
594
|
+
if (watchExposeRequested(options)) {
|
|
595
|
+
const result = {
|
|
596
|
+
schema: "humanish.run-result.v1",
|
|
597
|
+
ok: false,
|
|
598
|
+
cwd: options.cwd,
|
|
599
|
+
warnings: [],
|
|
600
|
+
error: {
|
|
601
|
+
code: "HUMANISH_WATCH_OPTION_CONFLICT",
|
|
602
|
+
message: "--expose/--tunnel/--oauth apply only to a live CUA lab run; to expose finished evidence use `humanish serve --expose`."
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
writeResult(command, io, result, formatRunHuman);
|
|
606
|
+
io.setExitCode(2);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
566
609
|
const runOptionSource = typeof command.getOptionValueSource === "function"
|
|
567
610
|
? command.getOptionValueSource("run")
|
|
568
611
|
: undefined;
|
|
@@ -788,6 +831,203 @@ async function serveObserveUntilSignal(io, server, options) {
|
|
|
788
831
|
}
|
|
789
832
|
});
|
|
790
833
|
}
|
|
834
|
+
function registerServeCommand(parent, io) {
|
|
835
|
+
parent
|
|
836
|
+
.command("serve")
|
|
837
|
+
.description("Serve the local run library over loopback http, with optional tunnel-edge authenticated exposure.")
|
|
838
|
+
.summary("Serve the run library; optional tunnel-edge exposure.")
|
|
839
|
+
.option("--cwd <path>", "Target project directory.", ".")
|
|
840
|
+
.option("--port <port>", "Loopback port to bind on 127.0.0.1. Defaults to an ephemeral port.", "0")
|
|
841
|
+
.option("--run <id>", "Land on this run id (or latest) instead of the library index.")
|
|
842
|
+
.option("--safe", "Serve only runs whose verify shareSafety is share_ready; everything else is absent (fail-closed).")
|
|
843
|
+
.option("--expose", "Declare exposure intent. Requires edge auth (--oauth or --public-url) OR --safe.")
|
|
844
|
+
.addOption(new Option("--tunnel <provider>", "Spawn the external tunnel binary against the loopback port.").choices(["ngrok"]))
|
|
845
|
+
.option("--tunnel-domain <domain>", "Reserved domain passed to ngrok as --url (e.g. observer.example.com). Requires --tunnel.")
|
|
846
|
+
.addOption(new Option("--oauth <provider>", "Turn on ngrok edge OAuth. Requires --tunnel.").choices(["google"]))
|
|
847
|
+
.option("--allow-email <addr>", "Edge OAuth allow rule: permit this email. Repeatable. Requires --oauth.", collectRepeated, [])
|
|
848
|
+
.option("--allow-domain <domain>", "Edge OAuth allow rule: permit this domain. Repeatable. Requires --oauth.", collectRepeated, [])
|
|
849
|
+
.option("--public-url <origin>", "Bring-your-own authed edge (e.g. https://observer.example.com). Requires --expose; never affects binding.")
|
|
850
|
+
.option("--open", "Open the library in the default browser.")
|
|
851
|
+
.option("--no-open", "Serve without opening a browser.")
|
|
852
|
+
.option("--json", JSON_OPTION_DESCRIPTION)
|
|
853
|
+
.addHelpText("after", [
|
|
854
|
+
"",
|
|
855
|
+
"Happy path:",
|
|
856
|
+
" humanish serve",
|
|
857
|
+
" humanish serve --expose --tunnel ngrok --oauth google --allow-email you@example.com",
|
|
858
|
+
" humanish serve --safe --expose --tunnel ngrok",
|
|
859
|
+
" humanish serve --expose --public-url https://observer.example.com",
|
|
860
|
+
"",
|
|
861
|
+
"Agent/CI path:",
|
|
862
|
+
" humanish serve --json --no-open",
|
|
863
|
+
"",
|
|
864
|
+
"The server always binds 127.0.0.1; exposure only ever happens through an authenticated",
|
|
865
|
+
"edge (ngrok --oauth google, or an operator --public-url you secure) forwarding to the",
|
|
866
|
+
"loopback port. humanish carries no in-process auth — the gate lives at the edge. Live",
|
|
867
|
+
"desktop stream URLs are never served here; remote viewers see persisted evidence only.",
|
|
868
|
+
"--safe composes with any exposure for defense in depth."
|
|
869
|
+
].join("\n"))
|
|
870
|
+
.action(async (options, command) => {
|
|
871
|
+
const wantsMachine = wantsJson(command);
|
|
872
|
+
const fail = (code, message) => {
|
|
873
|
+
const result = {
|
|
874
|
+
schema: SERVE_SCHEMA,
|
|
875
|
+
ok: false,
|
|
876
|
+
cwd: options.cwd,
|
|
877
|
+
mode: "loopback",
|
|
878
|
+
safe: options.safe === true,
|
|
879
|
+
host: "127.0.0.1",
|
|
880
|
+
runsListed: 0,
|
|
881
|
+
warnings: [],
|
|
882
|
+
error: { code, message }
|
|
883
|
+
};
|
|
884
|
+
writeResult(command, io, result, formatServeHuman);
|
|
885
|
+
io.setExitCode(2);
|
|
886
|
+
};
|
|
887
|
+
const port = parseObserverPort(options.port);
|
|
888
|
+
if (port === null) {
|
|
889
|
+
fail("HUMANISH_INVALID_PORT", "--port must be an integer between 0 and 65535.");
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
// Fail-closed exposure matrix (shared validator; tunnel-edge auth only). All guards run before
|
|
893
|
+
// any bind/spawn.
|
|
894
|
+
const exposeValidation = validateExposure("serve", {
|
|
895
|
+
expose: options.expose === true,
|
|
896
|
+
...(options.tunnel === undefined ? {} : { tunnel: options.tunnel }),
|
|
897
|
+
...(options.tunnelDomain === undefined ? {} : { tunnelDomain: options.tunnelDomain }),
|
|
898
|
+
...(options.oauth === undefined ? {} : { oauth: options.oauth }),
|
|
899
|
+
allowEmails: options.allowEmail,
|
|
900
|
+
allowDomains: options.allowDomain,
|
|
901
|
+
...(options.publicUrl === undefined ? {} : { publicUrl: options.publicUrl }),
|
|
902
|
+
safe: options.safe === true
|
|
903
|
+
});
|
|
904
|
+
if (!exposeValidation.ok) {
|
|
905
|
+
fail(exposeValidation.error.code, exposeValidation.error.message);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const plan = exposeValidation.plan;
|
|
909
|
+
const started = await serveObserverLibrary(options.cwd, {
|
|
910
|
+
port,
|
|
911
|
+
safe: options.safe === true,
|
|
912
|
+
expose: plan.exposed,
|
|
913
|
+
edgeAuthed: plan.edgeAuthed,
|
|
914
|
+
...(plan.publicOrigin ? { publicOrigin: plan.publicOrigin.origin } : {}),
|
|
915
|
+
...(options.run ? { entryRunId: options.run } : {})
|
|
916
|
+
});
|
|
917
|
+
if (!started.ok) {
|
|
918
|
+
fail(started.error.code, started.error.message);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const server = started.server;
|
|
922
|
+
let tunnel;
|
|
923
|
+
let publicUrl;
|
|
924
|
+
const warnings = [];
|
|
925
|
+
if (plan.exposed) {
|
|
926
|
+
try {
|
|
927
|
+
const exposeResult = await startExposedObserver(server, plan);
|
|
928
|
+
tunnel = exposeResult.tunnel;
|
|
929
|
+
publicUrl = exposeResult.publicUrl;
|
|
930
|
+
warnings.push(...exposeResult.warnings);
|
|
931
|
+
}
|
|
932
|
+
catch (error) {
|
|
933
|
+
await server.close();
|
|
934
|
+
if (error instanceof ServeTunnelError) {
|
|
935
|
+
fail(error.code, error.message);
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
fail("HUMANISH_SERVE_TUNNEL_START_FAILED", `Tunnel startup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
if (server.mode === "exposed" && options.safe !== true) {
|
|
944
|
+
warnings.push(`edge-authed exposure grants read access to all ${server.runsListed} local runs, including any not verified share_ready (local_only raw screenshots, blocked bundles); anyone who clears the edge auth can view them; add --safe to restrict to share_ready`);
|
|
945
|
+
}
|
|
946
|
+
if (server.mode === "exposed" && options.safe === true) {
|
|
947
|
+
warnings.push(`edge-authed exposure grants read access to ${server.shareReadyCount ?? 0} share_ready runs; non-share_ready runs are absent even behind the edge`);
|
|
948
|
+
}
|
|
949
|
+
if (server.mode === "share-safe-open") {
|
|
950
|
+
warnings.push(`serving ${server.shareReadyCount ?? 0} share_ready runs to anyone who can reach ${publicUrl ?? server.url}; non-share_ready runs are absent and their URLs 404`);
|
|
951
|
+
}
|
|
952
|
+
warnings.push("live desktop stream URLs are never served here; remote viewers see persisted evidence (screenshots, events, terminal tails) only");
|
|
953
|
+
// Auto-open is suppressed under --expose so the public URL is not shoved into a local opener's
|
|
954
|
+
// argv unasked — the exposure target is a remote device anyway. Explicit --open still honors
|
|
955
|
+
// intent and opens the loopback library.
|
|
956
|
+
const shouldOpen = options.open === false
|
|
957
|
+
? false
|
|
958
|
+
: options.open === true
|
|
959
|
+
? true
|
|
960
|
+
: !wantsMachine && process.stdout.isTTY === true && plan.exposed !== true;
|
|
961
|
+
const openResult = shouldOpen ? openTarget(server.url) : { opened: false };
|
|
962
|
+
if (openResult.warning) {
|
|
963
|
+
warnings.push(openResult.warning);
|
|
964
|
+
}
|
|
965
|
+
const result = {
|
|
966
|
+
schema: SERVE_SCHEMA,
|
|
967
|
+
ok: true,
|
|
968
|
+
cwd: options.cwd,
|
|
969
|
+
mode: server.mode,
|
|
970
|
+
safe: options.safe === true,
|
|
971
|
+
host: "127.0.0.1",
|
|
972
|
+
port: server.port,
|
|
973
|
+
url: server.url,
|
|
974
|
+
...(publicUrl ? { publicUrl } : {}),
|
|
975
|
+
...(tunnel ? { tunnel: { provider: "ngrok", url: tunnel.url } } : {}),
|
|
976
|
+
...(plan.oauth ? { oauth: { provider: plan.oauth.provider, allowEmails: plan.oauth.allowEmails, allowDomains: plan.oauth.allowDomains } } : {}),
|
|
977
|
+
runsListed: server.runsListed,
|
|
978
|
+
...(server.shareReadyCount !== undefined ? { shareReadyCount: server.shareReadyCount } : {}),
|
|
979
|
+
...(server.entryRunId ? { entryRunId: server.entryRunId } : {}),
|
|
980
|
+
opened: openResult.opened,
|
|
981
|
+
...(openResult.command ? { openCommand: openResult.command } : {}),
|
|
982
|
+
warnings
|
|
983
|
+
};
|
|
984
|
+
writeResult(command, io, result, formatServeHuman);
|
|
985
|
+
io.setExitCode(0);
|
|
986
|
+
await serveObserveUntilSignal(io, {
|
|
987
|
+
url: server.url,
|
|
988
|
+
close: async () => {
|
|
989
|
+
if (tunnel) {
|
|
990
|
+
await tunnel.close();
|
|
991
|
+
}
|
|
992
|
+
await server.close();
|
|
993
|
+
}
|
|
994
|
+
}, { json: wantsMachine });
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
function formatServeHuman(result) {
|
|
998
|
+
if (!result.ok) {
|
|
999
|
+
return [
|
|
1000
|
+
"humanish serve failed",
|
|
1001
|
+
...(result.error ? [`error: ${result.error.code} ${result.error.message}`] : []),
|
|
1002
|
+
...result.warnings.map((warning) => `warning: ${warning}`)
|
|
1003
|
+
].join("\n") + "\n";
|
|
1004
|
+
}
|
|
1005
|
+
const modeSuffix = result.safe ? " (share_ready only)" : "";
|
|
1006
|
+
const lines = [
|
|
1007
|
+
"humanish serve",
|
|
1008
|
+
`mode: ${result.mode}${modeSuffix}`,
|
|
1009
|
+
`library: ${result.url ?? ""}`,
|
|
1010
|
+
`runs: ${result.runsListed}`
|
|
1011
|
+
];
|
|
1012
|
+
if (result.publicUrl) {
|
|
1013
|
+
lines.push(`public: ${result.publicUrl}`);
|
|
1014
|
+
}
|
|
1015
|
+
if (result.tunnel) {
|
|
1016
|
+
lines.push(`tunnel: ${result.tunnel.provider} ${result.tunnel.url}`);
|
|
1017
|
+
}
|
|
1018
|
+
if (result.oauth) {
|
|
1019
|
+
const rules = [...result.oauth.allowEmails, ...result.oauth.allowDomains];
|
|
1020
|
+
lines.push(`edge auth: ${result.oauth.provider} oauth${rules.length > 0 ? ` (allow: ${rules.join(", ")})` : " (no allow rule — any Google account)"}`);
|
|
1021
|
+
}
|
|
1022
|
+
if (result.entryRunId) {
|
|
1023
|
+
lines.push(`entry: ${result.entryRunId}`);
|
|
1024
|
+
}
|
|
1025
|
+
lines.push(`opened: ${result.opened === true ? "yes" : "no"}`);
|
|
1026
|
+
for (const warning of result.warnings) {
|
|
1027
|
+
lines.push(`warning: ${warning}`);
|
|
1028
|
+
}
|
|
1029
|
+
return lines.join("\n") + "\n";
|
|
1030
|
+
}
|
|
791
1031
|
function registerFeedbackCommands(parent, io) {
|
|
792
1032
|
const feedback = parent
|
|
793
1033
|
.command("feedback")
|
|
@@ -1251,6 +1491,23 @@ async function runLabCommand(args) {
|
|
|
1251
1491
|
writeUnsupportedRerunFlagsResult(args, backend);
|
|
1252
1492
|
return;
|
|
1253
1493
|
}
|
|
1494
|
+
// Exposure serves a live desktop, which only the computer-use backend produces. Refuse it on any
|
|
1495
|
+
// other backend rather than silently ignoring it.
|
|
1496
|
+
if (backend !== "cua" && watchExposeRequested(args.options)) {
|
|
1497
|
+
const result = {
|
|
1498
|
+
schema: "humanish.run-result.v1",
|
|
1499
|
+
ok: false,
|
|
1500
|
+
cwd: resolve(args.options.cwd),
|
|
1501
|
+
warnings: [],
|
|
1502
|
+
error: {
|
|
1503
|
+
code: "HUMANISH_WATCH_OPTION_CONFLICT",
|
|
1504
|
+
message: `--expose/--tunnel/--oauth stream a live desktop and apply only to computer-use labs; this lab resolved to ${backend}.`
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
writeResult(args.command, args.io, result, formatRunHuman);
|
|
1508
|
+
args.io.setExitCode(2);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1254
1511
|
switch (backend) {
|
|
1255
1512
|
case "synthetic":
|
|
1256
1513
|
await runSyntheticBackend({ ...args, config });
|
|
@@ -1379,30 +1636,162 @@ async function runCuaBackend(args) {
|
|
|
1379
1636
|
args.io.setExitCode(2);
|
|
1380
1637
|
return;
|
|
1381
1638
|
}
|
|
1382
|
-
const
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
:
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
}
|
|
1639
|
+
const dryRun = resolveLabDryRun(args.config, args.options.dryRun, true) ?? true;
|
|
1640
|
+
const port = parseObserverPort(args.options.port ?? "0");
|
|
1641
|
+
const wantsFollow = args.mode === "watch" && !wantsMachine && args.options.detach !== true && dryRun !== true;
|
|
1642
|
+
const failCua = (code, message) => {
|
|
1643
|
+
const result = {
|
|
1644
|
+
schema: CUA_ACTOR_LAB_SCHEMA,
|
|
1645
|
+
ok: false,
|
|
1646
|
+
cwd: args.options.cwd,
|
|
1647
|
+
labId: args.config.id,
|
|
1648
|
+
actor: args.config.actors[0]?.type ?? "",
|
|
1649
|
+
appUrl: "",
|
|
1650
|
+
dryRun,
|
|
1651
|
+
runId: args.options.runId ?? "not-created",
|
|
1652
|
+
warnings: [],
|
|
1653
|
+
error: { code, message }
|
|
1654
|
+
};
|
|
1655
|
+
writeResult(args.command, args.io, result, formatCuaLabHuman);
|
|
1656
|
+
args.io.setExitCode(2);
|
|
1657
|
+
};
|
|
1658
|
+
if (port === null) {
|
|
1659
|
+
failCua("HUMANISH_WATCH_OPTION_CONFLICT", "--port must be an integer between 0 and 65535.");
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
// Validate exposure up front (fail-closed matrix), before any run/spend. A live CUA watch is the
|
|
1663
|
+
// one surface that serves runtime E2B stream URLs, so it MUST sit behind edge auth.
|
|
1664
|
+
const exposeValidation = validateExposure("watch", exposureRequestFromOptions(args.options), {
|
|
1665
|
+
dryRun,
|
|
1666
|
+
detach: args.options.detach === true,
|
|
1667
|
+
json: wantsMachine
|
|
1397
1668
|
});
|
|
1669
|
+
if (!exposeValidation.ok) {
|
|
1670
|
+
failCua(exposeValidation.error.code, exposeValidation.error.message);
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
const plan = exposeValidation.plan;
|
|
1674
|
+
const exposeRequested = plan.exposed;
|
|
1675
|
+
let server = null;
|
|
1676
|
+
let attachedObserver = null;
|
|
1677
|
+
let tunnel;
|
|
1678
|
+
let exposeWarnings = [];
|
|
1679
|
+
let exposePublicTarget;
|
|
1680
|
+
let outcome;
|
|
1681
|
+
try {
|
|
1682
|
+
outcome = await runLab(args.config, {
|
|
1683
|
+
cwd: args.options.cwd,
|
|
1684
|
+
// Watch mode opens the served Observer below (or prints the phone target under --expose)
|
|
1685
|
+
// instead of the static render — preserved byte-for-byte from the pre-0.18 open policy so a
|
|
1686
|
+
// non-follow watch (dry-run/--json) does not double-open. Run mode keeps the static open.
|
|
1687
|
+
open: args.mode === "watch" ? false : shouldOpen,
|
|
1688
|
+
count,
|
|
1689
|
+
dryRun,
|
|
1690
|
+
...(wantsFollow
|
|
1691
|
+
? {
|
|
1692
|
+
// Fires INSIDE runLab, before the actor loop and before sandbox creation, so a
|
|
1693
|
+
// tunnel-auth failure aborts before any spend and leaves no orphaned sandbox.
|
|
1694
|
+
onObserverReady: async (observer) => {
|
|
1695
|
+
attachedObserver = observer;
|
|
1696
|
+
if (!server) {
|
|
1697
|
+
server = await serveObserver(observer, {
|
|
1698
|
+
open: shouldOpen && !exposeRequested,
|
|
1699
|
+
port,
|
|
1700
|
+
exposed: exposeRequested
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
if (exposeRequested) {
|
|
1704
|
+
const activeServer = server;
|
|
1705
|
+
const exposeResult = await startExposedObserver(activeServer, plan);
|
|
1706
|
+
if (exposeResult.tunnel) {
|
|
1707
|
+
tunnel = exposeResult.tunnel;
|
|
1708
|
+
}
|
|
1709
|
+
exposeWarnings = exposeResult.warnings;
|
|
1710
|
+
const phoneTarget = exposeResult.publicUrl ?? activeServer.url;
|
|
1711
|
+
exposePublicTarget = phoneTarget;
|
|
1712
|
+
args.io.writeOut(`watch: exposed live desktop at ${phoneTarget} (edge-authed; open it on your phone)\n`);
|
|
1713
|
+
for (const warning of exposeResult.warnings) {
|
|
1714
|
+
args.io.writeErr(`warning: ${warning}\n`);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
: {}),
|
|
1720
|
+
...(args.options.runId === undefined ? {} : { runId: args.options.runId }),
|
|
1721
|
+
...(args.options.rerunFailedFrom === undefined
|
|
1722
|
+
? {}
|
|
1723
|
+
: {
|
|
1724
|
+
rerun: {
|
|
1725
|
+
sourceRunId: args.options.rerunFailedFrom,
|
|
1726
|
+
...(laneIds.length === 0 ? {} : { laneIds })
|
|
1727
|
+
}
|
|
1728
|
+
})
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
catch (error) {
|
|
1732
|
+
// Tear down the loopback server and any tunnel started inside onObserverReady before rethrowing
|
|
1733
|
+
// (or surfacing a structured tunnel-startup failure). The sandbox is created AFTER
|
|
1734
|
+
// onObserverReady returns, so a tunnel failure here cannot orphan one.
|
|
1735
|
+
const earlyServer = server;
|
|
1736
|
+
await earlyServer?.close().catch((cleanupError) => {
|
|
1737
|
+
args.io.writeErr(`watch cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`);
|
|
1738
|
+
});
|
|
1739
|
+
server = null;
|
|
1740
|
+
if (tunnel) {
|
|
1741
|
+
await tunnel.close().catch(() => undefined);
|
|
1742
|
+
tunnel = undefined;
|
|
1743
|
+
}
|
|
1744
|
+
if (error instanceof ServeTunnelError) {
|
|
1745
|
+
failCua(error.code, error.message);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
throw error;
|
|
1749
|
+
}
|
|
1398
1750
|
if (outcome.backend !== "cua") {
|
|
1399
1751
|
throw new Error(`Expected cua backend, got ${outcome.backend}.`);
|
|
1400
1752
|
}
|
|
1401
1753
|
const result = outcome.result;
|
|
1402
|
-
|
|
1754
|
+
// Serving is NOT gated on result.ok: a timed_out/failed run still comes up so the operator can
|
|
1755
|
+
// inspect its evidence live (and, under --expose, from a phone). budget_reached now makes a
|
|
1756
|
+
// productive open-ended watch result.ok true.
|
|
1757
|
+
let output = result;
|
|
1758
|
+
if (server && attachedObserver) {
|
|
1759
|
+
const activeServer = server;
|
|
1760
|
+
const attachedResult = result.observer?.ok ? result.observer : attachedObserver;
|
|
1761
|
+
output = {
|
|
1762
|
+
...result,
|
|
1763
|
+
observer: withObserverServer(attachedResult, activeServer),
|
|
1764
|
+
warnings: [
|
|
1765
|
+
...result.warnings,
|
|
1766
|
+
"Live CUA server is polling observer-data.json with no-store caching.",
|
|
1767
|
+
...(exposeRequested
|
|
1768
|
+
? [
|
|
1769
|
+
`Exposed live desktop stream URLs to an edge-authenticated remote viewer${tunnel ? ` via ${tunnel.url.replace(/\/$/, "")}` : ""}.`,
|
|
1770
|
+
`this live run's raw, unverified evidence (screenshots, events) is viewable by anyone who clears the edge auth at ${exposePublicTarget ?? activeServer.url}; only the run being watched is served, not your other runs`
|
|
1771
|
+
]
|
|
1772
|
+
: []),
|
|
1773
|
+
...exposeWarnings,
|
|
1774
|
+
...(activeServer.warning ? [activeServer.warning] : [])
|
|
1775
|
+
]
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
writeResult(args.command, args.io, output, formatCuaLabHuman);
|
|
1403
1779
|
args.io.setExitCode(result.ok ? 0 : 2);
|
|
1404
|
-
|
|
1405
|
-
|
|
1780
|
+
if (server && (result.observer?.ok || attachedObserver)) {
|
|
1781
|
+
const activeServer = server;
|
|
1782
|
+
const followResult = output.observer?.ok ? output.observer : withObserverServer(attachedObserver, activeServer);
|
|
1783
|
+
await followObserver(args.io, followResult, activeServer, {
|
|
1784
|
+
onStop: async () => {
|
|
1785
|
+
if (tunnel) {
|
|
1786
|
+
await tunnel.close();
|
|
1787
|
+
return ["closed ngrok tunnel"];
|
|
1788
|
+
}
|
|
1789
|
+
return [];
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
else if (args.mode === "watch" && result.ok && !wantsMachine) {
|
|
1794
|
+
// Non-follow / dry-run watch keeps today's fallback: render the finished bundle and follow it.
|
|
1406
1795
|
await renderAndMaybeFollowObserver({
|
|
1407
1796
|
command: args.command,
|
|
1408
1797
|
cwd: args.options.cwd,
|
|
@@ -2299,6 +2688,31 @@ function formatOssMetaLabCleanupHuman(result) {
|
|
|
2299
2688
|
function collectRepeated(value, previous) {
|
|
2300
2689
|
return [...previous, value];
|
|
2301
2690
|
}
|
|
2691
|
+
// True when any tunnel-edge exposure flag is present (not --safe, which is an orthogonal filter).
|
|
2692
|
+
// Used to refuse exposure on the non-lab watch path and on non-CUA backends, where there is no live
|
|
2693
|
+
// desktop to stream.
|
|
2694
|
+
function watchExposeRequested(o) {
|
|
2695
|
+
return o.expose === true
|
|
2696
|
+
|| o.tunnel !== undefined
|
|
2697
|
+
|| o.tunnelDomain !== undefined
|
|
2698
|
+
|| o.oauth !== undefined
|
|
2699
|
+
|| (o.allowEmail?.length ?? 0) > 0
|
|
2700
|
+
|| (o.allowDomain?.length ?? 0) > 0
|
|
2701
|
+
|| o.publicUrl !== undefined;
|
|
2702
|
+
}
|
|
2703
|
+
// Map LabCommandOptions onto the shared exposure validator input.
|
|
2704
|
+
function exposureRequestFromOptions(options) {
|
|
2705
|
+
return {
|
|
2706
|
+
expose: options.expose === true,
|
|
2707
|
+
...(options.tunnel === undefined ? {} : { tunnel: options.tunnel }),
|
|
2708
|
+
...(options.tunnelDomain === undefined ? {} : { tunnelDomain: options.tunnelDomain }),
|
|
2709
|
+
...(options.oauth === undefined ? {} : { oauth: options.oauth }),
|
|
2710
|
+
allowEmails: options.allowEmail ?? [],
|
|
2711
|
+
allowDomains: options.allowDomain ?? [],
|
|
2712
|
+
...(options.publicUrl === undefined ? {} : { publicUrl: options.publicUrl }),
|
|
2713
|
+
safe: options.safe === true
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2302
2716
|
function formatInitHuman(result) {
|
|
2303
2717
|
const title = result.ok
|
|
2304
2718
|
? `humanish init ${result.mode}`
|