@monster-orange/den 0.0.0 → 0.2.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/CHANGELOG.md +204 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +68 -0
- package/docs/distributions.md +499 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +101 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +403 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +124 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +757 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +688 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +635 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +108 -0
- package/src/shared/environment-manager/log-tail.ts +213 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +335 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1603 -0
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +326 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/placement.ts +321 -0
- package/src/shared/manifest/read-manifest.ts +278 -0
- package/src/shared/manifest/schema.ts +313 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +200 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +190 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +118 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +701 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +148 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +81 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +570 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3742 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +117 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1319 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/mcp/api.ts +284 -0
- package/src/ui/mcp/guards.ts +141 -0
- package/src/ui/mcp/server.ts +147 -0
- package/src/ui/mcp/tools.ts +565 -0
- package/src/ui/server/audit.ts +71 -0
- package/src/ui/server/changelog-parser.ts +105 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3350 -0
- package/src/ui/server/log-history.ts +453 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +496 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +976 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +1373 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +5344 -0
- package/src/ui/server/public/logs.html +1496 -0
- package/src/ui/server/public/panel.js +787 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1103 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/readonly-routes.ts +200 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +2185 -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
|
+
: ``;
|
|
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
|
+
}
|