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 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
- for (const f of codeReview.findings) {
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}${f.evidence && f.evidence.status !== 'exercised' ? `\n\n*CI evidence: ${f.evidence.detail}*` : ''}`,
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(`${c.path}:${c.line}:${c.body}`)
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(`${c.path}:${c.line}:${c.body}`))
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, fetchPrHeadSha } from './evidence/ci.js';
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
- while (page <= MAX_PR_FILE_PAGES) {
710
- const url = `https://api.github.com/repos/${repo}/pulls/${pr}/files?per_page=100&page=${page}`;
711
- const controller = new AbortController();
712
- const timeout = setTimeout(() => controller.abort(), 30_000);
713
- try {
714
- const res = await fetch(url, {
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 writeFile(codeReviewPath, `${JSON.stringify(skipped, null, 2)}\n`, 'utf8');
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
- const headSha = await fetchPrHeadSha(repo, pr, token, ctx);
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
- const hasBlocker = finalFindings.some((f) => blockSeverities.includes(f.severity ?? ''));
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 writeFile(codeReviewPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
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
- const resolved = { ...defaults, ...input, provider };
37
- const cap = resolved.recordStepCap;
38
- resolved.recordStepCap =
39
- cap !== undefined && Number.isFinite(cap) && cap >= 1
40
- ? Math.floor(cap)
41
- : DEFAULT_RECORD_STEP_CAP;
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) => Promise<ExecResult>;
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
- execFile(cmd, args, { timeout: timeoutMs }, (err, stdout, stderr) => {
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({ code: 1, stdout: String(stdout), stderr: String(stderr) || err.message });
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) });
@@ -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
- /** PR head SHA — the commit the PR's check-runs are attached to. */
15
- export declare function fetchPrHeadSha(repo: string, pr: string, token: string, ctx: Ctx): Promise<string | undefined>;
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 {};