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.
@@ -0,0 +1,129 @@
1
+ // Shared fail-closed exposure validation + tunnel orchestration for BOTH `serve` (a library of
2
+ // finished runs) and `watch` (one live run). Exposure auth is TUNNEL-EDGE only: humanish carries no
3
+ // in-process auth. Exposure is admitted only behind edge auth (ngrok --oauth google, or an operator
4
+ // --public-url they secure) OR, for serve, behind --safe (share_ready runs only). A live watch run
5
+ // is never share_ready (raw, unverified screenshots), so watch --expose ALWAYS requires edge auth.
6
+ //
7
+ // This module is pure with respect to the fail-closed matrix (validateExposure) and thin over the
8
+ // tunnel launcher (startExposedObserver), so the CLI just maps flags in and results out.
9
+ import { parsePublicOrigin } from "./serve-http.js";
10
+ import { startNgrokTunnel } from "./serve-tunnel.js";
11
+ function code(surface, suffix) {
12
+ return `HUMANISH_${surface.toUpperCase()}_${suffix}`;
13
+ }
14
+ export function validateExposure(surface, request, live) {
15
+ const fail = (suffix, message) => ({
16
+ ok: false,
17
+ error: { code: code(surface, suffix), message }
18
+ });
19
+ // Structural guards (both surfaces), in the fail-closed order documented in the matrix. These run
20
+ // before any bind/spawn so a mis-configured exposure aborts before sandbox/provider spend.
21
+ if ((request.allowEmails.length > 0 || request.allowDomains.length > 0) && !request.oauth) {
22
+ return fail("ALLOW_REQUIRES_OAUTH", "--allow-email/--allow-domain configure the ngrok edge OAuth allow-list; they require --oauth google.");
23
+ }
24
+ if (request.oauth && !request.tunnel) {
25
+ return fail("OAUTH_REQUIRES_TUNNEL", "--oauth turns on edge OAuth on the ngrok tunnel; it requires --tunnel ngrok (a --public-url operator brings their own edge auth).");
26
+ }
27
+ if (request.tunnel && request.publicUrl !== undefined) {
28
+ return fail("OPTION_CONFLICT", "Use either --tunnel or --public-url as the public origin, not both.");
29
+ }
30
+ if (request.tunnelDomain !== undefined && !request.tunnel) {
31
+ return fail("OPTION_CONFLICT", "--tunnel-domain requires --tunnel.");
32
+ }
33
+ const publicOrigin = request.publicUrl !== undefined ? parsePublicOrigin(request.publicUrl) : null;
34
+ if (request.publicUrl !== undefined && !publicOrigin) {
35
+ return fail("OPTION_CONFLICT", "--public-url must be an http(s) origin like https://observer.example.com.");
36
+ }
37
+ if (!request.expose) {
38
+ // Exposure flags without --expose are refused (no silent wide-open). --safe is orthogonal and
39
+ // stays valid without --expose (a loopback share_ready filter).
40
+ if (request.tunnel) {
41
+ return fail("TUNNEL_REQUIRES_EXPOSE", "--tunnel exposes the surface; declare that intent with --expose.");
42
+ }
43
+ if (request.publicUrl !== undefined) {
44
+ return fail("OPTION_CONFLICT", "--public-url only applies with --expose.");
45
+ }
46
+ return {
47
+ ok: true,
48
+ plan: { exposed: false, edgeAuthed: false, mode: "loopback", safe: request.safe, warnings: [] }
49
+ };
50
+ }
51
+ const oauth = request.oauth
52
+ ? { provider: "google", allowEmails: request.allowEmails, allowDomains: request.allowDomains }
53
+ : undefined;
54
+ const edgeAuthed = Boolean(request.oauth) || Boolean(publicOrigin);
55
+ const warnings = [];
56
+ if (surface === "watch") {
57
+ // A live, in-progress run is never share_ready (raw screenshots, unverified), so --safe would
58
+ // admit nothing and expose nothing: watch --expose ALWAYS requires edge auth, and the live-follow
59
+ // preconditions are checked first so `--dry-run`/`--detach`/`--json` fail with the clearer reason.
60
+ if (live && (live.dryRun || live.detach || live.json)) {
61
+ return fail("EXPOSE_REQUIRES_LIVE_FOLLOW", "watch --expose streams a live desktop over an attached follow channel; it cannot combine with --dry-run, --detach, or --json.");
62
+ }
63
+ // --safe is a share_ready LIBRARY filter for `serve`; watch streams a single live run that is
64
+ // never share_ready, so --safe would silently do nothing here. Reject it rather than ignore it.
65
+ if (request.safe) {
66
+ return fail("SAFE_NOT_APPLICABLE", "watch streams a single live run that is never share_ready; --safe (a share_ready library filter) applies to `serve`, not `watch`. Restrict viewers with edge auth: --allow-email / --allow-domain.");
67
+ }
68
+ if (!edgeAuthed) {
69
+ return fail("EXPOSE_REQUIRES_EDGE_AUTH", "watch --expose serves a live run that is never share_ready, so --safe cannot gate it; require edge auth: --tunnel ngrok --oauth google, or an operator-secured --public-url.");
70
+ }
71
+ }
72
+ else {
73
+ // serve: --expose must ALWAYS resolve to a reachable public origin (a tunnel or a --public-url),
74
+ // even under --safe. Without one the exposed server is an unreachable loopback no-op, so fail
75
+ // closed before the origin-less bind. When an origin IS present, keep the edge-auth-OR-safe gate.
76
+ if (!request.tunnel && !publicOrigin) {
77
+ return fail("EXPOSE_REQUIRES_ORIGIN", "--expose needs a declared public origin: pass --tunnel ngrok or --public-url <origin>.");
78
+ }
79
+ if (!edgeAuthed && !request.safe) {
80
+ return fail("EXPOSE_REQUIRES_EDGE_AUTH_OR_SAFE", "--expose opens a public URL to local run bundles; require edge auth (--oauth google with --tunnel, or a --public-url you secure) OR --safe (share_ready runs only).");
81
+ }
82
+ }
83
+ if (request.oauth && request.allowEmails.length === 0 && request.allowDomains.length === 0) {
84
+ warnings.push("ngrok --oauth google with NO --allow-email/--allow-domain lets ANY Google account that reaches the URL in; add at least one allow rule to restrict who can watch.");
85
+ }
86
+ const mode = edgeAuthed ? "exposed" : "share-safe-open";
87
+ return {
88
+ ok: true,
89
+ plan: {
90
+ exposed: true,
91
+ edgeAuthed,
92
+ mode,
93
+ safe: request.safe,
94
+ ...(request.tunnel ? { tunnel: request.tunnel } : {}),
95
+ ...(request.tunnelDomain ? { tunnelDomain: request.tunnelDomain } : {}),
96
+ ...(oauth ? { oauth } : {}),
97
+ ...(publicOrigin ? { publicOrigin } : {}),
98
+ warnings
99
+ }
100
+ };
101
+ }
102
+ // Orchestrate the edge in front of an already-bound loopback server: spawn the ngrok tunnel (with
103
+ // oauth/allow args) or declare the operator's --public-url, then extend the server's Host allowlist.
104
+ // May throw a ServeTunnelError (ngrok missing/failed); callers close the loopback server on throw.
105
+ export async function startExposedObserver(server, plan, deps = {}) {
106
+ const warnings = [...plan.warnings];
107
+ if (plan.tunnel === "ngrok") {
108
+ const startTunnel = deps.startTunnel ?? startNgrokTunnel;
109
+ const tunnel = await startTunnel({
110
+ port: server.port,
111
+ ...(plan.tunnelDomain ? { domain: plan.tunnelDomain } : {}),
112
+ ...(plan.oauth
113
+ ? {
114
+ oauthProvider: plan.oauth.provider,
115
+ oauthAllowEmails: plan.oauth.allowEmails,
116
+ oauthAllowDomains: plan.oauth.allowDomains
117
+ }
118
+ : {})
119
+ });
120
+ server.addPublicOrigin(tunnel.url);
121
+ return { tunnel, publicUrl: tunnel.url.replace(/\/$/, ""), warnings };
122
+ }
123
+ if (plan.publicOrigin) {
124
+ server.addPublicOrigin(plan.publicOrigin.origin);
125
+ return { publicUrl: plan.publicOrigin.origin, warnings };
126
+ }
127
+ return { warnings };
128
+ }
129
+ //# sourceMappingURL=serve-exposure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve-exposure.js","sourceRoot":"","sources":["../src/serve-exposure.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ export type ServeMode = "loopback" | "exposed" | "share-safe-open";
2
+ export declare function buildServeSecurityHeaders(): Record<string, string>;
3
+ export declare function hostAllowed(hostHeader: string | undefined, allowlist: ReadonlySet<string>): boolean;
4
+ export declare function parsePublicOrigin(value: string): {
5
+ origin: string;
6
+ host: string;
7
+ scheme: "http" | "https";
8
+ } | null;
@@ -0,0 +1,37 @@
1
+ // Shared HTTP hardening primitives for the serve surfaces. Extracted here so BOTH the live
2
+ // Observer server (src/observer.ts, exposed mode) and the run-library server
3
+ // (src/observer-serve.ts) can enforce the identical Host allowlist + security-header posture
4
+ // without a module cycle. This file imports nothing from the serve modules.
5
+ export function buildServeSecurityHeaders() {
6
+ return {
7
+ "cache-control": "no-store",
8
+ "referrer-policy": "no-referrer",
9
+ "x-content-type-options": "nosniff",
10
+ "x-frame-options": "DENY",
11
+ "x-robots-tag": "noindex, nofollow"
12
+ };
13
+ }
14
+ export function hostAllowed(hostHeader, allowlist) {
15
+ return typeof hostHeader === "string" && allowlist.has(hostHeader.trim().toLowerCase());
16
+ }
17
+ export function parsePublicOrigin(value) {
18
+ let parsed;
19
+ try {
20
+ parsed = new URL(value);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
26
+ return null;
27
+ }
28
+ if ((parsed.pathname !== "/" && parsed.pathname !== "") || parsed.search || parsed.hash || !parsed.host) {
29
+ return null;
30
+ }
31
+ return {
32
+ origin: parsed.origin,
33
+ host: parsed.host.toLowerCase(),
34
+ scheme: parsed.protocol === "https:" ? "https" : "http"
35
+ };
36
+ }
37
+ //# sourceMappingURL=serve-http.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve-http.js","sourceRoot":"","sources":["../src/serve-http.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAC3F,6EAA6E;AAC7E,6FAA6F;AAC7F,4EAA4E;AAS5E,MAAM,UAAU,yBAAyB;IACvC,OAAO;QACL,eAAe,EAAE,UAAU;QAC3B,iBAAiB,EAAE,aAAa;QAChC,wBAAwB,EAAE,SAAS;QACnC,iBAAiB,EAAE,MAAM;QACzB,cAAc,EAAE,mBAAmB;KACpC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,UAA8B,EAAE,SAA8B;IACxF,OAAO,OAAO,UAAU,KAAK,QAAQ,IAAI,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC;AAC1F,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,KAAa;IAC7C,IAAI,MAAW,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAChE,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,QAAQ,KAAK,GAAG,IAAI,MAAM,CAAC,QAAQ,KAAK,EAAE,CAAC,IAAI,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QACxG,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE;QAC/B,MAAM,EAAE,MAAM,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM;KACxD,CAAC;AACJ,CAAC"}
@@ -8,9 +8,13 @@ export interface ServeTunnel {
8
8
  url: string;
9
9
  close(): Promise<void>;
10
10
  }
11
- export declare function startNgrokTunnel(options: {
11
+ export interface StartNgrokTunnelOptions {
12
12
  port: number;
13
13
  domain?: string;
14
+ oauthProvider?: "google";
15
+ oauthAllowEmails?: string[];
16
+ oauthAllowDomains?: string[];
14
17
  timeoutMs?: number;
15
18
  spawnImpl?: typeof spawn;
16
- }): Promise<ServeTunnel>;
19
+ }
20
+ export declare function startNgrokTunnel(options: StartNgrokTunnelOptions): Promise<ServeTunnel>;
@@ -10,6 +10,14 @@ export class ServeTunnelError extends Error {
10
10
  export async function startNgrokTunnel(options) {
11
11
  const spawnImpl = options.spawnImpl ?? spawn;
12
12
  const timeoutMs = options.timeoutMs ?? 15_000;
13
+ const oauthArgs = options.oauthProvider
14
+ ? [
15
+ "--oauth",
16
+ options.oauthProvider,
17
+ ...(options.oauthAllowEmails ?? []).flatMap((email) => ["--oauth-allow-email", email]),
18
+ ...(options.oauthAllowDomains ?? []).flatMap((domain) => ["--oauth-allow-domain", domain])
19
+ ]
20
+ : [];
13
21
  const args = [
14
22
  "http",
15
23
  "--log",
@@ -17,6 +25,7 @@ export async function startNgrokTunnel(options) {
17
25
  "--log-format",
18
26
  "json",
19
27
  ...(options.domain ? ["--url", options.domain] : []),
28
+ ...oauthArgs,
20
29
  String(options.port)
21
30
  ];
22
31
  const child = spawnImpl("ngrok", args, { stdio: ["ignore", "pipe", "ignore"] });
@@ -1 +1 @@
1
- {"version":3,"file":"serve-tunnel.js","sourceRoot":"","sources":["../src/serve-tunnel.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"serve-tunnel.js","sourceRoot":"","sources":["../src/serve-tunnel.ts"],"names":[],"mappings":"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"}
@@ -111,7 +111,10 @@ export type ActorCompletionReason =
111
111
  | "turn_completed" // harness saw an explicit done signal, no predicate
112
112
  | "gave_up" // persona abandoned in character: friction exceeded its tolerance
113
113
  | "blocked_approval" // an action was auto-declined and the actor could not proceed
114
- | "timed_out"
114
+ | "timed_out" // wall-clock deadline hit with ZERO material progress → still a FAILURE
115
+ | "budget_reached" // wall-clock time budget hit AFTER productive activity (>=1 material
116
+ // action) → ActorStatus "passed", a NON-FAILURE open-ended-watch
117
+ // completion; distinct from goal_satisfied (no goal was claimed)
115
118
  | "actor_error"
116
119
  | "step_failed" // a deterministic scripted step/expectation evaluated false: the
117
120
  // SUBJECT failed the script; the harness executed faithfully
@@ -245,8 +248,31 @@ Completion semantics: `goal_satisfied` means the scenario's `expect` blocks —
245
248
  predicate — all held ("the app still affords this exact journey", nothing about user
246
249
  behavior); `step_failed` means a deterministic step or expectation evaluated false (the
247
250
  subject failed the script; the harness ran faithfully); `timed_out` is the journey wall-clock
248
- budget; `harness_error` is a browser that could not launch. `gave_up` and `blocked_approval`
249
- are unreachable — no persona patience, no approvals exist on a deterministic replay.
251
+ budget hit with NO material progress (still a failure); `harness_error` is a browser that could
252
+ not launch. `gave_up` and `blocked_approval` are unreachable — no persona patience, no
253
+ approvals exist on a deterministic replay.
254
+
255
+ ### The time budget vs. a stuck timeout (`budget_reached`)
256
+
257
+ `execution.timeoutMs` is a GENEROUS wall-clock SAFETY cap, not a goal. For an open-ended
258
+ "watch it play" session there is no success predicate — productive play IS the outcome — so the
259
+ computer-use loop distinguishes two ways to hit the cap:
260
+
261
+ - **`budget_reached`** — the deadline was reached AFTER at least one material (non-idle) action.
262
+ This maps to `ActorStatus: "passed"`: a non-failure completion, `laneOutcomeOk` returns true,
263
+ the verdict is `pass`, and the CLI exits `0`. It stays a distinct `completionReason` (never
264
+ `goal_satisfied`), and the trace `reason` says it reached the budget after productive activity,
265
+ so a reviewer of a strictly goal-directed lab sees it hit the cap rather than reaching a goal
266
+ (goal-directed labs should set a tight `timeoutMs`).
267
+ - **`timed_out`** — the deadline was reached with ZERO material actions (a hung provider, an
268
+ idle-only stall). This maps to `ActorStatus: "timed_out"`, `laneOutcomeOk` is false, the
269
+ verdict is `fail`, and the CLI exits `2`. "Made zero progress then timed out" stays a failure.
270
+
271
+ `ActorStatus` intentionally gains NO new member — the honest distinction lives in
272
+ `completionReason`/`reason` — which keeps the change from rippling through ~10 provider mappers.
273
+ `statusForCompletion` is an exhaustive switch with no default, so a new completion reason forces a
274
+ compile-time decision about its status. ~30 min (`1_800_000`) is a reasonable default for
275
+ open-ended watch; the persona still stops early on `goal_satisfied`/`gave_up`/`stopWhen`.
250
276
 
251
277
  Actuation-vs-spend gate: on the scripted lab route `scenario.mode: live` is still required
252
278
  even though provider spend is $0 by mechanism. The gate's justification there is ACTUATION,
@@ -78,6 +78,36 @@ LIBRARY surface — every run under `.humanish/runs/` — and never serves runti
78
78
  stream URLs in any mode; remote viewers see persisted evidence only. See
79
79
  [Serve: the run library surface](serve.md).
80
80
 
81
+ ### Exposed hardening and `watch --expose`
82
+
83
+ The live `serveObserver` server binds `127.0.0.1` and, by default, is a
84
+ permissive local-dev server (no Host allowlist, no security headers). Under its
85
+ `exposed` option — set by `watch --expose` — it enforces the SAME
86
+ DNS-rebinding defense as the library surface: a strict Host allowlist (loopback
87
+ names at bind, extended by `addPublicOrigin(tunnel.url | public-url)`, `421
88
+ Misdirected Request` otherwise) and the shared `buildServeSecurityHeaders()` on
89
+ every response (both live in `src/serve-http.ts`, shared without a module cycle).
90
+ Loopback (non-exposed) behavior is byte-identical to before.
91
+
92
+ Exposed mode also SCOPES the surface to the attached live run (`result.run`): the
93
+ `/_humanish/history.json` index is filtered to that one run, and `/_humanish/runs/<id>/…`
94
+ 404s byte-identically to a nonexistent run for any other id. A remote viewer who
95
+ clears the edge auth can therefore see only the run being watched — never enumerate
96
+ or fetch a prior run's raw, unverified evidence. Loopback keeps the full cross-run
97
+ library (history + any run by id) exactly as before.
98
+
99
+ `watch --expose` is the ONE surface that DELIBERATELY streams the live E2B
100
+ desktop to a remote viewer: the attached watch process genuinely holds the
101
+ runtime stream URLs (in the in-memory `WeakMap`, never persisted), and streaming
102
+ them is the whole point of watching from a phone. It is safe only because the
103
+ ngrok edge (Google OAuth + allow rules) or an operator `--public-url` edge
104
+ authenticates the viewer first — `watch --expose` therefore always requires edge
105
+ auth (a live run is never `share_ready`, so `--safe` alone cannot gate it). The
106
+ attached server comes up DURING the run and survives a `timed_out`/`failed` run
107
+ (serving is not gated on pass/fail), so a failed run's evidence stays inspectable
108
+ to Ctrl-C. `serve` still never injects stream URLs. See
109
+ [Serve: the run library surface](serve.md).
110
+
81
111
  ## UI Shape
82
112
 
83
113
  The Observer shell has:
@@ -2,10 +2,19 @@
2
2
 
3
3
  Date: 2026-08-02
4
4
 
5
- Status: shipped in v1 — loopback, capability-link, and share-safe-open modes
6
- (`src/observer-serve.ts`, `src/observer-auth.ts`, `src/observer-library.ts`,
7
- `src/serve-tunnel.ts`; CLI wiring in `src/program.ts`). The `/_humanish/api/*`
8
- control-plane namespace is reserved and answers `501`; no mutating route ships.
5
+ Status: shipped — `loopback`, `exposed` (edge-authed), and `share-safe-open`
6
+ modes (`src/observer-serve.ts`, `src/observer-library.ts`, `src/serve-http.ts`,
7
+ `src/serve-exposure.ts`, `src/serve-tunnel.ts`; CLI wiring in `src/program.ts`).
8
+ The `/_humanish/api/*` control-plane namespace is reserved and answers `501`; no
9
+ mutating route ships.
10
+
11
+ Exposure auth is **tunnel-edge only**. As of 0.18.0 humanish carries NO
12
+ in-process auth: the hand-rolled capability-link (cookie/token/TTL, the whole
13
+ `observer-auth.ts` module and the `serve --auth link|none` flags) was removed as
14
+ a pre-1.0 breaking change. The gate now lives entirely at the edge — ngrok
15
+ `--oauth google` (with `--allow-email`/`--allow-domain` allow rules), or an
16
+ operator-secured `--public-url` (Cloudflare Access, Tailscale, a reverse proxy
17
+ you own).
9
18
 
10
19
  ## What serve is
11
20
 
@@ -13,69 +22,107 @@ control-plane namespace is reserved and answers `501`; no mutating route ships.
13
22
  the LIBRARY rather than a run:
14
23
 
15
24
  - `humanish watch` — one ATTACHED run: the process that created the run serves
16
- it and may inject runtime stream URLs for live following;
25
+ it and may inject runtime stream URLs for live following (see the stream-URL
26
+ doctrine below and `watch --expose`);
17
27
  - `humanish observe` — one FINISHED run, re-served read-only;
18
28
  - `humanish serve` — the whole local library under `.humanish/runs/`: a
19
29
  library index, per-run Observer pages, `/_humanish/history.json` polling, and
20
- optional exposure beyond the machine.
30
+ optional edge-authenticated exposure beyond the machine.
21
31
 
22
32
  The server binds `127.0.0.1` unconditionally (`serveObserverLibrary`); exposure
23
33
  only ever happens through a tunnel or proxy forwarding to the loopback port.
24
- `--expose` gates every request behind a capability link: a per-process secret
25
- whose SHA-256 digest is compared timing-safe (`verifyTokenDigest`), minting an
26
- HttpOnly viewer-session cookie with a bounded TTL. Ctrl-C revokes the link and
27
- all sessions (`revokeAll` on close); restarting mints a new link.
34
+ `--expose` never changes the bind it declares intent and requires an
35
+ authenticated edge (or `--safe`, see the fail-closed matrix).
36
+
37
+ ## Fail-closed exposure matrix
38
+
39
+ One shared validator (`validateExposure` in `src/serve-exposure.ts`) governs
40
+ both `serve` and `watch`. `--expose` must ALWAYS resolve to a reachable public
41
+ origin (a `--tunnel` or a `--public-url`) — even under `--safe`, since an
42
+ origin-less exposed server is an unreachable loopback no-op. With an origin
43
+ present, exposure requires EITHER edge auth (`--oauth` on the ngrok edge, or a
44
+ `--public-url` you secure) OR `--safe` (share_ready runs only). A tunnel with
45
+ neither is a wide-open public URL to local bundles and is refused.
46
+
47
+ | `--expose` | `--tunnel` | `--oauth` | `--public-url` | `--safe` | Outcome |
48
+ | --- | --- | --- | --- | --- | --- |
49
+ | — | — | — | — | any | `loopback` (no exposure) |
50
+ | ✓ | ✓ | ✓ | — | any | OK → `exposed` (edge-authed; all runs unless `--safe`) |
51
+ | ✓ | ✓ | — | — | ✓ | OK → `share-safe-open` (public, share_ready only) |
52
+ | ✓ | ✓ | — | — | — | **REFUSED** `HUMANISH_SERVE_EXPOSE_REQUIRES_EDGE_AUTH_OR_SAFE` |
53
+ | ✓ | — | — | ✓ | any | OK → `exposed` (operator-secured edge) |
54
+ | ✓ | — | — | — | any | **REFUSED** `HUMANISH_SERVE_EXPOSE_REQUIRES_ORIGIN` (no reachable origin, even with `--safe`) |
55
+ | ✓ | — | ✓ | — | any | **REFUSED** `HUMANISH_SERVE_OAUTH_REQUIRES_TUNNEL` |
56
+ | ✓ | ✓ | ✓ | ✓ | any | **REFUSED** `HUMANISH_SERVE_OPTION_CONFLICT` (tunnel + public-url) |
57
+
58
+ Guard order (all before any bind/spawn): `--allow-email`/`--allow-domain`
59
+ without `--oauth` → `HUMANISH_SERVE_ALLOW_REQUIRES_OAUTH`; `--oauth` without
60
+ `--tunnel` → `HUMANISH_SERVE_OAUTH_REQUIRES_TUNNEL`; `--tunnel`+`--public-url` →
61
+ conflict; `--tunnel-domain` without `--tunnel` → conflict; `--expose` with no
62
+ tunnel and no `--public-url` (even under `--safe`) →
63
+ `HUMANISH_SERVE_EXPOSE_REQUIRES_ORIGIN`; `--expose` with an origin but without
64
+ edge auth and without `--safe` →
65
+ `HUMANISH_SERVE_EXPOSE_REQUIRES_EDGE_AUTH_OR_SAFE`; a tunnel/public-url without
66
+ `--expose` → `HUMANISH_SERVE_TUNNEL_REQUIRES_EXPOSE`/conflict. `--oauth google`
67
+ with NO allow rule is ALLOWED (any Google account authenticates) but pushes a
68
+ prominent warning recommending at least one `--allow-email`/`--allow-domain`.
69
+
70
+ The `watch` surface reuses the same validator but is stricter: a live,
71
+ in-progress run is never `share_ready` (raw, unverified screenshots), so `--safe`
72
+ would admit nothing. `watch --expose --safe` is therefore REFUSED outright with
73
+ `HUMANISH_WATCH_SAFE_NOT_APPLICABLE` (rather than silently ignoring the flag);
74
+ `watch --expose` ALWAYS requires edge auth (`--tunnel --oauth` or `--public-url`),
75
+ and is additionally refused with `--dry-run`/`--detach`/`--json` (no live desktop
76
+ / no attached follow). An exposed watch serves ONLY the attached live run: its
77
+ `/_humanish/history.json` lists just that run and every other run id 404s
78
+ byte-identically to a nonexistent one, so a remote viewer can never enumerate or
79
+ reach any prior run's raw evidence (loopback watch still serves the full library).
80
+
81
+ ## ngrok edge OAuth
82
+
83
+ `startNgrokTunnel` builds `ngrok http <port> [--url <domain>] [--oauth google
84
+ [--oauth-allow-email <addr>]… [--oauth-allow-domain <domain>]…] --log stdout
85
+ --log-format json`. ngrok authenticates the viewer at its edge before any request
86
+ reaches the loopback port; the stdout JSON parser skips every line except
87
+ `msg:"started tunnel"`, so ngrok's `--oauth has been deprecated` info line (it is
88
+ still accepted and functional on ngrok 3.39.x) is ignored automatically. The
89
+ forward-compatible path if ngrok removes the flags is a Traffic Policy YAML — a
90
+ documented fast-follow, out of scope for 0.18.0.
28
91
 
29
92
  ## Threat model
30
93
 
31
- **The tunnel-agent-connects-from-loopback trap.** The classic mistake on a
32
- tunneled local server is "peer address is 127.0.0.1, so this is the local
33
- operator skip auth." The tunnel agent connects from loopback, so that
34
- shortcut disables auth in exactly the deployment it exists to protect. Serve
35
- auth therefore ignores source address entirely: every route below the auth
36
- mint requires a valid session, loopback peer or not.
37
-
38
- **Referer and unfurler leakage.** The capability URL embeds the secret in the
39
- path. Every response carries `referrer-policy: no-referrer` (plus `no-store`
40
- and `x-robots-tag: noindex`), so a link inside a served page can never leak the
41
- origin or token onward. The residual channel is the operator pasting the link
42
- into a chat app whose unfurler prefetches it server-side — which is why the
43
- link is deliberately NOT single-use (a preview fetch must not burn it before
44
- the phone taps it), and why the README steers sharing toward AirDrop or manual
45
- entry.
46
-
47
- **Host-only cookie scope on shared tunnel domains.** The session cookie sets
48
- no `Domain` attribute (`buildSessionCookie`), so it is host-only. On a shared
49
- tunnel apex (many customers under one provider domain), a `Domain` cookie
50
- would be presented to sibling subdomains an attacker can rent; a host-only
51
- cookie can never leave the exact public origin the operator declared.
94
+ **No in-process secret to leak.** The old capability-link posture (a token in
95
+ the URL path, an HttpOnly cookie, unfurler/history leakage, host-only cookie
96
+ scope, TTL/revocation) is gone with the module. There is no secret in any URL
97
+ served by humanish; the operator's edge owns authentication and session
98
+ lifetime.
52
99
 
53
100
  **DNS rebinding and the strict Host allowlist.** A malicious page can point an
54
101
  attacker-controlled DNS name at 127.0.0.1 and read a permissive local server
55
102
  from the victim's browser. Serve keeps a strict Host allowlist in ALL modes —
56
- loopback names plus the declared tunnel/public origin only — and answers
57
- `421 Misdirected Request` otherwise. Even the unauthenticated loopback default
58
- never trusts an arbitrary Host header.
103
+ loopback names plus the declared tunnel/public origin only — and answers `421
104
+ Misdirected Request` otherwise. Even the unauthenticated loopback default never
105
+ trusts an arbitrary Host header. The live `serveObserver` server gains the same
106
+ allowlist + security headers under its new `exposed` option (see observer.md), so
107
+ `watch --expose` is not a header-less, rebinding-vulnerable surface.
59
108
 
60
- **Token in phone history (residual).** After the tap, the capability URL
61
- remains in the phone's browser history and share sheet. TTL-bounded sessions,
62
- per-process minting, and Ctrl-C revocation bound the damage — a link recovered
63
- from history after the process exits or restarts is dead — but while the same
64
- process runs, a leaked history entry is a live credential. This risk is
65
- accepted and documented, not solved.
109
+ **Security headers.** Every response carries `cache-control: no-store`,
110
+ `referrer-policy: no-referrer`, `x-content-type-options: nosniff`,
111
+ `x-frame-options: DENY`, and `x-robots-tag: noindex, nofollow`
112
+ (`buildServeSecurityHeaders`).
66
113
 
67
114
  ## Mode-to-boundary mapping
68
115
 
69
116
  | Mode | Invocation | Boundary class |
70
117
  | --- | --- | --- |
71
118
  | `loopback` | `humanish serve` | Capture-side trust: readable only by whoever can already read gitignored `.humanish/` on this machine; no new boundary is crossed. |
72
- | `capability-link` | `--expose` (auth defaults to `link`) | Declared-friction exposure: publishing-adjacent, not publishing. Anyone holding the secret link reads everything it grants until Ctrl-C the printed warning names the run count, including non-`share_ready` runs unless `--safe` composes in. |
73
- | `share-safe-open` | `--expose --safe --auth none` | Genuine publishing, behind the feedback-grade `share_ready` gate: only runs that pass verify are served — admission is re-checked when a bundle changes and re-verified within a bounded window (default 30s), so it is fresh, not perfectly live; everything else is absent, 404ing byte-identically to a nonexistent run (no existence oracle). |
119
+ | `exposed` | `--expose --tunnel ngrok --oauth google …`, or `--expose --public-url <origin>` | Edge-authed exposure: only viewers who clear the edge OAuth (or the operator's own edge) reach the loopback server, which then serves everything it grants unless `--safe` composes in. The gate is the edge, not humanish. |
120
+ | `share-safe-open` | `--expose --safe --tunnel ngrok` (no `--oauth`) | Genuine publishing behind the feedback-grade `share_ready` gate: only runs that pass verify are served — admission is re-checked when a bundle changes and re-verified within a bounded window (default 30s); everything else is absent, 404ing byte-identically to a nonexistent run (no existence oracle). |
74
121
 
75
122
  ## Stream-URL doctrine
76
123
 
77
- Live desktop stream URLs (auth-bearing hosted-VNC links) are never served by
78
- this surface, in any mode, and the guarantee is layered:
124
+ Live desktop stream URLs (auth-bearing hosted-VNC links) are never served by the
125
+ LIBRARY surface, in any mode, and the guarantee is layered:
79
126
 
80
127
  - **structural** — runtime stream URLs live in a `WeakMap` keyed by the watch
81
128
  process's in-memory `ObserverResult` (`src/observer.ts`) and are never
@@ -87,54 +134,63 @@ this surface, in any mode, and the guarantee is layered:
87
134
  - **tested** — the serve suite pins a no-injection test: observer data served
88
135
  through the library surface carries no runtime stream URLs.
89
136
 
90
- A `--live-streams` flag on serve was considered and deliberately refused: the
91
- persisted bundle carries no stream URLs, so the flag would be a claim without a
92
- mechanism (invariant 6). Remote live following belongs to a designed
93
- `watch --expose` (see Future work), where the attached process actually holds
94
- the URLs.
137
+ `watch --expose` is the ONE surface that deliberately serves runtime E2B stream
138
+ URLs the attached watch process genuinely holds them, and streaming the live
139
+ desktop is the whole point of watching from a phone. It is safe only because the
140
+ edge authenticates first, and the URLs are still never persisted (they are
141
+ injected into the in-memory bundle, not disk). See observer.md.
95
142
 
96
143
  ## The share_ready doctrine
97
144
 
98
145
  `share_ready` was designed as the bar for FEEDBACK payloads: evidence eligible
99
- to leave the machine inside a public issue draft. Serve's open mode extends
100
- that same gate to arbitrary-audience BROWSING, which is a broader exposure of
101
- the same artifacts. The honest caveat carries over unchanged: `humanish
102
- verify` does not yet detect free-form PII/PHI (names, emails, medical
146
+ to leave the machine inside a public issue draft. Serve's `share-safe-open` mode
147
+ extends that same gate to arbitrary-audience BROWSING, which is a broader
148
+ exposure of the same artifacts. The honest caveat carries over unchanged:
149
+ `humanish verify` does not yet detect free-form PII/PHI (names, emails, medical
103
150
  identifiers — see the README's public-safety boundary and issue #108), so
104
- `share_ready` means the automated secret/path scan passed, not that a human
105
- would publish every pixel. Maintainers should treat open mode accordingly:
106
- synthetic data upstream, review before exposing, and treat `--safe --auth
107
- none` as publishing because it is.
151
+ `share_ready` means the automated secret/path scan passed, not that a human would
152
+ publish every pixel. Maintainers should treat open mode accordingly: synthetic
153
+ data upstream, review before exposing, and treat `--safe` without edge auth as
154
+ publishing because it is.
108
155
 
109
156
  ## v2 control-plane seam contract
110
157
 
111
158
  The reserved `/_humanish/api/*` namespace answers `501` with
112
- `HUMANISH_SERVE_CONTROL_PLANE_DISABLED` in v1 to any request that clears the
113
- auth gate so under `--expose --auth link` a session-less request gets the
114
- uniform `401` first, and the `501` is what an authenticated (or loopback) caller
115
- sees. No run artifact can ever shadow the namespace. The seam is already typed:
116
- `createServeRequestHandler` accepts an optional `ServeControlPlane`, and v1
117
- always passes `undefined`. Before any mutating route ships, the contract is:
118
-
119
- - sessions grow a real scope splittoday every session is scope `viewer`;
120
- operators get a DISTINCT operator token, never an upgraded viewer cookie;
121
- - mutating routes require double-submit CSRF on top of the operator session,
122
- because a cookie alone is exactly what a cross-site request forges;
123
- - the spend rule is invariant 3 applied to remote hands: a phone-initiated
124
- LIVE run needs its own affirmative declaration at serve startup (an explicit
125
- opt-in naming the lab and budget), never a default the viewer UI can reach.
159
+ `HUMANISH_SERVE_CONTROL_PLANE_DISABLED` to any request. Because the in-process
160
+ auth gate is gone, a request that clears the edge (or a loopback caller) reaches
161
+ the `501` directly there is no `401`-first anymore. No run artifact can ever
162
+ shadow the namespace. The seam is already typed: `createServeRequestHandler`
163
+ accepts an optional `ServeControlPlane`, and v1 always passes `undefined`. Before
164
+ any mutating route ships, the contract is:
165
+
166
+ - an operator identity distinct from a viewer but sourced from the edge/control
167
+ plane, not a humanish-minted cookie;
168
+ - mutating routes require CSRF defenses appropriate to the chosen edge session;
169
+ - the spend rule is invariant 3 applied to remote hands: a phone-initiated LIVE
170
+ run needs its own affirmative declaration at serve startup (an explicit opt-in
171
+ naming the lab and budget), never a default the viewer UI can reach.
172
+
173
+ ## Why not Better Auth here
174
+
175
+ Better Auth is a strong TypeScript auth framework, but it is deliberately NOT
176
+ used for this CLI serve surface: serve is an ephemeral, no-database,
177
+ per-invocation loopback server whose only job is to hand persisted evidence to an
178
+ already-authenticated edge. A password/session/social-login framework with a
179
+ schema and a datastore is the wrong shape for a process that lives for the length
180
+ of a `Ctrl-C`. The right home for Better Auth is a FUTURE hosted humanish
181
+ dashboard / control-plane — a persistent, multi-user service with a database —
182
+ where accounts, org membership, and durable sessions actually exist.
126
183
 
127
184
  ## Future work
128
185
 
129
- - **Persistent capability links.** A link that survives restart is a stored
130
- secret and needs a real secret-storage design (keychain/agent integration,
131
- rotation, revocation records) not a `--token <value>` CLI string, which
132
- would land in shell history and process listings.
186
+ - **Traffic Policy for ngrok.** Migrate off the deprecated `--oauth*` flags to a
187
+ generated Traffic Policy YAML once ngrok requires it (the `yaml` dep is already
188
+ available); pin the behavior in `serve-tunnel` tests first.
133
189
  - **Google Fonts inlining for per-run observer pages.** The library index is
134
- self-contained; the per-run observer HTML still references remote Google
135
- Fonts, which degrade gracefully offline but should be inlined (or dropped)
136
- so a served run page makes no third-party requests from a viewer's browser.
137
- - **`watch --expose --live-streams`.** Remote LIVE following is a separate
138
- design: the attached watch process genuinely holds runtime stream URLs, so
139
- it can expose them deliberately with the same capability-link gate and its
140
- own doctrine for auth-bearing hosted-desktop URLs.
190
+ self-contained; the per-run observer HTML still references remote Google Fonts,
191
+ which degrade gracefully offline but should be inlined (or dropped) so a served
192
+ run page makes no third-party requests from a viewer's browser.
193
+ - **Shipped in 0.18.0: `watch --expose`.** Remote LIVE following of a run,
194
+ including its live E2B desktop stream, behind the same edge auth — the one
195
+ surface that deliberately serves runtime stream URLs. See observer.md and
196
+ live_watch_wiring.