@monster-orange/den 0.0.0 → 0.1.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.
Files changed (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +99 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +406 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +108 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +729 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,1025 @@
1
+ /**
2
+ * Journeys runner — runJourney(spec, env, deps): one @cursor/sdk local
3
+ * agent per journey, confined to the mounted custom tools with shell
4
+ * denied by the beforeShellExecution hook (the OS sandbox is disabled —
5
+ * it blocks the custom-tool MCP calls; see the Agent.create call below).
6
+ * (plan: jrn-runner packet). Library only: no CLI
7
+ * entrypoint, no teardown invocation — the caller (jrn-pilot-journeys)
8
+ * orchestrates runJourney → verify → report → teardown.
9
+ *
10
+ * Isolation ordering (load-bearing, pinned by tests/unit/journeys/
11
+ * isolation.test.ts): captureSecrets → provisionBrowserSession (browser|
12
+ * mixed surfaces — getCredentials/headlessLogin read E2E_* env vars in CI)
13
+ * → sanitizeEnvironment → Agent.create (apiKey passed explicitly); env
14
+ * restored and agent + browser context disposed in finally.
15
+ *
16
+ * Budgets are FAIL-CLOSED (Approach §Budget enforcement):
17
+ * - max_turns counts the guaranteed turn-ended InteractionUpdate (onDelta),
18
+ * never optional usage events;
19
+ * - max_total_tokens is enforced on ONE monotonic cumulative total at
20
+ * each turn-ended, reconciled via max() across per-turn payload sums
21
+ * and the run handle's cumulative usage (never undercounts after a
22
+ * missing-usage turn);
23
+ * - a turn-ended with NO usage and NO cumulative run.usage cancels the run
24
+ * as budget_unverifiable — unverifiable spend never continues;
25
+ * - timeout_s is a harness wall-clock timer → run.cancel().
26
+ *
27
+ * Failure kinds are disjoint: policy refusals THROW JourneyPolicyError
28
+ * (caller bugs, not run outcomes); CursorAgentError at startup →
29
+ * infra_error; result.status === "error" → agent_error; budget
30
+ * cancellations → budget_exceeded / budget_unverifiable; transcript secret
31
+ * leaks → infra_error. RunOutcome deliberately has NO pass field — the
32
+ * deterministic verifier (jrn-verifier) is the sole pass/fail authority.
33
+ */
34
+
35
+ import * as fs from "fs";
36
+ import * as path from "path";
37
+ import { getDistRoot } from "../../shared/paths.js";
38
+ import { CursorAgentError, type SDKMessage } from "@cursor/sdk";
39
+ import { envSelector } from "../../shared/environment/env-selector.js";
40
+ import type { Environment } from "../../shared/types/index.js";
41
+ import {
42
+ captureSecrets as defaultCaptureSecrets,
43
+ prepareSandbox as defaultPrepareSandbox,
44
+ sanitizeEnvironment as defaultSanitizeEnvironment,
45
+ SecretLeakError,
46
+ TranscriptScrubber,
47
+ TranscriptWriter,
48
+ type CapturedSecrets,
49
+ type SanitizeResult,
50
+ type StagedSandbox,
51
+ } from "./sandbox.js";
52
+ import type { JourneyPersonaRole, JourneySpec } from "./spec/schema.js";
53
+ import {
54
+ provisionBrowserSession as defaultProvisionBrowserSession,
55
+ deriveApiTokenFromSession as defaultDeriveApiToken,
56
+ type SessionApiToken,
57
+ } from "./tools/browser-session.js";
58
+ import {
59
+ buildBrowserToolset as defaultBuildBrowserToolset,
60
+ type BrowserToolset,
61
+ type BrowserToolsetOptions,
62
+ } from "./tools/browser-tool.js";
63
+ import {
64
+ allowedBaseUrlsFromEnv,
65
+ allowedMethodsForSurface,
66
+ buildScopedHttpTool as defaultBuildHttpTool,
67
+ HTTP_TOOL_NAME,
68
+ type ScopedHttpToolOptions,
69
+ } from "./tools/http-tool.js";
70
+ import { RunLedger } from "./tools/ledger.js";
71
+ import {
72
+ ledgerRegistryFromApp,
73
+ type JourneysAppConfig,
74
+ type JourneysSessionTokenConfig,
75
+ } from "./app-config.js";
76
+ import {
77
+ SnapshotStore,
78
+ type SnapshotIndexEntry,
79
+ } from "./tools/snapshot-store.js";
80
+ import {
81
+ JourneyEventBus,
82
+ type JourneyEvent,
83
+ type JourneyEventSink,
84
+ } from "./observability/events.js";
85
+ import {
86
+ createMapperState,
87
+ flushAssistantText,
88
+ sdkMessageToEvents,
89
+ } from "./observability/sdk-mapper.js";
90
+
91
+ // ── SDK-facing structural types (unit tests provide plain fakes) ────────────
92
+
93
+ /** Cumulative token usage as reported by the SDK run. */
94
+ export interface RunUsage {
95
+ inputTokens: number;
96
+ outputTokens: number;
97
+ cacheReadTokens: number;
98
+ cacheWriteTokens: number;
99
+ totalTokens: number;
100
+ reasoningTokens?: number;
101
+ }
102
+
103
+ /** Per-turn usage payload on a turn-ended InteractionUpdate. */
104
+ export interface TurnUsage {
105
+ inputTokens: number;
106
+ outputTokens: number;
107
+ cacheReadTokens: number;
108
+ cacheWriteTokens: number;
109
+ reasoningTokens?: number;
110
+ }
111
+
112
+ /** Structural subset of the SDK's InteractionUpdate the runner consumes. */
113
+ export interface InteractionUpdateLike {
114
+ type: string;
115
+ usage?: TurnUsage;
116
+ }
117
+
118
+ export interface RunResultLike {
119
+ status: string;
120
+ result?: string;
121
+ error?: { message: string; code?: string };
122
+ usage?: RunUsage;
123
+ durationMs?: number;
124
+ }
125
+
126
+ export interface AgentRunLike {
127
+ stream(): AsyncGenerator<unknown, void>;
128
+ wait(): Promise<RunResultLike>;
129
+ cancel(): Promise<void>;
130
+ readonly usage?: RunUsage;
131
+ }
132
+
133
+ export interface AgentSendOptionsLike {
134
+ onDelta: (args: { update: InteractionUpdateLike }) => void | Promise<void>;
135
+ }
136
+
137
+ export interface AgentLike {
138
+ send(message: string, options: AgentSendOptionsLike): Promise<AgentRunLike>;
139
+ close(): void;
140
+ }
141
+
142
+ /** Options the runner passes to Agent.create (structural subset). */
143
+ export interface AgentCreateOptionsLike {
144
+ apiKey: string;
145
+ model: { id: string };
146
+ local: {
147
+ cwd: string;
148
+ // Load the staged `.cursor/` config (hooks.json + permissions.json).
149
+ // Defaults to [] ("inline only") in the SDK, which would ignore the
150
+ // sandbox's shell-deny hook AND the custom-tools mcpAllowlist.
151
+ settingSources: string[];
152
+ sandboxOptions: { enabled: boolean };
153
+ customTools: Record<string, unknown>;
154
+ };
155
+ }
156
+
157
+ // ── Policy ───────────────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * A journey was refused before anything ran: production target, env not in
161
+ * the spec's allowlist, no model (neither spec `model` nor JOURNEYS_MODEL),
162
+ * or a spec without budgets.
163
+ * Thrown (never a RunOutcome) — these are operator/config mistakes.
164
+ */
165
+ export class JourneyPolicyError extends Error {
166
+ constructor(message: string) {
167
+ super(message);
168
+ this.name = "JourneyPolicyError";
169
+ }
170
+ }
171
+
172
+ // ── Runner deps / outcome ────────────────────────────────────────────────────
173
+
174
+ export type FailureKind =
175
+ | "infra_error"
176
+ | "agent_error"
177
+ | "budget_exceeded"
178
+ | "budget_unverifiable";
179
+
180
+ export interface RunOutcome {
181
+ runId: string;
182
+ transcriptPath: string;
183
+ /** Absolute path of the structured harness event log (events.jsonl). */
184
+ eventsPath: string;
185
+ /** Structured timeline (tool calls, bindings, denials, agent error). */
186
+ events: readonly JourneyEvent[];
187
+ snapshotIndex: readonly SnapshotIndexEntry[];
188
+ ledger: RunLedger;
189
+ usage?: RunUsage;
190
+ durationMs: number;
191
+ /** Absent on a clean run. NO pass verdict here — the verifier owns it. */
192
+ failure_kind?: FailureKind;
193
+ /**
194
+ * Secret-redacted message of the error that produced an infra_error
195
+ * outcome (review finding 8: post-ledger failures return the ledger
196
+ * instead of throwing, so the caller always tears down + reports).
197
+ */
198
+ error?: string;
199
+ }
200
+
201
+ export interface RunnerEnvConfigLike {
202
+ name: string;
203
+ baseUrls: {
204
+ web: string;
205
+ api: string;
206
+ services: Record<string, string>;
207
+ };
208
+ }
209
+
210
+ export interface RunnerTimers {
211
+ setTimeout: (callback: () => void, ms: number) => NodeJS.Timeout;
212
+ clearTimeout: (handle: NodeJS.Timeout) => void;
213
+ }
214
+
215
+ export interface RunnerDeps {
216
+ loadEnvironment: (env: Environment) => Promise<RunnerEnvConfigLike>;
217
+ captureSecrets: (
218
+ role: JourneyPersonaRole,
219
+ env: Environment,
220
+ ) => Promise<CapturedSecrets>;
221
+ provisionBrowserSession: (
222
+ role: JourneyPersonaRole,
223
+ env: Environment,
224
+ ) => Promise<string>;
225
+ /**
226
+ * Derive the API assertion token from the browser session. Called with
227
+ * the auth-file path produced by provisionBrowserSession and the app's
228
+ * `session_token` config, BEFORE sanitizeEnvironment (it reaches the web
229
+ * origin over the session cookies). Returns the real-user token + any
230
+ * session-derived headers.
231
+ */
232
+ deriveApiToken: (
233
+ authFilePath: string,
234
+ env: Environment,
235
+ sessionToken?: JourneysSessionTokenConfig,
236
+ ) => Promise<SessionApiToken>;
237
+ /**
238
+ * The app under test (`journeys.app` in the spec-hosting repo's
239
+ * manifest): tracked resources, web-origin API feed, session token.
240
+ * Defaults to {} — nothing tracked, no web-origin feed.
241
+ */
242
+ app: JourneysAppConfig;
243
+ /**
244
+ * `baseline` is the process-env snapshot taken BEFORE captureSecrets;
245
+ * restore() reinstates from it so secrets introduced during capture
246
+ * are never restored.
247
+ */
248
+ sanitizeEnvironment: (baseline: NodeJS.ProcessEnv) => SanitizeResult;
249
+ prepareSandbox: (journeyName: string) => StagedSandbox;
250
+ createAgent: (options: AgentCreateOptionsLike) => Promise<AgentLike>;
251
+ buildHttpTool: (options: ScopedHttpToolOptions) => unknown;
252
+ buildBrowserToolset: (options: BrowserToolsetOptions) => BrowserToolset;
253
+ /**
254
+ * Build the structured observability sink for one run. Writes
255
+ * `<reportDir>/events.jsonl`, records the in-memory timeline the report
256
+ * reads, and (when JOURNEYS_VERBOSE is truthy) live-prints one condensed
257
+ * line per event to stdout. Injectable so tests get a silent recorder.
258
+ */
259
+ buildEventSink: (options: {
260
+ filePath: string;
261
+ verbose: boolean;
262
+ secrets: readonly string[];
263
+ }) => JourneyEventSink;
264
+ /** Root for per-run report dirs; reports land in <reportRoot>/<runId>/. */
265
+ reportRoot: string;
266
+ /** Env-var source for runner configuration (JOURNEYS_MODEL override). */
267
+ env: NodeJS.ProcessEnv;
268
+ /** Injectable wall-clock timer (budget timeout_s). */
269
+ timers: RunnerTimers;
270
+ }
271
+
272
+ async function defaultCreateAgent(
273
+ options: AgentCreateOptionsLike,
274
+ ): Promise<AgentLike> {
275
+ const { Agent } = await import("@cursor/sdk");
276
+ return (await Agent.create(options as never)) as unknown as AgentLike;
277
+ }
278
+
279
+ /** Production wiring — every collaborator is the real den machinery. */
280
+ export function defaultRunnerDeps(): RunnerDeps {
281
+ return {
282
+ loadEnvironment: (env) => envSelector.loadEnvironment(env),
283
+ captureSecrets: (role, env) => defaultCaptureSecrets(role, env),
284
+ provisionBrowserSession: (role, env) =>
285
+ defaultProvisionBrowserSession(role, env),
286
+ deriveApiToken: (authFilePath, env, sessionToken) =>
287
+ defaultDeriveApiToken(authFilePath, env, sessionToken),
288
+ app: {},
289
+ sanitizeEnvironment: (baseline) =>
290
+ defaultSanitizeEnvironment(process.env, baseline),
291
+ prepareSandbox: (journeyName) => defaultPrepareSandbox(journeyName),
292
+ createAgent: defaultCreateAgent,
293
+ buildHttpTool: defaultBuildHttpTool,
294
+ buildBrowserToolset: defaultBuildBrowserToolset,
295
+ buildEventSink: ({ filePath, verbose, secrets }) =>
296
+ new JourneyEventBus({
297
+ filePath,
298
+ secrets,
299
+ liveWriter: verbose
300
+ ? (line) => process.stdout.write(`${line}\n`)
301
+ : undefined,
302
+ }),
303
+ reportRoot: path.resolve(getDistRoot(), "reports/journeys"),
304
+ env: process.env,
305
+ timers: { setTimeout, clearTimeout },
306
+ };
307
+ }
308
+
309
+ // ── Helpers ──────────────────────────────────────────────────────────────────
310
+
311
+ /**
312
+ * Narrow an opaque stream item to a mappable SDK message. The stream is
313
+ * typed `unknown` (fakes in tests push plain objects); the mapper only
314
+ * reads `type` + shape-guarded fields, so a structural `type: string`
315
+ * check is sufficient — unknown types map to [] harmlessly.
316
+ */
317
+ function isSdkMessage(message: unknown): message is SDKMessage {
318
+ return (
319
+ typeof message === "object" &&
320
+ message !== null &&
321
+ typeof (message as { type?: unknown }).type === "string"
322
+ );
323
+ }
324
+
325
+ /** Truthy JOURNEYS_VERBOSE values enable the live event stream to stdout. */
326
+ function isVerbose(value: string | undefined): boolean {
327
+ if (value === undefined) return false;
328
+ const v = value.trim().toLowerCase();
329
+ return v === "1" || v === "true" || v === "yes" || v === "on";
330
+ }
331
+
332
+ function generateRunId(journeyName: string, now: Date = new Date()): string {
333
+ const slug = journeyName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
334
+ const rand4 = Math.random().toString(36).slice(2, 6).padEnd(4, "0");
335
+ return `jrn-${slug}-${now.toISOString().replace(/[:.]/g, "-")}-${rand4}`;
336
+ }
337
+
338
+ function buildPersonaPrompt(spec: JourneySpec, runId: string): string {
339
+ return [
340
+ `You are a synthetic user executing the journey "${spec.name}".`,
341
+ `Run id: ${runId}. Any resource you create that accepts a display name`,
342
+ `MUST have its name prefixed with "${runId}".`,
343
+ ``,
344
+ `Persona brief:`,
345
+ spec.persona.brief,
346
+ ``,
347
+ `Use only the mounted journey tools. Shell access is denied.`,
348
+ ].join("\n");
349
+ }
350
+
351
+ function enforcePolicy(
352
+ spec: JourneySpec,
353
+ env: Environment,
354
+ configuredModel: string,
355
+ ): void {
356
+ if (env === "production" || env === "prod") {
357
+ throw new JourneyPolicyError(
358
+ "Journeys never run against production — the runner hard-refuses " +
359
+ "the production environment regardless of spec content.",
360
+ );
361
+ }
362
+ if (!spec.environments.includes(env)) {
363
+ throw new JourneyPolicyError(
364
+ `Journey "${spec.name}" does not allow environment "${env}" — its ` +
365
+ `environments allowlist is [${spec.environments.join(", ")}].`,
366
+ );
367
+ }
368
+ if (configuredModel.length === 0) {
369
+ throw new JourneyPolicyError(
370
+ `No model configured for journey "${spec.name}". Declare a default ` +
371
+ "in the spec (`model: <model-id>`) or set the JOURNEYS_MODEL env " +
372
+ "var (operator override) — there is no invented default model.",
373
+ );
374
+ }
375
+ const budgets = (spec as Partial<JourneySpec>).budgets;
376
+ if (
377
+ budgets === undefined ||
378
+ typeof budgets.max_turns !== "number" ||
379
+ typeof budgets.timeout_s !== "number" ||
380
+ typeof budgets.max_total_tokens !== "number"
381
+ ) {
382
+ throw new JourneyPolicyError(
383
+ `Journey "${spec.name}" is missing required budgets ` +
384
+ "(max_turns, timeout_s, max_total_tokens) — budgets are fail-closed " +
385
+ "and never defaulted.",
386
+ );
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Mount tools per the spec's surface: browser => browser toolset only,
392
+ * api => scoped HTTP tool only, mixed => both.
393
+ */
394
+ function mountToolsForSurface(
395
+ spec: JourneySpec,
396
+ httpTool: unknown,
397
+ browserToolset: BrowserToolset | undefined,
398
+ ): Record<string, unknown> {
399
+ const tools: Record<string, unknown> = {};
400
+ if (spec.surface === "api" || spec.surface === "mixed") {
401
+ tools[HTTP_TOOL_NAME] = httpTool;
402
+ }
403
+ if (
404
+ (spec.surface === "browser" || spec.surface === "mixed") &&
405
+ browserToolset !== undefined
406
+ ) {
407
+ Object.assign(tools, browserToolset.tools);
408
+ }
409
+ return tools;
410
+ }
411
+
412
+ function turnTotalTokens(usage: TurnUsage): number {
413
+ return (
414
+ usage.inputTokens +
415
+ usage.outputTokens +
416
+ usage.cacheReadTokens +
417
+ usage.cacheWriteTokens
418
+ );
419
+ }
420
+
421
+ /** The run's wall-clock deadline (budgets.timeout_s) expired. */
422
+ export class DeadlineExceededError extends Error {
423
+ constructor() {
424
+ super(
425
+ "Journey wall-clock deadline (budgets.timeout_s) exceeded — the run " +
426
+ "was aborted.",
427
+ );
428
+ this.name = "DeadlineExceededError";
429
+ }
430
+ }
431
+
432
+ interface RunDeadline {
433
+ /** Aborts when the deadline fires; threaded into tool fetches. */
434
+ signal: AbortSignal;
435
+ clear(): void;
436
+ /** Await `work`, rejecting with DeadlineExceededError on expiry. */
437
+ race<T>(work: Promise<T>): Promise<T>;
438
+ }
439
+
440
+ /**
441
+ * Cap on post-deadline / teardown-side cleanup waits (review finding
442
+ * R2-2): run cancellation and browser disposal are best-effort — a hung
443
+ * cleanup must never stall the outcome past this bound.
444
+ */
445
+ export const CLEANUP_TIMEOUT_MS = 5_000;
446
+
447
+ /**
448
+ * Await a cleanup promise for at most `ms` (review finding R2-2). Never
449
+ * rejects; the work's eventual settlement is always observed so an
450
+ * abandoned cleanup can never unhandled-reject later.
451
+ */
452
+ async function settleBounded(
453
+ work: Promise<unknown> | undefined,
454
+ ms: number,
455
+ ): Promise<void> {
456
+ if (work === undefined) return;
457
+ const observed = work.then(
458
+ () => undefined,
459
+ () => undefined,
460
+ );
461
+ let timer: NodeJS.Timeout | undefined;
462
+ const cap = new Promise<void>((resolve) => {
463
+ timer = setTimeout(resolve, ms);
464
+ });
465
+ try {
466
+ await Promise.race([observed, cap]);
467
+ } finally {
468
+ if (timer !== undefined) clearTimeout(timer);
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Wall-clock deadline covering the WHOLE run — provisioning, agent
474
+ * lifecycle waits, and (via `signal`) tool fetches (review finding 6).
475
+ * Started BEFORE provisioning so a hung secret capture or browser mint
476
+ * can never stall past budgets.timeout_s.
477
+ */
478
+ function startDeadline(
479
+ ms: number,
480
+ timers: RunnerTimers,
481
+ onExpire: () => void,
482
+ ): RunDeadline {
483
+ const controller = new AbortController();
484
+ const handle = timers.setTimeout(() => {
485
+ // onExpire first so the budget violation is recorded before any
486
+ // race() rejection handler observes the abort.
487
+ onExpire();
488
+ controller.abort(new DeadlineExceededError());
489
+ }, ms);
490
+
491
+ return {
492
+ signal: controller.signal,
493
+ clear(): void {
494
+ timers.clearTimeout(handle);
495
+ },
496
+ race<T>(work: Promise<T>): Promise<T> {
497
+ // On expiry the orphaned `work` keeps running: neither Agent.create
498
+ // nor the provisioning collaborators accept an AbortSignal today
499
+ // (residual orphan risk — the process-level bound is the caller's
500
+ // overall lifecycle). Its eventual settlement is OBSERVED here so a
501
+ // late rejection can never surface as an unhandled rejection
502
+ // (review finding R2-2).
503
+ const observeOrphan = (): void => {
504
+ work.then(
505
+ () => undefined,
506
+ () => undefined,
507
+ );
508
+ };
509
+ return new Promise<T>((resolve, reject) => {
510
+ if (controller.signal.aborted) {
511
+ observeOrphan();
512
+ reject(new DeadlineExceededError());
513
+ return;
514
+ }
515
+ const onAbort = (): void => {
516
+ observeOrphan();
517
+ reject(new DeadlineExceededError());
518
+ };
519
+ controller.signal.addEventListener("abort", onAbort, { once: true });
520
+ work.then(
521
+ (value) => {
522
+ controller.signal.removeEventListener("abort", onAbort);
523
+ resolve(value);
524
+ },
525
+ (error: unknown) => {
526
+ controller.signal.removeEventListener("abort", onAbort);
527
+ reject(error as Error);
528
+ },
529
+ );
530
+ });
531
+ },
532
+ };
533
+ }
534
+
535
+ /**
536
+ * Fail-closed budget accounting over turn-ended updates. The enforcer only
537
+ * decides; the runner owns cancelling the active run.
538
+ */
539
+ class BudgetEnforcer {
540
+ private turns = 0;
541
+ /**
542
+ * ONE monotonic cumulative total across both usage sources (review
543
+ * finding 5): each turn advances it to
544
+ * max(prevTotal, prevTotal + turnUsage, run.usage.totalTokens) — so a
545
+ * missing-usage turn covered only by the run handle's cumulative
546
+ * counter can never make a later per-turn sum undercount total spend.
547
+ */
548
+ private cumulativeTotal: number | undefined;
549
+ violation: Extract<
550
+ FailureKind,
551
+ "budget_exceeded" | "budget_unverifiable"
552
+ > | null = null;
553
+
554
+ constructor(
555
+ private readonly budgets: JourneySpec["budgets"],
556
+ private readonly cumulativeRunUsage: () => RunUsage | undefined,
557
+ ) {}
558
+
559
+ /** The monotonic cumulative total (undefined until any usage evidence). */
560
+ get reportedUsage(): RunUsage | undefined {
561
+ if (this.cumulativeTotal === undefined) return undefined;
562
+ return {
563
+ inputTokens: 0,
564
+ outputTokens: 0,
565
+ cacheReadTokens: 0,
566
+ cacheWriteTokens: 0,
567
+ totalTokens: this.cumulativeTotal,
568
+ };
569
+ }
570
+
571
+ /** Process one turn-ended update; returns true when the run must cancel. */
572
+ onTurnEnded(update: InteractionUpdateLike): boolean {
573
+ if (this.violation !== null) return false;
574
+ this.turns += 1;
575
+
576
+ if (this.turns > this.budgets.max_turns) {
577
+ this.violation = "budget_exceeded";
578
+ return true;
579
+ }
580
+
581
+ const candidates: number[] = [];
582
+ if (update.usage !== undefined) {
583
+ candidates.push(
584
+ (this.cumulativeTotal ?? 0) + turnTotalTokens(update.usage),
585
+ );
586
+ }
587
+ const runTotal = this.cumulativeRunUsage()?.totalTokens;
588
+ if (runTotal !== undefined) {
589
+ candidates.push(runTotal);
590
+ }
591
+
592
+ if (candidates.length === 0) {
593
+ // FAIL-CLOSED: spend we cannot verify never continues.
594
+ this.violation = "budget_unverifiable";
595
+ return true;
596
+ }
597
+ this.cumulativeTotal = Math.max(this.cumulativeTotal ?? 0, ...candidates);
598
+
599
+ if (this.cumulativeTotal > this.budgets.max_total_tokens) {
600
+ this.violation = "budget_exceeded";
601
+ return true;
602
+ }
603
+ return false;
604
+ }
605
+
606
+ /** Wall-clock expiry (timeout_s). */
607
+ onTimeout(): void {
608
+ if (this.violation === null) {
609
+ this.violation = "budget_exceeded";
610
+ }
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Render the scrubbed transcript as Markdown, interleaving SnapshotIndex
616
+ * references at their action positions: whenever a message mentions a
617
+ * browser_* tool, the next unconsumed snapshot entry is attached to it.
618
+ * Remaining snapshots (e.g. failure frames) are appended at the end.
619
+ */
620
+ export function renderTranscriptMarkdown(
621
+ runId: string,
622
+ messages: readonly string[],
623
+ snapshotIndex: readonly SnapshotIndexEntry[],
624
+ ): string {
625
+ const lines: string[] = [`# Journey transcript — ${runId}`, ""];
626
+ let nextSnapshot = 0;
627
+
628
+ const snapshotLine = (entry: SnapshotIndexEntry): string =>
629
+ entry.path === null
630
+ ? `_Snapshot ${entry.seq} (${entry.action}) suppressed${entry.masked ? " — sensitive input masked" : ""}._`
631
+ : `![${entry.seq}-${entry.action}](${entry.path})`;
632
+
633
+ for (const message of messages) {
634
+ lines.push("```json", message, "```", "");
635
+ if (nextSnapshot < snapshotIndex.length && message.includes("browser_")) {
636
+ lines.push(snapshotLine(snapshotIndex[nextSnapshot]), "");
637
+ nextSnapshot += 1;
638
+ }
639
+ }
640
+
641
+ if (nextSnapshot < snapshotIndex.length) {
642
+ lines.push("## Remaining snapshots", "");
643
+ for (const entry of snapshotIndex.slice(nextSnapshot)) {
644
+ lines.push(snapshotLine(entry), "");
645
+ }
646
+ }
647
+
648
+ return lines.join("\n");
649
+ }
650
+
651
+ // ── runJourney ───────────────────────────────────────────────────────────────
652
+
653
+ /**
654
+ * Execute one journey against one environment. Returns a RunOutcome with
655
+ * deliberately NO pass field — deterministic assertions (jrn-verifier) are
656
+ * the sole pass/fail authority. Never invokes teardown.
657
+ */
658
+ export async function runJourney(
659
+ spec: JourneySpec,
660
+ env: Environment,
661
+ deps: Partial<RunnerDeps> = {},
662
+ ): Promise<RunOutcome> {
663
+ const resolved: RunnerDeps = { ...defaultRunnerDeps(), ...deps };
664
+ const startedAt = Date.now();
665
+
666
+ // Model resolution: JOURNEYS_MODEL (operator override, e.g. CI cost
667
+ // pinning) wins over the spec's declared default; neither → policy refusal.
668
+ const modelId =
669
+ resolved.env.JOURNEYS_MODEL?.trim() || (spec.model?.trim() ?? "");
670
+ enforcePolicy(spec, env, modelId);
671
+
672
+ const runId = generateRunId(spec.name);
673
+ const reportDir = path.join(resolved.reportRoot, runId);
674
+ const transcriptPath = path.join(reportDir, "transcript.jsonl");
675
+ const eventsPath = path.join(reportDir, "events.jsonl");
676
+ const snapshotStore = new SnapshotStore(reportDir);
677
+
678
+ // Structured observability timeline: live-prints under JOURNEYS_VERBOSE and
679
+ // is persisted to events.jsonl + surfaced in report.md. Constructed with
680
+ // the Cursor key already redacted; the session token is added post-derive.
681
+ const verbose = isVerbose(resolved.env.JOURNEYS_VERBOSE);
682
+ const events = resolved.buildEventSink({
683
+ filePath: eventsPath,
684
+ verbose,
685
+ secrets: [],
686
+ });
687
+
688
+ // The ledger feeds bindings/unmanaged-mutations/interception-errors into
689
+ // the event timeline the instant they are recognized — this is how the
690
+ // `{<type>_id}` binding question is answered live, not only at teardown.
691
+ const ledger = new RunLedger(ledgerRegistryFromApp(resolved.app), {
692
+ onResource: (entry) =>
693
+ events.emit({
694
+ kind: "ledger_binding",
695
+ resourceType: entry.resourceType,
696
+ id: entry.id,
697
+ method: entry.method,
698
+ pathValue: entry.path,
699
+ status: entry.status,
700
+ source: entry.source,
701
+ }),
702
+ onUnmanagedMutation: (warning) =>
703
+ events.emit({
704
+ kind: "unmanaged_mutation",
705
+ method: warning.method,
706
+ pathValue: warning.path,
707
+ status: warning.status,
708
+ source: warning.source,
709
+ }),
710
+ onInterceptionError: (message) =>
711
+ events.emit({ kind: "interception_error", message }),
712
+ });
713
+
714
+ let browserToolset: BrowserToolset | undefined;
715
+ let agent: AgentLike | undefined;
716
+ let activeRun: AgentRunLike | undefined;
717
+ let sanitized: SanitizeResult | undefined;
718
+ let secrets: CapturedSecrets | undefined;
719
+ let sessionToken: SessionApiToken | undefined;
720
+ let runEndedEmitted = false;
721
+
722
+ const enforcer = new BudgetEnforcer(spec.budgets, () => activeRun?.usage);
723
+
724
+ // The wall-clock deadline starts BEFORE provisioning (review finding
725
+ // 6): a hung secret capture, browser mint, or SDK lifecycle wait can
726
+ // never stall the journey past budgets.timeout_s. Its AbortSignal is
727
+ // threaded into the scoped HTTP tool's fetches.
728
+ const deadline = startDeadline(
729
+ spec.budgets.timeout_s * 1000,
730
+ resolved.timers,
731
+ () => {
732
+ enforcer.onTimeout();
733
+ // Fire-and-forget cancel: rejection observed so it can never
734
+ // unhandled-reject (the catch path re-cancels with a bound).
735
+ void activeRun?.cancel().catch(() => undefined);
736
+ },
737
+ );
738
+
739
+ const redactSecrets = (message: string): string => {
740
+ let redacted = message;
741
+ // Cursor key (captureSecrets) + the session-derived API token, once
742
+ // available. Session-derived headers are not secrets, so not redacted.
743
+ for (const value of [secrets?.cursorApiKey, sessionToken?.apiToken]) {
744
+ if (value !== undefined && value.length > 0) {
745
+ redacted = redacted.split(value).join("[REDACTED]");
746
+ }
747
+ }
748
+ return redacted;
749
+ };
750
+
751
+ const outcome = (
752
+ usage: RunUsage | undefined,
753
+ durationMs: number | undefined,
754
+ failureKind?: FailureKind,
755
+ errorMessage?: string,
756
+ ): RunOutcome => {
757
+ const result: RunOutcome = {
758
+ runId,
759
+ transcriptPath,
760
+ eventsPath,
761
+ events: events.events,
762
+ snapshotIndex: snapshotStore.index,
763
+ ledger,
764
+ usage: usage ?? enforcer.reportedUsage,
765
+ durationMs: durationMs ?? Date.now() - startedAt,
766
+ };
767
+ if (failureKind !== undefined) {
768
+ result.failure_kind = failureKind;
769
+ }
770
+ if (errorMessage !== undefined) {
771
+ result.error = redactSecrets(errorMessage);
772
+ }
773
+ return result;
774
+ };
775
+
776
+ const scrubbedMessages: string[] = [];
777
+
778
+ try {
779
+ const envConfig = await deadline.race(resolved.loadEnvironment(env));
780
+
781
+ // ── Provisioning (BEFORE sanitization — reads E2E_* env vars in CI) ──
782
+ // Env snapshot BEFORE capture: restore() reinstates from this
783
+ // baseline, so any secret a capture path exports into process.env is
784
+ // deleted at sanitize time and never restored afterwards.
785
+ const envBaseline: NodeJS.ProcessEnv = { ...process.env };
786
+ secrets = await deadline.race(
787
+ resolved.captureSecrets(spec.persona.role, env),
788
+ );
789
+
790
+ // Every journey mints a real browser session and derives its API
791
+ // assertion token from that same session — navigation and deterministic
792
+ // assertions share
793
+ // one real-user credential. The browser TOOLSET is mounted only for
794
+ // browser|mixed; api-surface journeys still need the session to obtain
795
+ // the token, but drive nothing through the UI.
796
+ const authFilePath = await deadline.race(
797
+ resolved.provisionBrowserSession(spec.persona.role, env),
798
+ );
799
+ sessionToken = await deadline.race(
800
+ resolved.deriveApiToken(authFilePath, env, resolved.app.session_token),
801
+ );
802
+ // The session-derived API token is the second live secret — register it
803
+ // with the event sink so no event line can ever carry it (the sink also
804
+ // fails closed if one slips through).
805
+ events.addSecret(sessionToken.apiToken);
806
+ events.addSecret(secrets.cursorApiKey);
807
+ events.emit({
808
+ kind: "run_started",
809
+ journey: spec.name,
810
+ env,
811
+ surface: spec.surface,
812
+ model: modelId,
813
+ });
814
+
815
+ // ── Sanitize env, then build the sandboxed agent ──
816
+ sanitized = resolved.sanitizeEnvironment(envBaseline);
817
+
818
+ const scrubber = new TranscriptScrubber([
819
+ secrets.cursorApiKey,
820
+ sessionToken.apiToken,
821
+ ]);
822
+ const transcriptWriter = new TranscriptWriter(transcriptPath, scrubber);
823
+ const derivedToken = sessionToken;
824
+
825
+ const sandbox = resolved.prepareSandbox(spec.name);
826
+
827
+ const httpTool = resolved.buildHttpTool({
828
+ allowedBaseUrls: allowedBaseUrlsFromEnv(envConfig),
829
+ tokenProvider: async () => derivedToken.apiToken,
830
+ // Session-derived scoping headers (journeys.app.session_token.headers).
831
+ headersProvider: async () => derivedToken.headers,
832
+ ledger,
833
+ // Per-journey method capability derives from the spec surface;
834
+ // DELETE is structurally impossible (harness-only teardown).
835
+ allowedMethods: allowedMethodsForSurface(spec.surface),
836
+ // Mutating grants come from the spec's http_capabilities (review
837
+ // finding R2-1); an absent field means read-only for mutations.
838
+ capabilities: (spec.http_capabilities ?? []).map((grant) => ({
839
+ methods: grant.methods,
840
+ pathPattern: grant.path_pattern,
841
+ })),
842
+ signal: deadline.signal,
843
+ });
844
+
845
+ // The browser toolset is mounted only for surfaces that travel the UI.
846
+ // An api-surface journey holds a session (for the token) but exposes no
847
+ // browser tools to the agent.
848
+ if (spec.surface === "browser" || spec.surface === "mixed") {
849
+ browserToolset = resolved.buildBrowserToolset({
850
+ env: envConfig,
851
+ authFilePath,
852
+ snapshotStore,
853
+ ledger,
854
+ webApi: resolved.app.web_api,
855
+ });
856
+ }
857
+
858
+ agent = await deadline.race(
859
+ resolved.createAgent({
860
+ apiKey: secrets.cursorApiKey,
861
+ model: { id: modelId },
862
+ local: {
863
+ cwd: sandbox.dir,
864
+ // Load the staged .cursor/ config from the cwd so the
865
+ // beforeShellExecution deny hook (+ the custom-tools mcpAllowlist)
866
+ // take effect. settingSources defaults to [] ("inline only"),
867
+ // which would ignore both.
868
+ settingSources: ["project"],
869
+ // Sandbox is DISABLED on purpose. Custom tools are exposed as the
870
+ // `custom-user-tools` MCP server, and the SDK's OS-level sandbox
871
+ // does not run MCP tools inside it: with the sandbox ON, every
872
+ // browser_*/http_request call is blocked outright (verified on two
873
+ // staging runs — "MCP calls rejected by sandbox policy despite
874
+ // permissions allowing custom-user-tools", 0 snapshots). Journeys
875
+ // need the network anyway (browser navigation + API probes), so an
876
+ // OS sandbox is the wrong tool here. The threat model — no arbitrary
877
+ // shell, confine the agent to the mounted tools — is enforced by the
878
+ // fail-closed beforeShellExecution deny hook instead.
879
+ sandboxOptions: { enabled: false },
880
+ customTools: mountToolsForSurface(spec, httpTool, browserToolset),
881
+ },
882
+ }),
883
+ );
884
+
885
+ const run = await deadline.race(
886
+ agent.send(buildPersonaPrompt(spec, runId), {
887
+ onDelta: async ({ update }) => {
888
+ if (update.type !== "turn-ended") return;
889
+ if (enforcer.onTurnEnded(update)) {
890
+ await activeRun?.cancel();
891
+ }
892
+ },
893
+ }),
894
+ );
895
+ activeRun = run;
896
+
897
+ const mapperState = createMapperState();
898
+ await deadline.race(
899
+ (async () => {
900
+ for await (const message of run.stream()) {
901
+ const line = JSON.stringify(message);
902
+ await transcriptWriter.append(message);
903
+ scrubbedMessages.push(line);
904
+ // Interpret the raw SDK message into condensed timeline events
905
+ // (tool calls with results, thinking, assistant text, per-turn
906
+ // usage). transcript.jsonl keeps the raw line; events.jsonl gets
907
+ // the harness's own reading.
908
+ if (isSdkMessage(message)) {
909
+ for (const body of sdkMessageToEvents(message, mapperState)) {
910
+ events.emit(body);
911
+ }
912
+ }
913
+ }
914
+ // The mapper buffers streamed assistant text (one token per SDK
915
+ // frame) and flushes on the next non-text signal; a reply that ends
916
+ // the stream has no following signal, so flush the tail here.
917
+ for (const body of flushAssistantText(mapperState)) {
918
+ events.emit(body);
919
+ }
920
+ })(),
921
+ );
922
+ const result = await deadline.race(run.wait());
923
+
924
+ // Interception feeds are async — drain them so every ledger record
925
+ // lands before the outcome is returned (review finding 7).
926
+ await browserToolset?.drain();
927
+
928
+ // Rendered transcript with snapshot references at action positions.
929
+ fs.mkdirSync(reportDir, { recursive: true });
930
+ const rendered = renderTranscriptMarkdown(
931
+ runId,
932
+ scrubbedMessages,
933
+ snapshotStore.index,
934
+ );
935
+ scrubber.assertClean(rendered);
936
+ fs.writeFileSync(path.join(reportDir, "transcript.md"), rendered);
937
+
938
+ let failureKind: FailureKind | undefined;
939
+ let agentErrorMessage: string | undefined;
940
+ if (result.status === "error") {
941
+ failureKind = "agent_error";
942
+ // Capture the SDK's terminal error detail (previously dropped): the
943
+ // message is what turns "agent_error" from an opaque verdict into an
944
+ // actionable line in the report header + event timeline.
945
+ agentErrorMessage = result.error?.message ?? "agent run failed";
946
+ events.emit({
947
+ kind: "agent_error",
948
+ message: redactSecrets(agentErrorMessage),
949
+ code: result.error?.code,
950
+ });
951
+ } else if (result.status === "cancelled") {
952
+ failureKind = enforcer.violation ?? "budget_exceeded";
953
+ }
954
+
955
+ const finalDurationMs = result.durationMs ?? Date.now() - startedAt;
956
+ events.emit({
957
+ kind: "run_ended",
958
+ failureKind,
959
+ durationMs: finalDurationMs,
960
+ });
961
+ runEndedEmitted = true;
962
+
963
+ return outcome(
964
+ result.usage ?? run.usage,
965
+ result.durationMs,
966
+ failureKind,
967
+ agentErrorMessage,
968
+ );
969
+ } catch (error) {
970
+ // Best-effort, BOUNDED cancel (review finding R2-2): a hung SDK
971
+ // cancel must never stall the outcome past the cleanup cap.
972
+ await settleBounded(activeRun?.cancel(), CLEANUP_TIMEOUT_MS);
973
+ if (error instanceof SecretLeakError) {
974
+ // Layer 4: a live secret was about to be persisted — fail the run
975
+ // (never rethrow: the message is safe but the run is compromised).
976
+ return outcome(activeRun?.usage, undefined, "infra_error");
977
+ }
978
+ if (error instanceof DeadlineExceededError) {
979
+ return outcome(
980
+ activeRun?.usage,
981
+ undefined,
982
+ enforcer.violation ?? "budget_exceeded",
983
+ );
984
+ }
985
+ if (error instanceof CursorAgentError) {
986
+ // Startup/config failure (bad key, invalid model, transport).
987
+ return outcome(undefined, undefined, "infra_error", error.message);
988
+ }
989
+ // Review finding 8: ANY other post-policy failure (stream errors,
990
+ // transcript I/O, SDK surprises) becomes an infra_error outcome that
991
+ // CARRIES the ledger — never a rethrow that would skip the caller's
992
+ // teardown + reporting for already-created resources.
993
+ return outcome(
994
+ activeRun?.usage,
995
+ undefined,
996
+ "infra_error",
997
+ error instanceof Error ? error.message : String(error),
998
+ );
999
+ } finally {
1000
+ deadline.clear();
1001
+ // Ensure the event log always closes with a terminal event, even on the
1002
+ // failure paths above (infra_error / budget / secret leak) that return
1003
+ // before the happy-path run_ended. Best-effort — a sink write failure
1004
+ // must never mask the real outcome.
1005
+ if (!runEndedEmitted) {
1006
+ try {
1007
+ events.emit({
1008
+ kind: "run_ended",
1009
+ durationMs: Date.now() - startedAt,
1010
+ });
1011
+ } catch {
1012
+ // Sink already failed (e.g. EventLeakError); the outcome stands.
1013
+ }
1014
+ }
1015
+ if (browserToolset !== undefined) {
1016
+ // Best-effort drain on failure paths too (drain is internally
1017
+ // bounded — review finding R2-2), then a bounded dispose: a hung
1018
+ // browser close must never stall the outcome.
1019
+ await browserToolset.drain().catch(() => undefined);
1020
+ await settleBounded(browserToolset.dispose(), CLEANUP_TIMEOUT_MS);
1021
+ }
1022
+ agent?.close();
1023
+ sanitized?.restore();
1024
+ }
1025
+ }