humanish 0.17.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 +32 -21
- 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 +1 -1
- package/dist/observer-library.js +1 -6
- package/dist/observer-library.js.map +1 -1
- package/dist/observer-serve.d.ts +11 -23
- package/dist/observer-serve.js +12 -92
- package/dist/observer-serve.js.map +1 -1
- package/dist/observer.d.ts +3 -0
- package/dist/observer.js +47 -0
- package/dist/observer.js.map +1 -1
- package/dist/program.js +294 -122
- 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 +6 -2
- package/dist/serve-tunnel.js +9 -0
- package/dist/serve-tunnel.js.map +1 -1
- package/docs/architecture/actor-contract.md +29 -3
- package/docs/architecture/observer.md +30 -0
- package/docs/architecture/serve.md +138 -82
- package/docs/contracts/schemas.md +24 -15
- package/docs/goals/current.md +1 -1
- package/docs/principles/invariants-and-defaults.md +2 -1
- package/docs/ramp/README.md +1 -1
- package/package.json +1 -1
- package/dist/observer-auth.d.ts +0 -21
- package/dist/observer-auth.js +0 -92
- package/dist/observer-auth.js.map +0 -1
package/dist/program.js
CHANGED
|
@@ -11,10 +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";
|
|
16
|
-
import { SERVE_SCHEMA,
|
|
17
|
-
import {
|
|
17
|
+
import { SERVE_SCHEMA, serveObserverLibrary } from "./observer-serve.js";
|
|
18
|
+
import { startExposedObserver, validateExposure } from "./serve-exposure.js";
|
|
19
|
+
import { ServeTunnelError } from "./serve-tunnel.js";
|
|
18
20
|
import { DEFAULT_OSS_REPOS, runOssLab } from "./oss-lab.js";
|
|
19
21
|
import { cleanupOssMetaLabSandboxes, cleanupStaleOssMetaLabSandboxes, runOssMetaLab, startOssMetaLabLiveRefresh } from "./oss-meta-lab.js";
|
|
20
22
|
import { cleanupRun, doctor, listRuns, readReview, runDryRun, verifyRun } from "./run.js";
|
|
@@ -487,6 +489,14 @@ function registerWatchCommand(parent, io) {
|
|
|
487
489
|
.addOption(new Option("--follow", "Deprecated; human output follows by default.").hideHelp())
|
|
488
490
|
.option("--detach", "Render/open once and exit without attached watch server.")
|
|
489
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).")
|
|
490
500
|
.option("--json", JSON_OPTION_DESCRIPTION)
|
|
491
501
|
.addHelpText("after", [
|
|
492
502
|
"",
|
|
@@ -495,6 +505,9 @@ function registerWatchCommand(parent, io) {
|
|
|
495
505
|
" humanish watch first-run",
|
|
496
506
|
" humanish watch --lab .humanish/labs/local.yaml",
|
|
497
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
|
+
"",
|
|
498
511
|
"Agent/CI path:",
|
|
499
512
|
" humanish watch --json --no-open",
|
|
500
513
|
"",
|
|
@@ -561,11 +574,38 @@ function registerWatchCommand(parent, io) {
|
|
|
561
574
|
repo: options.repo,
|
|
562
575
|
...(options.repos === undefined ? {} : { repos: options.repos }),
|
|
563
576
|
...(options.runId === undefined ? {} : { runId: options.runId }),
|
|
564
|
-
...(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 })
|
|
565
587
|
}
|
|
566
588
|
});
|
|
567
589
|
return;
|
|
568
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
|
+
}
|
|
569
609
|
const runOptionSource = typeof command.getOptionValueSource === "function"
|
|
570
610
|
? command.getOptionValueSource("run")
|
|
571
611
|
: undefined;
|
|
@@ -794,18 +834,19 @@ async function serveObserveUntilSignal(io, server, options) {
|
|
|
794
834
|
function registerServeCommand(parent, io) {
|
|
795
835
|
parent
|
|
796
836
|
.command("serve")
|
|
797
|
-
.description("Serve the local run library over loopback http, with optional
|
|
798
|
-
.summary("Serve the run library; optional
|
|
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.")
|
|
799
839
|
.option("--cwd <path>", "Target project directory.", ".")
|
|
800
840
|
.option("--port <port>", "Loopback port to bind on 127.0.0.1. Defaults to an ephemeral port.", "0")
|
|
801
841
|
.option("--run <id>", "Land on this run id (or latest) instead of the library index.")
|
|
802
842
|
.option("--safe", "Serve only runs whose verify shareSafety is share_ready; everything else is absent (fail-closed).")
|
|
803
|
-
.option("--expose", "Declare exposure intent
|
|
804
|
-
.addOption(new Option("--
|
|
805
|
-
.option("--
|
|
806
|
-
.addOption(new Option("--
|
|
807
|
-
.option("--
|
|
808
|
-
.option("--
|
|
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.")
|
|
809
850
|
.option("--open", "Open the library in the default browser.")
|
|
810
851
|
.option("--no-open", "Serve without opening a browser.")
|
|
811
852
|
.option("--json", JSON_OPTION_DESCRIPTION)
|
|
@@ -813,17 +854,18 @@ function registerServeCommand(parent, io) {
|
|
|
813
854
|
"",
|
|
814
855
|
"Happy path:",
|
|
815
856
|
" humanish serve",
|
|
816
|
-
" humanish serve --expose --tunnel ngrok --
|
|
817
|
-
" humanish serve --safe --expose --
|
|
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",
|
|
818
860
|
"",
|
|
819
861
|
"Agent/CI path:",
|
|
820
862
|
" humanish serve --json --no-open",
|
|
821
863
|
"",
|
|
822
|
-
"The server always binds 127.0.0.1; exposure only ever happens through
|
|
823
|
-
"
|
|
824
|
-
"
|
|
825
|
-
"
|
|
826
|
-
"--safe composes with
|
|
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."
|
|
827
869
|
].join("\n"))
|
|
828
870
|
.action(async (options, command) => {
|
|
829
871
|
const wantsMachine = wantsJson(command);
|
|
@@ -847,57 +889,29 @@ function registerServeCommand(parent, io) {
|
|
|
847
889
|
fail("HUMANISH_INVALID_PORT", "--port must be an integer between 0 and 65535.");
|
|
848
890
|
return;
|
|
849
891
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
}
|
|
865
|
-
if (options.auth === "none" && options.safe !== true) {
|
|
866
|
-
fail("HUMANISH_SERVE_OPEN_REQUIRES_SAFE", "--auth none serves without a secret; that is publishing, so it requires --safe (share_ready runs only).");
|
|
867
|
-
return;
|
|
868
|
-
}
|
|
869
|
-
if (options.tunnel && options.expose !== true) {
|
|
870
|
-
fail("HUMANISH_SERVE_TUNNEL_REQUIRES_EXPOSE", "--tunnel exposes the library; declare that intent with --expose.");
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
if (options.publicUrl !== undefined && options.expose !== true) {
|
|
874
|
-
fail("HUMANISH_SERVE_OPTION_CONFLICT", "--public-url only applies with --expose.");
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
if (options.tunnelDomain !== undefined && !options.tunnel) {
|
|
878
|
-
fail("HUMANISH_SERVE_OPTION_CONFLICT", "--tunnel-domain requires --tunnel.");
|
|
879
|
-
return;
|
|
880
|
-
}
|
|
881
|
-
if (options.tunnel && options.publicUrl !== undefined) {
|
|
882
|
-
fail("HUMANISH_SERVE_OPTION_CONFLICT", "Use either --tunnel or --public-url as the public origin, not both.");
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
if (options.expose === true && !options.tunnel && options.publicUrl === undefined) {
|
|
886
|
-
fail("HUMANISH_SERVE_EXPOSE_REQUIRES_ORIGIN", "--expose needs a declared public origin: pass --tunnel ngrok or --public-url <origin>. The Host allowlist stays strict in every mode.");
|
|
887
|
-
return;
|
|
888
|
-
}
|
|
889
|
-
const declaredOrigin = options.publicUrl !== undefined ? parsePublicOrigin(options.publicUrl) : null;
|
|
890
|
-
if (options.publicUrl !== undefined && !declaredOrigin) {
|
|
891
|
-
fail("HUMANISH_SERVE_OPTION_CONFLICT", "--public-url must be an http(s) origin like https://observer.example.dev.");
|
|
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);
|
|
892
906
|
return;
|
|
893
907
|
}
|
|
908
|
+
const plan = exposeValidation.plan;
|
|
894
909
|
const started = await serveObserverLibrary(options.cwd, {
|
|
895
910
|
port,
|
|
896
911
|
safe: options.safe === true,
|
|
897
|
-
expose:
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
...(declaredOrigin ? { publicOrigin: declaredOrigin.origin } : {}),
|
|
912
|
+
expose: plan.exposed,
|
|
913
|
+
edgeAuthed: plan.edgeAuthed,
|
|
914
|
+
...(plan.publicOrigin ? { publicOrigin: plan.publicOrigin.origin } : {}),
|
|
901
915
|
...(options.run ? { entryRunId: options.run } : {})
|
|
902
916
|
});
|
|
903
917
|
if (!started.ok) {
|
|
@@ -906,12 +920,14 @@ function registerServeCommand(parent, io) {
|
|
|
906
920
|
}
|
|
907
921
|
const server = started.server;
|
|
908
922
|
let tunnel;
|
|
909
|
-
|
|
923
|
+
let publicUrl;
|
|
924
|
+
const warnings = [];
|
|
925
|
+
if (plan.exposed) {
|
|
910
926
|
try {
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
927
|
+
const exposeResult = await startExposedObserver(server, plan);
|
|
928
|
+
tunnel = exposeResult.tunnel;
|
|
929
|
+
publicUrl = exposeResult.publicUrl;
|
|
930
|
+
warnings.push(...exposeResult.warnings);
|
|
915
931
|
}
|
|
916
932
|
catch (error) {
|
|
917
933
|
await server.close();
|
|
@@ -923,38 +939,26 @@ function registerServeCommand(parent, io) {
|
|
|
923
939
|
}
|
|
924
940
|
return;
|
|
925
941
|
}
|
|
926
|
-
// Declares the tunnel's https origin: extends the Host allowlist and
|
|
927
|
-
// marks minted cookies Secure (every ngrok tunnel is https).
|
|
928
|
-
server.addPublicOrigin(tunnel.url);
|
|
929
942
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
? `${server.url.replace(/\/$/, "")}/_humanish/auth/${server.capabilityToken}`
|
|
933
|
-
: undefined;
|
|
934
|
-
const publicCapabilityUrl = server.capabilityToken && publicUrl
|
|
935
|
-
? `${publicUrl}/_humanish/auth/${server.capabilityToken}`
|
|
936
|
-
: undefined;
|
|
937
|
-
const warnings = [];
|
|
938
|
-
if (server.mode === "capability-link" && options.safe !== true) {
|
|
939
|
-
warnings.push(`capability link grants read access to all ${server.runsListed} local runs, including any not verified share_ready (local_only raw screenshots, blocked bundles); anyone holding the link can view them until this process exits; restart rotates the link; add --safe to restrict to share_ready`);
|
|
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`);
|
|
940
945
|
}
|
|
941
|
-
if (server.mode === "
|
|
942
|
-
warnings.push(`
|
|
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`);
|
|
943
948
|
}
|
|
944
949
|
if (server.mode === "share-safe-open") {
|
|
945
|
-
warnings.push(`serving ${server.shareReadyCount ?? 0} share_ready runs to anyone who can reach ${publicUrl}; non-share_ready runs are absent and their URLs 404`);
|
|
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`);
|
|
946
951
|
}
|
|
947
|
-
warnings.push("live desktop stream URLs are never served; remote viewers see persisted evidence (screenshots, events, terminal tails) only");
|
|
948
|
-
// Auto-open is suppressed under --expose so the
|
|
949
|
-
//
|
|
950
|
-
//
|
|
951
|
-
// Explicit --open still honors intent and opens the capability URL.
|
|
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.
|
|
952
956
|
const shouldOpen = options.open === false
|
|
953
957
|
? false
|
|
954
958
|
: options.open === true
|
|
955
959
|
? true
|
|
956
|
-
: !wantsMachine && process.stdout.isTTY === true &&
|
|
957
|
-
const openResult = shouldOpen ? openTarget(
|
|
960
|
+
: !wantsMachine && process.stdout.isTTY === true && plan.exposed !== true;
|
|
961
|
+
const openResult = shouldOpen ? openTarget(server.url) : { opened: false };
|
|
958
962
|
if (openResult.warning) {
|
|
959
963
|
warnings.push(openResult.warning);
|
|
960
964
|
}
|
|
@@ -967,11 +971,9 @@ function registerServeCommand(parent, io) {
|
|
|
967
971
|
host: "127.0.0.1",
|
|
968
972
|
port: server.port,
|
|
969
973
|
url: server.url,
|
|
970
|
-
...(capabilityUrl ? { capabilityUrl } : {}),
|
|
971
974
|
...(publicUrl ? { publicUrl } : {}),
|
|
972
|
-
...(publicCapabilityUrl ? { publicCapabilityUrl } : {}),
|
|
973
975
|
...(tunnel ? { tunnel: { provider: "ngrok", url: tunnel.url } } : {}),
|
|
974
|
-
...(
|
|
976
|
+
...(plan.oauth ? { oauth: { provider: plan.oauth.provider, allowEmails: plan.oauth.allowEmails, allowDomains: plan.oauth.allowDomains } } : {}),
|
|
975
977
|
runsListed: server.runsListed,
|
|
976
978
|
...(server.shareReadyCount !== undefined ? { shareReadyCount: server.shareReadyCount } : {}),
|
|
977
979
|
...(server.entryRunId ? { entryRunId: server.entryRunId } : {}),
|
|
@@ -1007,20 +1009,16 @@ function formatServeHuman(result) {
|
|
|
1007
1009
|
`library: ${result.url ?? ""}`,
|
|
1008
1010
|
`runs: ${result.runsListed}`
|
|
1009
1011
|
];
|
|
1010
|
-
if (result.
|
|
1011
|
-
lines.push("SECRET LINK (anyone holding it can read the library until Ctrl-C):");
|
|
1012
|
-
lines.push(` ${result.capabilityUrl}`);
|
|
1013
|
-
if (result.publicCapabilityUrl) {
|
|
1014
|
-
lines.push(` ${result.publicCapabilityUrl}`);
|
|
1015
|
-
}
|
|
1016
|
-
lines.push("revocation: Ctrl-C revokes the link and all sessions; restarting mints a new link");
|
|
1017
|
-
}
|
|
1018
|
-
else if (result.publicUrl) {
|
|
1012
|
+
if (result.publicUrl) {
|
|
1019
1013
|
lines.push(`public: ${result.publicUrl}`);
|
|
1020
1014
|
}
|
|
1021
1015
|
if (result.tunnel) {
|
|
1022
1016
|
lines.push(`tunnel: ${result.tunnel.provider} ${result.tunnel.url}`);
|
|
1023
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
|
+
}
|
|
1024
1022
|
if (result.entryRunId) {
|
|
1025
1023
|
lines.push(`entry: ${result.entryRunId}`);
|
|
1026
1024
|
}
|
|
@@ -1493,6 +1491,23 @@ async function runLabCommand(args) {
|
|
|
1493
1491
|
writeUnsupportedRerunFlagsResult(args, backend);
|
|
1494
1492
|
return;
|
|
1495
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
|
+
}
|
|
1496
1511
|
switch (backend) {
|
|
1497
1512
|
case "synthetic":
|
|
1498
1513
|
await runSyntheticBackend({ ...args, config });
|
|
@@ -1621,30 +1636,162 @@ async function runCuaBackend(args) {
|
|
|
1621
1636
|
args.io.setExitCode(2);
|
|
1622
1637
|
return;
|
|
1623
1638
|
}
|
|
1624
|
-
const
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
:
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
}
|
|
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
|
|
1639
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
|
+
}
|
|
1640
1750
|
if (outcome.backend !== "cua") {
|
|
1641
1751
|
throw new Error(`Expected cua backend, got ${outcome.backend}.`);
|
|
1642
1752
|
}
|
|
1643
1753
|
const result = outcome.result;
|
|
1644
|
-
|
|
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);
|
|
1645
1779
|
args.io.setExitCode(result.ok ? 0 : 2);
|
|
1646
|
-
|
|
1647
|
-
|
|
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.
|
|
1648
1795
|
await renderAndMaybeFollowObserver({
|
|
1649
1796
|
command: args.command,
|
|
1650
1797
|
cwd: args.options.cwd,
|
|
@@ -2541,6 +2688,31 @@ function formatOssMetaLabCleanupHuman(result) {
|
|
|
2541
2688
|
function collectRepeated(value, previous) {
|
|
2542
2689
|
return [...previous, value];
|
|
2543
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
|
+
}
|
|
2544
2716
|
function formatInitHuman(result) {
|
|
2545
2717
|
const title = result.ok
|
|
2546
2718
|
? `humanish init ${result.mode}`
|