@tangle-network/agent-bench 0.1.0 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/HARNESS.md +302 -0
  3. package/README.md +26 -1
  4. package/fixtures/aec-bench.json +18 -0
  5. package/fixtures/agentbench-dbbench.json +22 -0
  6. package/fixtures/bfcl.json +45 -0
  7. package/fixtures/commit0.json +72 -0
  8. package/fixtures/crag.json +10 -0
  9. package/fixtures/dabstep.json +22 -0
  10. package/fixtures/enterpriseops-gym.json +103 -0
  11. package/fixtures/finresearchbench.json +21 -0
  12. package/fixtures/finsearchcomp.json +66 -0
  13. package/fixtures/frames.json +26 -0
  14. package/fixtures/hotpotqa.json +182 -0
  15. package/fixtures/nomiracl.json +26 -0
  16. package/fixtures/open-rag-bench.json +16 -0
  17. package/fixtures/pier-agent/no-model-task/environment/Dockerfile +16 -0
  18. package/fixtures/pier-agent/no-model-task/environment/seed/src/status.txt +1 -0
  19. package/fixtures/pier-agent/no-model-task/instruction.md +6 -0
  20. package/fixtures/pier-agent/no-model-task/pre_artifacts.sh +6 -0
  21. package/fixtures/pier-agent/no-model-task/task.toml +35 -0
  22. package/fixtures/pier-agent/no-model-task/tests/Dockerfile +17 -0
  23. package/fixtures/pier-agent/no-model-task/tests/seed/src/status.txt +1 -0
  24. package/fixtures/pier-agent/no-model-task/tests/test.sh +19 -0
  25. package/fixtures/programbench.json +17 -0
  26. package/fixtures/ragbench.json +21 -0
  27. package/fixtures/simpleqa.json +121 -0
  28. package/fixtures/t2-ragbench.json +13 -0
  29. package/fixtures/tau2-bench.json +16 -0
  30. package/fixtures/tau3-banking.json +16 -0
  31. package/fixtures/toollm.json +28 -0
  32. package/fixtures/webarena-verified.json +20 -0
  33. package/package.json +39 -15
  34. package/pier_agents/__init__.py +18 -0
  35. package/pier_agents/candidate_contract.py +755 -0
  36. package/pier_agents/process_boundary.py +321 -0
  37. package/pier_agents/tangle_candidate.py +907 -0
  38. package/pier_agents/workspace_boundary.py +368 -0
  39. package/scripts/appworld_driver.py +359 -0
  40. package/scripts/cadbench_prepare.py +22 -0
  41. package/scripts/cadgenbench_hard_parts.py +48 -0
  42. package/scripts/clbench_codebase_judge.py +73 -0
  43. package/scripts/commit0_judge.py +170 -0
  44. package/scripts/dabstep_judge.py +42 -0
  45. package/scripts/enterpriseops_gym_judge.py +281 -0
  46. package/scripts/programbench_judge.py +120 -0
  47. package/scripts/render-gate-chart.mjs +176 -0
  48. package/scripts/run-package-tests.mjs +56 -0
  49. package/scripts/terminate-pier-trial.mts +66 -0
  50. package/scripts/trata-hedge/README.md +56 -0
  51. package/scripts/trata-hedge/run.sh +60 -0
  52. package/scripts/trata-hedge/solve.py +83 -0
  53. package/scripts/verify-packed-consumer.mjs +224 -0
  54. package/scripts/verify-pier-agent.mts +715 -0
  55. package/scripts/verify-pier-pair.mts +74 -0
  56. package/scripts/verify-pier-recovery.mts +139 -0
  57. package/src/adapters.ts +26 -0
  58. package/src/benchmarks/_harness.test.mts +178 -0
  59. package/src/benchmarks/_harness.ts +239 -16
  60. package/src/benchmarks/agentbench.ts +163 -0
  61. package/src/benchmarks/appworld.test.mts +15 -9
  62. package/src/benchmarks/bfcl.ts +346 -0
  63. package/src/benchmarks/crag.ts +137 -0
  64. package/src/benchmarks/dabstep.test.mts +70 -0
  65. package/src/benchmarks/dabstep.ts +212 -0
  66. package/src/benchmarks/external-adapters.test.mts +150 -0
  67. package/src/benchmarks/finresearchbench.ts +269 -0
  68. package/src/benchmarks/humaneval.ts +20 -8
  69. package/src/benchmarks/nomiracl.ts +180 -0
  70. package/src/benchmarks/open-rag-bench.ts +153 -0
  71. package/src/benchmarks/rag-benchmarks.test.mts +138 -0
  72. package/src/benchmarks/rag-shared.ts +327 -0
  73. package/src/benchmarks/ragbench.ts +171 -0
  74. package/src/benchmarks/swe-bench.test.mts +61 -0
  75. package/src/benchmarks/swe-bench.ts +201 -19
  76. package/src/benchmarks/t2-ragbench.ts +166 -0
  77. package/src/benchmarks/tau-bench-shared.ts +214 -0
  78. package/src/benchmarks/tau2-bench.ts +30 -0
  79. package/src/benchmarks/tau3-banking.ts +29 -0
  80. package/src/benchmarks/terminal-bench.test.mts +33 -0
  81. package/src/benchmarks/terminal-bench.ts +23 -8
  82. package/src/benchmarks/toollm.ts +254 -0
  83. package/src/benchmarks/types.ts +42 -0
  84. package/src/benchmarks/webarena-verified.ts +200 -0
  85. package/src/commit0-prereqs.sh +0 -0
  86. package/src/coordination-mcp-container-reach.mts +181 -0
  87. package/src/decoder-live.mts +1 -1
  88. package/src/examples/README.md +103 -39
  89. package/src/examples/benchmark-matrix.mts +101 -0
  90. package/src/examples/lean-proof-gate.README.md +77 -0
  91. package/src/examples/lean-proof-gate.mts +162 -0
  92. package/src/examples/lean-verify.ts +95 -0
  93. package/src/examples/lean.Dockerfile +12 -0
  94. package/src/examples/math-demo.mts +9 -7
  95. package/src/examples/strategy-demo.mts +10 -12
  96. package/src/gate.ts +3 -2
  97. package/src/hev-eval.mts +69 -0
  98. package/src/hev-improve.mts +169 -0
  99. package/src/hev-structural.mts +688 -0
  100. package/src/index.ts +73 -0
  101. package/src/mbpp-structural.mts +662 -0
  102. package/src/pier-agent.test-fixtures.mts +19 -0
  103. package/src/pier-agent.test.mts +363 -0
  104. package/src/pier-agent.ts +657 -0
  105. package/src/pier-result-grader.mjs +30 -0
  106. package/src/pier-result-grader.test.mts +62 -0
  107. package/src/pier-result-grader.ts +108 -0
  108. package/src/pier-task-outcome.test.mts +117 -0
  109. package/src/pier-task-outcome.ts +240 -0
  110. package/src/pier-trial-controller.test.mts +412 -0
  111. package/src/pier-trial-controller.ts +858 -0
  112. package/src/pier-trial-supervisor.mjs +352 -0
  113. package/src/resolve-client.ts +25 -2
  114. package/src/run-benchmarks-cli.mts +72 -0
  115. package/src/run-benchmarks-report.ts +66 -0
  116. package/src/run-benchmarks.test.mts +231 -0
  117. package/src/run-benchmarks.ts +589 -0
  118. package/src/smoke-structural-rollout.mts +393 -0
  119. package/src/swe-bench-env.test.ts +207 -0
  120. package/src/swe-bench-env.ts +554 -0
  121. package/src/swe-jail.ts +293 -0
  122. package/src/swe-self-improve.mts +84 -0
  123. package/src/swe-structural-judge-policy.test.ts +117 -0
  124. package/src/swe-structural-judge-policy.ts +133 -0
  125. package/src/swe-structural-policy.test.ts +124 -0
  126. package/src/swe-structural-policy.ts +132 -0
  127. package/src/swe-structural-provenance.test.ts +93 -0
  128. package/src/swe-structural-provenance.ts +138 -0
  129. package/src/swe-structural.mts +1260 -0
  130. package/src/swe-temp.ts +14 -0
  131. package/src/tb-container-executor.mts +234 -0
  132. package/src/tb-container-executor.test.mts +99 -0
  133. package/src/tb-supervisor-sidecar.mts +222 -0
  134. package/src/trata-gepa.mts +1 -1
  135. package/steerers/eops-itsm-population.json +1 -0
  136. package/tb_agents/opencode_refine_agent.py +117 -0
  137. package/tb_agents/opencode_router_agent.py +406 -0
  138. package/tb_agents/opencode_supervisor_agent.py +239 -0
  139. package/tb_agents/script_agent.py +66 -0
@@ -0,0 +1,62 @@
1
+ import assert from 'node:assert/strict'
2
+ import { createHash } from 'node:crypto'
3
+ import { readFile } from 'node:fs/promises'
4
+ import test from 'node:test'
5
+
6
+ import type { AgentCandidateArtifactRef } from '@tangle-network/agent-interface'
7
+
8
+ import { createPierResultGrader } from './pier-result-grader'
9
+
10
+ const digest = (bytes: Uint8Array): `sha256:${string}` =>
11
+ `sha256:${createHash('sha256').update(bytes).digest('hex')}`
12
+
13
+ const artifact: AgentCandidateArtifactRef = {
14
+ locator: { kind: 's3', bucket: 'pier-graders', key: 'pier-result-grader.mjs' },
15
+ sha256: `sha256:${'a'.repeat(64)}`,
16
+ byteLength: 1,
17
+ }
18
+
19
+ test('executes admitted Pier grader bytes over exact official result evidence', async () => {
20
+ const implementation = await readFile(new URL('./pier-result-grader.mjs', import.meta.url))
21
+ const resultBytes = Buffer.from(
22
+ JSON.stringify({ verifier_result: { rewards: { reward: 1, patch_applied: 1 } } }),
23
+ )
24
+ const grader = createPierResultGrader({ name: 'pier-result', version: '1', artifact })
25
+ const output = await grader.run({
26
+ executionId: 'execution-1',
27
+ termination: { kind: 'exit', exitCode: 0 },
28
+ outcome: { evidence: { digest: `sha256:${'b'.repeat(64)}` } } as never,
29
+ implementation: { byteLength: implementation.byteLength, bytes: implementation },
30
+ signal: new AbortController().signal,
31
+ officialResult: { value: JSON.parse(resultBytes.toString('utf8')), bytes: resultBytes },
32
+ })
33
+
34
+ assert.deepEqual(output.evaluation, {
35
+ score: 1,
36
+ passed: true,
37
+ dimensions: { patch_applied: 1, reward: 1 },
38
+ raw: {},
39
+ })
40
+ assert.deepEqual(Buffer.from(output.evidence), resultBytes)
41
+ assert.equal(output.binding.implementationDigest, digest(implementation))
42
+ assert.equal(output.binding.outputDigest, digest(resultBytes))
43
+ })
44
+
45
+ test('fails closed for missing official rewards and pre-aborted execution', async () => {
46
+ const implementation = await readFile(new URL('./pier-result-grader.mjs', import.meta.url))
47
+ const grader = createPierResultGrader({ name: 'pier-result', version: '1', artifact })
48
+ const base = {
49
+ executionId: 'execution-1',
50
+ termination: { kind: 'exit', exitCode: 0 } as const,
51
+ outcome: { evidence: { digest: `sha256:${'b'.repeat(64)}` } } as never,
52
+ implementation: { byteLength: implementation.byteLength, bytes: implementation },
53
+ officialResult: { value: {}, bytes: Buffer.from('{}') },
54
+ }
55
+ await assert.rejects(
56
+ grader.run({ ...base, signal: new AbortController().signal }),
57
+ /omitted verifier_result.rewards/,
58
+ )
59
+ const controller = new AbortController()
60
+ controller.abort(new Error('deadline'))
61
+ await assert.rejects(grader.run({ ...base, signal: controller.signal }), /deadline/)
62
+ })
@@ -0,0 +1,108 @@
1
+ /** Execute the exact admitted Pier result parser bytes in a fresh Node process. */
2
+ import { spawn } from 'node:child_process'
3
+ import { createHash } from 'node:crypto'
4
+ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ import type { BenchmarkEvaluation } from '@tangle-network/agent-eval'
9
+
10
+ import type { PierCandidateGraderPort } from './pier-agent'
11
+
12
+ const sha256 = (bytes: Uint8Array): `sha256:${string}` =>
13
+ `sha256:${createHash('sha256').update(bytes).digest('hex')}`
14
+
15
+ export function createPierResultGrader(
16
+ descriptor: Pick<PierCandidateGraderPort, 'name' | 'version' | 'artifact'>,
17
+ ): PierCandidateGraderPort {
18
+ return {
19
+ ...descriptor,
20
+ run: async (input) => {
21
+ input.signal.throwIfAborted()
22
+ const implementation = Uint8Array.from(input.implementation.bytes)
23
+ if (implementation.byteLength !== input.implementation.byteLength) {
24
+ throw new Error('Pier grader implementation length changed before execution')
25
+ }
26
+ const evidence = Uint8Array.from(input.officialResult.bytes)
27
+ const evaluation = await executeGrader(implementation, evidence, input.signal)
28
+ input.signal.throwIfAborted()
29
+ return {
30
+ evaluation,
31
+ evidence,
32
+ binding: {
33
+ implementationDigest: sha256(implementation),
34
+ taskOutcomeDigest: input.outcome.evidence.digest,
35
+ outputDigest: sha256(evidence),
36
+ },
37
+ }
38
+ },
39
+ }
40
+ }
41
+
42
+ async function executeGrader(
43
+ implementation: Uint8Array,
44
+ resultBytes: Uint8Array,
45
+ signal: AbortSignal,
46
+ ): Promise<BenchmarkEvaluation> {
47
+ const directory = await mkdtemp(join(tmpdir(), 'pier-result-grader-'))
48
+ const script = join(directory, 'grader.mjs')
49
+ try {
50
+ await writeFile(script, implementation, { flag: 'wx', mode: 0o600 })
51
+ await chmod(script, 0o500)
52
+ const stdout = await runNode(script, resultBytes, signal)
53
+ const parsed = JSON.parse(stdout.toString('utf8')) as BenchmarkEvaluation
54
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
55
+ throw new Error('Pier result grader returned a non-object evaluation')
56
+ }
57
+ return parsed
58
+ } finally {
59
+ await rm(directory, { recursive: true, force: true })
60
+ }
61
+ }
62
+
63
+ async function runNode(
64
+ script: string,
65
+ input: Uint8Array,
66
+ signal: AbortSignal,
67
+ ): Promise<Buffer> {
68
+ signal.throwIfAborted()
69
+ return await new Promise((resolveResult, reject) => {
70
+ const child = spawn(process.execPath, ['--no-warnings', script], {
71
+ env: {
72
+ PATH: process.env.PATH,
73
+ HOME: process.env.HOME,
74
+ LANG: 'C',
75
+ LC_ALL: 'C',
76
+ },
77
+ stdio: ['pipe', 'pipe', 'pipe'],
78
+ signal,
79
+ })
80
+ const stdout: Buffer[] = []
81
+ const stderr: Buffer[] = []
82
+ let bytes = 0
83
+ const collect = (target: Buffer[]) => (chunk: Buffer) => {
84
+ bytes += chunk.byteLength
85
+ if (bytes > 1024 * 1024) {
86
+ child.kill('SIGKILL')
87
+ reject(new Error('Pier result grader exceeded its output limit'))
88
+ return
89
+ }
90
+ target.push(Buffer.from(chunk))
91
+ }
92
+ child.stdout.on('data', collect(stdout))
93
+ child.stderr.on('data', collect(stderr))
94
+ child.once('error', reject)
95
+ child.once('close', (code, childSignal) => {
96
+ if (code !== 0) {
97
+ reject(
98
+ new Error(
99
+ `Pier result grader failed (${childSignal ?? code}): ${Buffer.concat(stderr).toString('utf8').trim()}`,
100
+ ),
101
+ )
102
+ return
103
+ }
104
+ resolveResult(Buffer.concat(stdout))
105
+ })
106
+ child.stdin.end(input)
107
+ })
108
+ }
@@ -0,0 +1,117 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import test from 'node:test'
7
+
8
+ import {
9
+ captureAgentCandidateWorkspaceFiles,
10
+ createAgentCandidateWorkspacePort,
11
+ } from '@tangle-network/agent-runtime/candidate-execution'
12
+
13
+ import { capturePierTaskOutcome } from './pier-task-outcome'
14
+
15
+ test('Pier task outcome reconstructs the exact tree and deterministic archive from a patch', async () => {
16
+ const root = mkdtempSync(join(tmpdir(), 'pier-task-outcome-test-'))
17
+ try {
18
+ mkdirSync(join(root, 'src'))
19
+ writeFileSync(join(root, 'src/status.txt'), 'not-ready\n')
20
+ chmodSync(join(root, 'src/status.txt'), 0o644)
21
+ git(root, ['init', '-b', 'main'])
22
+ git(root, ['config', 'user.email', 'fixture@example.com'])
23
+ git(root, ['config', 'user.name', 'Fixture'])
24
+ git(root, ['add', '-A'])
25
+ git(root, ['commit', '-m', 'base'])
26
+ const baseCommit = git(root, ['rev-parse', 'HEAD'])
27
+ const baseTree = git(root, ['rev-parse', 'HEAD^{tree}'])
28
+ writeFileSync(join(root, 'src/status.txt'), 'ready\n')
29
+ const patch = execFileSync('git', ['-C', root, 'diff', '--binary'])
30
+ git(root, ['restore', '.'])
31
+
32
+ const captured = await capturePierTaskOutcome({ repositoryRoot: root, baseCommit, baseTree, patch })
33
+ assert.notEqual(captured.resultTree, baseTree)
34
+ assert.deepEqual(Buffer.from(captured.gitDiff), patch)
35
+ const expected = await captureAgentCandidateWorkspaceFiles([
36
+ { path: 'src/status.txt', mode: 0o644, bytes: Buffer.from('ready\n') },
37
+ ])
38
+ assert.deepEqual(captured.afterState, expected.snapshot.material)
39
+ assert.deepEqual(Buffer.from(captured.archive), Buffer.from(expected.archive))
40
+ const destination = join(root, 'materialized')
41
+ await createAgentCandidateWorkspacePort().materialize({
42
+ role: 'candidate',
43
+ archive: captured.archive,
44
+ snapshot: expected.snapshot,
45
+ destination,
46
+ })
47
+ assert.equal(readFileSync(join(destination, 'src/status.txt'), 'utf8'), 'ready\n')
48
+ } finally {
49
+ rmSync(root, { recursive: true, force: true })
50
+ }
51
+ })
52
+
53
+ test('Pier task outcome preserves the signed base for an empty patch', async () => {
54
+ const root = mkdtempSync(join(tmpdir(), 'pier-task-outcome-test-'))
55
+ try {
56
+ writeFileSync(join(root, 'README.md'), 'base\n')
57
+ git(root, ['init', '-b', 'main'])
58
+ git(root, ['config', 'user.email', 'fixture@example.com'])
59
+ git(root, ['config', 'user.name', 'Fixture'])
60
+ git(root, ['add', '-A'])
61
+ git(root, ['commit', '-m', 'base'])
62
+ const baseCommit = git(root, ['rev-parse', 'HEAD'])
63
+ const baseTree = git(root, ['rev-parse', 'HEAD^{tree}'])
64
+
65
+ const captured = await capturePierTaskOutcome({
66
+ repositoryRoot: root,
67
+ baseCommit,
68
+ baseTree,
69
+ patch: Buffer.alloc(0),
70
+ })
71
+ assert.equal(captured.resultTree, baseTree)
72
+ assert.equal(captured.gitDiff.byteLength, 0)
73
+ } finally {
74
+ rmSync(root, { recursive: true, force: true })
75
+ }
76
+ })
77
+
78
+ test('Pier task outcome returns large workspace bytes for the runtime capture path', async () => {
79
+ const root = mkdtempSync(join(tmpdir(), 'pier-task-outcome-test-'))
80
+ try {
81
+ const payload = Buffer.alloc(1024 * 1024 + 1, 7)
82
+ writeFileSync(join(root, 'large.bin'), payload)
83
+ git(root, ['init', '-b', 'main'])
84
+ git(root, ['config', 'user.email', 'fixture@example.com'])
85
+ git(root, ['config', 'user.name', 'Fixture'])
86
+ git(root, ['add', '-A'])
87
+ git(root, ['commit', '-m', 'base'])
88
+ const baseCommit = git(root, ['rev-parse', 'HEAD'])
89
+ const baseTree = git(root, ['rev-parse', 'HEAD^{tree}'])
90
+ const captured = await capturePierTaskOutcome({
91
+ repositoryRoot: root,
92
+ baseCommit,
93
+ baseTree,
94
+ patch: Buffer.alloc(0),
95
+ })
96
+
97
+ assert.ok(captured.archive.byteLength > 1024 * 1024)
98
+ assert.equal(captured.afterState.files[0]?.byteLength, payload.byteLength)
99
+ } finally {
100
+ rmSync(root, { recursive: true, force: true })
101
+ }
102
+ })
103
+
104
+ function git(root: string, args: string[]): string {
105
+ return execFileSync('git', ['-C', root, ...args], {
106
+ encoding: 'utf8',
107
+ env: {
108
+ ...process.env,
109
+ GIT_CONFIG_NOSYSTEM: '1',
110
+ GIT_CONFIG_GLOBAL: '/dev/null',
111
+ GIT_CONFIG_SYSTEM: '/dev/null',
112
+ GIT_TERMINAL_PROMPT: '0',
113
+ GIT_NO_REPLACE_OBJECTS: '1',
114
+ LC_ALL: 'C',
115
+ },
116
+ }).trim()
117
+ }
@@ -0,0 +1,240 @@
1
+ /** Reconstruct a runtime task outcome from Pier's evaluator-captured binary patch. */
2
+ import { spawn } from 'node:child_process'
3
+ import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+
7
+ import {
8
+ type AgentCandidateExecutorTaskOutcomeCapture,
9
+ captureAgentCandidateWorkspaceFiles,
10
+ } from '@tangle-network/agent-runtime/candidate-execution'
11
+
12
+ interface GitResult {
13
+ readonly stdout: Buffer
14
+ readonly stderr: Buffer
15
+ }
16
+
17
+ /** Apply the official patch in an isolated Git object directory and capture its exact tree. */
18
+ export async function capturePierTaskOutcome(input: {
19
+ readonly repositoryRoot: string
20
+ readonly baseCommit: string
21
+ readonly baseTree: string
22
+ readonly patch: Uint8Array
23
+ readonly signal?: AbortSignal
24
+ }): Promise<Extract<AgentCandidateExecutorTaskOutcomeCapture, { kind: 'workspace' }>> {
25
+ input.signal?.throwIfAborted()
26
+ const repositoryRoot = resolve(input.repositoryRoot)
27
+ const head = (await git(repositoryRoot, ['rev-parse', 'HEAD'], undefined, {}, input.signal)).stdout
28
+ .toString('utf8')
29
+ .trim()
30
+ const headTree = (
31
+ await git(repositoryRoot, ['rev-parse', 'HEAD^{tree}'], undefined, {}, input.signal)
32
+ ).stdout
33
+ .toString('utf8')
34
+ .trim()
35
+ if (head !== input.baseCommit || headTree !== input.baseTree) {
36
+ throw new Error('Pier task outcome repository drifted from its signed base')
37
+ }
38
+ const gitDir = resolve(
39
+ (
40
+ await git(repositoryRoot, ['rev-parse', '--absolute-git-dir'], undefined, {}, input.signal)
41
+ ).stdout
42
+ .toString('utf8')
43
+ .trim(),
44
+ )
45
+ if ((await realpath(gitDir)) !== gitDir || gitDir.includes(':')) {
46
+ throw new Error('Pier task outcome Git directory is unsupported')
47
+ }
48
+ const replacements = (
49
+ await git(
50
+ repositoryRoot,
51
+ ['for-each-ref', '--format=%(refname)', 'refs/replace'],
52
+ undefined,
53
+ {},
54
+ input.signal,
55
+ )
56
+ ).stdout
57
+ .toString('utf8')
58
+ .trim()
59
+ if (replacements) throw new Error('Pier task outcome repository contains Git replace refs')
60
+
61
+ const temporary = await mkdtemp(join(tmpdir(), 'pier-task-outcome-'))
62
+ try {
63
+ const objectDirectory = join(temporary, 'objects')
64
+ const indexFile = join(temporary, 'index')
65
+ await mkdir(objectDirectory)
66
+ const environment = {
67
+ GIT_INDEX_FILE: indexFile,
68
+ GIT_OBJECT_DIRECTORY: objectDirectory,
69
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: join(gitDir, 'objects'),
70
+ }
71
+ await git(repositoryRoot, ['read-tree', input.baseTree], undefined, environment, input.signal)
72
+ const patch = Uint8Array.from(input.patch)
73
+ if (patch.byteLength > 0) {
74
+ await git(
75
+ repositoryRoot,
76
+ ['apply', '--cached', '--binary', '--whitespace=nowarn', '-'],
77
+ patch,
78
+ environment,
79
+ input.signal,
80
+ )
81
+ }
82
+ const resultTree = (
83
+ await git(repositoryRoot, ['write-tree'], undefined, environment, input.signal)
84
+ ).stdout
85
+ .toString('utf8')
86
+ .trim()
87
+ const entries = parseTreeEntries(
88
+ (
89
+ await git(
90
+ repositoryRoot,
91
+ ['ls-tree', '-rz', '--full-tree', resultTree],
92
+ undefined,
93
+ environment,
94
+ input.signal,
95
+ )
96
+ ).stdout,
97
+ )
98
+ if (entries.length === 0) throw new Error('Pier task outcome tree cannot be empty')
99
+ const files = await Promise.all(
100
+ entries.map(async (entry) => {
101
+ if (entry.type !== 'blob' || (entry.mode !== '100644' && entry.mode !== '100755')) {
102
+ throw new Error(`Pier task outcome contains a non-regular entry: ${entry.path}`)
103
+ }
104
+ return {
105
+ path: safeGitPath(entry.path),
106
+ mode: entry.mode === '100755' ? (0o755 as const) : (0o644 as const),
107
+ bytes: (
108
+ await git(
109
+ repositoryRoot,
110
+ ['cat-file', 'blob', entry.object],
111
+ undefined,
112
+ environment,
113
+ input.signal,
114
+ )
115
+ ).stdout,
116
+ }
117
+ }),
118
+ )
119
+ // The executor outcome must still return exact bytes; the runtime verifies
120
+ // and persists its final task references after this capture completes.
121
+ const captured = await captureAgentCandidateWorkspaceFiles(files, {
122
+ limits: { maxEmbeddedArtifactBytes: 512 * 1024 * 1024 },
123
+ })
124
+ return {
125
+ kind: 'workspace',
126
+ resultTree,
127
+ afterState: captured.snapshot.material,
128
+ archive: captured.archive,
129
+ gitDiff: patch,
130
+ }
131
+ } finally {
132
+ await rm(temporary, { recursive: true, force: true })
133
+ }
134
+ }
135
+
136
+ function parseTreeEntries(bytes: Uint8Array): Array<{
137
+ readonly mode: string
138
+ readonly type: string
139
+ readonly object: string
140
+ readonly path: string
141
+ }> {
142
+ const raw = Buffer.from(bytes)
143
+ const decoded = raw.toString('utf8')
144
+ if (!Buffer.from(decoded, 'utf8').equals(raw)) {
145
+ throw new Error('Pier task outcome contains a non-UTF-8 path')
146
+ }
147
+ return decoded
148
+ .split('\0')
149
+ .filter(Boolean)
150
+ .map((row) => {
151
+ const tab = row.indexOf('\t')
152
+ const [mode, type, object] = row.slice(0, tab).split(' ')
153
+ const path = row.slice(tab + 1)
154
+ if (tab < 1 || !mode || !type || !object || !path) {
155
+ throw new Error('Pier task outcome contains a malformed Git entry')
156
+ }
157
+ return { mode, type, object, path }
158
+ })
159
+ }
160
+
161
+ function safeGitPath(path: string): string {
162
+ const parts = path.split('/')
163
+ if (
164
+ !path ||
165
+ path.startsWith('/') ||
166
+ path.includes('\\') ||
167
+ path.includes('\0') ||
168
+ parts.some((part) => !part || part === '.' || part === '..') ||
169
+ parts[0]?.toLowerCase() === '.git'
170
+ ) {
171
+ throw new Error(`Pier task outcome contains an unsafe Git path: ${path}`)
172
+ }
173
+ return path
174
+ }
175
+
176
+ async function git(
177
+ repositoryRoot: string,
178
+ args: readonly string[],
179
+ input: Uint8Array | undefined,
180
+ extraEnvironment: Readonly<Record<string, string>>,
181
+ signal: AbortSignal | undefined,
182
+ ): Promise<GitResult> {
183
+ signal?.throwIfAborted()
184
+ const environment = Object.fromEntries(
185
+ Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
186
+ ) as Record<string, string>
187
+ Object.assign(environment, {
188
+ GIT_CONFIG_NOSYSTEM: '1',
189
+ GIT_CONFIG_GLOBAL: '/dev/null',
190
+ GIT_CONFIG_SYSTEM: '/dev/null',
191
+ GIT_TERMINAL_PROMPT: '0',
192
+ GIT_NO_REPLACE_OBJECTS: '1',
193
+ LC_ALL: 'C',
194
+ ...extraEnvironment,
195
+ })
196
+ return await new Promise((resolveResult, reject) => {
197
+ const child = spawn(
198
+ 'git',
199
+ [
200
+ '-c',
201
+ 'core.hooksPath=/dev/null',
202
+ '-c',
203
+ 'protocol.file.allow=never',
204
+ '-C',
205
+ repositoryRoot,
206
+ ...args,
207
+ ],
208
+ { env: environment, stdio: ['pipe', 'pipe', 'pipe'], signal },
209
+ )
210
+ const stdout: Buffer[] = []
211
+ const stderr: Buffer[] = []
212
+ let outputBytes = 0
213
+ const collect = (target: Buffer[]) => (chunk: Buffer) => {
214
+ outputBytes += chunk.byteLength
215
+ if (outputBytes > 64 * 1024 * 1024) {
216
+ child.kill('SIGKILL')
217
+ reject(new Error(`git ${args[0] ?? '<command>'} exceeded the output limit`))
218
+ return
219
+ }
220
+ target.push(Buffer.from(chunk))
221
+ }
222
+ child.stdout.on('data', collect(stdout))
223
+ child.stderr.on('data', collect(stderr))
224
+ child.once('error', reject)
225
+ child.once('close', (code, childSignal) => {
226
+ const out = Buffer.concat(stdout)
227
+ const err = Buffer.concat(stderr)
228
+ if (code !== 0) {
229
+ reject(
230
+ new Error(
231
+ `git ${args[0] ?? '<command>'} failed (${childSignal ?? code}): ${err.toString('utf8').trim()}`,
232
+ ),
233
+ )
234
+ return
235
+ }
236
+ resolveResult({ stdout: out, stderr: err })
237
+ })
238
+ child.stdin.end(input)
239
+ })
240
+ }