Skip to content

Commit fe3b781

Browse files
authored
Bound retained recipe command evidence (#1947)
* Bound retained recipe command evidence * Preserve later command evidence under bounds
1 parent 3605347 commit fe3b781

10 files changed

Lines changed: 457 additions & 14 deletions

File tree

.github/workflows/agent-task-contracts.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ jobs:
9696
- run: npm run test:native-agent-task-playground-e2e
9797
- run: npm run test:native-agent-task-interruption
9898
- run: npm run test:trusted-apply-artifact-channel
99+
- run: npm run test:runtime-command-artifact-bounds
99100
- run: npm run test:redaction
100101
- run: npm run test:production-boundary-enforcement
101102
- run: npm run test:runtime-tool-policy

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@
154154
"test:workspace-preload-artifacts": "tsx tests/workspace-preload-artifacts.test.ts",
155155
"test:runner-workspace-apply": "tsx tests/runner-workspace-apply.test.ts",
156156
"test:trusted-apply-artifact-channel": "tsx tests/trusted-apply-artifact-channel.integration.test.ts",
157+
"test:runtime-command-artifact-bounds": "tsx tests/runtime-command-artifact-bounds.test.ts",
157158
"test:runner-workspace-publisher": "node tests/runner-workspace-publisher.test.mjs",
158159
"test:native-agent-task-lifecycle": "node tests/execute-native-agent-task-lifecycle.test.mjs",
159160
"test:native-agent-task-interruption": "node tests/execute-native-agent-task-interruption.test.mjs",

packages/cli/src/commands/recipe-run-output.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { setTimeout as delay } from "node:timers/promises"
2-
import type { RecipeRunSummary, RuntimeRunRecord } from "@automattic/wp-codebox-core"
2+
import type { ExecutionResult, RecipeRunSummary, RuntimeRunRecord } from "@automattic/wp-codebox-core"
3+
import { boundedExecutionResultsForArtifacts } from "@automattic/wp-codebox-core/internals"
34
import { serializeError } from "../output.js"
45
import type { RunOutput } from "../runtime-command-wrappers.js"
56
import { RecipePhaseError } from "./recipe-run-phases.js"
@@ -95,7 +96,14 @@ function hasTerminalRecipePhaseFailure(output: RecipeRunOutput): boolean {
9596
}
9697

9798
export async function writeRecipeJsonOutput(output: unknown): Promise<void> {
98-
await writeStdout(`${JSON.stringify(output, null, 2)}\n`)
99+
await writeStdout(`${JSON.stringify(boundedRecipeJsonOutput(output), null, 2)}\n`)
100+
}
101+
102+
export function boundedRecipeJsonOutput(output: unknown): unknown {
103+
if (!output || typeof output !== "object" || Array.isArray(output)) return output
104+
const record = output as Record<string, unknown>
105+
if (!Array.isArray(record.executions)) return output
106+
return { ...record, executions: boundedExecutionResultsForArtifacts(record.executions as ExecutionResult[]) }
99107
}
100108

101109
export async function writeRecipeSummaryHumanOutput(summary: RecipeRunSummary): Promise<void> {

packages/cli/src/commands/recipe-run.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
327327
await awaitRecipe("runtime.observe:runtime-info", runtime!.observe({ type: "runtime-info" }))
328328
await awaitRecipe("runtime.observe:mounts", runtime!.observe({ type: "mounts" }))
329329
runRecord = await runRegistry.update(runRecord.runId, { status: "collecting_artifacts", runtime: await runtime!.info() })
330-
artifacts = await awaitRecipe("runtime.collect-artifacts", collectRecipeRuntimeArtifacts(runtime!, { includeLogs: true, includeObservations: true }, { snapshotTimeoutMs: SUCCESSFUL_RECIPE_RUNTIME_SNAPSHOT_TIMEOUT_MS }))
330+
artifacts = await awaitRecipe("runtime.collect-artifacts", collectRecipeRuntimeArtifacts(runtime!, { includeLogs: true, includeObservations: true }, { snapshotTimeoutMs: SUCCESSFUL_RECIPE_RUNTIME_SNAPSHOT_TIMEOUT_MS, activeExecution: executions.at(-1) }))
331331
browserEvidence = await recipeBrowserEvidence(artifacts, executions, recipe)
332332
await artifactPointer.update({ runtime: await runtime!.info(), artifacts, phases: phaseTracker.list(), browserEvidence })
333333
await materializeTypedRecipeDeclaredArtifacts(artifacts, declaredArtifacts)
@@ -350,7 +350,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
350350
const recipeFailure = strictFailure ?? agentFailure ?? verifyFailure
351351
const successfulRecipe = !recipeFailure
352352
if (successfulRecipe && options.previewHoldSeconds) {
353-
artifacts = await awaitRecipe("runtime.collect-artifacts.preview-hold", collectRecipeRuntimeArtifacts(runtime, { includeLogs: true, includeObservations: true, previewHoldSeconds: options.previewHoldSeconds }, { snapshotTimeoutMs: SUCCESSFUL_RECIPE_RUNTIME_SNAPSHOT_TIMEOUT_MS }))
353+
artifacts = await awaitRecipe("runtime.collect-artifacts.preview-hold", collectRecipeRuntimeArtifacts(runtime, { includeLogs: true, includeObservations: true, previewHoldSeconds: options.previewHoldSeconds }, { snapshotTimeoutMs: SUCCESSFUL_RECIPE_RUNTIME_SNAPSHOT_TIMEOUT_MS, activeExecution: executions.at(-1) }))
354354
browserEvidence = await recipeBrowserEvidence(artifacts, executions, recipe)
355355
await artifactPointer.update({ runtime: await runtime.info(), artifacts, phases: phaseTracker.list(), browserEvidence })
356356
declaredArtifacts = await collectRecipeDeclaredArtifacts(recipe, runtime)

packages/cli/src/recipe-evidence.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,31 @@ export interface RecipeArtifactsFinalizationController {
301301
interface RecipeRuntimeArtifactCollectionOptions {
302302
timeoutMs?: number
303303
snapshotTimeoutMs?: number
304+
activeExecution?: RecipeEvidenceExecutionResult
305+
}
306+
307+
export class RecipeRuntimeArtifactCollectionError extends Error {
308+
readonly code = "recipe-artifact-collection-failed"
309+
readonly operation = "runtime.collect-artifacts"
310+
readonly subsystem = "artifact-bundle"
311+
readonly workflowStepIndex?: number
312+
readonly command?: string
313+
readonly artifactPath?: string
314+
readonly payload?: unknown
315+
readonly limits?: unknown
316+
readonly causeStack?: string
317+
318+
constructor(activeExecution: RecipeEvidenceExecutionResult | undefined, cause: Error) {
319+
const causeRecord = cause as Error & Record<string, unknown>
320+
super(`Recipe artifact collection failed${activeExecution?.recipeStepIndex === undefined ? "" : ` at workflow step ${activeExecution.recipeStepIndex}`}: ${cause.message}`, { cause })
321+
this.name = "RecipeRuntimeArtifactCollectionError"
322+
this.workflowStepIndex = activeExecution?.recipeStepIndex
323+
this.command = activeExecution?.recipeCommand ?? activeExecution?.command
324+
this.artifactPath = typeof causeRecord.artifactPath === "string" ? causeRecord.artifactPath : undefined
325+
this.payload = causeRecord.payload
326+
this.limits = causeRecord.limits
327+
this.causeStack = typeof causeRecord.causeStack === "string" ? causeRecord.causeStack : cause.stack?.slice(0, 16 * 1024)
328+
}
304329
}
305330

306331
const execFileAsync = promisify(execFile)
@@ -322,7 +347,7 @@ export async function collectAndFinalizeFailedRecipeArtifacts(args: {
322347

323348
if (!artifacts) {
324349
try {
325-
artifacts = await collectRecipeRuntimeArtifacts(args.runtime, { includeLogs: true, includeObservations: true }, { snapshotTimeoutMs: 20_000, timeoutMs: 30_000 })
350+
artifacts = await collectRecipeRuntimeArtifacts(args.runtime, { includeLogs: true, includeObservations: true }, { snapshotTimeoutMs: 20_000, timeoutMs: 30_000, activeExecution: args.executions.at(-1) })
326351
} catch {
327352
return undefined
328353
}
@@ -354,11 +379,19 @@ export async function collectRecipeRuntimeArtifacts(runtime: Runtime, spec: Arti
354379
}
355380
}
356381

357-
const artifacts = runtime.collectArtifacts(spec)
358-
if (options.timeoutMs && options.timeoutMs > 0) {
359-
return timeoutOrReject(artifacts, options.timeoutMs, `Runtime artifact collection exceeded ${options.timeoutMs}ms`)
382+
try {
383+
const artifacts = runtime.collectArtifacts(spec)
384+
if (options.timeoutMs && options.timeoutMs > 0) {
385+
return await timeoutOrReject(artifacts, options.timeoutMs, `Runtime artifact collection exceeded ${options.timeoutMs}ms`)
386+
}
387+
return await artifacts
388+
} catch (error) {
389+
const candidate = error instanceof Error ? error as Error & Record<string, unknown> : undefined
390+
if (candidate?.code === "command-artifact-collection-failed" || error instanceof RangeError) {
391+
throw new RecipeRuntimeArtifactCollectionError(options.activeExecution, candidate ?? Object.assign(new Error(String(error)), { code: "runtime-allocation-failed" }))
392+
}
393+
throw error
360394
}
361-
return artifacts
362395
}
363396

364397
async function timeoutOrUndefined<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import type { ExecutionResult } from "./runtime-contracts.js"
2+
import { normalizeJsonValue } from "./object-utils.js"
3+
4+
export const COMMAND_ARTIFACT_STRING_MAX_BYTES = 1024 * 1024
5+
export const COMMAND_ARTIFACT_COMMAND_STRING_MAX_BYTES = 2 * 1024 * 1024
6+
export const COMMAND_ARTIFACT_TOTAL_STRING_MAX_BYTES = 16 * 1024 * 1024
7+
export const COMMAND_ARTIFACT_MAX_NODES = 25_000
8+
export const COMMAND_ARTIFACT_MAX_RECORDS = 1_000
9+
const COMMAND_ARTIFACT_MAX_TRUNCATIONS = 1_000
10+
const COMMAND_ARTIFACT_KEY_MAX_BYTES = 256
11+
const COMMAND_ARTIFACT_IDENTITY_MAX_BYTES = 1024
12+
const COMMAND_ARTIFACT_PATH_MAX_BYTES = 4096
13+
const EXECUTION_RESULT_KEYS = new Set(["id", "command", "args", "exitCode", "stdout", "stderr", "result", "diagnostics", "artifactRefs", "startedAt", "finishedAt", "artifactCapture"])
14+
const EXECUTION_IDENTITY_EXTENSION_KEYS = new Set(["recipePhase", "recipeCommand", "fuzzCaseId", "fuzzPhase"])
15+
16+
export interface CommandArtifactTruncation {
17+
path: string
18+
reason: "string-byte-limit" | "command-string-byte-limit" | "total-string-byte-limit" | "node-limit"
19+
observedBytes?: number
20+
capturedBytes?: number
21+
configuredLimitBytes?: number
22+
}
23+
24+
export interface CommandArtifactCapture {
25+
schema: "wp-codebox/command-artifact-capture/v1"
26+
truncated: true
27+
limits: {
28+
capturedStringBytesPerValue: number
29+
capturedStringBytesPerCommand: number
30+
capturedStringBytesTotal: number
31+
nodes: number
32+
records: number
33+
}
34+
fields: CommandArtifactTruncation[]
35+
omittedFieldCount?: number
36+
omittedCommandCount?: number
37+
}
38+
39+
export type BoundedExecutionResult<T extends ExecutionResult = ExecutionResult> = T & { artifactCapture?: CommandArtifactCapture }
40+
41+
export function boundedExecutionResultsForArtifacts<T extends ExecutionResult>(commands: T[]): Array<BoundedExecutionResult<T>> {
42+
const budget = {
43+
remainingBytes: COMMAND_ARTIFACT_TOTAL_STRING_MAX_BYTES,
44+
remainingCommandBytes: COMMAND_ARTIFACT_COMMAND_STRING_MAX_BYTES,
45+
remainingNodes: COMMAND_ARTIFACT_MAX_NODES,
46+
remainingTruncations: COMMAND_ARTIFACT_MAX_TRUNCATIONS,
47+
}
48+
const selectedCommands = commands.length <= COMMAND_ARTIFACT_MAX_RECORDS
49+
? commands
50+
: [...commands.slice(0, COMMAND_ARTIFACT_MAX_RECORDS - 1), commands.at(-1)!]
51+
52+
return selectedCommands.map((command, selectedIndex) => {
53+
budget.remainingCommandBytes = COMMAND_ARTIFACT_COMMAND_STRING_MAX_BYTES
54+
const fields: CommandArtifactTruncation[] = []
55+
let omittedFieldCount = 0
56+
const capture = (value: unknown, path: string): unknown => boundedArtifactValue(value, path, budget, (field) => {
57+
if (fields.length < 100 && budget.remainingTruncations > 0) {
58+
fields.push(field)
59+
budget.remainingTruncations -= 1
60+
} else {
61+
omittedFieldCount += 1
62+
}
63+
})
64+
const extensions = Object.fromEntries(
65+
Object.entries(command)
66+
.filter(([key]) => !EXECUTION_RESULT_KEYS.has(key))
67+
.map(([key, value]) => [key, typeof value === "string" && EXECUTION_IDENTITY_EXTENSION_KEYS.has(key) ? boundedIdentityString(value) : capture(normalizeJsonValue(value), key)]),
68+
)
69+
const projected = {
70+
...extensions,
71+
id: boundedIdentityString(command.id),
72+
command: boundedIdentityString(command.command),
73+
args: capture(normalizeJsonValue(command.args), "args") as string[],
74+
exitCode: command.exitCode,
75+
stdout: capture(command.stdout, "stdout") as string,
76+
stderr: capture(command.stderr, "stderr") as string,
77+
...(command.result === undefined ? {} : { result: capture(normalizeJsonValue(command.result), "result") as ExecutionResult["result"] }),
78+
...(command.diagnostics === undefined ? {} : { diagnostics: capture(normalizeJsonValue(command.diagnostics), "diagnostics") }),
79+
...(command.artifactRefs === undefined ? {} : { artifactRefs: capture(normalizeJsonValue(command.artifactRefs), "artifactRefs") as ExecutionResult["artifactRefs"] }),
80+
startedAt: boundedIdentityString(command.startedAt),
81+
finishedAt: boundedIdentityString(command.finishedAt),
82+
} as BoundedExecutionResult<T>
83+
const omittedCommandCount = commands.length - selectedCommands.length
84+
if (fields.length > 0 || omittedFieldCount > 0 || (omittedCommandCount > 0 && selectedIndex === selectedCommands.length - 1)) {
85+
projected.artifactCapture = {
86+
schema: "wp-codebox/command-artifact-capture/v1",
87+
truncated: true,
88+
limits: {
89+
capturedStringBytesPerValue: COMMAND_ARTIFACT_STRING_MAX_BYTES,
90+
capturedStringBytesPerCommand: COMMAND_ARTIFACT_COMMAND_STRING_MAX_BYTES,
91+
capturedStringBytesTotal: COMMAND_ARTIFACT_TOTAL_STRING_MAX_BYTES,
92+
nodes: COMMAND_ARTIFACT_MAX_NODES,
93+
records: COMMAND_ARTIFACT_MAX_RECORDS,
94+
},
95+
fields,
96+
...(omittedFieldCount > 0 ? { omittedFieldCount } : {}),
97+
...(omittedCommandCount > 0 && selectedIndex === selectedCommands.length - 1 ? { omittedCommandCount } : {}),
98+
}
99+
}
100+
return projected
101+
})
102+
}
103+
104+
function boundedArtifactValue(
105+
value: unknown,
106+
path: string,
107+
budget: { remainingBytes: number; remainingCommandBytes: number; remainingNodes: number; remainingTruncations: number },
108+
recordTruncation: (field: CommandArtifactTruncation) => void,
109+
): unknown {
110+
if (value === null || value === undefined || (typeof value !== "string" && typeof value !== "object")) {
111+
return value
112+
}
113+
if (budget.remainingNodes <= 0) {
114+
recordTruncation({ path: truncateUtf8(path, COMMAND_ARTIFACT_PATH_MAX_BYTES), reason: "node-limit" })
115+
if (typeof value === "string") return ""
116+
return Array.isArray(value) ? [] : {}
117+
}
118+
budget.remainingNodes -= 1
119+
if (typeof value === "string") {
120+
const observedBytes = Buffer.byteLength(value, "utf8")
121+
const totalBudgetLimited = budget.remainingBytes < Math.min(observedBytes, COMMAND_ARTIFACT_STRING_MAX_BYTES)
122+
const commandBudgetLimited = budget.remainingCommandBytes < Math.min(observedBytes, COMMAND_ARTIFACT_STRING_MAX_BYTES)
123+
const configuredLimitBytes = Math.min(COMMAND_ARTIFACT_STRING_MAX_BYTES, budget.remainingCommandBytes, budget.remainingBytes)
124+
const captured = truncateUtf8(value, configuredLimitBytes)
125+
const capturedBytes = Buffer.byteLength(captured, "utf8")
126+
budget.remainingBytes -= capturedBytes
127+
budget.remainingCommandBytes -= capturedBytes
128+
if (capturedBytes < observedBytes) {
129+
recordTruncation({
130+
path: truncateUtf8(path, COMMAND_ARTIFACT_PATH_MAX_BYTES),
131+
reason: totalBudgetLimited ? "total-string-byte-limit" : commandBudgetLimited ? "command-string-byte-limit" : "string-byte-limit",
132+
observedBytes,
133+
capturedBytes,
134+
configuredLimitBytes,
135+
})
136+
}
137+
return captured
138+
}
139+
if (Array.isArray(value)) {
140+
return value.map((item, index) => boundedArtifactValue(item, `${path}[${index}]`, budget, recordTruncation))
141+
}
142+
if (value && typeof value === "object") {
143+
return Object.fromEntries(Object.entries(value).map(([key, item]) => {
144+
const capturedKey = truncateUtf8(key, COMMAND_ARTIFACT_KEY_MAX_BYTES)
145+
return [capturedKey, boundedArtifactValue(item, `${path}.${capturedKey}`, budget, recordTruncation)]
146+
}))
147+
}
148+
return value
149+
}
150+
151+
function boundedIdentityString(value: string): string {
152+
return truncateUtf8(value, COMMAND_ARTIFACT_IDENTITY_MAX_BYTES)
153+
}
154+
155+
export function truncateUtf8(value: string, maxBytes: number): string {
156+
if (maxBytes <= 0) return ""
157+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value
158+
159+
let low = 0
160+
let high = Math.min(value.length, maxBytes)
161+
while (low < high) {
162+
const middle = Math.ceil((low + high) / 2)
163+
if (Buffer.byteLength(value.slice(0, middle), "utf8") <= maxBytes) low = middle
164+
else high = middle - 1
165+
}
166+
const captured = value.slice(0, low)
167+
return /[\uD800-\uDBFF]$/.test(captured) ? captured.slice(0, -1) : captured
168+
}

packages/runtime-core/src/internals.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* subpath instead.
55
*/
66
export * from "./benchmark-substrate.js"
7+
export * from "./bounded-execution-results.js"
78
export * from "./fanout-aggregation.js"
89
export * from "./fanout-execution.js"
910
export * from "./object-utils.js"

0 commit comments

Comments
 (0)