Skip to content

Commit 0c33a7d

Browse files
authored
Merge pull request #36 from superagent-ai/daytona-webapp-modality
Extend Daytona-transport autobrin contender to support webapp modality (fixes #32)
2 parents 296ee44 + a9d6b4d commit 0c33a7d

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
@@ -109,7 +109,8 @@ export type AttemptRecord = {
109109
export function buildWebappPayload(input: {
110110
target: TargetHandle;
111111
controls: RunControls;
112-
workspaceRoot: string;
112+
/** Omit for the Daytona transport: the sandbox-side launcher defaults this to its own root. */
113+
workspaceRoot?: string;
113114
contributors?: number;
114115
}): Record<string, unknown> {
115116
const webapp = webappTargetMetadata(input.target);
@@ -132,7 +133,7 @@ export function buildWebappPayload(input: {
132133
secret: webapp.secret,
133134
secretUploadingUrl: webapp.secretUploadingUrl,
134135
},
135-
workspaceRoot: input.workspaceRoot,
136+
...(input.workspaceRoot !== undefined ? { workspaceRoot: input.workspaceRoot } : {}),
136137
model: input.controls.model,
137138
contributors: input.contributors ?? input.controls.contributors,
138139
...buildGuardrails(input.controls),
@@ -301,6 +302,12 @@ export function buildReadAttemptsScript(attacksDir: string): string {
301302
* `runDaytonaEngagement`'s `afterEngagement` hook (before sandbox cleanup) -- by the time
302303
* `runDaytonaEngagement` itself resolves, the sandbox is already deleted.
303304
*
305+
* The `<workspaceRoot>/workspace/attacks/<attempt>/*.json` layout is a modality-agnostic
306+
* autobrin-flue convention (both `repo` and `webapp` modalities call the same `prepareWorkspace()`
307+
* layout code), so this reads attempts back the same way regardless of `payload.modality` -- this
308+
* mirrors the local-transport reader (`extractClaimFromWorkspace`), which is likewise never gated
309+
* on modality.
310+
*
304311
* Deliberately does NOT tolerate a script-level failure (non-zero exit, unparsable/non-array
305312
* output) by falling back to `[]`: the script's own per-file reads already tolerate an
306313
* individual attempt missing evaluate/report/disclosure.json (still mid-run, a legitimate `{}`),
@@ -312,8 +319,6 @@ export function buildReadAttemptsScript(attacksDir: string): string {
312319
* contract (cleanup still runs, the overall call rejects) instead of corrupting the claim.
313320
*/
314321
export async function fetchAttemptsFromSandbox(sandbox: Sandbox, payload: EngagementPayload): Promise<AttemptRecord[]> {
315-
if (payload.modality !== 'repo') return [];
316-
317322
const attacksDir = `${engagementWorkspaceDir(payload)}/attacks`;
318323
const script = ['set -euo pipefail', "python3 - <<'PY'", buildReadAttemptsScript(attacksDir), 'PY'].join('\n');
319324
const response = await executeChecked(sandbox, script, '/', 60);
@@ -450,11 +455,14 @@ async function runViaLocalNpx(input: RunInput): Promise<NormalizedResult> {
450455
async function runViaDaytona(input: RunInput): Promise<NormalizedResult> {
451456
const { contenderId, config, contributors, task, target, controls, context } = input;
452457

453-
if (target.modality !== 'repo' || !target.repo) {
458+
if (target.modality !== 'repo' && target.modality !== 'webapp') {
454459
throw new Error(
455-
`autobrin contender "${contenderId}": transport "daytona" currently only supports modality "repo" (got "${target.modality}")`,
460+
`autobrin contender "${contenderId}": transport "daytona" does not support modality "${target.modality}"`,
456461
);
457462
}
463+
if (target.modality === 'repo' && !target.repo) {
464+
throw new Error(`autobrin contender "${contenderId}": modality "repo" requires target.repo`);
465+
}
458466

459467
const started = Date.now();
460468
const engagementDir = path.join(
@@ -465,8 +473,14 @@ async function runViaDaytona(input: RunInput): Promise<NormalizedResult> {
465473
await mkdir(context.resultsDir, { recursive: true });
466474

467475
// No workspaceRoot: the sandbox-side launcher defaults it to its own root (BENCHPRESS_ROOT),
468-
// never this (local-only) engagementDir.
469-
const payload = buildRepoPayload({ target, controls, contributors });
476+
// never this (local-only) engagementDir. Webapp targets skip repo-modality-specific target
477+
// materialization entirely: there is no target repo to clone into the sandbox, only a URL the
478+
// sandbox reaches over the network (runDaytonaEngagement's own modality branch calls
479+
// prepareWebappTarget instead of prepareRepoTarget -- see src/daytona/bootstrap.ts).
480+
const payload =
481+
target.modality === 'webapp'
482+
? buildWebappPayload({ target, controls, contributors })
483+
: buildRepoPayload({ target, controls, contributors });
470484

471485
const stdoutChunks: string[] = [];
472486
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
@@ -239,6 +239,9 @@ describe('fetchAttemptsFromSandbox (real python3 execution over a fake, real-she
239239
const repoPayload = (workspaceRoot: string): EngagementPayload =>
240240
({ modality: 'repo', repo: 'owner/repo', workspaceRoot, targetPreparation: 'prepared', resume: false }) as EngagementPayload;
241241

242+
const webappPayload = (workspaceRoot: string): EngagementPayload =>
243+
({ modality: 'webapp', target: { url: 'http://127.0.0.1:8080' }, workspaceRoot, resume: false }) as EngagementPayload;
244+
242245
it('returns one record per attempt directory, tolerating missing files', async () => {
243246
const { root, attacksDir } = makeSandboxRoot();
244247
const confirmedDir = path.join(attacksDir, 'attempt-confirmed');
@@ -273,13 +276,27 @@ describe('fetchAttemptsFromSandbox (real python3 execution over a fake, real-she
273276
expect(attempts).toEqual([]);
274277
});
275278

276-
it('returns an empty array for non-repo modalities without attempting a fetch', async () => {
277-
const attempts = await fetchAttemptsFromSandbox(realShellSandbox(), {
278-
modality: 'webapp',
279-
target: { url: 'http://127.0.0.1:8080' },
280-
workspaceRoot: '/home/daytona/benchpress',
281-
resume: false,
282-
} as EngagementPayload);
279+
it('reads attempts back for webapp modality too (regression: previously skipped modalities other than repo)', async () => {
280+
const { root, attacksDir } = makeSandboxRoot();
281+
const confirmedDir = path.join(attacksDir, 'attempt-confirmed');
282+
mkdirSync(confirmedDir, { recursive: true });
283+
writeFileSync(path.join(confirmedDir, 'evaluate.json'), JSON.stringify({ verdict: 'confirmed' }));
284+
writeFileSync(path.join(confirmedDir, 'report.json'), JSON.stringify({ location: 'http://127.0.0.1:8080/login' }));
285+
writeFileSync(path.join(confirmedDir, 'disclosure.json'), JSON.stringify({ cve_id: 'CVE-2024-3234' }));
286+
287+
const attempts = await fetchAttemptsFromSandbox(realShellSandbox(), webappPayload(root));
288+
289+
expect(attempts).toHaveLength(1);
290+
expect(attempts[0]?.evaluate).toEqual({ verdict: 'confirmed' });
291+
expect(computeClaimFromAttempts(attempts).confirmedFindings).toEqual([
292+
{ location: 'http://127.0.0.1:8080/login', cve: 'CVE-2024-3234', summary: undefined, verdict: 'confirmed' },
293+
]);
294+
});
295+
296+
it('returns an empty array when the webapp attacks directory does not exist at all', async () => {
297+
const emptyRoot = mkdtempSync(path.join(tmpdir(), 'benchpress-sandbox-empty-webapp-'));
298+
tmpDirs.push(emptyRoot);
299+
const attempts = await fetchAttemptsFromSandbox(realShellSandbox(), webappPayload(emptyRoot));
283300
expect(attempts).toEqual([]);
284301
});
285302

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)