humanish 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -3
- package/dist/actor-contract.d.ts +1 -1
- package/dist/actor-contract.js.map +1 -1
- package/dist/computer-use.js +33 -5
- package/dist/computer-use.js.map +1 -1
- package/dist/cua-actor-lab.d.ts +1 -1
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/observer-library.d.ts +19 -0
- package/dist/observer-library.js +184 -0
- package/dist/observer-library.js.map +1 -0
- package/dist/observer-serve.d.ts +99 -0
- package/dist/observer-serve.js +289 -0
- package/dist/observer-serve.js.map +1 -0
- package/dist/observer.d.ts +34 -0
- package/dist/observer.js +57 -5
- package/dist/observer.js.map +1 -1
- package/dist/program.js +433 -19
- package/dist/program.js.map +1 -1
- package/dist/serve-exposure.d.ts +62 -0
- package/dist/serve-exposure.js +129 -0
- package/dist/serve-exposure.js.map +1 -0
- package/dist/serve-http.d.ts +8 -0
- package/dist/serve-http.js +37 -0
- package/dist/serve-http.js.map +1 -0
- package/dist/serve-tunnel.d.ts +20 -0
- package/dist/serve-tunnel.js +113 -0
- package/dist/serve-tunnel.js.map +1 -0
- package/docs/architecture/actor-contract.md +29 -3
- package/docs/architecture/observer.md +37 -0
- package/docs/architecture/serve.md +196 -0
- package/docs/contracts/schemas.md +33 -2
- package/docs/goals/current.md +2 -1
- package/docs/principles/invariants-and-defaults.md +2 -0
- package/docs/ramp/README.md +1 -1
- package/package.json +1 -1
|
@@ -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"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export type ServeTunnelErrorCode = "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
|
|
3
|
+
export declare class ServeTunnelError extends Error {
|
|
4
|
+
readonly code: ServeTunnelErrorCode;
|
|
5
|
+
constructor(code: ServeTunnelErrorCode, message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface ServeTunnel {
|
|
8
|
+
url: string;
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface StartNgrokTunnelOptions {
|
|
12
|
+
port: number;
|
|
13
|
+
domain?: string;
|
|
14
|
+
oauthProvider?: "google";
|
|
15
|
+
oauthAllowEmails?: string[];
|
|
16
|
+
oauthAllowDomains?: string[];
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
spawnImpl?: typeof spawn;
|
|
19
|
+
}
|
|
20
|
+
export declare function startNgrokTunnel(options: StartNgrokTunnelOptions): Promise<ServeTunnel>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export class ServeTunnelError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ServeTunnelError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export async function startNgrokTunnel(options) {
|
|
11
|
+
const spawnImpl = options.spawnImpl ?? spawn;
|
|
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
|
+
: [];
|
|
21
|
+
const args = [
|
|
22
|
+
"http",
|
|
23
|
+
"--log",
|
|
24
|
+
"stdout",
|
|
25
|
+
"--log-format",
|
|
26
|
+
"json",
|
|
27
|
+
...(options.domain ? ["--url", options.domain] : []),
|
|
28
|
+
...oauthArgs,
|
|
29
|
+
String(options.port)
|
|
30
|
+
];
|
|
31
|
+
const child = spawnImpl("ngrok", args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
32
|
+
const url = await new Promise((resolve, reject) => {
|
|
33
|
+
let settled = false;
|
|
34
|
+
let buffered = "";
|
|
35
|
+
const settle = (outcome) => {
|
|
36
|
+
if (settled) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
settled = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
if ("url" in outcome) {
|
|
42
|
+
resolve(outcome.url);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
killChild(child);
|
|
46
|
+
reject(outcome.error);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
settle({
|
|
51
|
+
error: new ServeTunnelError("HUMANISH_SERVE_TUNNEL_START_FAILED", `ngrok did not report a started tunnel within ${timeoutMs}ms.`)
|
|
52
|
+
});
|
|
53
|
+
}, timeoutMs);
|
|
54
|
+
child.once("error", (error) => {
|
|
55
|
+
settle({
|
|
56
|
+
error: error.code === "ENOENT"
|
|
57
|
+
? new ServeTunnelError("HUMANISH_SERVE_TUNNEL_NOT_FOUND", "ngrok binary not found on PATH. Install ngrok, or run your own tunnel and pass --public-url <origin>.")
|
|
58
|
+
: new ServeTunnelError("HUMANISH_SERVE_TUNNEL_START_FAILED", `ngrok failed to start: ${error.message}`)
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
child.once("exit", (code) => {
|
|
62
|
+
settle({
|
|
63
|
+
error: new ServeTunnelError("HUMANISH_SERVE_TUNNEL_START_FAILED", `ngrok exited (${code ?? "signal"}) before reporting a started tunnel.`)
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
child.stdout?.setEncoding("utf8");
|
|
67
|
+
child.stdout?.on("data", (chunk) => {
|
|
68
|
+
buffered += chunk;
|
|
69
|
+
const lines = buffered.split("\n");
|
|
70
|
+
buffered = lines.pop() ?? "";
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(line);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (typeof parsed === "object"
|
|
80
|
+
&& parsed !== null
|
|
81
|
+
&& parsed.msg === "started tunnel"
|
|
82
|
+
&& typeof parsed.url === "string") {
|
|
83
|
+
settle({ url: parsed.url });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
url,
|
|
91
|
+
close: async () => {
|
|
92
|
+
await killChild(child);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function killChild(child) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
99
|
+
resolve();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
child.once("exit", () => resolve());
|
|
103
|
+
// Reclamation stays scoped to the exact child this call created.
|
|
104
|
+
child.kill("SIGTERM");
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
107
|
+
child.kill("SIGKILL");
|
|
108
|
+
}
|
|
109
|
+
resolve();
|
|
110
|
+
}, 2_000).unref();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=serve-tunnel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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
|
|
249
|
-
are unreachable — no persona patience, no
|
|
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,
|
|
@@ -71,6 +71,43 @@ sanitized transcripts, traces, and verdict events are available. This gives a
|
|
|
71
71
|
served Observer a truthful active state to poll while noninteractive local
|
|
72
72
|
actors are still running.
|
|
73
73
|
|
|
74
|
+
Watch is deliberately distinct from `humanish serve`. Watch serves ONE
|
|
75
|
+
attached run, and the process that created it may inject runtime stream URLs
|
|
76
|
+
(live hosted-desktop viewers) into the observer data it serves. Serve is the
|
|
77
|
+
LIBRARY surface — every run under `.humanish/runs/` — and never serves runtime
|
|
78
|
+
stream URLs in any mode; remote viewers see persisted evidence only. See
|
|
79
|
+
[Serve: the run library surface](serve.md).
|
|
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
|
+
|
|
74
111
|
## UI Shape
|
|
75
112
|
|
|
76
113
|
The Observer shell has:
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Serve: the run library surface
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-02
|
|
4
|
+
|
|
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).
|
|
18
|
+
|
|
19
|
+
## What serve is
|
|
20
|
+
|
|
21
|
+
`humanish serve` is the third observer surface, and the first whose subject is
|
|
22
|
+
the LIBRARY rather than a run:
|
|
23
|
+
|
|
24
|
+
- `humanish watch` — one ATTACHED run: the process that created the run serves
|
|
25
|
+
it and may inject runtime stream URLs for live following (see the stream-URL
|
|
26
|
+
doctrine below and `watch --expose`);
|
|
27
|
+
- `humanish observe` — one FINISHED run, re-served read-only;
|
|
28
|
+
- `humanish serve` — the whole local library under `.humanish/runs/`: a
|
|
29
|
+
library index, per-run Observer pages, `/_humanish/history.json` polling, and
|
|
30
|
+
optional edge-authenticated exposure beyond the machine.
|
|
31
|
+
|
|
32
|
+
The server binds `127.0.0.1` unconditionally (`serveObserverLibrary`); exposure
|
|
33
|
+
only ever happens through a tunnel or proxy forwarding to the loopback port.
|
|
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.
|
|
91
|
+
|
|
92
|
+
## Threat model
|
|
93
|
+
|
|
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.
|
|
99
|
+
|
|
100
|
+
**DNS rebinding and the strict Host allowlist.** A malicious page can point an
|
|
101
|
+
attacker-controlled DNS name at 127.0.0.1 and read a permissive local server
|
|
102
|
+
from the victim's browser. Serve keeps a strict Host allowlist in ALL modes —
|
|
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.
|
|
108
|
+
|
|
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`).
|
|
113
|
+
|
|
114
|
+
## Mode-to-boundary mapping
|
|
115
|
+
|
|
116
|
+
| Mode | Invocation | Boundary class |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `loopback` | `humanish serve` | Capture-side trust: readable only by whoever can already read gitignored `.humanish/` on this machine; no new boundary is crossed. |
|
|
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). |
|
|
121
|
+
|
|
122
|
+
## Stream-URL doctrine
|
|
123
|
+
|
|
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:
|
|
126
|
+
|
|
127
|
+
- **structural** — runtime stream URLs live in a `WeakMap` keyed by the watch
|
|
128
|
+
process's in-memory `ObserverResult` (`src/observer.ts`) and are never
|
|
129
|
+
persisted into any bundle artifact; serve is a separate process reading disk,
|
|
130
|
+
so there is nothing for it to find;
|
|
131
|
+
- **defensive** — the serve handler passes an explicit empty array at its
|
|
132
|
+
`serveRunPath` call site, so a future refactor that makes injection ambient
|
|
133
|
+
would still serve zero stream URLs here;
|
|
134
|
+
- **tested** — the serve suite pins a no-injection test: observer data served
|
|
135
|
+
through the library surface carries no runtime stream URLs.
|
|
136
|
+
|
|
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.
|
|
142
|
+
|
|
143
|
+
## The share_ready doctrine
|
|
144
|
+
|
|
145
|
+
`share_ready` was designed as the bar for FEEDBACK payloads: evidence eligible
|
|
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
|
|
150
|
+
identifiers — see the README's public-safety boundary and issue #108), so
|
|
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.
|
|
155
|
+
|
|
156
|
+
## v2 control-plane seam contract
|
|
157
|
+
|
|
158
|
+
The reserved `/_humanish/api/*` namespace answers `501` with
|
|
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.
|
|
183
|
+
|
|
184
|
+
## Future work
|
|
185
|
+
|
|
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.
|
|
189
|
+
- **Google Fonts inlining for per-run observer pages.** The library index is
|
|
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.
|