|
1 | 1 | import { constants } from "node:fs" |
2 | | -import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises" |
| 2 | +import { lstat, mkdir, open, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises" |
3 | 3 | import { isUtf8 } from "node:buffer" |
4 | 4 | import { createHash } from "node:crypto" |
5 | 5 | import { fileURLToPath, pathToFileURL } from "node:url" |
6 | 6 | import { isAbsolute, join, relative, resolve } from "node:path" |
7 | 7 | import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson } from "./runtime-source-sanitizer.mjs" |
8 | 8 |
|
9 | 9 | const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024 |
| 10 | +const MAX_TRANSCRIPT_EXECUTIONS = 64 |
| 11 | +const MAX_REVIEW_TEXT_BYTES = 32 * 1024 |
10 | 12 | const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd()) |
11 | 13 | const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload")) |
12 | 14 | const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json")) |
@@ -154,20 +156,99 @@ async function canonicalTranscript(result) { |
154 | 156 | return refs[0] |
155 | 157 | } |
156 | 158 |
|
| 159 | +function digest(bytes) { |
| 160 | + return createHash("sha256").update(bytes).digest("hex") |
| 161 | +} |
| 162 | + |
| 163 | +function boundedText(value) { |
| 164 | + if (typeof value !== "string") return undefined |
| 165 | + const text = redact(sanitizeText(value)).slice(0, MAX_REVIEW_TEXT_BYTES) |
| 166 | + return containsRuntimeSourceContent(text) ? "[redacted-source-content]" : text |
| 167 | +} |
| 168 | + |
| 169 | +function safeTargetPath(value) { |
| 170 | + const path = safeRelativeArtifactPath(value) |
| 171 | + return path ? `workspace/${path}` : undefined |
| 172 | +} |
| 173 | + |
| 174 | +function projectToolCall(value) { |
| 175 | + const entry = record(value) |
| 176 | + const tool = boundedText(entry.tool_id ?? entry.toolId ?? entry.name ?? entry.tool_name) |
| 177 | + const path = safeTargetPath(record(entry.args ?? entry.arguments).path ?? entry.path) |
| 178 | + const result = record(entry.result ?? entry.output) |
| 179 | + const rawContent = result.content ?? entry.content |
| 180 | + const content = path && /workspace/i.test(tool ?? "") && typeof rawContent === "string" |
| 181 | + ? redact(sanitizeText(rawContent)).slice(0, MAX_REVIEW_TEXT_BYTES) |
| 182 | + : undefined |
| 183 | + return Object.fromEntries(Object.entries({ tool, path, status: boundedText(entry.status), arguments: path ? { path } : undefined, content, error: boundedText(record(entry.error).message ?? entry.error) }).filter(([, item]) => item !== undefined)) |
| 184 | +} |
| 185 | + |
| 186 | +function projectParsed(value) { |
| 187 | + const parsed = record(value) |
| 188 | + const messages = Array.isArray(parsed.messages) ? parsed.messages : Array.isArray(parsed.model_messages) ? parsed.model_messages : [] |
| 189 | + const tools = Array.isArray(parsed.tool_calls) ? parsed.tool_calls : Array.isArray(parsed.toolCalls) ? parsed.toolCalls : [] |
| 190 | + const results = Array.isArray(parsed.tool_results) ? parsed.tool_results : Array.isArray(parsed.toolResults) ? parsed.toolResults : [] |
| 191 | + const errors = Array.isArray(parsed.errors) ? parsed.errors : parsed.error ? [parsed.error] : [] |
| 192 | + const agent = record(parsed.agent ?? parsed.agent_metadata) |
| 193 | + return Object.fromEntries(Object.entries({ |
| 194 | + agent: Object.fromEntries(["id", "name", "model", "provider", "status"].flatMap((key) => boundedText(agent[key]) ? [[key, boundedText(agent[key])]] : [])), |
| 195 | + model_messages: messages.slice(0, MAX_TRANSCRIPT_EXECUTIONS).flatMap((message) => boundedText(record(message).content ?? record(message).text ?? message) ? [{ role: boundedText(record(message).role), content: boundedText(record(message).content ?? record(message).text ?? message) }] : []), |
| 196 | + tool_calls: tools.slice(0, MAX_TRANSCRIPT_EXECUTIONS).map(projectToolCall), |
| 197 | + tool_results: results.slice(0, MAX_TRANSCRIPT_EXECUTIONS).map(projectToolCall), |
| 198 | + errors: errors.slice(0, MAX_TRANSCRIPT_EXECUTIONS).flatMap((error) => boundedText(record(error).message ?? error) ? [boundedText(record(error).message ?? error)] : []), |
| 199 | + }).filter(([, item]) => Array.isArray(item) ? item.length > 0 : Object.keys(item).length > 0)) |
| 200 | +} |
| 201 | + |
| 202 | +async function trustedTranscriptFile(path) { |
| 203 | + const root = await realpath(artifactsPath).catch(() => "") |
| 204 | + if (!root) return { unavailable: "artifact-root-missing" } |
| 205 | + const parts = path.split("/") |
| 206 | + let current = root |
| 207 | + for (const part of parts) { |
| 208 | + current = join(current, part) |
| 209 | + const stat = await lstat(current).catch((error) => error?.code === "ENOENT" ? undefined : Promise.reject(error)) |
| 210 | + if (!stat) return { unavailable: "referenced-file-missing" } |
| 211 | + if (stat.isSymbolicLink()) throw new Error("Canonical transcript must not traverse symlinks.") |
| 212 | + } |
| 213 | + const stat = await lstat(current) |
| 214 | + if (!stat.isFile() || stat.size > MAX_UPLOAD_FILE_BYTES) throw new Error("Canonical transcript must be a bounded regular file.") |
| 215 | + const resolved = await realpath(current) |
| 216 | + const contained = relative(root, resolved) |
| 217 | + if (contained === ".." || contained.startsWith(`..${String.fromCharCode(47)}`) || isAbsolute(contained)) throw new Error("Canonical transcript escapes the trusted artifact root.") |
| 218 | + return { source: resolved } |
| 219 | +} |
| 220 | + |
157 | 221 | async function stageCanonicalTranscript(result) { |
158 | 222 | const ref = await canonicalTranscript(result) |
159 | 223 | if (!ref) return undefined |
160 | 224 | const path = safeRelativeArtifactPath(ref.path) |
161 | | - const source = resolve(artifactsPath, path) |
162 | | - if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) throw new Error("Canonical transcript must stay under the trusted artifact root.") |
| 225 | + if (sourceCategory(path, resolve(artifactsPath, path))) throw new Error("Canonical transcript must stay under the trusted artifact root.") |
| 226 | + const trusted = await trustedTranscriptFile(path) |
| 227 | + if (trusted.unavailable) return { unavailable: trusted.unavailable, provenance: { kind: ref.kind, artifact_path: path } } |
| 228 | + const source = trusted.source |
| 229 | + const bytes = await readFile(source) |
| 230 | + if (bytes.includes(0) || !isUtf8(bytes)) throw new Error("Canonical transcript must be UTF-8 JSON.") |
| 231 | + const actualDigest = digest(bytes) |
| 232 | + const expectedDigest = typeof ref.sha256 === "string" ? ref.sha256 : record(ref.digest).value |
| 233 | + if (expectedDigest && expectedDigest !== actualDigest) throw new Error("Canonical transcript digest does not match its normalized ref.") |
| 234 | + const raw = parseJsonOrEmpty(bytes.toString("utf8")) |
| 235 | + if (raw.schema !== "wp-codebox/agent-transcript/v1" || !Array.isArray(raw.executions) || raw.executions.length > MAX_TRANSCRIPT_EXECUTIONS) throw new Error("Canonical transcript must be a bounded wp-codebox/agent-transcript/v1 envelope.") |
| 236 | + const projection = { |
| 237 | + schema: "wp-codebox/reviewer-agent-transcript/v1", |
| 238 | + executions: raw.executions.map((execution, index) => { |
| 239 | + const entry = record(execution) |
| 240 | + if (typeof entry.command !== "string" || typeof entry.exitCode !== "number") throw new Error("Canonical transcript execution is malformed.") |
| 241 | + return Object.fromEntries(Object.entries({ execution_index: typeof entry.executionIndex === "number" ? entry.executionIndex : index, command: boundedText(entry.command), status: entry.exitCode === 0 ? "succeeded" : "failed", exit_code: entry.exitCode, parsed: Object.keys(record(entry.parsed)).length ? projectParsed(entry.parsed) : undefined, error: boundedText(entry.stderr) }).filter(([, item]) => item !== undefined)) |
| 242 | + }), |
| 243 | + } |
163 | 244 | const destination = join(uploadPath, ".codebox", "agent-task-artifacts", "transcript.json") |
164 | | - if (!await stageTextFile(source, destination, { allowTargetCode: true })) throw new Error("Canonical transcript must be a bounded regular UTF-8 file.") |
165 | | - const bytes = await readFile(destination) |
166 | | - if (!Object.keys(record(parseJsonOrEmpty(bytes.toString("utf8")))).length) throw new Error("Canonical transcript must be a JSON object.") |
| 245 | + await mkdir(resolve(destination, ".."), { recursive: true }) |
| 246 | + await writeFile(destination, `${JSON.stringify(projection, null, 2)}\n`) |
| 247 | + const projectionDigest = digest(await readFile(destination)) |
167 | 248 | return { |
168 | 249 | path: ".codebox/agent-task-artifacts/transcript.json", |
169 | | - sha256: createHash("sha256").update(bytes).digest("hex"), |
170 | | - provenance: { kind: ref.kind, artifact_path: path, ...(ref.sha256 ? { source_sha256: ref.sha256 } : {}) }, |
| 250 | + sha256: projectionDigest, |
| 251 | + provenance: { kind: ref.kind, artifact_path: path, source_sha256: actualDigest, projection_sha256: projectionDigest }, |
171 | 252 | } |
172 | 253 | } |
173 | 254 |
|
@@ -277,6 +358,12 @@ for (const path of declaredPaths) { |
277 | 358 | await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path)) |
278 | 359 | } |
279 | 360 | const transcript = await stageCanonicalTranscript(result) |
| 361 | +if (transcript?.provenance?.source_sha256) { |
| 362 | + const stagedResultPath = join(uploadPath, ".codebox", "agent-task-workflow-result.json") |
| 363 | + const stagedResult = parseJsonOrEmpty(await readFile(stagedResultPath, "utf8")) |
| 364 | + stagedResult.artifact_upload = { canonical_transcript: transcript.provenance } |
| 365 | + await writeFile(stagedResultPath, `${JSON.stringify(stagedResult, null, 2)}\n`) |
| 366 | +} |
280 | 367 | await mkdir(join(uploadPath, ".codebox", "agent-task-artifacts"), { recursive: true }) |
281 | 368 | await writeFile(join(uploadPath, ".codebox", "agent-task-artifacts", "runtime-provenance.json"), `${JSON.stringify({ schema: "wp-codebox/agent-task-runtime-provenance/v1", sources: runtimeProvenance(request) }, null, 2)}\n`) |
282 | 369 | await writeFile(join(uploadPath, ".codebox", "agent-task-artifacts", "exclusions.json"), `${JSON.stringify({ schema: "wp-codebox/agent-task-upload-exclusions/v1", ...(await exclusions(artifactsPath, declaredPaths, transcript)) }, null, 2)}\n`) |
|
0 commit comments