argus-reviewer-e2e 0.1.2 → 0.1.3
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.
- package/README.md +1 -0
- package/action/action.yml +5 -0
- package/action/sticky-comment.mjs +36 -8
- package/dist/cli.js +83 -40
- package/dist/config.d.ts +43 -1
- package/dist/config.js +30 -6
- package/dist/detect.d.ts +11 -1
- package/dist/detect.js +19 -3
- package/dist/evidence/ci.d.ts +44 -2
- package/dist/evidence/ci.js +96 -6
- package/dist/evidence/gate.d.ts +10 -0
- package/dist/evidence/gate.js +29 -0
- package/dist/evidence/link.d.ts +6 -1
- package/dist/evidence/link.js +7 -5
- package/dist/executor/sandbox.d.ts +105 -0
- package/dist/executor/sandbox.js +231 -0
- package/dist/probe/author.d.ts +50 -0
- package/dist/probe/author.js +149 -0
- package/dist/probe/harness.d.ts +30 -0
- package/dist/probe/harness.js +99 -0
- package/dist/probe/queue.d.ts +102 -0
- package/dist/probe/queue.js +408 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ Open-source, self-hosted vision-model E2E testing — the hundred-eyed watcher f
|
|
|
10
10
|
- **Cache-first**: replay costs zero vision calls on an unchanged UI; heals re-spend only on drift and show up as reviewable cache diffs.
|
|
11
11
|
- **Cost-explicit**: every call is metered from OpenRouter's per-call cost and rolled into a per-run dollar figure on the PR.
|
|
12
12
|
- **Grounding specialist**: a `grounding_model` (e.g. a ui-tars-class model) can drive element location with its native coordinate output, verified against the DOM before any click executes.
|
|
13
|
+
- **Execution-backed review**: `code-review` findings carry CI evidence, and with the opt-in sandbox lane (`sandbox: { enabled: true }`) Argus authors a test probe for unexercised findings and runs it in a hardened, network-less Docker container — a finding that fails on head and passes on base is stamped **reproduced**, not just suspected.
|
|
13
14
|
|
|
14
15
|
```bash
|
|
15
16
|
npm i -D argus-reviewer-e2e # or github:duketopceo/argus-reviewer
|
package/action/action.yml
CHANGED
|
@@ -42,6 +42,10 @@ inputs:
|
|
|
42
42
|
description: Report output dir, passed to both run and code-review so the post step can find run.json/code-review.json regardless of config reportDir
|
|
43
43
|
default: argus-reviewer-report
|
|
44
44
|
required: false
|
|
45
|
+
sandbox:
|
|
46
|
+
description: Enable the B.2 probe lane — authored test probes for unexercised findings run in a hardened Docker container (no network, no secrets, read-only fs). Enable-only — 'false' does not override a config-enabled lane. Requires Docker on the runner; forced off on pull_request_target.
|
|
47
|
+
default: 'false'
|
|
48
|
+
required: false
|
|
45
49
|
outputs:
|
|
46
50
|
conclusion:
|
|
47
51
|
description: 'Run conclusion: success, failure, or neutral'
|
|
@@ -99,6 +103,7 @@ runs:
|
|
|
99
103
|
env:
|
|
100
104
|
OPENROUTER_API_KEY: ${{ inputs.openrouter-api-key }}
|
|
101
105
|
GITHUB_TOKEN: ${{ github.token }}
|
|
106
|
+
ARGUS_SANDBOX: ${{ inputs.sandbox == 'true' && '1' || '' }}
|
|
102
107
|
ARGUS_DEBUG: '1'
|
|
103
108
|
ARGUS_REVIEWER_TRACE: >-
|
|
104
109
|
{"repo":"${{ github.repository }}",
|
|
@@ -9,6 +9,11 @@ function formatUsd(n) {
|
|
|
9
9
|
return `$${(n || 0).toFixed(6)}`
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** Escape a report string for one markdown table cell. */
|
|
13
|
+
function cell(s) {
|
|
14
|
+
return String(s ?? '').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ').slice(0, 200)
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
function renderMissingKeyBody() {
|
|
13
18
|
const lines = []
|
|
14
19
|
lines.push(SENTINEL)
|
|
@@ -193,18 +198,32 @@ function renderBody(report, codeReview, runUrl, ok) {
|
|
|
193
198
|
lines.push('<summary>🧠 Code review</summary>')
|
|
194
199
|
lines.push('')
|
|
195
200
|
lines.push(`**Verdict:** ${codeReview.verdict} · ${codeReview.model} · ${codeReview.tokens}tok ${formatUsd(codeReview.visionCostUsd)}`)
|
|
201
|
+
if (Array.isArray(codeReview.probes) && codeReview.probes.length > 0) {
|
|
202
|
+
const reproduced = codeReview.probes.filter((p) => p.outcome === 'reproduced').length
|
|
203
|
+
lines.push(` · 🧪 ${codeReview.probes.length} probe${codeReview.probes.length === 1 ? '' : 's'} run, ${reproduced} reproduced`)
|
|
204
|
+
}
|
|
205
|
+
if (typeof codeReview.probeLaneSkipped === 'string') {
|
|
206
|
+
lines.push(` · 🧪 probe lane skipped — ${cell(codeReview.probeLaneSkipped)}`)
|
|
207
|
+
}
|
|
196
208
|
lines.push('')
|
|
197
209
|
lines.push(codeReview.summary)
|
|
198
210
|
lines.push('')
|
|
199
211
|
if (codeReview.findings.length > 0) {
|
|
200
|
-
const evidenceIcon = { exercised: '✅', corroborated: '🔴', not_exercised: '⚪', inconclusive: '❔' }
|
|
212
|
+
const evidenceIcon = { exercised: '✅', corroborated: '🔴', not_exercised: '⚪', inconclusive: '❔', reproduced: '🧪' }
|
|
201
213
|
lines.push('| File | Severity | Evidence | Finding |')
|
|
202
214
|
lines.push('| --- | --- | --- | --- |')
|
|
203
|
-
|
|
215
|
+
// Findings/evidence strings are model- and probe-emitted — sanitize
|
|
216
|
+
// for the markdown table and bound the section so an oversized report
|
|
217
|
+
// can't push the body past GitHub's 65536-char comment limit.
|
|
218
|
+
const MAX_FINDING_ROWS = 25
|
|
219
|
+
for (const f of codeReview.findings.slice(0, MAX_FINDING_ROWS)) {
|
|
204
220
|
const ev = f.evidence
|
|
205
|
-
? `${evidenceIcon[f.evidence.status] ?? '❔'} ${f.evidence.detail}`
|
|
221
|
+
? `${evidenceIcon[f.evidence.status] ?? '❔'} ${cell(f.evidence.detail)}`
|
|
206
222
|
: '—'
|
|
207
|
-
lines.push(`| \`${f.file}\` | ${f.severity} | ${ev} | ${f.message} |`)
|
|
223
|
+
lines.push(`| \`${cell(f.file)}\` | ${cell(f.severity)} | ${ev} | ${cell(f.message)} |`)
|
|
224
|
+
}
|
|
225
|
+
if (codeReview.findings.length > MAX_FINDING_ROWS) {
|
|
226
|
+
lines.push(`| … | — | — | ${codeReview.findings.length - MAX_FINDING_ROWS} more findings in \`code-review.json\` |`)
|
|
208
227
|
}
|
|
209
228
|
lines.push('')
|
|
210
229
|
}
|
|
@@ -281,14 +300,23 @@ async function postInlineComments(pr, codeReview) {
|
|
|
281
300
|
path: f.file,
|
|
282
301
|
line: f.line,
|
|
283
302
|
side: 'RIGHT',
|
|
284
|
-
body: `**argus-reviewer ${f.severity}:** ${f.message}${
|
|
303
|
+
body: `**argus-reviewer ${f.severity}:** ${f.message}${
|
|
304
|
+
f.evidence && f.evidence.status === 'reproduced'
|
|
305
|
+
? '\n\n*🧪 Reproduced by an Argus probe — fails on this PR head, clean on base. See workflow artifacts.*'
|
|
306
|
+
: f.evidence && f.evidence.status !== 'exercised'
|
|
307
|
+
? `\n\n*CI evidence: ${f.evidence.detail}*`
|
|
308
|
+
: ''
|
|
309
|
+
}`,
|
|
285
310
|
}))
|
|
286
311
|
if (comments.length === 0) return
|
|
287
312
|
|
|
288
313
|
// Re-runs on the same SHA must not duplicate inline comments — the sticky
|
|
289
314
|
// body is upserted but review comments are not. Paginate fully (100/page)
|
|
290
315
|
// and scope dedup to the current head: comments on older commits must not
|
|
291
|
-
// suppress findings that still apply to this head.
|
|
316
|
+
// suppress findings that still apply to this head. The key is the body's
|
|
317
|
+
// first line (the finding itself) — trailing evidence notes like the
|
|
318
|
+
// `reproduced` upgrade change the body but must not re-post a duplicate.
|
|
319
|
+
const dedupKey = (path, line, body) => `${path}:${line}:${body.split('\n')[0]}`
|
|
292
320
|
const posted = new Set()
|
|
293
321
|
let page = 1
|
|
294
322
|
for (;;) {
|
|
@@ -301,13 +329,13 @@ async function postInlineComments(pr, codeReview) {
|
|
|
301
329
|
})
|
|
302
330
|
for (const c of existing) {
|
|
303
331
|
if (c.body && c.body.startsWith('**argus-reviewer') && c.commit_id === pr.head.sha) {
|
|
304
|
-
posted.add(
|
|
332
|
+
posted.add(dedupKey(c.path, c.line, c.body))
|
|
305
333
|
}
|
|
306
334
|
}
|
|
307
335
|
if (existing.length < 100) break
|
|
308
336
|
page += 1
|
|
309
337
|
}
|
|
310
|
-
const fresh = comments.filter((c) => !posted.has(
|
|
338
|
+
const fresh = comments.filter((c) => !posted.has(dedupKey(c.path, c.line, c.body)))
|
|
311
339
|
if (fresh.length === 0) return
|
|
312
340
|
|
|
313
341
|
// One batched review instead of N createReviewComment calls — avoids
|
package/dist/cli.js
CHANGED
|
@@ -17,8 +17,9 @@ import { buildReviewContext, CONTEXT_PREFIX } from './index/context.js';
|
|
|
17
17
|
import { diffChangedFiles } from './index/diff.js';
|
|
18
18
|
import { invalidateForDiff } from './index/invalidate.js';
|
|
19
19
|
import { readIndex, scanRepo, writeIndex } from './index/scan.js';
|
|
20
|
-
import { fetchCheckRuns,
|
|
20
|
+
import { fetchCheckRuns, fetchPrMeta, ghGet } from './evidence/ci.js';
|
|
21
21
|
import { linkFindings } from './evidence/link.js';
|
|
22
|
+
import { runProbeLane } from './probe/queue.js';
|
|
22
23
|
import { A0_DEFAULT_TIMEOUT_MS, a0TaskPrompt, runA0Task } from './executor/a0.js';
|
|
23
24
|
import { buildJournalEntry } from './journal/build.js';
|
|
24
25
|
import { newRunId, writeJournal } from './journal/store.js';
|
|
@@ -27,6 +28,7 @@ import { liveLog } from './live.js';
|
|
|
27
28
|
import { writeJunitXml } from './report/junit.js';
|
|
28
29
|
import { buildRunReport, writeRunReport } from './report/run.js';
|
|
29
30
|
import { flowPath, loadFlow } from './cache/store.js';
|
|
31
|
+
import { writeAtomicJson } from './fsutil.js';
|
|
30
32
|
import { OpenRouterClient } from './vision/openrouter.js';
|
|
31
33
|
import { Ledger } from './vision/ledger.js';
|
|
32
34
|
const USAGE = `argus-reviewer — vision-model E2E testing harness (BYOK via OPENROUTER_API_KEY)
|
|
@@ -705,41 +707,13 @@ const CHUNK_FILE_OVERHEAD = 100;
|
|
|
705
707
|
const MAX_PR_FILE_PAGES = 10;
|
|
706
708
|
async function fetchPrFiles(repo, pr, token, ctx) {
|
|
707
709
|
const files = [];
|
|
708
|
-
let page = 1;
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
signal: controller.signal,
|
|
716
|
-
headers: {
|
|
717
|
-
Authorization: `Bearer ${token}`,
|
|
718
|
-
Accept: 'application/vnd.github+json',
|
|
719
|
-
'X-GitHub-Api-Version': '2022-11-28',
|
|
720
|
-
},
|
|
721
|
-
});
|
|
722
|
-
if (!res.ok) {
|
|
723
|
-
ctx.err(`failed to fetch PR files: ${res.status} ${res.statusText}`);
|
|
724
|
-
return undefined;
|
|
725
|
-
}
|
|
726
|
-
const batch = (await res.json());
|
|
727
|
-
const withPatches = batch.filter((f) => typeof f.patch === 'string' && f.patch.length > 0);
|
|
728
|
-
files.push(...withPatches);
|
|
729
|
-
if (batch.length < 100)
|
|
730
|
-
break;
|
|
731
|
-
page++;
|
|
732
|
-
}
|
|
733
|
-
catch (e) {
|
|
734
|
-
if (e instanceof Error && e.name === 'AbortError') {
|
|
735
|
-
ctx.err(`failed to fetch PR files: request timed out after 30s (page ${page})`);
|
|
736
|
-
return undefined;
|
|
737
|
-
}
|
|
738
|
-
throw e;
|
|
739
|
-
}
|
|
740
|
-
finally {
|
|
741
|
-
clearTimeout(timeout);
|
|
742
|
-
}
|
|
710
|
+
for (let page = 1; page <= MAX_PR_FILE_PAGES; page++) {
|
|
711
|
+
const batch = (await ghGet(`https://api.github.com/repos/${repo}/pulls/${pr}/files?per_page=100&page=${page}`, token, ctx));
|
|
712
|
+
if (batch === undefined)
|
|
713
|
+
return undefined;
|
|
714
|
+
files.push(...batch.filter((f) => typeof f.patch === 'string' && f.patch.length > 0));
|
|
715
|
+
if (batch.length < 100)
|
|
716
|
+
break;
|
|
743
717
|
}
|
|
744
718
|
return files;
|
|
745
719
|
}
|
|
@@ -892,7 +866,7 @@ async function cmdCodeReview(args, ctx, deps) {
|
|
|
892
866
|
model,
|
|
893
867
|
budgetExceeded: false,
|
|
894
868
|
};
|
|
895
|
-
await
|
|
869
|
+
await writeAtomicJson(codeReviewPath, skipped);
|
|
896
870
|
return 0;
|
|
897
871
|
};
|
|
898
872
|
if (!repo || !pr)
|
|
@@ -916,6 +890,9 @@ async function cmdCodeReview(args, ctx, deps) {
|
|
|
916
890
|
try {
|
|
917
891
|
const client = createClient(deps, config, ctx);
|
|
918
892
|
const ledger = new Ledger(budget);
|
|
893
|
+
// Kick off PR metadata now — it only needs repo/pr/token and its
|
|
894
|
+
// round-trip hides behind the model calls. Degrades to undefined.
|
|
895
|
+
const prMetaPromise = fetchPrMeta(repo, pr, token, ctx).catch(() => undefined);
|
|
919
896
|
const allFindings = [];
|
|
920
897
|
const allCalls = [];
|
|
921
898
|
let totalTokens = 0;
|
|
@@ -1003,25 +980,81 @@ async function cmdCodeReview(args, ctx, deps) {
|
|
|
1003
980
|
// exercised the implicated path. Post-pass annotation only — evidence
|
|
1004
981
|
// never downgrades a finding, and check-run names are sanitized before
|
|
1005
982
|
// they reach the comment.
|
|
1006
|
-
|
|
983
|
+
// prMeta also carries isFork/authorAssociation/labels — the B.2 probe
|
|
984
|
+
// lane's fork gate (evidence/gate.ts) consumes them; only headSha feeds
|
|
985
|
+
// evidence linkage here.
|
|
986
|
+
const prMeta = await prMetaPromise;
|
|
987
|
+
const headSha = prMeta?.headSha;
|
|
1007
988
|
const checkRuns = headSha === undefined ? undefined : await fetchCheckRuns(repo, headSha, token, ctx);
|
|
1008
989
|
const linkedFindings = linkFindings(finalFindings, index, checkRuns);
|
|
1009
990
|
debug('code-review', `evidence: ${linkedFindings.map((f) => f.evidence.status).join(',')}`);
|
|
1010
991
|
const blockSeverities = config.severity ?? ['bug'];
|
|
1011
|
-
|
|
992
|
+
// B.2 probe lane: authored tests executed in the Docker sandbox can
|
|
993
|
+
// upgrade a not_exercised finding to `reproduced`. Strictly additive —
|
|
994
|
+
// failures degrade to a detail note and the lane never changes verdict,
|
|
995
|
+
// ok, or the exit code (KTD8). Opt-in via config.sandbox.enabled or the
|
|
996
|
+
// ARGUS_SANDBOX=1 env flag (enable-only; other values leave config
|
|
997
|
+
// authoritative).
|
|
998
|
+
let probes;
|
|
999
|
+
let probeLaneSkipped;
|
|
1000
|
+
const sandbox = { ...config.sandbox, enabled: config.sandbox.enabled || ctx.env.ARGUS_SANDBOX === '1' };
|
|
1001
|
+
// pull_request_target runs with the base repo's write token and ambient
|
|
1002
|
+
// secrets — the docs call the lane unsupported there; enforce it in
|
|
1003
|
+
// code too so a miswired workflow fails closed instead of executing
|
|
1004
|
+
// PR code beside real credentials.
|
|
1005
|
+
if (ctx.env.GITHUB_EVENT_NAME === 'pull_request_target')
|
|
1006
|
+
sandbox.enabled = false;
|
|
1007
|
+
if (sandbox.enabled && !ledger.budgetExceeded) {
|
|
1008
|
+
try {
|
|
1009
|
+
const lane = await runProbeLane(linkedFindings, {
|
|
1010
|
+
cwd: ctx.cwd,
|
|
1011
|
+
reportDir,
|
|
1012
|
+
sandbox,
|
|
1013
|
+
meta: prMeta,
|
|
1014
|
+
token,
|
|
1015
|
+
client,
|
|
1016
|
+
model,
|
|
1017
|
+
provider: config.provider,
|
|
1018
|
+
ledger,
|
|
1019
|
+
budgetUsd: budget,
|
|
1020
|
+
severityGates: blockSeverities,
|
|
1021
|
+
index,
|
|
1022
|
+
calls: allCalls,
|
|
1023
|
+
exec: deps.exec,
|
|
1024
|
+
log: (line) => ctx.err(line),
|
|
1025
|
+
});
|
|
1026
|
+
if (lane !== undefined) {
|
|
1027
|
+
probes = lane.records;
|
|
1028
|
+
probeLaneSkipped = lane.skipReason;
|
|
1029
|
+
// Probe authoring spend lands on the shared ledger — the report's
|
|
1030
|
+
// headline cost fields must count it too or they understate the run.
|
|
1031
|
+
for (const p of lane.records) {
|
|
1032
|
+
totalCost += p.costUsd ?? 0;
|
|
1033
|
+
totalTokens += p.tokens ?? 0;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
catch (e) {
|
|
1038
|
+
debug('code-review', `probe lane failed: ${e.message}`);
|
|
1039
|
+
ctx.err(`code-review probe lane failed: ${e.message}`);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
const hasBlocker = finalFindings.some((f) => blockSeverities.includes(f.severity));
|
|
1012
1043
|
const report = {
|
|
1013
1044
|
ok: !hasBlocker && !ledger.budgetExceeded,
|
|
1014
1045
|
skipped: false,
|
|
1015
1046
|
summary,
|
|
1016
1047
|
verdict,
|
|
1017
1048
|
findings: linkedFindings,
|
|
1049
|
+
...(probes !== undefined ? { probes } : {}),
|
|
1050
|
+
...(probeLaneSkipped !== undefined ? { probeLaneSkipped } : {}),
|
|
1018
1051
|
calls: allCalls,
|
|
1019
1052
|
visionCostUsd: totalCost,
|
|
1020
1053
|
tokens: totalTokens,
|
|
1021
1054
|
model: lastModel,
|
|
1022
1055
|
budgetExceeded: ledger.budgetExceeded,
|
|
1023
1056
|
};
|
|
1024
|
-
await
|
|
1057
|
+
await writeAtomicJson(codeReviewPath, report);
|
|
1025
1058
|
ctx.out(`code review complete: ${finalFindings.length} findings, verdict ${verdict}, ` +
|
|
1026
1059
|
`${totalTokens}tok $${totalCost.toFixed(6)}${ledger.budgetExceeded ? ' (budget exceeded)' : ''}`);
|
|
1027
1060
|
return 0;
|
|
@@ -1199,10 +1232,16 @@ const INIT_WORKFLOW = `name: argus-reviewer
|
|
|
1199
1232
|
|
|
1200
1233
|
on:
|
|
1201
1234
|
pull_request:
|
|
1235
|
+
# 'labeled' lets a maintainer re-trigger with the argus-probe label when
|
|
1236
|
+
# sandbox probes are enabled for fork PRs.
|
|
1237
|
+
types: [opened, synchronize, reopened, labeled]
|
|
1202
1238
|
|
|
1203
1239
|
jobs:
|
|
1204
1240
|
argus:
|
|
1205
1241
|
runs-on: ubuntu-latest
|
|
1242
|
+
# 'labeled' fires on EVERY label — only argus-probe is the fork-gate
|
|
1243
|
+
# signal worth a full review run.
|
|
1244
|
+
if: github.event.action != 'labeled' || github.event.label.name == 'argus-probe'
|
|
1206
1245
|
permissions:
|
|
1207
1246
|
contents: read
|
|
1208
1247
|
issues: write
|
|
@@ -1210,7 +1249,11 @@ jobs:
|
|
|
1210
1249
|
checks: write
|
|
1211
1250
|
statuses: write
|
|
1212
1251
|
steps:
|
|
1252
|
+
# persist-credentials: false keeps the GITHUB_TOKEN out of .git/config —
|
|
1253
|
+
# the probe sandbox masks .git regardless, but don't store it at all.
|
|
1213
1254
|
- uses: actions/checkout@v4
|
|
1255
|
+
with:
|
|
1256
|
+
persist-credentials: false
|
|
1214
1257
|
# Pin a tag or commit for supply-chain safety once releases are cut.
|
|
1215
1258
|
- uses: duketopceo/Argus/action@main
|
|
1216
1259
|
with:
|
package/dist/config.d.ts
CHANGED
|
@@ -10,6 +10,41 @@ export interface Target {
|
|
|
10
10
|
url: string;
|
|
11
11
|
readyTimeoutMs: number;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Sandbox probe lane (Phase B.2, KTD5): runs authored test probes against
|
|
15
|
+
* `not_exercised` code-review findings inside a hardened Docker container.
|
|
16
|
+
* Opt-in — `enabled` defaults to false. Fork PRs are gated by `allowForks`,
|
|
17
|
+
* the `argus-probe` label, or a trusted author_association (see
|
|
18
|
+
* `evidence/gate.ts`).
|
|
19
|
+
*/
|
|
20
|
+
export interface Sandbox {
|
|
21
|
+
/** Master switch for the probe lane. Default false. */
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Docker image probes run in — trusted maintainer config (a custom image
|
|
25
|
+
* extends the sandbox's trusted computing base). Default undefined →
|
|
26
|
+
* resolved at probe time as `node:<host Node major>-slim`, because native
|
|
27
|
+
* `node_modules` are ABI-bound to the Node version that installed them.
|
|
28
|
+
*/
|
|
29
|
+
image: string | undefined;
|
|
30
|
+
/** Max probes authored/executed per code-review run. Default 3. */
|
|
31
|
+
maxProbes: number;
|
|
32
|
+
/** Hard wall-clock timeout per probe in milliseconds. Default 120_000. */
|
|
33
|
+
timeoutMs: number;
|
|
34
|
+
/** Container memory limit (`--memory`). Default '2g'. */
|
|
35
|
+
memory: string;
|
|
36
|
+
/** Container CPU limit (`--cpus`). Default '2'. */
|
|
37
|
+
cpus: string;
|
|
38
|
+
/** Container PID limit (`--pids-limit`). Default 256. */
|
|
39
|
+
pidsLimit: number;
|
|
40
|
+
/**
|
|
41
|
+
* When true, probes run on fork PRs without further approval. When false,
|
|
42
|
+
* fork PRs require a head-bound `argus-probe` label (the labeled event must
|
|
43
|
+
* postdate the head's pushed_at) or a MEMBER/OWNER/COLLABORATOR
|
|
44
|
+
* author_association. Same-repo PRs are unaffected either way.
|
|
45
|
+
*/
|
|
46
|
+
allowForks: boolean;
|
|
47
|
+
}
|
|
13
48
|
export interface Config {
|
|
14
49
|
model: string;
|
|
15
50
|
escalation_model: string;
|
|
@@ -112,11 +147,18 @@ export interface Config {
|
|
|
112
147
|
* the app or the expectation is wrong.
|
|
113
148
|
*/
|
|
114
149
|
heal: 'local' | 'a0' | undefined;
|
|
150
|
+
/**
|
|
151
|
+
* Sandbox probe lane for `code-review` (Phase B.2). Always populated after
|
|
152
|
+
* `resolveConfig` — `enabled: false` by default so the lane is opt-in.
|
|
153
|
+
*/
|
|
154
|
+
sandbox: Sandbox;
|
|
115
155
|
}
|
|
116
|
-
export type ConfigInput = Partial<Omit<Config, 'provider'>> & {
|
|
156
|
+
export type ConfigInput = Partial<Omit<Config, 'provider' | 'sandbox'>> & {
|
|
117
157
|
provider?: Partial<ProviderRules>;
|
|
158
|
+
sandbox?: Partial<Sandbox>;
|
|
118
159
|
};
|
|
119
160
|
export declare const DEFAULT_RECORD_STEP_CAP = 40;
|
|
161
|
+
export declare const DEFAULT_SANDBOX: Sandbox;
|
|
120
162
|
export declare function defineConfig(input: ConfigInput): ConfigInput;
|
|
121
163
|
export declare function resolveConfig(input?: ConfigInput): Config;
|
|
122
164
|
export declare function loadConfig(cwd: string): Promise<Config>;
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { pathToFileURL } from 'node:url';
|
|
2
2
|
export const DEFAULT_RECORD_STEP_CAP = 40;
|
|
3
|
+
export const DEFAULT_SANDBOX = {
|
|
4
|
+
enabled: false,
|
|
5
|
+
image: undefined,
|
|
6
|
+
maxProbes: 3,
|
|
7
|
+
timeoutMs: 120_000,
|
|
8
|
+
memory: '2g',
|
|
9
|
+
cpus: '2',
|
|
10
|
+
pidsLimit: 256,
|
|
11
|
+
allowForks: false,
|
|
12
|
+
};
|
|
3
13
|
const defaults = {
|
|
4
14
|
model: 'google/gemini-2.5-flash-lite',
|
|
5
15
|
escalation_model: 'moonshotai/kimi-k2.5',
|
|
@@ -27,18 +37,32 @@ const defaults = {
|
|
|
27
37
|
recordStepCap: DEFAULT_RECORD_STEP_CAP,
|
|
28
38
|
a0: undefined,
|
|
29
39
|
heal: 'local',
|
|
40
|
+
sandbox: { ...DEFAULT_SANDBOX },
|
|
30
41
|
};
|
|
31
42
|
export function defineConfig(input) {
|
|
32
43
|
return input;
|
|
33
44
|
}
|
|
45
|
+
/** Positive-integer config values fall back to their default, floored. */
|
|
46
|
+
function posInt(v, dflt) {
|
|
47
|
+
return v !== undefined && Number.isFinite(v) && v >= 1 ? Math.floor(v) : dflt;
|
|
48
|
+
}
|
|
34
49
|
export function resolveConfig(input = {}) {
|
|
35
50
|
const provider = { ...defaults.provider, ...(input.provider ?? {}) };
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
// Wrong-typed sandbox values (e.g. `sandbox: true`, `enabled: 'yes'`,
|
|
52
|
+
// `memory: 2048`) degrade silently to defaults — the lane is opt-in and a
|
|
53
|
+
// mis-typed flag must never feed docker argv or self-enable.
|
|
54
|
+
const raw = typeof input.sandbox === 'object' && input.sandbox !== null ? input.sandbox : {};
|
|
55
|
+
const sandbox = { ...defaults.sandbox, ...raw };
|
|
56
|
+
sandbox.enabled = raw.enabled === true;
|
|
57
|
+
sandbox.allowForks = raw.allowForks === true;
|
|
58
|
+
sandbox.image = typeof raw.image === 'string' && raw.image !== '' ? raw.image : undefined;
|
|
59
|
+
sandbox.memory = typeof raw.memory === 'string' && raw.memory !== '' ? raw.memory : DEFAULT_SANDBOX.memory;
|
|
60
|
+
sandbox.cpus = typeof raw.cpus === 'string' && raw.cpus !== '' ? raw.cpus : DEFAULT_SANDBOX.cpus;
|
|
61
|
+
sandbox.maxProbes = posInt(sandbox.maxProbes, DEFAULT_SANDBOX.maxProbes);
|
|
62
|
+
sandbox.timeoutMs = posInt(sandbox.timeoutMs, DEFAULT_SANDBOX.timeoutMs);
|
|
63
|
+
sandbox.pidsLimit = posInt(sandbox.pidsLimit, DEFAULT_SANDBOX.pidsLimit);
|
|
64
|
+
const resolved = { ...defaults, ...input, provider, sandbox };
|
|
65
|
+
resolved.recordStepCap = posInt(resolved.recordStepCap, DEFAULT_RECORD_STEP_CAP);
|
|
42
66
|
if (resolved.heal !== 'a0')
|
|
43
67
|
resolved.heal = 'local';
|
|
44
68
|
return resolved;
|
package/dist/detect.d.ts
CHANGED
|
@@ -7,8 +7,18 @@ export interface ExecResult {
|
|
|
7
7
|
code: number;
|
|
8
8
|
stdout: string;
|
|
9
9
|
stderr: string;
|
|
10
|
+
/**
|
|
11
|
+
* The timeout kill fired — execFile killed the process for exceeding
|
|
12
|
+
* timeoutMs (`err.killed`). Without this a timed-out command is
|
|
13
|
+
* indistinguishable from a nonzero exit.
|
|
14
|
+
*/
|
|
15
|
+
timedOut?: boolean;
|
|
16
|
+
/** Signal the process was terminated by, when killed (e.g. 'SIGTERM'). */
|
|
17
|
+
signal?: string | undefined;
|
|
10
18
|
}
|
|
11
|
-
export type ExecFn = (cmd: string, args: string[], timeoutMs: number
|
|
19
|
+
export type ExecFn = (cmd: string, args: string[], timeoutMs: number,
|
|
20
|
+
/** Extra env merged over process.env — keeps secrets out of `ps`/`/proc` argv. */
|
|
21
|
+
env?: Record<string, string>) => Promise<ExecResult>;
|
|
12
22
|
export declare const defaultExec: ExecFn;
|
|
13
23
|
export type ProbeFn = (url: string, timeoutMs: number) => Promise<boolean>;
|
|
14
24
|
/**
|
package/dist/detect.js
CHANGED
|
@@ -2,12 +2,28 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { readdir, readFile } from 'node:fs/promises';
|
|
5
|
-
export const defaultExec = (cmd, args, timeoutMs) => new Promise((resolve) => {
|
|
6
|
-
|
|
5
|
+
export const defaultExec = (cmd, args, timeoutMs, env) => new Promise((resolve) => {
|
|
6
|
+
// 4 MiB headroom — the sandbox caps output itself after capture, and a
|
|
7
|
+
// chatty probe hitting execFile's 1 MiB default would error instead of
|
|
8
|
+
// reaching the harness classifier.
|
|
9
|
+
execFile(cmd, args, {
|
|
10
|
+
timeout: timeoutMs,
|
|
11
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
12
|
+
...(env !== undefined ? { env: { ...process.env, ...env } } : {}),
|
|
13
|
+
}, (err, stdout, stderr) => {
|
|
7
14
|
if (err) {
|
|
8
15
|
// stderr is '' (not undefined) on spawn ENOENT — fall back to the
|
|
9
16
|
// error message so callers can distinguish "missing" from "failed".
|
|
10
|
-
resolve({
|
|
17
|
+
resolve({
|
|
18
|
+
code: 1,
|
|
19
|
+
stdout: String(stdout),
|
|
20
|
+
stderr: String(stderr) || err.message,
|
|
21
|
+
// killed is also true on maxBuffer overflow — that's an output
|
|
22
|
+
// problem, not a timeout; classify by the error code.
|
|
23
|
+
timedOut: err.killed === true &&
|
|
24
|
+
err.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
|
25
|
+
signal: typeof err.signal === 'string' ? err.signal : undefined,
|
|
26
|
+
});
|
|
11
27
|
}
|
|
12
28
|
else {
|
|
13
29
|
resolve({ code: 0, stdout: String(stdout), stderr: String(stderr) });
|
package/dist/evidence/ci.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
interface Ctx {
|
|
2
2
|
err: (line: string) => void;
|
|
3
3
|
}
|
|
4
|
+
/** Maintainer-applied PR label that opts a fork PR into sandbox probes. */
|
|
5
|
+
export declare const PROBE_LABEL = "argus-probe";
|
|
6
|
+
/**
|
|
7
|
+
* GitHub `author_association` values trusted to run probes on fork PRs —
|
|
8
|
+
* repo members/owners/collaborators. CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR,
|
|
9
|
+
* FIRST_TIMER, MANNEQUIN, and NONE are not.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isTrustedAssociation(association: string | undefined): boolean;
|
|
4
12
|
export interface CheckRun {
|
|
5
13
|
name: string;
|
|
6
14
|
/** GitHub check-run conclusion once status === 'completed'; undefined while pending. */
|
|
@@ -9,10 +17,44 @@ export interface CheckRun {
|
|
|
9
17
|
url: string | undefined;
|
|
10
18
|
}
|
|
11
19
|
export interface PrMeta {
|
|
20
|
+
/** PR head SHA — the commit the PR's check-runs are attached to. */
|
|
12
21
|
headSha: string | undefined;
|
|
22
|
+
/** PR base SHA — the merge base probes run against for the double-run. */
|
|
23
|
+
baseSha: string | undefined;
|
|
24
|
+
/** `head.repo.fork` — true when the PR head branch lives in a fork. */
|
|
25
|
+
isFork: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Raw `author_association` for the PR (OWNER, MEMBER, COLLABORATOR,
|
|
28
|
+
* CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, FIRST_TIMER, MANNEQUIN, NONE).
|
|
29
|
+
*/
|
|
30
|
+
authorAssociation: string | undefined;
|
|
31
|
+
/** Names of labels currently applied to the PR. */
|
|
32
|
+
labels: string[];
|
|
33
|
+
/**
|
|
34
|
+
* `head.repo.pushed_at` — the freshest push timestamp the probe label gate
|
|
35
|
+
* can compare against (head commits arrive via pushes; comparing against
|
|
36
|
+
* this is what binds label approval to the current head).
|
|
37
|
+
*/
|
|
38
|
+
pushedAt: string | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Timestamp of the newest `argus-probe` `labeled` event on the PR, from the
|
|
41
|
+
* issue timeline — undefined when the label was never applied or the
|
|
42
|
+
* timeline fetch failed (the gate fails closed either way).
|
|
43
|
+
*/
|
|
44
|
+
labelApprovedAt: string | undefined;
|
|
13
45
|
}
|
|
14
|
-
/**
|
|
15
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Shared GitHub GET scaffold — Bearer auth, API headers, 30s abort timeout,
|
|
48
|
+
* `ctx.err` on non-ok/timeout, undefined on failure. Reuse for any
|
|
49
|
+
* api.github.com read (fetchPrFiles in cli.ts paginates over it).
|
|
50
|
+
*/
|
|
51
|
+
export declare function ghGet(url: string, token: string, ctx: Ctx): Promise<unknown | undefined>;
|
|
52
|
+
/**
|
|
53
|
+
* PR metadata for the evidence + probe lanes — the head SHA check-runs attach
|
|
54
|
+
* to, plus the fork/association/label signals the sandbox fork gate (KTD5)
|
|
55
|
+
* evaluates. One `/pulls/{pr}` request; undefined when the request fails.
|
|
56
|
+
*/
|
|
57
|
+
export declare function fetchPrMeta(repo: string, pr: string, token: string, ctx: Ctx): Promise<PrMeta | undefined>;
|
|
16
58
|
/** Check-runs on a commit — the consumer's own CI signal. */
|
|
17
59
|
export declare function fetchCheckRuns(repo: string, sha: string, token: string, ctx: Ctx): Promise<CheckRun[] | undefined>;
|
|
18
60
|
export {};
|