humanish 0.0.1 → 0.15.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/AGENTS.md +77 -0
- package/CONTRIBUTING.md +39 -0
- package/LICENSE +21 -0
- package/README.md +435 -8
- package/SECURITY.md +34 -0
- package/dist/actor-contract.d.ts +107 -0
- package/dist/actor-contract.js +286 -0
- package/dist/actor-contract.js.map +1 -0
- package/dist/actor-registry.d.ts +73 -0
- package/dist/actor-registry.js +93 -0
- package/dist/actor-registry.js.map +1 -0
- package/dist/adapter-extension.d.ts +54 -0
- package/dist/adapter-extension.js +214 -0
- package/dist/adapter-extension.js.map +1 -0
- package/dist/argv.d.ts +1 -0
- package/dist/argv.js +8 -0
- package/dist/argv.js.map +1 -0
- package/dist/artifact-reference.d.ts +22 -0
- package/dist/artifact-reference.js +47 -0
- package/dist/artifact-reference.js.map +1 -0
- package/dist/browser-evidence-hygiene.d.ts +7 -0
- package/dist/browser-evidence-hygiene.js +40 -0
- package/dist/browser-evidence-hygiene.js.map +1 -0
- package/dist/claude-agent-sdk.d.ts +94 -0
- package/dist/claude-agent-sdk.js +356 -0
- package/dist/claude-agent-sdk.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -0
- package/dist/codex-app-server-ui.d.ts +39 -0
- package/dist/codex-app-server-ui.js +327 -0
- package/dist/codex-app-server-ui.js.map +1 -0
- package/dist/codex-app-server.d.ts +141 -0
- package/dist/codex-app-server.js +747 -0
- package/dist/codex-app-server.js.map +1 -0
- package/dist/command-failure.d.ts +29 -0
- package/dist/command-failure.js +54 -0
- package/dist/command-failure.js.map +1 -0
- package/dist/computer-use-actor.d.ts +52 -0
- package/dist/computer-use-actor.js +48 -0
- package/dist/computer-use-actor.js.map +1 -0
- package/dist/computer-use.d.ts +232 -0
- package/dist/computer-use.js +615 -0
- package/dist/computer-use.js.map +1 -0
- package/dist/concurrency.d.ts +1 -0
- package/dist/concurrency.js +27 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/concurrent-shared-world-lab.d.ts +115 -0
- package/dist/concurrent-shared-world-lab.js +1092 -0
- package/dist/concurrent-shared-world-lab.js.map +1 -0
- package/dist/core/git-state.d.ts +31 -0
- package/dist/core/git-state.js +142 -0
- package/dist/core/git-state.js.map +1 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/run-primitives.d.ts +66 -0
- package/dist/core/run-primitives.js +120 -0
- package/dist/core/run-primitives.js.map +1 -0
- package/dist/cua-actor-lab.d.ts +620 -0
- package/dist/cua-actor-lab.js +2834 -0
- package/dist/cua-actor-lab.js.map +1 -0
- package/dist/device-presets.d.ts +67 -0
- package/dist/device-presets.js +50 -0
- package/dist/device-presets.js.map +1 -0
- package/dist/e2b-desktop-executor.d.ts +101 -0
- package/dist/e2b-desktop-executor.js +309 -0
- package/dist/e2b-desktop-executor.js.map +1 -0
- package/dist/e2b-desktop-launch.d.ts +144 -0
- package/dist/e2b-desktop-launch.js +59 -0
- package/dist/e2b-desktop-launch.js.map +1 -0
- package/dist/e2b-detached.d.ts +53 -0
- package/dist/e2b-detached.js +130 -0
- package/dist/e2b-detached.js.map +1 -0
- package/dist/e2b-terminal-lab.d.ts +345 -0
- package/dist/e2b-terminal-lab.js +1467 -0
- package/dist/e2b-terminal-lab.js.map +1 -0
- package/dist/env-file.d.ts +14 -0
- package/dist/env-file.js +108 -0
- package/dist/env-file.js.map +1 -0
- package/dist/feedback.d.ts +51 -0
- package/dist/feedback.js +313 -0
- package/dist/feedback.js.map +1 -0
- package/dist/image-evidence.d.ts +2 -0
- package/dist/image-evidence.js +33 -0
- package/dist/image-evidence.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/init-templates.d.ts +12 -0
- package/dist/init-templates.js +369 -0
- package/dist/init-templates.js.map +1 -0
- package/dist/init.d.ts +26 -0
- package/dist/init.js +343 -0
- package/dist/init.js.map +1 -0
- package/dist/lab-app-runner.d.ts +78 -0
- package/dist/lab-app-runner.js +403 -0
- package/dist/lab-app-runner.js.map +1 -0
- package/dist/lab-config.d.ts +578 -0
- package/dist/lab-config.js +1751 -0
- package/dist/lab-config.js.map +1 -0
- package/dist/lab-engine.d.ts +77 -0
- package/dist/lab-engine.js +216 -0
- package/dist/lab-engine.js.map +1 -0
- package/dist/lab-preflight.d.ts +67 -0
- package/dist/lab-preflight.js +385 -0
- package/dist/lab-preflight.js.map +1 -0
- package/dist/labs.d.ts +53 -0
- package/dist/labs.js +176 -0
- package/dist/labs.js.map +1 -0
- package/dist/observer-assets.d.ts +2 -0
- package/dist/observer-assets.js +2630 -0
- package/dist/observer-assets.js.map +1 -0
- package/dist/observer-data.d.ts +63 -0
- package/dist/observer-data.js +145 -0
- package/dist/observer-data.js.map +1 -0
- package/dist/observer-static.d.ts +39 -0
- package/dist/observer-static.js +192 -0
- package/dist/observer-static.js.map +1 -0
- package/dist/observer.d.ts +46 -0
- package/dist/observer.js +353 -0
- package/dist/observer.js.map +1 -0
- package/dist/openai-responses-cu.d.ts +113 -0
- package/dist/openai-responses-cu.js +531 -0
- package/dist/openai-responses-cu.js.map +1 -0
- package/dist/oss-lab.d.ts +52 -0
- package/dist/oss-lab.js +299 -0
- package/dist/oss-lab.js.map +1 -0
- package/dist/oss-meta-lab-scoring.d.ts +15 -0
- package/dist/oss-meta-lab-scoring.js +145 -0
- package/dist/oss-meta-lab-scoring.js.map +1 -0
- package/dist/oss-meta-lab.d.ts +279 -0
- package/dist/oss-meta-lab.js +4889 -0
- package/dist/oss-meta-lab.js.map +1 -0
- package/dist/oss-remote-telemetry.d.ts +77 -0
- package/dist/oss-remote-telemetry.js +403 -0
- package/dist/oss-remote-telemetry.js.map +1 -0
- package/dist/persona.d.ts +35 -0
- package/dist/persona.js +133 -0
- package/dist/persona.js.map +1 -0
- package/dist/pi-agent-core.d.ts +77 -0
- package/dist/pi-agent-core.js +203 -0
- package/dist/pi-agent-core.js.map +1 -0
- package/dist/program.d.ts +54 -0
- package/dist/program.js +2347 -0
- package/dist/program.js.map +1 -0
- package/dist/redaction.d.ts +90 -0
- package/dist/redaction.js +292 -0
- package/dist/redaction.js.map +1 -0
- package/dist/run.d.ts +784 -0
- package/dist/run.js +4555 -0
- package/dist/run.js.map +1 -0
- package/dist/scripted-browser-actor.d.ts +294 -0
- package/dist/scripted-browser-actor.js +1286 -0
- package/dist/scripted-browser-actor.js.map +1 -0
- package/dist/scripted-browser-lab.d.ts +107 -0
- package/dist/scripted-browser-lab.js +738 -0
- package/dist/scripted-browser-lab.js.map +1 -0
- package/dist/shared-world-lab.d.ts +208 -0
- package/dist/shared-world-lab.js +1048 -0
- package/dist/shared-world-lab.js.map +1 -0
- package/dist/source-archive.d.ts +82 -0
- package/dist/source-archive.js +365 -0
- package/dist/source-archive.js.map +1 -0
- package/dist/stop-conditions.d.ts +26 -0
- package/dist/stop-conditions.js +66 -0
- package/dist/stop-conditions.js.map +1 -0
- package/dist/terminal-agent-actor.d.ts +36 -0
- package/dist/terminal-agent-actor.js +23 -0
- package/dist/terminal-agent-actor.js.map +1 -0
- package/docs/architecture/actor-contract.md +431 -0
- package/docs/architecture/github-feedback-loop.md +189 -0
- package/docs/architecture/local-codex-tui-actor.md +213 -0
- package/docs/architecture/observer.md +118 -0
- package/docs/architecture/oss-lab-poc.md +242 -0
- package/docs/architecture/project-layout.md +166 -0
- package/docs/architecture/state-driven-executor.md +203 -0
- package/docs/architecture/terminal-product-lane.md +148 -0
- package/docs/contracts/adapter-fixtures.md +80 -0
- package/docs/contracts/core.md +71 -0
- package/docs/contracts/feedback.md +145 -0
- package/docs/contracts/policy.md +305 -0
- package/docs/contracts/run-bundle.md +358 -0
- package/docs/contracts/schemas.md +984 -0
- package/docs/goals/current.md +409 -0
- package/docs/principles/invariants-and-defaults.md +135 -0
- package/docs/principles/self-driving-harness.md +129 -0
- package/docs/product/open-source-install-experience.md +229 -0
- package/docs/ramp/README.md +200 -0
- package/docs/release/open-source-readiness.md +208 -0
- package/docs/release/public-readiness-standard.md +205 -0
- package/docs/roadmap/world-class-open-source-v0.md +311 -0
- package/package.json +102 -8
- package/skills/humanish/SKILL.md +244 -0
- package/skills/humanish/agents/openai.yaml +7 -0
- package/index.js +0 -1
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
// The scripted-browser lab backend: either an app-url subject (a loopback app the operator
|
|
2
|
+
// already runs) or one provisioned synthetic clone subject (served in E2B and exposed through
|
|
3
|
+
// getHost) driven by the REGISTRY-RESOLVED scripted-browser actor.
|
|
4
|
+
// Mirrors cua-actor-lab.ts: the descriptor returned by the registry runs the session; this
|
|
5
|
+
// backend consumes `scenario.ref` (resolves the committed scenario whose browser steps ARE the
|
|
6
|
+
// actor's behavior — no built-in fallback on the lab route, unlike `run --app-url`), composes
|
|
7
|
+
// the per-surface sessions, persists the evidence bundle, and renders the Observer.
|
|
8
|
+
//
|
|
9
|
+
// Spend posture: no model/provider-token spend BY MECHANISM — nothing on this code path can
|
|
10
|
+
// construct a provider client, and every projected trace records tokenUsage zeros. Local
|
|
11
|
+
// app-url runs also spend no sandbox minutes; live provisioned clone runs can spend E2B
|
|
12
|
+
// sandbox minutes to clone/serve the synthetic subject. `scenario.mode: live` is still
|
|
13
|
+
// required because the gate's justification here is ACTUATION: a live scripted run drives a
|
|
14
|
+
// real browser against a real running app (fills forms, clicks buttons — state-mutating
|
|
15
|
+
// effects), which deserves the same affirmative declaration as spend. Dry-run (the default)
|
|
16
|
+
// parses and digest-pins the scenario and emits the contract bundle without touching anything.
|
|
17
|
+
//
|
|
18
|
+
// Subject provenance (invariant 5): local app-url runs declare that the lab did NOT provision
|
|
19
|
+
// the subject, so build/commit provenance is UNPINNED and the evidence binds to the scenario
|
|
20
|
+
// digest instead. Provisioned clone runs persist structured commit/env-name/state provenance
|
|
21
|
+
// plus a host digest while never writing the raw getHost URL or secret values into artifacts.
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { parse as parseYaml } from "yaml";
|
|
26
|
+
import { actorRegistry, isScriptedBrowserActorDescriptor } from "./actor-registry.js";
|
|
27
|
+
import { toErrorMessage } from "./command-failure.js";
|
|
28
|
+
import { commandDigestOf, provisionCloneSubject, resolveSubjectState } from "./cua-actor-lab.js";
|
|
29
|
+
import { createDesktopSandbox, loadE2BDesktopModule } from "./e2b-desktop-launch.js";
|
|
30
|
+
import { renderObserver } from "./observer.js";
|
|
31
|
+
import { digestText, redactText } from "./redaction.js";
|
|
32
|
+
import { buildRunSource, PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA } from "./run.js";
|
|
33
|
+
import { browserSurfaces, normalizeLocalAppUrl, parseBrowserPersonaJourneyFromScenario, resolveBrowserCommand } from "./scripted-browser-actor.js";
|
|
34
|
+
export const SCRIPTED_BROWSER_LAB_SCHEMA = "humanish.scripted-lab-result.v1";
|
|
35
|
+
// Journey wall-clock budget per surface — same default as `run --app-url`.
|
|
36
|
+
const DEFAULT_SESSION_TIMEOUT_MS = 60_000;
|
|
37
|
+
const SANDBOX_TIMEOUT_BUFFER_MS = 10 * 60_000;
|
|
38
|
+
const SUBJECT_PROVISION_BUDGET_MS = 30 * 60_000;
|
|
39
|
+
const DEFAULT_STATE_STEP_TIMEOUT_MS = 5 * 60_000;
|
|
40
|
+
// Default surface roster is 1 (desktop only): the defaults-table single-lane row governs;
|
|
41
|
+
// `count: 2` is the declared override that adds the mobile surface.
|
|
42
|
+
const DEFAULT_SURFACE_COUNT = 1;
|
|
43
|
+
// Same public-safe token shape the lab id uses; an id-style scenario.ref must match it before
|
|
44
|
+
// it is interpolated into a repo path.
|
|
45
|
+
const SCENARIO_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
46
|
+
export async function runScriptedBrowserLab(options) {
|
|
47
|
+
const { config, dryRun } = options;
|
|
48
|
+
const cwd = path.resolve(options.cwd);
|
|
49
|
+
const hooks = options.hooks ?? {};
|
|
50
|
+
const render = hooks.renderObserverFn ?? renderObserver;
|
|
51
|
+
const warnings = [];
|
|
52
|
+
const actorType = config.actors[0]?.type ?? "";
|
|
53
|
+
const failed = (code, message, extras) => ({
|
|
54
|
+
schema: SCRIPTED_BROWSER_LAB_SCHEMA,
|
|
55
|
+
ok: false,
|
|
56
|
+
cwd,
|
|
57
|
+
labId: config.id,
|
|
58
|
+
actor: extras?.actor ?? actorType,
|
|
59
|
+
appUrl: extras?.appUrl ?? config.subject.appUrl ?? "",
|
|
60
|
+
dryRun,
|
|
61
|
+
runId: options.runId ?? "not-created",
|
|
62
|
+
sessions: [],
|
|
63
|
+
warnings,
|
|
64
|
+
error: { code, message }
|
|
65
|
+
});
|
|
66
|
+
// Resolve the actor through the registry — the parse layer already validated this, but the
|
|
67
|
+
// engine fails closed rather than trusting a config that arrived through another door
|
|
68
|
+
// (runScriptedBrowserLab is itself exported npm surface).
|
|
69
|
+
const descriptor = actorRegistry[actorType];
|
|
70
|
+
if (!descriptor || !isScriptedBrowserActorDescriptor(descriptor)) {
|
|
71
|
+
return failed("HUMANISH_SCRIPTED_LAB_ACTOR_UNSUPPORTED", `actors[0].type "${actorType}" is not a registered scripted-browser actor.`);
|
|
72
|
+
}
|
|
73
|
+
const runSession = hooks.runSession ?? descriptor.runSession;
|
|
74
|
+
const provisionedRoute = config.subject.source === "clone";
|
|
75
|
+
const evidenceAppUrl = provisionedRoute ? "[provisioned-subject]" : normalizeLocalAppUrl(config.subject.appUrl ?? "") ?? "";
|
|
76
|
+
const urlPolicy = provisionedRoute
|
|
77
|
+
? { kind: "provisioned-subject", evidenceOrigin: evidenceAppUrl }
|
|
78
|
+
: { kind: "loopback" };
|
|
79
|
+
const serve = config.subject.serve;
|
|
80
|
+
const subjectRepo = provisionedRoute ? config.subject.repos?.[0] ?? "" : undefined;
|
|
81
|
+
const subjectEnvNames = provisionedRoute ? config.subject.env ?? [] : [];
|
|
82
|
+
const env = hooks.env ?? process.env;
|
|
83
|
+
const e2bApiKey = env.E2B_API_KEY?.trim() ?? "";
|
|
84
|
+
const hasGithubToken = subjectEnvNames.includes("GITHUB_TOKEN");
|
|
85
|
+
const redactRepoLabel = config.policies?.redactRepos ?? hasGithubToken;
|
|
86
|
+
const publicRepo = provisionedRoute && subjectRepo ? (redactRepoLabel ? "repo-01" : subjectRepo) : undefined;
|
|
87
|
+
const scrubSourceValues = [
|
|
88
|
+
...(subjectRepo ? [subjectRepo] : []),
|
|
89
|
+
...subjectEnvNames.map((name) => env[name] ?? "")
|
|
90
|
+
].filter(Boolean);
|
|
91
|
+
const scrubKnownValues = (text) => scrubSourceValues.reduce((acc, value) => acc.split(value).join("[redacted]"), text);
|
|
92
|
+
// Re-enforce the local loopback entry boundary at the engine. The provisioned clone route
|
|
93
|
+
// mints its own getHost URL later and persists only evidenceAppUrl.
|
|
94
|
+
let appUrl = provisionedRoute ? serve?.url ?? "" : evidenceAppUrl;
|
|
95
|
+
if (!provisionedRoute && !appUrl) {
|
|
96
|
+
return failed("HUMANISH_SCRIPTED_LAB_SUBJECT_UNSAFE", "subject.appUrl must be a loopback http(s) URL (127.0.0.1 or localhost) on the scripted-browser route.", { actor: descriptor.id });
|
|
97
|
+
}
|
|
98
|
+
if (provisionedRoute && (!serve || !subjectRepo || !publicRepo)) {
|
|
99
|
+
return failed("HUMANISH_SCRIPTED_LAB_SUBJECT_UNSAFE", "clone scripted-browser labs require one subject repo plus subject.serve; parseLabConfig should have rejected this config.", { actor: descriptor.id, appUrl: evidenceAppUrl });
|
|
100
|
+
}
|
|
101
|
+
// Consume scenario.ref (fail-closed: invariant 6 — the steps ARE the actor; there is no
|
|
102
|
+
// built-in journey fallback on the lab route).
|
|
103
|
+
const scenario = await resolveScriptedScenario(cwd, config.scenario?.ref);
|
|
104
|
+
if (!scenario.ok) {
|
|
105
|
+
return failed("HUMANISH_SCRIPTED_LAB_SCENARIO_INVALID", scenario.message, { actor: descriptor.id, appUrl: evidenceAppUrl });
|
|
106
|
+
}
|
|
107
|
+
const journey = scenario.journey;
|
|
108
|
+
if (!dryRun && provisionedRoute) {
|
|
109
|
+
if (!e2bApiKey) {
|
|
110
|
+
return failed("HUMANISH_SCRIPTED_LAB_KEYS_MISSING", "Live clone scripted-browser labs require E2B_API_KEY (dry-run remains $0 and does not provision a subject).", { actor: descriptor.id, appUrl: evidenceAppUrl });
|
|
111
|
+
}
|
|
112
|
+
const missingSubjectEnv = subjectEnvNames.filter((name) => !env[name]?.trim());
|
|
113
|
+
if (missingSubjectEnv.length > 0) {
|
|
114
|
+
return failed("HUMANISH_SCRIPTED_LAB_SUBJECT_ENV_MISSING", `Subject env values missing for live clone scripted-browser lab: ${missingSubjectEnv.join(", ")}.`, { actor: descriptor.id, appUrl: evidenceAppUrl });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const surfaces = browserSurfaces.slice(0, config.actors[0]?.count ?? DEFAULT_SURFACE_COUNT);
|
|
118
|
+
const timeoutMs = config.execution?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
|
|
119
|
+
const persona = {
|
|
120
|
+
id: config.actors[0]?.persona ?? "scripted-journey",
|
|
121
|
+
traitsApplied: [],
|
|
122
|
+
// The step manifest IS the "prompt" on this lane; the digest binds the trace to the
|
|
123
|
+
// committed scenario text.
|
|
124
|
+
promptDigest: journey.sourceDigest.slice(0, 16)
|
|
125
|
+
};
|
|
126
|
+
// Live runs need a browser BEFORE any actuation (unless one is injected).
|
|
127
|
+
let browserCommand = hooks.browserCommand;
|
|
128
|
+
if (!dryRun && !hooks.launchBrowser && !browserCommand) {
|
|
129
|
+
const resolved = await resolveBrowserCommand();
|
|
130
|
+
if (!resolved) {
|
|
131
|
+
return failed("HUMANISH_SCRIPTED_LAB_BROWSER_MISSING", "No Chrome/Chromium browser command was found for the scripted-browser actor. Set HUMANISH_BROWSER_COMMAND to a browser binary playwright-core can launch.", { actor: descriptor.id, appUrl: evidenceAppUrl });
|
|
132
|
+
}
|
|
133
|
+
browserCommand = resolved;
|
|
134
|
+
}
|
|
135
|
+
const runId = options.runId ?? makeScriptedRunId();
|
|
136
|
+
const artifactRoot = path.join(cwd, ".humanish", "runs", runId);
|
|
137
|
+
const createdAt = new Date().toISOString();
|
|
138
|
+
await mkdir(path.join(artifactRoot, "screenshots"), { recursive: true });
|
|
139
|
+
const source = await buildRunSource({
|
|
140
|
+
capturedAt: createdAt,
|
|
141
|
+
cwd,
|
|
142
|
+
humanishSource: "present",
|
|
143
|
+
packageName: "humanish"
|
|
144
|
+
});
|
|
145
|
+
let sessionResults = [];
|
|
146
|
+
let sessionError;
|
|
147
|
+
const stateStepRecords = [];
|
|
148
|
+
let subjectCommit;
|
|
149
|
+
let subjectSandboxId;
|
|
150
|
+
let subjectKilled = false;
|
|
151
|
+
let hostDigest;
|
|
152
|
+
if (!dryRun) {
|
|
153
|
+
let subjectModule;
|
|
154
|
+
let subjectDesktop;
|
|
155
|
+
try {
|
|
156
|
+
if (provisionedRoute) {
|
|
157
|
+
const requestTimeoutMs = readPositiveInt(env.HUMANISH_E2B_REQUEST_TIMEOUT_MS, 60_000);
|
|
158
|
+
const timers = hooks.detachedTimers ?? {};
|
|
159
|
+
subjectModule = await (hooks.loadDesktopModule ?? loadE2BDesktopModule)();
|
|
160
|
+
subjectDesktop = await createDesktopSandbox(subjectModule, {
|
|
161
|
+
apiKey: e2bApiKey,
|
|
162
|
+
requestTimeoutMs,
|
|
163
|
+
timeoutMs: timeoutMs + SUBJECT_PROVISION_BUDGET_MS
|
|
164
|
+
+ (config.subject.state?.seed ?? []).reduce((sum, step) => sum + (step.timeoutMs ?? DEFAULT_STATE_STEP_TIMEOUT_MS), 0)
|
|
165
|
+
+ SANDBOX_TIMEOUT_BUFFER_MS,
|
|
166
|
+
metadata: {
|
|
167
|
+
mode: "scripted-browser-lab",
|
|
168
|
+
tool: "humanish",
|
|
169
|
+
labId: config.id,
|
|
170
|
+
role: "subject",
|
|
171
|
+
actor: descriptor.id
|
|
172
|
+
},
|
|
173
|
+
...(subjectEnvNames.length > 0
|
|
174
|
+
? { envs: Object.fromEntries(subjectEnvNames.map((name) => [name, env[name]])) }
|
|
175
|
+
: {}),
|
|
176
|
+
dpi: 96,
|
|
177
|
+
lifecycle: { onTimeout: "kill" }
|
|
178
|
+
}, config.execution?.desktop?.template);
|
|
179
|
+
subjectSandboxId = subjectDesktop.sandboxId;
|
|
180
|
+
if (hooks.prepareDesktop) {
|
|
181
|
+
await hooks.prepareDesktop(subjectDesktop);
|
|
182
|
+
}
|
|
183
|
+
subjectCommit = await provisionCloneSubject(subjectDesktop, {
|
|
184
|
+
repo: subjectRepo,
|
|
185
|
+
depth: config.subject.clone?.depth ?? 1,
|
|
186
|
+
serve: serve,
|
|
187
|
+
...(config.subject.state === undefined ? {} : { state: config.subject.state }),
|
|
188
|
+
hasGithubToken,
|
|
189
|
+
requestTimeoutMs,
|
|
190
|
+
scrub: scrubKnownValues,
|
|
191
|
+
onCommit: (commit) => {
|
|
192
|
+
subjectCommit = commit;
|
|
193
|
+
},
|
|
194
|
+
onStateStep: (record) => {
|
|
195
|
+
stateStepRecords.push(record);
|
|
196
|
+
},
|
|
197
|
+
...timers
|
|
198
|
+
});
|
|
199
|
+
if (typeof subjectDesktop.getHost !== "function") {
|
|
200
|
+
throw new Error("the installed @e2b/desktop SDK does not expose getHost(port); clone scripted-browser labs require it to reach the provisioned subject");
|
|
201
|
+
}
|
|
202
|
+
const rawHost = subjectDesktop.getHost(servePort(serve.url));
|
|
203
|
+
const hostUrl = /^https?:\/\//i.test(rawHost) ? rawHost : `https://${rawHost}`;
|
|
204
|
+
if (!isTokenlessHost(hostUrl)) {
|
|
205
|
+
throw new Error("getHost returned a non-tokenless URL; refusing to persist or drive a host URL that may carry a credential");
|
|
206
|
+
}
|
|
207
|
+
appUrl = hostUrl;
|
|
208
|
+
hostDigest = hostOriginDigest(hostUrl);
|
|
209
|
+
}
|
|
210
|
+
// One session per surface, in parallel — parity with `run --app-url`.
|
|
211
|
+
sessionResults = await Promise.all(surfaces.map((surface) => runSession({
|
|
212
|
+
appUrl,
|
|
213
|
+
evidenceAppUrl,
|
|
214
|
+
urlPolicy,
|
|
215
|
+
journey,
|
|
216
|
+
surface,
|
|
217
|
+
persona,
|
|
218
|
+
timeoutMs,
|
|
219
|
+
artifactRoot,
|
|
220
|
+
...(browserCommand === undefined ? {} : { browserCommand }),
|
|
221
|
+
...(hooks.launchBrowser === undefined ? {} : { launchBrowser: hooks.launchBrowser }),
|
|
222
|
+
...(hooks.now === undefined ? {} : { now: hooks.now })
|
|
223
|
+
})));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
// The session itself maps launch failures to harness_error; reaching here means the
|
|
227
|
+
// harness around it failed. Redacted at this boundary before persisting anywhere.
|
|
228
|
+
sessionError = redactText(scrubKnownValues(toErrorMessage(error)));
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
if (subjectDesktop && subjectModule) {
|
|
232
|
+
if (typeof subjectModule.Sandbox.kill === "function") {
|
|
233
|
+
try {
|
|
234
|
+
await subjectModule.Sandbox.kill(subjectDesktop.sandboxId, { requestTimeoutMs: 60_000 });
|
|
235
|
+
subjectKilled = true;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
warnings.push(`Subject sandbox teardown failed (server-side kill-on-timeout will reclaim it): ${redactText(scrubKnownValues(toErrorMessage(error)))}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
warnings.push("Installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the subject sandbox.");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const result of sessionResults) {
|
|
247
|
+
// The backend writes the provider-neutral projection next to the session's native
|
|
248
|
+
// traces/<surface>.json (cua's actor.json convention, pluralized per surface).
|
|
249
|
+
await writeFile(path.join(artifactRoot, `actor-${result.capture.surface.id}.json`), `${JSON.stringify(result.trace, null, 2)}\n`, "utf8");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const screenshotsBySurface = new Map();
|
|
253
|
+
for (const result of sessionResults) {
|
|
254
|
+
screenshotsBySurface.set(result.capture.surface.id, await existingScreenshots(artifactRoot, result));
|
|
255
|
+
}
|
|
256
|
+
const subject = provisionedRoute
|
|
257
|
+
? {
|
|
258
|
+
source: "clone",
|
|
259
|
+
repo: publicRepo,
|
|
260
|
+
...(subjectCommit === undefined ? {} : { commit: subjectCommit }),
|
|
261
|
+
envNames: subjectEnvNames,
|
|
262
|
+
state: resolveSubjectState({ declared: config.subject.state, dryRun, executed: stateStepRecords })
|
|
263
|
+
}
|
|
264
|
+
: undefined;
|
|
265
|
+
const bundle = buildScriptedLabBundle({
|
|
266
|
+
actorId: descriptor.id,
|
|
267
|
+
appUrl: evidenceAppUrl,
|
|
268
|
+
createdAt,
|
|
269
|
+
dryRun,
|
|
270
|
+
journey,
|
|
271
|
+
labId: config.id,
|
|
272
|
+
...(config.title ? { labTitle: config.title } : {}),
|
|
273
|
+
persona,
|
|
274
|
+
runId,
|
|
275
|
+
scenarioSource: scenario.source,
|
|
276
|
+
scenarioSourceDigest: scenario.sourceDigest,
|
|
277
|
+
screenshotsBySurface,
|
|
278
|
+
sessionResults,
|
|
279
|
+
...(sessionError === undefined ? {} : { sessionError }),
|
|
280
|
+
source,
|
|
281
|
+
surfaces,
|
|
282
|
+
...(subject === undefined ? {} : { subject }),
|
|
283
|
+
...(config.execution?.desktop?.template === undefined ? {} : { desktopTemplate: config.execution.desktop.template }),
|
|
284
|
+
...(hostDigest === undefined ? {} : { hostDigest })
|
|
285
|
+
});
|
|
286
|
+
await writeFile(path.join(artifactRoot, "run.json"), `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
|
|
287
|
+
await writeFile(path.join(artifactRoot, "review.json"), `${JSON.stringify(bundle.review, null, 2)}\n`, "utf8");
|
|
288
|
+
await writeFile(path.join(artifactRoot, "review.md"), renderScriptedReviewMarkdown(bundle), "utf8");
|
|
289
|
+
await writeFile(path.join(artifactRoot, "events.ndjson"), `${bundle.events.map((event) => JSON.stringify(event)).join("\n")}\n`, "utf8");
|
|
290
|
+
// Keep `verify --run latest` honest: point it at THIS run (mirrors run.ts's RunPointer).
|
|
291
|
+
await writeFile(path.join(cwd, ".humanish", "runs", "latest.json"), `${JSON.stringify({
|
|
292
|
+
schema: "humanish.latest-run.v1",
|
|
293
|
+
runId,
|
|
294
|
+
path: path.join(".humanish", "runs", runId),
|
|
295
|
+
updatedAt: createdAt
|
|
296
|
+
}, null, 2)}\n`, "utf8");
|
|
297
|
+
// Surface the local-fidelity posture so the operator knows the bundle is not publish-safe as-is.
|
|
298
|
+
if (sessionResults.some((result) => result.trace.redaction.screenshots === "raw")) {
|
|
299
|
+
warnings.push("Screenshots are full-fidelity (raw) for local use — the bundle stays in gitignored .humanish and nothing scans these pixels; review them before sharing anywhere. policies.redactScreenshots is not yet supported on the scripted route.");
|
|
300
|
+
}
|
|
301
|
+
const observer = await render(cwd, runId, { open: options.open === true });
|
|
302
|
+
const harnessError = sessionResults.some((result) => result.completionReason === "harness_error");
|
|
303
|
+
const ok = observer.ok
|
|
304
|
+
&& sessionError === undefined
|
|
305
|
+
&& (dryRun || (sessionResults.length === surfaces.length && !harnessError));
|
|
306
|
+
return {
|
|
307
|
+
schema: SCRIPTED_BROWSER_LAB_SCHEMA,
|
|
308
|
+
ok,
|
|
309
|
+
cwd,
|
|
310
|
+
labId: config.id,
|
|
311
|
+
actor: descriptor.id,
|
|
312
|
+
appUrl: evidenceAppUrl,
|
|
313
|
+
dryRun,
|
|
314
|
+
runId,
|
|
315
|
+
...(subject === undefined ? {} : { subject }),
|
|
316
|
+
...(subjectSandboxId === undefined ? {} : { subjectSandbox: { sandboxId: subjectSandboxId, killed: subjectKilled } }),
|
|
317
|
+
...(hostDigest === undefined ? {} : { hostDigest }),
|
|
318
|
+
scenario: {
|
|
319
|
+
id: journey.scenarioId,
|
|
320
|
+
source: scenario.source,
|
|
321
|
+
sourceDigest: scenario.sourceDigest,
|
|
322
|
+
steps: journey.steps.length
|
|
323
|
+
},
|
|
324
|
+
sessions: sessionResults.map((result) => ({
|
|
325
|
+
surface: result.capture.surface.id,
|
|
326
|
+
status: result.status,
|
|
327
|
+
completionReason: result.completionReason,
|
|
328
|
+
reason: result.reason,
|
|
329
|
+
screenshots: screenshotsBySurface.get(result.capture.surface.id)?.length ?? 0
|
|
330
|
+
})),
|
|
331
|
+
observer,
|
|
332
|
+
warnings: [...warnings, ...observer.warnings],
|
|
333
|
+
...(ok
|
|
334
|
+
? {}
|
|
335
|
+
: {
|
|
336
|
+
error: {
|
|
337
|
+
code: "HUMANISH_SCRIPTED_LAB_FAILED",
|
|
338
|
+
message: sessionError
|
|
339
|
+
?? (observer.ok
|
|
340
|
+
? harnessError
|
|
341
|
+
? `Scripted session ended with a harness error: ${sessionResults.find((result) => result.completionReason === "harness_error")?.reason ?? "unknown"}`
|
|
342
|
+
: "Scripted lab did not produce terminal sessions for every surface."
|
|
343
|
+
: observer.error?.message ?? "Observer failed for the scripted lab run.")
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Resolve and consume `scenario.ref`. Path-style refs (contain a separator or end .yaml/.yml)
|
|
350
|
+
* resolve against cwd and are CLAMPED inside it — a ../../ escape is rejected, never recorded
|
|
351
|
+
* as repo-relative provenance. Id-style refs must be public-safe tokens and resolve to
|
|
352
|
+
* humanish/scenarios/<ref>.yaml (then .yml). Every failure mode is fail-closed.
|
|
353
|
+
*/
|
|
354
|
+
async function resolveScriptedScenario(cwd, ref) {
|
|
355
|
+
if (!ref || !ref.trim()) {
|
|
356
|
+
return {
|
|
357
|
+
ok: false,
|
|
358
|
+
message: "scripted-browser labs require `scenario.ref` — the committed scenario's browser steps are what this actor executes."
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const trimmed = ref.trim();
|
|
362
|
+
let absolutePath;
|
|
363
|
+
let source;
|
|
364
|
+
if (scenarioRefLooksLikePath(trimmed)) {
|
|
365
|
+
absolutePath = path.resolve(cwd, trimmed);
|
|
366
|
+
const relative = path.relative(cwd, absolutePath);
|
|
367
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
message: `scenario.ref path must stay inside the target cwd (got "${trimmed}") — provenance is recorded repo-relative and an escaping path cannot be.`
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
source = relative.split(path.sep).join("/");
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
if (!SCENARIO_REF_ID_PATTERN.test(trimmed)) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
message: `scenario.ref must be a public-safe scenario id or a .yaml path inside the repo (got "${trimmed}").`
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const candidates = [
|
|
383
|
+
path.posix.join("humanish", "scenarios", `${trimmed}.yaml`),
|
|
384
|
+
path.posix.join("humanish", "scenarios", `${trimmed}.yml`)
|
|
385
|
+
];
|
|
386
|
+
const found = await firstExistingFile(cwd, candidates);
|
|
387
|
+
if (!found) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
message: `scenario.ref "${trimmed}" was not found (looked for ${candidates.join(", ")}).`
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
source = found;
|
|
394
|
+
absolutePath = path.join(cwd, found);
|
|
395
|
+
}
|
|
396
|
+
let text;
|
|
397
|
+
try {
|
|
398
|
+
text = await readFile(absolutePath, "utf8");
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return { ok: false, message: `scenario.ref "${trimmed}" could not be read (${source}).` };
|
|
402
|
+
}
|
|
403
|
+
let raw;
|
|
404
|
+
try {
|
|
405
|
+
raw = parseYaml(text);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return { ok: false, message: `${source} could not be parsed as YAML; the scripted scenario failed closed.` };
|
|
409
|
+
}
|
|
410
|
+
const sourceDigest = digestText(text);
|
|
411
|
+
const parsed = parseBrowserPersonaJourneyFromScenario({ raw, relativePath: source, sourceDigest });
|
|
412
|
+
if (parsed.failure) {
|
|
413
|
+
return { ok: false, message: parsed.failure };
|
|
414
|
+
}
|
|
415
|
+
if (!parsed.journey) {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
message: `${source} declares no executable browser steps — the scripted-browser actor needs a scenario with browser.steps (there is no built-in fallback on the lab route).`
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
return { ok: true, journey: parsed.journey, source, sourceDigest };
|
|
422
|
+
}
|
|
423
|
+
function scenarioRefLooksLikePath(ref) {
|
|
424
|
+
return ref.endsWith(".yaml")
|
|
425
|
+
|| ref.endsWith(".yml")
|
|
426
|
+
|| ref.includes("/")
|
|
427
|
+
|| ref.includes("\\")
|
|
428
|
+
|| ref.startsWith(".");
|
|
429
|
+
}
|
|
430
|
+
async function firstExistingFile(cwd, candidates) {
|
|
431
|
+
for (const candidate of candidates) {
|
|
432
|
+
const stats = await stat(path.join(cwd, candidate)).catch(() => null);
|
|
433
|
+
if (stats?.isFile()) {
|
|
434
|
+
return candidate;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
async function existingScreenshots(artifactRoot, result) {
|
|
440
|
+
const existing = [];
|
|
441
|
+
for (const step of result.capture.steps) {
|
|
442
|
+
// Blocked steps whose evidence is the failure itself recorded no screenshot path.
|
|
443
|
+
if (!step.screenshotPath) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const stats = await stat(path.join(artifactRoot, step.screenshotPath)).catch(() => null);
|
|
447
|
+
if (stats?.isFile() && stats.size > 0) {
|
|
448
|
+
existing.push(step.screenshotPath);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return existing;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Project the scripted lab run into a humanish.run-bundle.v1 (no schema change — a new
|
|
455
|
+
* producer only). The load-bearing line is `stream.actor = result.trace`: the provider-neutral
|
|
456
|
+
* ActorTrace seam the Observer renders and verifyRun's engagement check reads. Exported for
|
|
457
|
+
* the bundle-builder tests.
|
|
458
|
+
*/
|
|
459
|
+
export function buildScriptedLabBundle(args) {
|
|
460
|
+
const resultBySurface = new Map(args.sessionResults.map((result) => [result.capture.surface.id, result]));
|
|
461
|
+
const simulations = [];
|
|
462
|
+
const streams = [];
|
|
463
|
+
args.surfaces.forEach((surface, index) => {
|
|
464
|
+
const simId = `scripted-${surface.id}`;
|
|
465
|
+
const streamId = `${simId}-stream`;
|
|
466
|
+
const result = resultBySurface.get(surface.id);
|
|
467
|
+
const screenshots = args.screenshotsBySurface.get(surface.id) ?? [];
|
|
468
|
+
const lastScreenshot = screenshots.at(-1);
|
|
469
|
+
const status = result
|
|
470
|
+
? result.status
|
|
471
|
+
: args.sessionError
|
|
472
|
+
? "failed"
|
|
473
|
+
: "contract_proof_only";
|
|
474
|
+
const reason = result?.reason
|
|
475
|
+
?? args.sessionError
|
|
476
|
+
?? "Contract bundle only: dry-run pinned the scenario contract without launching a browser or touching the subject app.";
|
|
477
|
+
simulations.push({
|
|
478
|
+
id: simId,
|
|
479
|
+
index: index + 1,
|
|
480
|
+
personaId: args.persona.id,
|
|
481
|
+
scenarioId: args.journey.scenarioId,
|
|
482
|
+
status,
|
|
483
|
+
streamKind: "browser",
|
|
484
|
+
mode: "browser-sim",
|
|
485
|
+
progress: 100,
|
|
486
|
+
currentStep: reason,
|
|
487
|
+
summary: result
|
|
488
|
+
? `Scripted-browser actor (${args.actorId}) replayed ${args.journey.scenarioId} on the ${surface.id} surface; ${result.completionReason}.`
|
|
489
|
+
: args.sessionError
|
|
490
|
+
? `Scripted lab failed before a terminal session verdict: ${args.sessionError}`
|
|
491
|
+
: `Contract lane for the scripted-browser actor (${args.actorId}) against ${args.appUrl}.`,
|
|
492
|
+
streamIds: [streamId],
|
|
493
|
+
startedAt: args.createdAt,
|
|
494
|
+
updatedAt: result?.capture.capturedAt ?? args.createdAt
|
|
495
|
+
});
|
|
496
|
+
streams.push({
|
|
497
|
+
id: streamId,
|
|
498
|
+
simId,
|
|
499
|
+
kind: "browser",
|
|
500
|
+
label: `${surface.label} — ${args.labId}`,
|
|
501
|
+
status,
|
|
502
|
+
transport: "snapshot",
|
|
503
|
+
updatedAt: result?.capture.capturedAt ?? args.createdAt,
|
|
504
|
+
embed: lastScreenshot
|
|
505
|
+
? { kind: "screenshot", url: `../${lastScreenshot}`, title: `${surface.label} (raw)` }
|
|
506
|
+
: { kind: "placeholder", title: surface.label },
|
|
507
|
+
// REAL emulated viewport: isMobile/deviceScaleFactor genuinely render on this route
|
|
508
|
+
// (playwright emulation), unlike the e2b-desktop route's prompt-signal-only fidelity.
|
|
509
|
+
viewport: surface.viewport,
|
|
510
|
+
ui: {
|
|
511
|
+
route: args.appUrl,
|
|
512
|
+
intent: args.journey.goal,
|
|
513
|
+
state: reason,
|
|
514
|
+
...(result ? { actorStatus: result.status } : {}),
|
|
515
|
+
...(lastScreenshot ? { screenshotUrl: `../${lastScreenshot}` } : {})
|
|
516
|
+
},
|
|
517
|
+
// The seam this registration exists to fill: the provider-neutral actor evidence.
|
|
518
|
+
...(result ? { actor: result.trace } : {}),
|
|
519
|
+
artifacts: [
|
|
520
|
+
{ label: "run bundle", path: "run.json", kind: "bundle" },
|
|
521
|
+
{ label: "review", path: "review.md", kind: "review" },
|
|
522
|
+
{ label: "events", path: "events.ndjson", kind: "events" },
|
|
523
|
+
...(result
|
|
524
|
+
? [
|
|
525
|
+
{ label: `${surface.id} browser trace`, path: result.capture.tracePath, kind: "trace" },
|
|
526
|
+
{ label: `${surface.id} actor trace`, path: `actor-${surface.id}.json`, kind: "trace" }
|
|
527
|
+
]
|
|
528
|
+
: []),
|
|
529
|
+
...screenshots.map((screenshot, screenshotIndex) => ({
|
|
530
|
+
label: `${surface.id} screenshot ${String(screenshotIndex + 1).padStart(2, "0")} (raw)`,
|
|
531
|
+
path: screenshot,
|
|
532
|
+
kind: "screenshot"
|
|
533
|
+
}))
|
|
534
|
+
]
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
const events = [
|
|
538
|
+
{
|
|
539
|
+
id: "event-000-created",
|
|
540
|
+
at: args.createdAt,
|
|
541
|
+
level: "info",
|
|
542
|
+
type: "scripted-lab.run.created",
|
|
543
|
+
message: `Created scripted-browser lab run for ${args.labId} (actor ${args.actorId}, ${args.surfaces.length} surface${args.surfaces.length === 1 ? "" : "s"}).`
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
id: "event-001-subject",
|
|
547
|
+
at: args.createdAt,
|
|
548
|
+
level: "info",
|
|
549
|
+
type: "scripted-lab.subject.declared",
|
|
550
|
+
// Invariant 5: provenance recorded or its absence DECLARED. The lab did not provision
|
|
551
|
+
// this subject on app-url routes; clone routes carry structured subject provenance below.
|
|
552
|
+
message: args.subject
|
|
553
|
+
? `Provisioned synthetic subject: clone of ${args.subject.repo}${args.subject.commit ? `@${args.subject.commit}` : ""}, served + getHost-exposed in-sandbox; env names: ${args.subject.envNames?.join(", ") || "none"} (values never persisted); state provenance: ${args.subject.state.provenance}; evidence host digest: ${args.hostDigest ?? "dry-run"}.`
|
|
554
|
+
: `Subject app declared at ${args.appUrl}; the lab did not provision it — subject build/commit provenance is UNPINNED; evidence binds to the scenario digest ${args.scenarioSourceDigest}.`
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
id: "event-002-spend",
|
|
558
|
+
at: args.createdAt,
|
|
559
|
+
level: "info",
|
|
560
|
+
type: "scripted-lab.spend",
|
|
561
|
+
message: args.subject
|
|
562
|
+
? "No model spend by construction; live provisioned scripted runs may spend E2B sandbox minutes to clone/serve the synthetic subject, then drive deterministic browser steps."
|
|
563
|
+
: "$0 provider spend by construction (no model and no sandbox in the loop); scenario.mode: live gates real browser actuation against the declared app, not cost."
|
|
564
|
+
}
|
|
565
|
+
];
|
|
566
|
+
if (args.sessionResults.length > 0) {
|
|
567
|
+
for (const result of args.sessionResults) {
|
|
568
|
+
events.push({
|
|
569
|
+
id: `event-${String(events.length).padStart(3, "0")}-session-${result.capture.surface.id}`,
|
|
570
|
+
at: result.capture.capturedAt,
|
|
571
|
+
level: result.status === "passed" ? "info" : "warn",
|
|
572
|
+
type: `scripted-lab.session.${result.completionReason}`,
|
|
573
|
+
message: `${result.capture.surface.id}: ${result.status} — ${result.reason}`,
|
|
574
|
+
simId: `scripted-${result.capture.surface.id}`,
|
|
575
|
+
streamId: `scripted-${result.capture.surface.id}-stream`
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
else if (args.sessionError) {
|
|
580
|
+
events.push({
|
|
581
|
+
id: "event-003-session-error",
|
|
582
|
+
at: args.createdAt,
|
|
583
|
+
level: "error",
|
|
584
|
+
type: "scripted-lab.session.error",
|
|
585
|
+
message: args.sessionError
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
events.push({
|
|
590
|
+
id: "event-003-contract",
|
|
591
|
+
at: args.createdAt,
|
|
592
|
+
level: "info",
|
|
593
|
+
type: "scripted-lab.contract.ready",
|
|
594
|
+
message: `Dry-run contract bundle ready: scenario ${args.journey.scenarioId} @ ${args.scenarioSourceDigest} (${args.scenarioSource}, ${args.journey.steps.length} step${args.journey.steps.length === 1 ? "" : "s"}) parsed and digest-pinned; switch scenario.mode to live to actuate a real browser.`
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
const review = buildScriptedReview(args);
|
|
598
|
+
const ranLive = args.sessionResults.length > 0 || args.sessionError !== undefined;
|
|
599
|
+
return {
|
|
600
|
+
schema: RUN_BUNDLE_SCHEMA,
|
|
601
|
+
runId: args.runId,
|
|
602
|
+
mode: args.dryRun ? "dry-run" : "live",
|
|
603
|
+
simCount: args.surfaces.length,
|
|
604
|
+
createdAt: args.createdAt,
|
|
605
|
+
cwd: PUBLIC_TARGET_CWD,
|
|
606
|
+
artifactRoot: path.join(".humanish", "runs", args.runId),
|
|
607
|
+
source: args.source,
|
|
608
|
+
persona: {
|
|
609
|
+
id: args.persona.id,
|
|
610
|
+
name: `Scripted journey persona (${args.persona.id})`,
|
|
611
|
+
source: `lab:${args.labId}`,
|
|
612
|
+
sourceDigest: args.persona.promptDigest
|
|
613
|
+
},
|
|
614
|
+
scenario: {
|
|
615
|
+
id: args.journey.scenarioId,
|
|
616
|
+
title: args.journey.scenarioTitle,
|
|
617
|
+
goal: args.journey.goal,
|
|
618
|
+
source: args.scenarioSource,
|
|
619
|
+
sourceDigest: args.scenarioSourceDigest
|
|
620
|
+
},
|
|
621
|
+
lifecycle: [
|
|
622
|
+
{
|
|
623
|
+
at: args.createdAt,
|
|
624
|
+
event: "scripted-lab.run.created",
|
|
625
|
+
message: `Created scripted-browser lab run with ${args.surfaces.length} surface lane${args.surfaces.length === 1 ? "" : "s"} (actor ${args.actorId}).`
|
|
626
|
+
}
|
|
627
|
+
],
|
|
628
|
+
simulations,
|
|
629
|
+
streams,
|
|
630
|
+
events,
|
|
631
|
+
redaction: {
|
|
632
|
+
status: "passed",
|
|
633
|
+
notes: ranLive
|
|
634
|
+
? "Scripted step URLs are sanitized to loopback origin+path (query/hash redacted) and step text passes text redaction. Screenshots are FULL-FIDELITY (raw), retained for local use in gitignored .humanish — NOT redacted for publishing; policies.redactScreenshots is not yet supported on this route."
|
|
635
|
+
: "Dry-run contract bundle: no browser ran and no screenshots were captured. The scenario contract is digest-pinned; live step text passes text redaction when a session runs."
|
|
636
|
+
},
|
|
637
|
+
artifacts: {
|
|
638
|
+
run: "run.json",
|
|
639
|
+
reviewJson: "review.json",
|
|
640
|
+
reviewMarkdown: "review.md",
|
|
641
|
+
observerData: "observer/observer-data.json",
|
|
642
|
+
events: "events.ndjson"
|
|
643
|
+
},
|
|
644
|
+
review,
|
|
645
|
+
feedbackCandidates: [],
|
|
646
|
+
...(args.subject === undefined ? {} : { subject: args.subject }),
|
|
647
|
+
...(args.desktopTemplate === undefined ? {} : { desktopTemplate: args.desktopTemplate })
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
function buildScriptedReview(args) {
|
|
651
|
+
if (args.sessionError) {
|
|
652
|
+
return {
|
|
653
|
+
schema: REVIEW_SCHEMA,
|
|
654
|
+
verdict: "fail",
|
|
655
|
+
summary: `Scripted lab failed before a terminal session verdict: ${args.sessionError}`,
|
|
656
|
+
gaps: []
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
if (args.sessionResults.length === 0) {
|
|
660
|
+
return {
|
|
661
|
+
schema: REVIEW_SCHEMA,
|
|
662
|
+
verdict: "contract_proof_only",
|
|
663
|
+
summary: `Dry-run contract for scenario ${args.journey.scenarioId} (${args.scenarioSource}, ${args.journey.steps.length} steps) against ${args.appUrl}: composition and scenario contract proven at $0; no browser ran.`,
|
|
664
|
+
gaps: ["Live scripted session not yet run (dry-run contract only)."]
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
// Worst-of across surfaces: harness/step failures outrank a timeout outranks a pass.
|
|
668
|
+
const reasons = args.sessionResults.map((result) => result.completionReason);
|
|
669
|
+
const verdict = reasons.some((reason) => reason === "harness_error" || reason === "step_failed")
|
|
670
|
+
? "fail"
|
|
671
|
+
: reasons.some((reason) => reason === "timed_out")
|
|
672
|
+
? "timed_out"
|
|
673
|
+
: "pass";
|
|
674
|
+
const passed = args.sessionResults.filter((result) => result.status === "passed").length;
|
|
675
|
+
return {
|
|
676
|
+
schema: REVIEW_SCHEMA,
|
|
677
|
+
verdict,
|
|
678
|
+
summary: `Scripted-browser actor replayed ${args.journey.scenarioId} on ${args.sessionResults.length} surface${args.sessionResults.length === 1 ? "" : "s"} against ${args.appUrl}: ${passed}/${args.sessionResults.length} satisfied the scenario predicate.`,
|
|
679
|
+
gaps: args.sessionResults
|
|
680
|
+
.filter((result) => result.status !== "passed")
|
|
681
|
+
.map((result) => `${result.capture.surface.id}: ${result.reason}`)
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function renderScriptedReviewMarkdown(bundle) {
|
|
685
|
+
const subject = bundle.events.find((event) => event.type === "scripted-lab.subject.declared");
|
|
686
|
+
const spend = bundle.events.find((event) => event.type === "scripted-lab.spend");
|
|
687
|
+
const traces = bundle.streams
|
|
688
|
+
.map((stream) => ({ stream, trace: stream.actor }))
|
|
689
|
+
.filter((entry) => entry.trace !== undefined);
|
|
690
|
+
return [
|
|
691
|
+
`# ${bundle.scenario.title}`,
|
|
692
|
+
"",
|
|
693
|
+
`- run: ${bundle.runId}`,
|
|
694
|
+
`- mode: ${bundle.mode}`,
|
|
695
|
+
`- verdict: ${bundle.review.verdict}`,
|
|
696
|
+
`- summary: ${bundle.review.summary}`,
|
|
697
|
+
`- scenario: ${bundle.scenario.id} @ ${bundle.scenario.sourceDigest} (${bundle.scenario.source})`,
|
|
698
|
+
...(subject ? [`- subject: ${subject.message}`] : []),
|
|
699
|
+
...(spend ? [`- spend: ${spend.message}`] : []),
|
|
700
|
+
...traces.map(({ stream, trace }) => `- ${stream.simId}: ${trace.provider} (${trace.lane}/${trace.protocol}) ${trace.status} (${trace.completionReason}); ${trace.counts.actions ?? 0} step action(s), ${trace.counts.screenshots ?? 0} raw screenshot(s)`),
|
|
701
|
+
...(bundle.review.gaps.length > 0 ? ["", "## Gaps", ...bundle.review.gaps.map((gap) => `- ${gap}`)] : []),
|
|
702
|
+
""
|
|
703
|
+
].join("\n");
|
|
704
|
+
}
|
|
705
|
+
function makeScriptedRunId() {
|
|
706
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
707
|
+
return `scripted-${stamp}-${randomBytes(4).toString("hex")}`;
|
|
708
|
+
}
|
|
709
|
+
function readPositiveInt(value, fallback) {
|
|
710
|
+
if (value === undefined)
|
|
711
|
+
return fallback;
|
|
712
|
+
const parsed = Number.parseInt(value, 10);
|
|
713
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
714
|
+
}
|
|
715
|
+
function servePort(serveUrl) {
|
|
716
|
+
const url = new URL(serveUrl);
|
|
717
|
+
if (url.port)
|
|
718
|
+
return Number(url.port);
|
|
719
|
+
return url.protocol === "https:" ? 443 : 80;
|
|
720
|
+
}
|
|
721
|
+
function isTokenlessHost(value) {
|
|
722
|
+
try {
|
|
723
|
+
const url = new URL(value);
|
|
724
|
+
return url.username === "" && url.password === "" && url.search === "";
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
function hostOriginDigest(url) {
|
|
731
|
+
try {
|
|
732
|
+
return commandDigestOf(new URL(url).origin);
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
return commandDigestOf(url);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
//# sourceMappingURL=scripted-browser-lab.js.map
|