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/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 outcome = await runLab(args.config, {
1383
- cwd: args.options.cwd,
1384
- // Watch mode opens the served Observer below instead of the static render.
1385
- open: args.mode === "watch" ? false : shouldOpen,
1386
- count,
1387
- ...(args.options.dryRun === undefined ? {} : { dryRun: args.options.dryRun }),
1388
- ...(args.options.runId === undefined ? {} : { runId: args.options.runId }),
1389
- ...(args.options.rerunFailedFrom === undefined
1390
- ? {}
1391
- : {
1392
- rerun: {
1393
- sourceRunId: args.options.rerunFailedFrom,
1394
- ...(laneIds.length === 0 ? {} : { laneIds })
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
- 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);
1403
1779
  args.io.setExitCode(result.ok ? 0 : 2);
1404
- // Watch mode serves the freshly rendered Observer (and opens it unless told not to).
1405
- 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.
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}`