From 99c00c7b2051984e5659d5a1dd5cd0196a78fb01 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 16 Jul 2026 20:58:49 -0400 Subject: [PATCH] Add portable workspace delta contract --- .../src/headless-agent-task-contracts.ts | 9 +- packages/runtime-core/src/index.ts | 1 + packages/runtime-core/src/public.ts | 1 + packages/runtime-core/src/workspace-delta.ts | 134 ++++++++++++++++++ tests/agent-task-contracts.test.ts | 56 +++++++- 5 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 packages/runtime-core/src/workspace-delta.ts diff --git a/packages/runtime-core/src/headless-agent-task-contracts.ts b/packages/runtime-core/src/headless-agent-task-contracts.ts index a71130759..52e2b79bd 100644 --- a/packages/runtime-core/src/headless-agent-task-contracts.ts +++ b/packages/runtime-core/src/headless-agent-task-contracts.ts @@ -2,6 +2,7 @@ import { AGENT_TASK_RUN_RESULT_SCHEMA, type AgentTaskRunResultSummary } from "./ import { RUNTIME_PROFILE_SCHEMA, normalizeRuntimeAccess, normalizeRuntimeProfile, type RuntimeAccess, type RuntimeProfile } from "./runtime-boundary-contracts.js" import { TASK_INPUT_JSON_SCHEMA, normalizeTaskInput, type TaskInput, type TaskInputRequest } from "./task-input.js" import { isPlainObject, objectValue, stringList, stringValue, stripUndefined } from "./object-utils.js" +import { WORKSPACE_DELTA_JSON_SCHEMA, workspaceDeltaFromAgentTaskRunResult, type WorkspaceDelta } from "./workspace-delta.js" export const HEADLESS_AGENT_TASK_REQUEST_SCHEMA = "wp-codebox/headless-agent-task-request/v1" as const export const HEADLESS_AGENT_TASK_RESULT_SCHEMA = "wp-codebox/headless-agent-task-result/v1" as const @@ -34,6 +35,7 @@ export interface HeadlessAgentTaskResult { refs: AgentTaskRunResultSummary["refs"] artifacts: AgentTaskRunResultSummary["artifacts"] evidence_refs: AgentTaskRunResultSummary["refs"]["evidence_bundles"] + workspace_delta: WorkspaceDelta diagnostics: Array> metadata: Record agent_task_run_result: AgentTaskRunResultSummary @@ -69,7 +71,7 @@ export const HEADLESS_AGENT_TASK_REQUEST_JSON_SCHEMA = { export const HEADLESS_AGENT_TASK_RESULT_JSON_SCHEMA = { $id: HEADLESS_AGENT_TASK_RESULT_SCHEMA, type: "object", - required: ["schema", "success", "status", "summary", "refs", "artifacts", "evidence_refs", "diagnostics", "metadata", "agent_task_run_result"], + required: ["schema", "success", "status", "summary", "refs", "artifacts", "evidence_refs", "workspace_delta", "diagnostics", "metadata", "agent_task_run_result"], additionalProperties: false, properties: { schema: { type: "string", const: HEADLESS_AGENT_TASK_RESULT_SCHEMA }, @@ -80,6 +82,7 @@ export const HEADLESS_AGENT_TASK_RESULT_JSON_SCHEMA = { refs: { type: "object" }, artifacts: { type: "array", items: { type: "object" } }, evidence_refs: { type: "array", items: { type: "object" } }, + workspace_delta: WORKSPACE_DELTA_JSON_SCHEMA, diagnostics: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, agent_task_run_result: { type: "object", properties: { schema: { type: "string", const: AGENT_TASK_RUN_RESULT_SCHEMA } } }, @@ -124,6 +127,7 @@ export function headlessAgentTaskRequestToRunInput(request: HeadlessAgentTaskReq } export function normalizeHeadlessAgentTaskResult(result: AgentTaskRunResultSummary, metadata: Record = {}): HeadlessAgentTaskResult { + const workspaceDelta = workspaceDeltaFromAgentTaskRunResult(result) return { schema: HEADLESS_AGENT_TASK_RESULT_SCHEMA, success: result.success, @@ -133,7 +137,8 @@ export function normalizeHeadlessAgentTaskResult(result: AgentTaskRunResultSumma refs: result.refs, artifacts: result.artifacts, evidence_refs: result.refs.evidence_bundles, - diagnostics: result.diagnostics, + workspace_delta: workspaceDelta, + diagnostics: result.diagnostics.concat(workspaceDelta.diagnostics), metadata: stripUndefined({ ...metadata, ...result.metadata }), agent_task_run_result: result, } diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 3e08009ce..2b5b65dfb 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -37,6 +37,7 @@ export * from "./structured-artifacts.js" export * from "./tool-call-artifacts.js" export * from "./agent-task-run-result.js" export * from "./headless-agent-task-contracts.js" +export * from "./workspace-delta.js" export * from "./status-taxonomy.js" export * from "./agent-terminal-result.js" export * from "./agent-runtime-workload.js" diff --git a/packages/runtime-core/src/public.ts b/packages/runtime-core/src/public.ts index 1eb626de7..1ea56e2aa 100644 --- a/packages/runtime-core/src/public.ts +++ b/packages/runtime-core/src/public.ts @@ -81,6 +81,7 @@ export * from "./task-input.js" export * from "./tool-call-artifacts.js" export * from "./transfer-proof.js" export * from "./workspace-policy.js" +export * from "./workspace-delta.js" export * from "./workspace-preload-artifacts.js" export * from "./wordpress-crud-contracts.js" export * from "./wordpress-block-exercise-contracts.js" diff --git a/packages/runtime-core/src/workspace-delta.ts b/packages/runtime-core/src/workspace-delta.ts new file mode 100644 index 000000000..1c53b17b2 --- /dev/null +++ b/packages/runtime-core/src/workspace-delta.ts @@ -0,0 +1,134 @@ +import { safeArtifactRelativePath } from "./artifact-paths.js" +import type { AgentTaskRunArtifactRef, AgentTaskRunResultSummary } from "./agent-task-run-result.js" +import { stripUndefined } from "./object-utils.js" + +export const WORKSPACE_DELTA_SCHEMA = "wp-codebox/workspace-delta/v1" as const + +export interface WorkspaceDeltaArtifactRef { + kind: "codebox-changed-files" | "codebox-patch" + path: string + sha256?: string + size_bytes?: number +} + +export interface WorkspaceDeltaDiagnostic extends Record { + code: "workspace_delta.artifact_not_portable" | "workspace_delta.incomplete" + message: string +} + +export interface WorkspaceDelta { + schema: typeof WORKSPACE_DELTA_SCHEMA + status: "changed" | "no_op" | "unavailable" + changed_files_count?: number + patch_bytes?: number + changed_files?: WorkspaceDeltaArtifactRef + patch?: WorkspaceDeltaArtifactRef + diagnostics: WorkspaceDeltaDiagnostic[] +} + +export const WORKSPACE_DELTA_JSON_SCHEMA = { + $id: WORKSPACE_DELTA_SCHEMA, + type: "object", + required: ["schema", "status", "diagnostics"], + additionalProperties: false, + properties: { + schema: { const: WORKSPACE_DELTA_SCHEMA }, + status: { enum: ["changed", "no_op", "unavailable"] }, + changed_files_count: { type: "integer", minimum: 0 }, + patch_bytes: { type: "integer", minimum: 0 }, + changed_files: workspaceDeltaArtifactJsonSchema("codebox-changed-files"), + patch: workspaceDeltaArtifactJsonSchema("codebox-patch"), + diagnostics: { + type: "array", + items: { + type: "object", + required: ["code", "message"], + additionalProperties: false, + properties: { + code: { enum: ["workspace_delta.artifact_not_portable", "workspace_delta.incomplete"] }, + message: { type: "string" }, + }, + }, + }, + }, +} as const + +/** + * Produces the Codebox-owned change handoff. Consumers apply it; Codebox never + * exposes the sandbox's host paths as part of that handoff. + */ +export function workspaceDeltaFromAgentTaskRunResult(result: AgentTaskRunResultSummary): WorkspaceDelta { + const changedFiles = portableWorkspaceArtifact(result.refs.changed_files[0], "codebox-changed-files") + const patch = portableWorkspaceArtifact(result.refs.patches[0], "codebox-patch") + const diagnostics: WorkspaceDeltaDiagnostic[] = [] + + for (const artifact of [changedFiles, patch]) { + if (artifact.diagnostic) diagnostics.push(artifact.diagnostic) + } + + if (result.no_op.detected) { + return { schema: WORKSPACE_DELTA_SCHEMA, status: "no_op", changed_files_count: 0, patch_bytes: 0, diagnostics } + } + + if (!changedFiles.ref || !patch.ref) { + diagnostics.push({ + code: "workspace_delta.incomplete", + message: "Workspace delta requires portable changed-files and patch artifacts.", + }) + return { schema: WORKSPACE_DELTA_SCHEMA, status: "unavailable", diagnostics } + } + + return stripUndefined({ + schema: WORKSPACE_DELTA_SCHEMA, + status: "changed" as const, + changed_files_count: nonNegativeInteger(result.no_op.changed_files_count), + patch_bytes: nonNegativeInteger(result.no_op.patch_bytes), + changed_files: changedFiles.ref, + patch: patch.ref, + diagnostics, + }) as WorkspaceDelta +} + +function portableWorkspaceArtifact(artifact: AgentTaskRunArtifactRef | undefined, kind: WorkspaceDeltaArtifactRef["kind"]): { ref?: WorkspaceDeltaArtifactRef; diagnostic?: WorkspaceDeltaDiagnostic } { + if (!artifact?.path) return {} + + try { + const path = artifact.path.trim().replace(/\\/g, "/") + if (path.startsWith("/") || /^[A-Za-z]:($|\/)/.test(path)) { + throw new Error("Workspace delta artifact paths must be relative.") + } + return { + ref: stripUndefined({ + kind, + path: safeArtifactRelativePath(path), + sha256: artifact.sha256, + size_bytes: nonNegativeInteger(artifact.size_bytes), + }) as WorkspaceDeltaArtifactRef, + } + } catch { + return { + diagnostic: { + code: "workspace_delta.artifact_not_portable", + message: `Codebox emitted a non-portable ${kind} artifact reference.`, + }, + } + } +} + +function workspaceDeltaArtifactJsonSchema(kind: WorkspaceDeltaArtifactRef["kind"]) { + return { + type: "object", + required: ["kind", "path"], + additionalProperties: false, + properties: { + kind: { const: kind }, + path: { type: "string", minLength: 1 }, + sha256: { type: "string", minLength: 1 }, + size_bytes: { type: "integer", minimum: 0 }, + }, + } +} + +function nonNegativeInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined +} diff --git a/tests/agent-task-contracts.test.ts b/tests/agent-task-contracts.test.ts index 9744bac85..657885c71 100644 --- a/tests/agent-task-contracts.test.ts +++ b/tests/agent-task-contracts.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } import { tmpdir } from "node:os" import { join } from "node:path" import { chdir, cwd } from "node:process" -import { AGENT_TASK_RUN_REQUEST_SCHEMA, AGENT_TASK_RUN_RESULT_JSON_SCHEMA, AGENT_TASK_RUN_RESULT_SCHEMA, ARTIFACT_RESULT_ENVELOPE_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_JSON_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_SCHEMA, HEADLESS_AGENT_TASK_RESULT_JSON_SCHEMA, HEADLESS_AGENT_TASK_RESULT_SCHEMA, PREVIEW_LEASE_SCHEMA, TYPED_ARTIFACT_SCHEMA, buildAgentTaskRecipe, headlessAgentTaskRequestToRunInput, normalizeAgentRuntimeExecutionChanges, normalizeAgentRuntimeWorkload, normalizeAgentTaskRunResult, normalizeAgentTerminalResult, normalizeArtifactResultTypedArtifacts, normalizeHeadlessAgentTaskRequest, normalizeHeadlessAgentTaskResult, normalizeRecipeRunSummary, normalizeTaskInput } from "../packages/runtime-core/src/index.js" +import { AGENT_TASK_RUN_REQUEST_SCHEMA, AGENT_TASK_RUN_RESULT_JSON_SCHEMA, AGENT_TASK_RUN_RESULT_SCHEMA, ARTIFACT_RESULT_ENVELOPE_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_JSON_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_SCHEMA, HEADLESS_AGENT_TASK_RESULT_JSON_SCHEMA, HEADLESS_AGENT_TASK_RESULT_SCHEMA, PREVIEW_LEASE_SCHEMA, TYPED_ARTIFACT_SCHEMA, WORKSPACE_DELTA_JSON_SCHEMA, WORKSPACE_DELTA_SCHEMA, buildAgentTaskRecipe, headlessAgentTaskRequestToRunInput, normalizeAgentRuntimeExecutionChanges, normalizeAgentRuntimeWorkload, normalizeAgentTaskRunResult, normalizeAgentTerminalResult, normalizeArtifactResultTypedArtifacts, normalizeHeadlessAgentTaskRequest, normalizeHeadlessAgentTaskResult, normalizeRecipeRunSummary, normalizeTaskInput, workspaceDeltaFromAgentTaskRunResult } from "../packages/runtime-core/src/index.js" import { effectivePolicyCommands } from "../packages/runtime-core/src/contracts.js" import { commandCatalogOutput } from "../packages/cli/src/commands/discovery.js" import { agentTaskResultFromRun, agentTaskRunExitCode, agentTaskRunJsonOutput, normalizeAgentTaskRunCliInput, writeAgentTaskRunResultFile } from "../packages/cli/src/commands/agent-task-run.js" @@ -222,6 +222,60 @@ assert.equal(headlessResult.preview?.public_url, "https://preview.example.test/" assert.equal(headlessResult.evidence_refs[0].kind, "codebox-evidence-bundle") assert.equal(headlessResult.agent_task_run_result.schema, AGENT_TASK_RUN_RESULT_SCHEMA) +const workspaceDeltaResult = normalizeAgentTaskRunResult({ + success: true, + artifacts: [ + { kind: "codebox-changed-files", path: "files/changed-files.json", bytes: 128, sha256: "changed" }, + { kind: "codebox-patch", path: "files/patch.diff", bytes: 256, sha256: "patch" }, + ], + agent_result: { + changedFiles: { artifact: "files/changed-files.json", bytes: 128, count: 1, sha256: "changed" }, + patch: { artifact: "files/patch.diff", bytes: 256, sha256: "patch" }, + }, +}) +const workspaceDelta = workspaceDeltaFromAgentTaskRunResult(workspaceDeltaResult) +assert.equal(workspaceDelta.schema, WORKSPACE_DELTA_SCHEMA) +assert.equal(workspaceDelta.status, "changed") +assert.equal(workspaceDelta.changed_files?.path, "files/changed-files.json") +assert.equal(workspaceDelta.patch?.path, "files/patch.diff") +assert.equal(normalizeHeadlessAgentTaskResult(workspaceDeltaResult).workspace_delta.status, "changed") +assert.equal(WORKSPACE_DELTA_JSON_SCHEMA.additionalProperties, false) +assert.equal(WORKSPACE_DELTA_JSON_SCHEMA.properties.changed_files.required.includes("path"), true) + +assert.equal(workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({ success: true, no_op: true })).status, "no_op") + +const hostPathDelta = workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({ + success: true, + artifacts: [ + { kind: "codebox-changed-files", path: "/private/codebox/files/changed-files.json" }, + { kind: "codebox-patch", path: "/private/codebox/files/patch.diff" }, + ], +})) +assert.equal(hostPathDelta.status, "unavailable") +assert.equal(hostPathDelta.diagnostics.filter((diagnostic) => diagnostic.code === "workspace_delta.artifact_not_portable").length, 2) + +for (const path of ["\\private\\codebox\\files\\changed-files.json", "\\\\host\\share\\files\\changed-files.json", "C:\\codebox\\files\\changed-files.json", "files/../changed-files.json"]) { + const malformedDelta = workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({ + success: true, + artifacts: [ + { kind: "codebox-changed-files", path }, + { kind: "codebox-patch", path: "files/patch.diff" }, + ], + })) + assert.equal(malformedDelta.status, "unavailable") + assert.equal(malformedDelta.changed_files, undefined) + assert.equal(JSON.stringify(malformedDelta).includes("private/codebox"), false, "workspace delta never leaks a host artifact path") +} + +const inferredFilenameDelta = workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({ + success: true, + artifacts: [ + { kind: "artifact", path: "files/changed-files.json" }, + { kind: "artifact", path: "files/patch.diff" }, + ], +})) +assert.equal(inferredFilenameDelta.status, "unavailable", "workspace delta only accepts explicitly typed artifacts") + const headlessJsonOutput = agentTaskRunJsonOutput({ success: true, schema: "wp-codebox/agent-task-run/v1",