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/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, parsePublicOrigin, serveObserverLibrary } from "./observer-serve.js";
17
- import { ServeTunnelError, startNgrokTunnel } from "./serve-tunnel.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";
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 capability-link exposure.")
798
- .summary("Serve the run library; optional capability-link exposure.")
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: enables the capability-link auth gate on every request and prints the secret link once. Requires --tunnel or --public-url.")
804
- .addOption(new Option("--auth <mode>", "Auth mode under --expose: capability link, or none (requires --safe).").choices(["link", "none"]).default("link"))
805
- .option("--ttl <minutes>", "Capability session lifetime in minutes under --expose.", "720")
806
- .addOption(new Option("--tunnel <provider>", "Spawn the OPTIONAL external tunnel binary against the loopback port.").choices(["ngrok"]))
807
- .option("--tunnel-domain <domain>", "Reserved domain passed to ngrok as --url (e.g. observer.example.dev). Requires --tunnel.")
808
- .option("--public-url <origin>", "Declared public origin when you run your own tunnel/proxy (e.g. https://observer.example.dev). Requires --expose; never affects binding.")
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 --tunnel-domain observer.example.dev",
817
- " humanish serve --safe --expose --auth none --tunnel ngrok",
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 a tunnel",
823
- "forwarding to the loopback port. The capability link is minted fresh per process:",
824
- "Ctrl-C revokes the link and every session. Live desktop stream URLs are never",
825
- "served in any mode; remote viewers see persisted evidence (screenshots, events).",
826
- "--safe composes with the capability link for defense in depth."
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
- const ttlMinutes = /^\d+$/.test(options.ttl) ? Number.parseInt(options.ttl, 10) : null;
851
- if (ttlMinutes === null || ttlMinutes < 1 || ttlMinutes > 10_080) {
852
- fail("HUMANISH_SERVE_INVALID_TTL", "--ttl must be an integer between 1 and 10080 (minutes).");
853
- return;
854
- }
855
- const authExplicit = command.getOptionValueSource("auth") === "cli";
856
- const ttlExplicit = command.getOptionValueSource("ttl") === "cli";
857
- if (authExplicit && options.expose !== true) {
858
- fail("HUMANISH_SERVE_OPTION_CONFLICT", "--auth only applies with --expose.");
859
- return;
860
- }
861
- if (ttlExplicit && options.expose !== true) {
862
- fail("HUMANISH_SERVE_OPTION_CONFLICT", "--ttl only applies with --expose.");
863
- return;
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: options.expose === true,
898
- authMode: options.auth,
899
- ttlMinutes,
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
- if (options.tunnel) {
923
+ let publicUrl;
924
+ const warnings = [];
925
+ if (plan.exposed) {
910
926
  try {
911
- tunnel = await startNgrokTunnel({
912
- port: server.port,
913
- ...(options.tunnelDomain ? { domain: options.tunnelDomain } : {})
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
- const publicUrl = tunnel ? tunnel.url.replace(/\/$/, "") : declaredOrigin?.origin;
931
- const capabilityUrl = server.capabilityToken
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 === "capability-link" && options.safe === true) {
942
- warnings.push(`capability link grants read access to ${server.shareReadyCount ?? 0} share_ready runs; non-share_ready runs are absent even to link holders`);
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 capability token is not
949
- // placed into a local opener process's argv (readable via `ps`) without
950
- // the operator asking the exposure target is a remote device anyway.
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 && options.expose !== true;
957
- const openResult = shouldOpen ? openTarget(capabilityUrl ?? server.url) : { opened: false };
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
- ...(server.mode === "capability-link" ? { ttlMinutes } : {}),
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.capabilityUrl) {
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 outcome = await runLab(args.config, {
1625
- cwd: args.options.cwd,
1626
- // Watch mode opens the served Observer below instead of the static render.
1627
- open: args.mode === "watch" ? false : shouldOpen,
1628
- count,
1629
- ...(args.options.dryRun === undefined ? {} : { dryRun: args.options.dryRun }),
1630
- ...(args.options.runId === undefined ? {} : { runId: args.options.runId }),
1631
- ...(args.options.rerunFailedFrom === undefined
1632
- ? {}
1633
- : {
1634
- rerun: {
1635
- sourceRunId: args.options.rerunFailedFrom,
1636
- ...(laneIds.length === 0 ? {} : { laneIds })
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
- writeResult(args.command, args.io, result, formatCuaLabHuman);
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
- // Watch mode serves the freshly rendered Observer (and opens it unless told not to).
1647
- if (args.mode === "watch" && result.ok && !wantsMachine) {
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}`