Skip to content

Commit a9d6b4d

Browse files
committed
Extend Daytona-transport autobrin contender to support webapp modality (fixes #32)
runViaDaytona() now builds buildWebappPayload() for modality "webapp" instead of hard-rejecting anything but "repo", skipping the repo-only target-materialization path entirely since a webapp target is a URL the sandbox reaches over the network. Widens the Daytona-side webapp payload (src/daytona/payload.ts) to carry the full WebappTargetSchema contract (username/password/role/outboundServiceUrl/ proofUploadingUrl/secret/etc.), which previously only forwarded target.url and silently dropped everything else needed for benchmarks like CVE-Bench. Also lets fetchAttemptsFromSandbox read back attempts for webapp modality, since the workspace/attacks layout is modality-agnostic, not repo-specific. Live-verified against a real CVE-Bench task (CVE-2024-3234) with transport: "daytona": real sandbox, real Docker target on the host tunneled to the sandbox, real engagement, real CVE-Bench grader response.
1 parent b1a0b98 commit a9d6b4d

5 files changed

Lines changed: 228 additions & 25 deletions

File tree

src/contenders/autobrin.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ export type AttemptRecord = {
108108
export function buildWebappPayload(input: {
109109
target: TargetHandle;
110110
controls: RunControls;
111-
workspaceRoot: string;
111+
/** Omit for the Daytona transport: the sandbox-side launcher defaults this to its own root. */
112+
workspaceRoot?: string;
112113
contributors?: number;
113114
}): Record<string, unknown> {
114115
const webapp = webappTargetMetadata(input.target);
@@ -131,7 +132,7 @@ export function buildWebappPayload(input: {
131132
secret: webapp.secret,
132133
secretUploadingUrl: webapp.secretUploadingUrl,
133134
},
134-
workspaceRoot: input.workspaceRoot,
135+
...(input.workspaceRoot !== undefined ? { workspaceRoot: input.workspaceRoot } : {}),
135136
model: input.controls.model,
136137
contributors: input.contributors ?? input.controls.contributors,
137138
...buildGuardrails(input.controls),
@@ -274,6 +275,12 @@ export function buildReadAttemptsScript(attacksDir: string): string {
274275
* `runDaytonaEngagement`'s `afterEngagement` hook (before sandbox cleanup) -- by the time
275276
* `runDaytonaEngagement` itself resolves, the sandbox is already deleted.
276277
*
278+
* The `<workspaceRoot>/workspace/attacks/<attempt>/*.json` layout is a modality-agnostic
279+
* autobrin-flue convention (both `repo` and `webapp` modalities call the same `prepareWorkspace()`
280+
* layout code), so this reads attempts back the same way regardless of `payload.modality` -- this
281+
* mirrors the local-transport reader (`extractClaimFromWorkspace`), which is likewise never gated
282+
* on modality.
283+
*
277284
* Deliberately does NOT tolerate a script-level failure (non-zero exit, unparsable/non-array
278285
* output) by falling back to `[]`: the script's own per-file reads already tolerate an
279286
* individual attempt missing evaluate/report/disclosure.json (still mid-run, a legitimate `{}`),
@@ -285,8 +292,6 @@ export function buildReadAttemptsScript(attacksDir: string): string {
285292
* contract (cleanup still runs, the overall call rejects) instead of corrupting the claim.
286293
*/
287294
export async function fetchAttemptsFromSandbox(sandbox: Sandbox, payload: EngagementPayload): Promise<AttemptRecord[]> {
288-
if (payload.modality !== 'repo') return [];
289-
290295
const attacksDir = `${engagementWorkspaceDir(payload)}/attacks`;
291296
const script = ['set -euo pipefail', "python3 - <<'PY'", buildReadAttemptsScript(attacksDir), 'PY'].join('\n');
292297
const response = await executeChecked(sandbox, script, '/', 60);
@@ -423,11 +428,14 @@ async function runViaLocalNpx(input: RunInput): Promise<NormalizedResult> {
423428
async function runViaDaytona(input: RunInput): Promise<NormalizedResult> {
424429
const { contenderId, config, contributors, task, target, controls, context } = input;
425430

426-
if (target.modality !== 'repo' || !target.repo) {
431+
if (target.modality !== 'repo' && target.modality !== 'webapp') {
427432
throw new Error(
428-
`autobrin contender "${contenderId}": transport "daytona" currently only supports modality "repo" (got "${target.modality}")`,
433+
`autobrin contender "${contenderId}": transport "daytona" does not support modality "${target.modality}"`,
429434
);
430435
}
436+
if (target.modality === 'repo' && !target.repo) {
437+
throw new Error(`autobrin contender "${contenderId}": modality "repo" requires target.repo`);
438+
}
431439

432440
const started = Date.now();
433441
const engagementDir = path.join(
@@ -438,8 +446,14 @@ async function runViaDaytona(input: RunInput): Promise<NormalizedResult> {
438446
await mkdir(context.resultsDir, { recursive: true });
439447

440448
// No workspaceRoot: the sandbox-side launcher defaults it to its own root (BENCHPRESS_ROOT),
441-
// never this (local-only) engagementDir.
442-
const payload = buildRepoPayload({ target, controls, contributors });
449+
// never this (local-only) engagementDir. Webapp targets skip repo-modality-specific target
450+
// materialization entirely: there is no target repo to clone into the sandbox, only a URL the
451+
// sandbox reaches over the network (runDaytonaEngagement's own modality branch calls
452+
// prepareWebappTarget instead of prepareRepoTarget -- see src/daytona/bootstrap.ts).
453+
const payload =
454+
target.modality === 'webapp'
455+
? buildWebappPayload({ target, controls, contributors })
456+
: buildRepoPayload({ target, controls, contributors });
443457

444458
const stdoutChunks: string[] = [];
445459
const stderrChunks: string[] = [];

src/daytona/payload.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,23 @@ export type RepoEngagementPayload = {
1818
resume?: boolean;
1919
};
2020

21+
/** Mirrors autobrin-flue's `WebappTargetSchema` (`docs/modalities.md` on `staging`) field-for-field. */
22+
export type WebappTargetPayload = {
23+
url: string;
24+
repo?: string;
25+
sha?: string;
26+
username?: string;
27+
password?: string;
28+
role?: string;
29+
outboundServiceUrl?: string;
30+
proofUploadingUrl?: string;
31+
secret?: string;
32+
secretUploadingUrl?: string;
33+
};
34+
2135
export type WebappEngagementPayload = {
2236
modality: 'webapp';
23-
target: { url: string };
37+
target: WebappTargetPayload;
2438
workspaceRoot: string;
2539
model?: string;
2640
thinking?: string;
@@ -54,17 +68,29 @@ export function buildRepoPayload(input: {
5468
};
5569
}
5670

57-
export function buildWebappPayload(input: {
58-
url: string;
59-
model?: string;
60-
thinking?: string;
61-
contributors?: number;
62-
guardrails?: EngagementGuardrails;
63-
workspaceRoot?: string;
64-
}): WebappEngagementPayload {
71+
export function buildWebappPayload(
72+
input: WebappTargetPayload & {
73+
model?: string;
74+
thinking?: string;
75+
contributors?: number;
76+
guardrails?: EngagementGuardrails;
77+
workspaceRoot?: string;
78+
},
79+
): WebappEngagementPayload {
6580
return {
6681
modality: 'webapp',
67-
target: { url: input.url },
82+
target: {
83+
url: input.url,
84+
repo: input.repo,
85+
sha: input.sha,
86+
username: input.username,
87+
password: input.password,
88+
role: input.role,
89+
outboundServiceUrl: input.outboundServiceUrl,
90+
proofUploadingUrl: input.proofUploadingUrl,
91+
secret: input.secret,
92+
secretUploadingUrl: input.secretUploadingUrl,
93+
},
6894
workspaceRoot: input.workspaceRoot ?? BENCHPRESS_ROOT,
6995
model: input.model,
7096
thinking: input.thinking,
@@ -114,6 +140,15 @@ export function normalizeEngagementPayload(input: unknown): EngagementPayload {
114140
}
115141
return buildWebappPayload({
116142
url: target.url.trim(),
143+
repo: typeof target.repo === 'string' ? target.repo : undefined,
144+
sha: typeof target.sha === 'string' ? target.sha : undefined,
145+
username: typeof target.username === 'string' ? target.username : undefined,
146+
password: typeof target.password === 'string' ? target.password : undefined,
147+
role: typeof target.role === 'string' ? target.role : undefined,
148+
outboundServiceUrl: typeof target.outboundServiceUrl === 'string' ? target.outboundServiceUrl : undefined,
149+
proofUploadingUrl: typeof target.proofUploadingUrl === 'string' ? target.proofUploadingUrl : undefined,
150+
secret: typeof target.secret === 'string' ? target.secret : undefined,
151+
secretUploadingUrl: typeof target.secretUploadingUrl === 'string' ? target.secretUploadingUrl : undefined,
117152
model: typeof input.model === 'string' ? input.model : undefined,
118153
thinking: typeof input.thinking === 'string' ? input.thinking : undefined,
119154
contributors: typeof input.contributors === 'number' ? input.contributors : undefined,

tests/autobrin-contender.test.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ describe('fetchAttemptsFromSandbox (real python3 execution over a fake, real-she
174174
const repoPayload = (workspaceRoot: string): EngagementPayload =>
175175
({ modality: 'repo', repo: 'owner/repo', workspaceRoot, targetPreparation: 'prepared', resume: false }) as EngagementPayload;
176176

177+
const webappPayload = (workspaceRoot: string): EngagementPayload =>
178+
({ modality: 'webapp', target: { url: 'http://127.0.0.1:8080' }, workspaceRoot, resume: false }) as EngagementPayload;
179+
177180
it('returns one record per attempt directory, tolerating missing files', async () => {
178181
const { root, attacksDir } = makeSandboxRoot();
179182
const confirmedDir = path.join(attacksDir, 'attempt-confirmed');
@@ -208,13 +211,27 @@ describe('fetchAttemptsFromSandbox (real python3 execution over a fake, real-she
208211
expect(attempts).toEqual([]);
209212
});
210213

211-
it('returns an empty array for non-repo modalities without attempting a fetch', async () => {
212-
const attempts = await fetchAttemptsFromSandbox(realShellSandbox(), {
213-
modality: 'webapp',
214-
target: { url: 'http://127.0.0.1:8080' },
215-
workspaceRoot: '/home/daytona/benchpress',
216-
resume: false,
217-
} as EngagementPayload);
214+
it('reads attempts back for webapp modality too (regression: previously skipped modalities other than repo)', async () => {
215+
const { root, attacksDir } = makeSandboxRoot();
216+
const confirmedDir = path.join(attacksDir, 'attempt-confirmed');
217+
mkdirSync(confirmedDir, { recursive: true });
218+
writeFileSync(path.join(confirmedDir, 'evaluate.json'), JSON.stringify({ verdict: 'confirmed' }));
219+
writeFileSync(path.join(confirmedDir, 'report.json'), JSON.stringify({ location: 'http://127.0.0.1:8080/login' }));
220+
writeFileSync(path.join(confirmedDir, 'disclosure.json'), JSON.stringify({ cve_id: 'CVE-2024-3234' }));
221+
222+
const attempts = await fetchAttemptsFromSandbox(realShellSandbox(), webappPayload(root));
223+
224+
expect(attempts).toHaveLength(1);
225+
expect(attempts[0]?.evaluate).toEqual({ verdict: 'confirmed' });
226+
expect(computeClaimFromAttempts(attempts).confirmedFindings).toEqual([
227+
{ location: 'http://127.0.0.1:8080/login', cve: 'CVE-2024-3234', summary: undefined, verdict: 'confirmed' },
228+
]);
229+
});
230+
231+
it('returns an empty array when the webapp attacks directory does not exist at all', async () => {
232+
const emptyRoot = mkdtempSync(path.join(tmpdir(), 'benchpress-sandbox-empty-webapp-'));
233+
tmpDirs.push(emptyRoot);
234+
const attempts = await fetchAttemptsFromSandbox(realShellSandbox(), webappPayload(emptyRoot));
218235
expect(attempts).toEqual([]);
219236
});
220237

tests/autobrin-daytona-sequencing.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,89 @@ describe('autobrin daytona transport: local checkout must resolve before the san
7676
expect(checkoutMocks.ensureAutobrinCheckout).toHaveBeenCalledTimes(1);
7777
});
7878
});
79+
80+
describe('autobrin daytona transport: webapp modality (superagent-ai/benchpress#32)', () => {
81+
const baseTask = { id: 'CVE-2024-3234', benchmarkId: 'cve-bench' };
82+
const webappTarget = {
83+
benchmarkId: 'cve-bench',
84+
taskId: 'CVE-2024-3234',
85+
modality: 'webapp' as const,
86+
metadata: {
87+
webapp: {
88+
url: 'http://127.0.0.1:54321',
89+
username: 'tester',
90+
password: 'hunter2',
91+
proofUploadingUrl: 'http://127.0.0.1:54322/upload',
92+
},
93+
},
94+
};
95+
const baseControls = { model: 'kimi-azure/kimi-k2.6' };
96+
97+
const tmpDirs: string[] = [];
98+
99+
afterEach(() => {
100+
vi.restoreAllMocks();
101+
for (const dir of tmpDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
102+
});
103+
104+
function makeContext() {
105+
const root = mkdtempSync(path.join(tmpdir(), 'benchpress-daytona-webapp-'));
106+
tmpDirs.push(root);
107+
return { runId: 'run1', resultsDir: path.join(root, 'results'), engagementsDir: path.join(root, 'engagements') };
108+
}
109+
110+
it('builds a webapp payload (not buildRepoPayload) and never requires target.repo', async () => {
111+
checkoutMocks.ensureAutobrinCheckout.mockReset().mockResolvedValue({ root: '/cache/x', ref: 'staging', commitSha: 'deadbeef' });
112+
launcherMocks.runDaytonaEngagement.mockReset().mockResolvedValue({
113+
sandboxId: 'sandbox-1',
114+
engagement: { exitCode: 0, streamLogPath: 'x', resultPath: 'y', resultJson: {} },
115+
computerUse: {},
116+
keptSandbox: false,
117+
});
118+
119+
const runner = createAutobrinRunner({ config: { id: 'x', type: 'autobrin', transport: 'daytona', image: 'test-image' } });
120+
const result = await runner.run({ task: baseTask, target: webappTarget, controls: baseControls, context: makeContext() });
121+
122+
expect(result.exitCode).toBe(0);
123+
expect(launcherMocks.runDaytonaEngagement).toHaveBeenCalledTimes(1);
124+
const options = launcherMocks.runDaytonaEngagement.mock.calls[0]?.[0] as { payload: Record<string, unknown> };
125+
expect(options.payload).toMatchObject({
126+
modality: 'webapp',
127+
target: {
128+
url: 'http://127.0.0.1:54321',
129+
username: 'tester',
130+
password: 'hunter2',
131+
proofUploadingUrl: 'http://127.0.0.1:54322/upload',
132+
},
133+
});
134+
// Daytona transport omits workspaceRoot for webapp too -- the sandbox-side launcher defaults
135+
// it to its own root, same as the existing repo-modality behavior.
136+
expect('workspaceRoot' in options.payload).toBe(false);
137+
expect('repo' in options.payload).toBe(false);
138+
});
139+
140+
it('rejects an unsupported modality (e.g. "model") with a clear error, and never starts the sandbox', async () => {
141+
checkoutMocks.ensureAutobrinCheckout.mockReset();
142+
launcherMocks.runDaytonaEngagement.mockReset();
143+
const runner = createAutobrinRunner({ config: { id: 'x', type: 'autobrin', transport: 'daytona', image: 'test-image' } });
144+
const modelTarget = { benchmarkId: 'b', taskId: 't1', modality: 'model' as const };
145+
146+
await expect(
147+
runner.run({ task: baseTask, target: modelTarget, controls: baseControls, context: makeContext() }),
148+
).rejects.toThrow(/does not support modality "model"/);
149+
expect(launcherMocks.runDaytonaEngagement).not.toHaveBeenCalled();
150+
expect(checkoutMocks.ensureAutobrinCheckout).not.toHaveBeenCalled();
151+
});
152+
153+
it('still rejects modality "repo" missing target.repo, unchanged from before', async () => {
154+
checkoutMocks.ensureAutobrinCheckout.mockReset();
155+
launcherMocks.runDaytonaEngagement.mockReset();
156+
const runner = createAutobrinRunner({ config: { id: 'x', type: 'autobrin', transport: 'daytona', image: 'test-image' } });
157+
const repoTargetMissingRepo = { benchmarkId: 'b', taskId: 't1', modality: 'repo' as const };
158+
159+
await expect(
160+
runner.run({ task: baseTask, target: repoTargetMissingRepo, controls: baseControls, context: makeContext() }),
161+
).rejects.toThrow(/modality "repo" requires target\.repo/);
162+
expect(launcherMocks.runDaytonaEngagement).not.toHaveBeenCalled();
163+
});
164+
});

tests/daytona.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,57 @@ describe('daytona payload', () => {
9494
});
9595
});
9696

97+
// Regression (superagent-ai/benchpress#32): the Daytona transport's webapp payload used to carry
98+
// only target.url, silently dropping the rest of the canonical WebappTargetMetadata contract
99+
// (username/password/proofUploadingUrl/etc.) that src/contenders/autobrin.ts's buildWebappPayload
100+
// already sends -- neutering benchmarks like CVE-Bench, whose tasks always require credentials
101+
// and a proof-upload URL (see src/benchmarks/cve-bench/metadata.ts).
102+
it('carries the full webapp target contract through, not just url', () => {
103+
const payload = buildWebappPayload({
104+
url: 'http://127.0.0.1:8080',
105+
repo: 'owner/repo',
106+
sha: 'abc123',
107+
username: 'attacker',
108+
password: 'hunter2',
109+
role: 'user',
110+
outboundServiceUrl: 'http://target-internal:9000',
111+
proofUploadingUrl: 'http://127.0.0.1:9091/upload',
112+
secret: 'topsecret',
113+
secretUploadingUrl: 'http://127.0.0.1:9091/secret',
114+
});
115+
expect(payload.target).toEqual({
116+
url: 'http://127.0.0.1:8080',
117+
repo: 'owner/repo',
118+
sha: 'abc123',
119+
username: 'attacker',
120+
password: 'hunter2',
121+
role: 'user',
122+
outboundServiceUrl: 'http://target-internal:9000',
123+
proofUploadingUrl: 'http://127.0.0.1:9091/upload',
124+
secret: 'topsecret',
125+
secretUploadingUrl: 'http://127.0.0.1:9091/secret',
126+
});
127+
});
128+
129+
it('normalizes webapp payloads without dropping credentials/proof-upload fields (regression: daytona transport silently stripped these)', () => {
130+
const normalized = normalizeEngagementPayload({
131+
modality: 'webapp',
132+
target: {
133+
url: 'http://127.0.0.1:8080',
134+
username: 'attacker',
135+
password: 'hunter2',
136+
proofUploadingUrl: 'http://127.0.0.1:9091/upload',
137+
},
138+
});
139+
if (normalized.modality !== 'webapp') throw new Error('expected a webapp payload');
140+
expect(normalized.target).toEqual({
141+
url: 'http://127.0.0.1:8080',
142+
username: 'attacker',
143+
password: 'hunter2',
144+
proofUploadingUrl: 'http://127.0.0.1:9091/upload',
145+
});
146+
});
147+
97148
it('normalizes payload JSON objects', () => {
98149
expect(
99150
normalizeEngagementPayload({

0 commit comments

Comments
 (0)