@bongos/core 1.19.340 → 1.19.342

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.
@@ -24,6 +24,24 @@
24
24
  // to /api/gds/security/reports. --canary proves the end-to-end pipe: plants a
25
25
  // constructed token, asserts the scanner catches it, files a low "[canary]"
26
26
  // report, and dispute-closes it so the queue stays clean.
27
+ //
28
+ // UNATTENDED RUNS (task 1003209). The scheduler is .github/workflows/redteam-patrol.yml
29
+ // — CI, not the always-on server, because all three checks read things only the
30
+ // REPO has: full git history, .github/workflows/, and a package-lock npm can audit
31
+ // (ADR 0188). A CI runner has no GDS session, so --file would otherwise hit
32
+ // cli-lib's interactive re-auth wall and print "run /builder-reauth" into a cron
33
+ // log. Instead --file preflights the session and, when there is none, follows the
34
+ // main-audit.js --require-roster doctrine: report LOUDLY rather than green-and-
35
+ // vacuous. The exit code says whether a finding was LOST, not whether findings
36
+ // exist — findings are advisory (ADR 0162) and a patrol that goes red merely for
37
+ // doing its job trains everyone to ignore it:
38
+ //
39
+ // ran + filed → 0
40
+ // ran, nothing to file → 0 (with or without a session)
41
+ // ran, has findings, cannot file → 2 — the one state where evidence evaporates
42
+ //
43
+ // Findings are ALSO appended to $GITHUB_STEP_SUMMARY when set, so a scheduled run
44
+ // is readable in the Actions UI without opening the log.
27
45
 
28
46
  'use strict';
29
47
 
@@ -211,6 +229,45 @@ function buildClassReport(className, findings, notes = []) {
211
229
  };
212
230
  }
213
231
 
232
+ // Append markdown to the Actions job summary when running under GitHub Actions.
233
+ // Best-effort and inert everywhere else — a patrol run must never fail because a
234
+ // log surface was unavailable.
235
+ function stepSummary(md) {
236
+ const dest = process.env.GITHUB_STEP_SUMMARY;
237
+ if (!dest) return;
238
+ try { fs.appendFileSync(dest, `${md}\n`); } catch { /* never fatal */ }
239
+ }
240
+
241
+ // Pure: what a completed sweep should DO with its findings, and the exit code
242
+ // that disposition deserves. Extracted so the honest-exit contract is a table a
243
+ // test can enumerate rather than branching buried in main().
244
+ //
245
+ // The load-bearing row is the last one. Findings are ADVISORY (ADR 0162), so a
246
+ // run that finds three things and files them is a SUCCESS — going red merely for
247
+ // doing its job is how a weekly lane becomes noise everyone filters. Red is
248
+ // reserved for the single state where evidence EVAPORATES: findings exist,
249
+ // filing was asked for, and it cannot happen.
250
+ function disposition({ findingClasses = 0, doFile = false, blocker = null } = {}) {
251
+ if (!findingClasses) return { action: 'clean', exitCode: 0 };
252
+ if (!doFile) return { action: 'dry-run', exitCode: 0 };
253
+ if (blocker) return { action: 'not-filed', exitCode: 2 };
254
+ return { action: 'file', exitCode: 0 };
255
+ }
256
+
257
+ // Is there a GDS session this run could file with? Returns the reason it cannot,
258
+ // or null when filing is possible. Read directly (readSessionToken) rather than
259
+ // probing with apiCall, because apiCall's 401 path exits the process with
260
+ // interactive re-auth guidance — useless in a cron log and indistinguishable from
261
+ // a crash.
262
+ function filingBlocker() {
263
+ try {
264
+ const { readSessionToken } = require('./cli-lib.js');
265
+ return readSessionToken() ? null : 'no GDS session on this runner';
266
+ } catch (e) {
267
+ return `session unreadable: ${e && e.message}`;
268
+ }
269
+ }
270
+
214
271
  async function fileReport(report) {
215
272
  const { apiCall } = require('./cli-lib.js');
216
273
  const r = await apiCall('POST', '/api/gds/security/reports', report);
@@ -283,10 +340,30 @@ async function main() {
283
340
  const pins = actionPinCheck();
284
341
  if (pins.findings.length) classes.push(buildClassReport('action-pins', pins.findings));
285
342
 
343
+ if (secrets.degraded) stepSummary(`> **DEGRADED** — ${secrets.degraded}`);
344
+
286
345
  if (!classes.length) {
287
346
  console.log('patrol: clean sweep — nothing to file.');
347
+ stepSummary('## red-team patrol\n\nClean sweep — no findings to file.');
288
348
  return;
289
349
  }
350
+
351
+ stepSummary(['## red-team patrol', ...classes.map((c) =>
352
+ `### ${c.title}\n\nseverity: **${c.severity}**\n\n${c.repro_steps_md}`)].join('\n\n'));
353
+
354
+ // Preflight the session ONCE, before the first POST: a half-filed sweep (class
355
+ // one lands, class two hits the auth wall and exits) is worse than not starting.
356
+ const blocker = doFile ? filingBlocker() : null;
357
+ const { action, exitCode } = disposition({ findingClasses: classes.length, doFile, blocker });
358
+ if (action === 'not-filed') {
359
+ console.error(`patrol: DID NOT FILE — ${blocker}. ${classes.length} finding class(es) above are NOT in the adjudication queue.`);
360
+ console.error('patrol: sign in on this runner (or set the scheduler\'s session secret) and re-run — see docs/recipes/redteam-patrol.md.');
361
+ for (const report of classes) console.error(` [${report.severity}] ${report.title}\n${report.repro_steps_md}`);
362
+ stepSummary(`\n> **NOT FILED** — ${blocker}. These findings exist only in this run's log.`);
363
+ process.exitCode = exitCode;
364
+ return;
365
+ }
366
+
290
367
  for (const report of classes) {
291
368
  if (doFile) {
292
369
  const id = await fileReport(report);
@@ -305,4 +382,5 @@ if (require.main === module) {
305
382
  module.exports = {
306
383
  parseGitleaksReport, unpinnedUses, ageDays, mapSeverity, maxSeverity,
307
384
  buildClassReport, lockedVersion, COOLDOWN_DAYS, YOUNG_PKG_DAYS,
385
+ stepSummary, filingBlocker, disposition,
308
386
  };
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/routine-schedule.js — a declared cadence must have something that
3
+ // fires it (task 1003209, goal 1000079; docs/audits/ai-dev-redteam-followup.md §7).
4
+ //
5
+ // THE GAP THIS CLOSES. Goal 1000076 shipped the redteam-weekly patrol, wired it
6
+ // into the control manifest, and documented it — and then nothing ever ran it.
7
+ // The follow-up audit's §7 named all three halves of the failure:
8
+ //
9
+ // 1. no committed scheduler for .claude/scheduled-tasks/ at all;
10
+ // 2. the SKILL.md declared `mode` and `steps` but no cadence — "nothing for a
11
+ // scheduler to read even if one looked";
12
+ // 3. control-manifest.js called the patrol "wired" on a substring (the SKILL.md
13
+ // mentions its own scanner), so the manifest was GREEN while the patrol was
14
+ // dormant.
15
+ //
16
+ // (2) is now a real frontmatter field (run-routine.js parseRoutineFrontmatter),
17
+ // (1) is .github/workflows/redteam-patrol.yml, and THIS file is (3): the check
18
+ // that refuses the combination of a declared cadence and nothing firing it.
19
+ //
20
+ // WHY A DEDICATED CHECK RATHER THAN ANOTHER MANIFEST ROW. The manifest's
21
+ // predicates are deliberately shallow — file exists, one wiring substring. That
22
+ // shape is exactly what let the patrol read as wired while dormant, and this goal
23
+ // has now hit the same failure five times: an enforcement surface that NAMES a
24
+ // file goes VACUOUS, not red, when the thing it names moves or stops. So the
25
+ // predicate here is relational, not existential: it reads the cadence each
26
+ // routine declares, reads the cron each scheduler actually carries, and fails
27
+ // when the two disagree in EITHER direction —
28
+ //
29
+ // • a routine declares `cadence:` and no scheduler fires it → red
30
+ // • a scheduler fires a routine that no longer exists → red (rename rot)
31
+ // • the cron's real period differs from the declared cadence → red
32
+ //
33
+ // The third is the one a substring check can never catch: edit the cron from
34
+ // weekly to yearly and every "is it wired?" predicate stays green.
35
+ //
36
+ // TWO-REPO TOLERANCE. Scheduled routines are TOPOLOGY — `bongos init/upgrade`
37
+ // materializes .claude/scheduled-tasks/ into each instance (claude-materialize.js),
38
+ // but .github/workflows/ is the CORE repo's and does not travel. So an instance
39
+ // checkout legitimately has routines and no workflows directory. There, the
40
+ // unscheduled routines are reported as WARNINGS (the instance's operator owns its
41
+ // own scheduler); in a repo that HAS workflows, they are violations. That keeps
42
+ // the check honest in the core without red-building every instance.
43
+
44
+ 'use strict';
45
+
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+
49
+ const ROOT = path.resolve(__dirname, '..', '..');
50
+ const TASKS_DIR = path.join(ROOT, '.claude', 'scheduled-tasks');
51
+ const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows');
52
+
53
+ const { parseRoutineFrontmatter } = require('./run-routine.js');
54
+
55
+ // The cadences a routine may declare. The value is the period a cron expression
56
+ // must resolve to for the two to agree. Add a name here and cronCadence() below
57
+ // must be able to produce it, or the pair can never match.
58
+ const CADENCES = new Set(['hourly', 'daily', 'weekly', 'monthly']);
59
+
60
+ // Pure: classify a 5-field cron expression into the cadence it actually realizes.
61
+ // Returns null for anything this vocabulary cannot describe (step values, lists,
62
+ // ranges) — a cadence can then never "match" it, which is the safe direction: an
63
+ // exotic cron must be described some other way rather than silently accepted.
64
+ //
65
+ // min hour dom mon dow
66
+ // 17 6 * * 1 → weekly (a specific day-of-week)
67
+ // 17 6 * * * → daily (a specific hour, every day)
68
+ // 17 6 3 * * → monthly (a specific day-of-month)
69
+ // 17 * * * * → hourly (a specific minute, every hour)
70
+ function cronCadence(expr) {
71
+ const f = String(expr == null ? '' : expr).trim().split(/\s+/);
72
+ if (f.length !== 5) return null;
73
+ const [min, hour, dom, mon, dow] = f;
74
+ const fixed = (v) => /^\d+$/.test(v);
75
+ const any = (v) => v === '*';
76
+ if (!fixed(min)) return null; // every cadence below pins the minute
77
+ if (!any(mon)) return null; // a specific month is a yearly shape
78
+ if (any(hour)) return any(dom) && any(dow) ? 'hourly' : null;
79
+ if (!fixed(hour)) return null;
80
+ if (fixed(dow) && any(dom)) return 'weekly';
81
+ if (fixed(dom) && any(dow)) return 'monthly';
82
+ if (any(dom) && any(dow)) return 'daily';
83
+ return null;
84
+ }
85
+
86
+ // Pure: every cron expression a workflow file declares under `on: schedule:`.
87
+ // A focused subset of YAML (the repo has no js-yaml dep and the parser in
88
+ // run-routine.js is the same shape): the `cron:` keys are unambiguous enough to
89
+ // read directly, and a workflow that hides one behind an anchor would simply
90
+ // read as unscheduled — the safe direction.
91
+ function workflowCrons(yamlText) {
92
+ const out = [];
93
+ for (const line of String(yamlText == null ? '' : yamlText).split('\n')) {
94
+ const m = /^\s*-?\s*cron:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/.exec(line);
95
+ if (m) out.push(m[1].trim());
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // Pure: does `yamlText` actually invoke routine `name`? Either the canonical
101
+ // dispatcher form (`run-routine.js <name>`) or one of the routine's own step
102
+ // commands verbatim. Both are real invocations; a mere mention in prose is not,
103
+ // which is the whole point — the substring predicate this replaces was satisfied
104
+ // by a comment.
105
+ function invokesRoutine(yamlText, name, steps = []) {
106
+ const hay = String(yamlText == null ? '' : yamlText);
107
+ const dispatched = new RegExp(`run-routine\\.js\\s+(?:--\\S+\\s+)*${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`);
108
+ if (dispatched.test(hay)) return true;
109
+ return steps.some((s) => s.command && hay.includes(s.command));
110
+ }
111
+
112
+ // Read every routine that declares a cadence, plus the ones that don't.
113
+ function readRoutines(dir = TASKS_DIR) {
114
+ const withCadence = [];
115
+ const withoutCadence = [];
116
+ let names = [];
117
+ try {
118
+ names = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
119
+ } catch (_) { return { withCadence, withoutCadence }; }
120
+ for (const name of names.sort()) {
121
+ let md;
122
+ try { md = fs.readFileSync(path.join(dir, name, 'SKILL.md'), 'utf8'); } catch (_) { continue; }
123
+ const { cadence, steps } = parseRoutineFrontmatter(md);
124
+ if (cadence) withCadence.push({ name, cadence, steps });
125
+ else withoutCadence.push({ name });
126
+ }
127
+ return { withCadence, withoutCadence };
128
+ }
129
+
130
+ function readWorkflows(dir = WORKFLOWS_DIR) {
131
+ let files = [];
132
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')); }
133
+ catch (_) { return null; } // null = no workflows directory at all (instance repo)
134
+ return files.sort().map((f) => ({ file: f, text: fs.readFileSync(path.join(dir, f), 'utf8') }));
135
+ }
136
+
137
+ // Pure evaluation — exported for the test, which drives it with fixtures.
138
+ // `workflows === null` means "this checkout has no CI to schedule with", which
139
+ // downgrades unscheduled routines from violations to warnings (see the two-repo
140
+ // note in the header).
141
+ function evaluate({ withCadence, withoutCadence }, workflows) {
142
+ const violations = [];
143
+ const warnings = [];
144
+ const hasCI = workflows !== null;
145
+ const wf = workflows || [];
146
+
147
+ for (const r of withCadence) {
148
+ if (!CADENCES.has(r.cadence)) {
149
+ violations.push(`routine '${r.name}': unknown cadence "${r.cadence}" (want ${[...CADENCES].join('|')})`);
150
+ continue;
151
+ }
152
+ const firing = wf.filter((w) => invokesRoutine(w.text, r.name, r.steps));
153
+ if (!firing.length) {
154
+ const msg = `routine '${r.name}' declares cadence: ${r.cadence} but NOTHING fires it — no scheduled workflow invokes it`;
155
+ (hasCI ? violations : warnings).push(msg);
156
+ continue;
157
+ }
158
+ // It is invoked somewhere. At least one of those invocations must be on a
159
+ // schedule whose real period is the declared cadence. A workflow_dispatch-only
160
+ // lane is a human running it — exactly the state §7 called "decorative".
161
+ const seen = [];
162
+ let matched = false;
163
+ for (const w of firing) {
164
+ for (const expr of workflowCrons(w.text)) {
165
+ const actual = cronCadence(expr);
166
+ seen.push(`${w.file}: "${expr}" → ${actual || 'unclassifiable'}`);
167
+ if (actual === r.cadence) matched = true;
168
+ }
169
+ }
170
+ if (!matched) {
171
+ violations.push(
172
+ seen.length
173
+ ? `routine '${r.name}' declares cadence: ${r.cadence} but no scheduler fires it at that period — ${seen.join('; ')}`
174
+ : `routine '${r.name}' declares cadence: ${r.cadence} but ${firing.map((w) => w.file).join(', ')} carries no 'on: schedule:' cron (manual dispatch only)`
175
+ );
176
+ }
177
+ }
178
+
179
+ // Rename rot, the other direction: a scheduler that dispatches a routine name
180
+ // no longer on disk. run-routine.js exits 2 on a missing SKILL.md, so this
181
+ // would be a weekly red job nobody watches; catch it at merge time instead.
182
+ // Deduped: a workflow legitimately names its own invocation more than once (the
183
+ // step itself plus the header comment explaining why it dispatches rather than
184
+ // calling the script), and one rename should read as one problem.
185
+ const known = new Set([...withCadence, ...withoutCadence].map((r) => r.name));
186
+ const dangling = new Set();
187
+ for (const w of wf) {
188
+ for (const m of w.text.matchAll(/run-routine\.js\s+(?:--\S+\s+)*([A-Za-z0-9_-]+)/g)) {
189
+ if (!known.has(m[1])) dangling.add(`${w.file} dispatches routine '${m[1]}', which has no .claude/scheduled-tasks/${m[1]}/SKILL.md`);
190
+ }
191
+ }
192
+ violations.push(...dangling);
193
+
194
+ for (const r of withoutCadence) {
195
+ warnings.push(`routine '${r.name}' declares no cadence: — it can only ever be run by hand`);
196
+ }
197
+ return { violations, warnings };
198
+ }
199
+
200
+ // The fitness.js check.
201
+ function checkRoutineCadencesScheduled() {
202
+ const routines = readRoutines();
203
+ const workflows = readWorkflows();
204
+ const { violations, warnings } = evaluate(routines, workflows);
205
+ const scheduled = routines.withCadence.length - violations.filter((v) => v.startsWith("routine '")).length;
206
+ return {
207
+ name: 'a declared cron cadence has something that fires it (task 1003209)',
208
+ ok: violations.length === 0,
209
+ hardFail: violations.length > 0,
210
+ violations,
211
+ warnings,
212
+ note: violations.length
213
+ ? 'a scheduled routine is declared but dormant — add/repair the scheduler, or drop the cadence: field and stop claiming it runs'
214
+ : `${scheduled}/${routines.withCadence.length} cadence-declaring routine(s) fired by a committed scheduler at the declared period; ${routines.withoutCadence.length} manual-only.`,
215
+ };
216
+ }
217
+
218
+ function main() {
219
+ const r = checkRoutineCadencesScheduled();
220
+ for (const v of r.violations) console.error(`✗ ${v}`);
221
+ for (const w of r.warnings) console.warn(`⚠ ${w}`);
222
+ console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
223
+ process.exit(r.hardFail ? 1 : 0);
224
+ }
225
+
226
+ if (require.main === module) main();
227
+
228
+ module.exports = {
229
+ checkRoutineCadencesScheduled, evaluate, cronCadence, workflowCrons,
230
+ invokesRoutine, readRoutines, readWorkflows, CADENCES,
231
+ };
@@ -47,12 +47,19 @@ function unquote(s) {
47
47
  return t;
48
48
  }
49
49
 
50
- // Pure: parse mode + steps[] from SKILL.md frontmatter. A focused YAML subset
51
- // (no js-yaml dep): scalar `mode:`/`description:` and a `steps:` list of
52
- // `- command: …` / `sink: …` items. Unknown keys are ignored. Exported + tested.
50
+ // Pure: parse mode + cadence + steps[] from SKILL.md frontmatter. A focused YAML
51
+ // subset (no js-yaml dep): scalar `mode:`/`cadence:`/`description:` and a `steps:`
52
+ // list of `- command: …` / `sink: …` items. Unknown keys are ignored. Exported + tested.
53
+ //
54
+ // `cadence:` (task 1003209) is the field the follow-up audit found missing — the
55
+ // routines declared HOW to run but never HOW OFTEN, so "there was nothing for a
56
+ // scheduler to read even if one looked." It is declarative only: run-routine.js
57
+ // still runs a routine exactly once per invocation. The cadence is the contract a
58
+ // committed scheduler must honour, and scripts/gds/routine-schedule.js is the check
59
+ // that refuses a cadence nothing fires.
53
60
  function parseRoutineFrontmatter(skillMd) {
54
61
  const norm = String(skillMd == null ? '' : skillMd).replace(/\r\n/g, '\n');
55
- const empty = { mode: null, steps: [], description: null };
62
+ const empty = { mode: null, cadence: null, steps: [], description: null };
56
63
  if (!norm.startsWith('---\n')) return empty;
57
64
  const lines = norm.split('\n');
58
65
  let close = -1;
@@ -62,6 +69,7 @@ function parseRoutineFrontmatter(skillMd) {
62
69
  if (close === -1) return empty;
63
70
 
64
71
  let mode = null;
72
+ let cadence = null;
65
73
  let description = null;
66
74
  const steps = [];
67
75
  let inSteps = false;
@@ -96,12 +104,13 @@ function parseRoutineFrontmatter(skillMd) {
96
104
 
97
105
  if (!inSteps && isTopLevel && topKv) {
98
106
  if (topKv[1] === 'mode') mode = unquote(topKv[2]) || null;
107
+ else if (topKv[1] === 'cadence') cadence = unquote(topKv[2]) || null;
99
108
  else if (topKv[1] === 'description') description = topKv[2].trim();
100
109
  else if (topKv[1] === 'steps' && topKv[2].trim() === '') inSteps = true;
101
110
  }
102
111
  }
103
112
  pushCur();
104
- return { mode, steps, description };
113
+ return { mode, cadence, steps, description };
105
114
  }
106
115
 
107
116
  // Pure: split a repo-authored command string into argv, honoring simple quotes.
package/src/module-api.js CHANGED
@@ -1110,8 +1110,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1110
1110
  // landed since 1.19.338 with no explicit bump. run 32797325535. (task 1002620)
1111
1111
  // 1.19.340 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1112
1112
  // landed since 1.19.339 with no explicit bump. run 32798342738. (task 1002620)
1113
+ // 1.19.341 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1114
+ // landed since 1.19.340 with no explicit bump. run 32798507686. (task 1002620)
1115
+ // 1.19.342 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1116
+ // landed since 1.19.341 with no explicit bump. run 32799103427. (task 1002620)
1113
1117
  // ---------------------------------------------------------------------------
1114
- const CORE_VERSION = '1.19.340'; // CI auto-patch carrier (ADR 0161); see changelog above
1118
+ const CORE_VERSION = '1.19.342'; // CI auto-patch carrier (ADR 0161); see changelog above
1115
1119
 
1116
1120
  // A namespaced logger so a module's log lines are attributable + consistent.
1117
1121
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,90 @@
1
+ // tests/push_path_brief.mjs
2
+ //
3
+ // Task 1003259 / idea 1000838 — a dev-box agent, unable to see WHY its git probes were
4
+ // failing, asked the owner to mint a PAT and store it on the box. push-path-brief.js is
5
+ // the SessionStart line that states the credential model natively so that request never
6
+ // gets made again. These tests pin BOTH halves: the line fires on the server-publish
7
+ // path with the load-bearing phrases intact, and it is SILENT everywhere else — the
8
+ // silence is half the design (a line firing on every healthy laptop becomes wallpaper).
9
+ //
10
+ // DB-free + network-free: briefLine is pure, and the CLI cases force the path with
11
+ // OTB_PUSH_VIA_SERVER so pushVia() never spawns gh. Run: node tests/push_path_brief.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import { spawnSync } from 'node:child_process';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const brief = require('../scripts/gds/push-path-brief.js');
21
+
22
+ let passed = 0, failed = 0;
23
+ function t(name, fn) {
24
+ try { fn(); passed++; console.log(` PASS ${name}`); }
25
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
26
+ }
27
+
28
+ // ---- the pure core -----------------------------------------------------------
29
+
30
+ t('server path gets the brief, with every load-bearing phrase', () => {
31
+ const l = brief.briefLine('server');
32
+ assert.ok(typeof l === 'string' && l.length > 0, 'expected a line on the server path');
33
+ // Each phrase is one of the three facts the 2026-08-24 incident showed an agent
34
+ // cannot infer from inside a box. Losing any of them re-opens the gap.
35
+ for (const must of [
36
+ 'NO GitHub push credential',
37
+ 'BY DESIGN',
38
+ '/builder-ship',
39
+ 'NEVER ask the owner for a PAT',
40
+ 'publish-status',
41
+ ]) {
42
+ assert.ok(l.includes(must), `line must state: ${must}`);
43
+ }
44
+ });
45
+
46
+ t('local path is SILENT — null, not an empty string', () => {
47
+ assert.equal(brief.briefLine('local'), null);
48
+ });
49
+
50
+ t('undetermined path stays silent (fail-open, exact vocabulary only)', () => {
51
+ assert.equal(brief.briefLine(null), null);
52
+ assert.equal(brief.briefLine(undefined), null);
53
+ assert.equal(brief.briefLine('SERVER'), null);
54
+ assert.equal(brief.briefLine('ci'), null);
55
+ });
56
+
57
+ t('the line is the exported static constant — nothing is interpolated', () => {
58
+ // Trust boundary: this string is injected into every box session's context. Pinning
59
+ // it to the constant proves no builder-authored text can ever ride along.
60
+ assert.equal(brief.briefLine('server'), brief.BRIEF);
61
+ });
62
+
63
+ // ---- the CLI, end-to-end through the pushVia() override -----------------------
64
+
65
+ const script = path.join(
66
+ path.dirname(fileURLToPath(import.meta.url)),
67
+ '..', 'scripts', 'gds', 'push-path-brief.js',
68
+ );
69
+ function runCli(env) {
70
+ return spawnSync(process.execPath, [script, '--line'], {
71
+ env: { ...process.env, ...env },
72
+ encoding: 'utf8',
73
+ timeout: 15000,
74
+ });
75
+ }
76
+
77
+ t('--line on the forced server path prints the brief and exits 0', () => {
78
+ const r = runCli({ OTB_PUSH_VIA_SERVER: '1' });
79
+ assert.equal(r.status, 0, `expected exit 0, got ${r.status} (stderr: ${r.stderr})`);
80
+ assert.ok(r.stdout.includes('publish-status'), 'expected the brief on stdout');
81
+ });
82
+
83
+ t('--line on the forced local path prints NOTHING and still exits 0', () => {
84
+ const r = runCli({ OTB_PUSH_VIA_SERVER: '0' });
85
+ assert.equal(r.status, 0, `expected exit 0, got ${r.status} (stderr: ${r.stderr})`);
86
+ assert.equal(r.stdout.trim(), '', 'the laptop case must be silent');
87
+ });
88
+
89
+ console.log(`\npush_path_brief: ${passed} passed, ${failed} failed`);
90
+ process.exit(failed ? 1 : 0);
@@ -65,3 +65,66 @@ test('buildClassReport caps the item list, counts the remainder, and takes max s
65
65
  assert.match(r.repro_steps_md, /…and 3 more/);
66
66
  assert.match(r.repro_steps_md, /Notes:/);
67
67
  });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Unattended-run behaviour (task 1003209): the patrol now runs on CI, which has
71
+ // no GDS session. These pin the two things that turns on — an exit code that
72
+ // means "a finding was LOST" rather than "a finding exists", and a job-summary
73
+ // surface so a scheduled run is readable without opening the log.
74
+ // ---------------------------------------------------------------------------
75
+ import fs from 'node:fs';
76
+ import os from 'node:os';
77
+ import { spawnSync } from 'node:child_process';
78
+
79
+ test('disposition: red is reserved for findings that could not be recorded', () => {
80
+ // A clean sweep is green even if filing was impossible — nothing was lost.
81
+ assert.deepEqual(p.disposition({ findingClasses: 0, doFile: true, blocker: 'no session' }),
82
+ { action: 'clean', exitCode: 0 });
83
+ // A dry run prints; it never claims to have filed, so it never fails.
84
+ assert.deepEqual(p.disposition({ findingClasses: 3, doFile: false, blocker: null }),
85
+ { action: 'dry-run', exitCode: 0 });
86
+ // Findings filed for adjudication = the patrol working as designed (ADR 0162).
87
+ // If this ever became a failure, a weekly red would train everyone to ignore it.
88
+ assert.deepEqual(p.disposition({ findingClasses: 3, doFile: true, blocker: null }),
89
+ { action: 'file', exitCode: 0 });
90
+ // The ONE red state: findings exist, filing was asked for, and it cannot happen.
91
+ assert.deepEqual(p.disposition({ findingClasses: 3, doFile: true, blocker: 'no GDS session on this runner' }),
92
+ { action: 'not-filed', exitCode: 2 });
93
+ assert.deepEqual(p.disposition(), { action: 'clean', exitCode: 0 });
94
+ });
95
+
96
+ test('stepSummary appends markdown for the Actions UI, and is inert off-CI', () => {
97
+ const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'patrol-')), 'summary.md');
98
+ const prev = process.env.GITHUB_STEP_SUMMARY;
99
+ try {
100
+ process.env.GITHUB_STEP_SUMMARY = f;
101
+ p.stepSummary('## one');
102
+ p.stepSummary('## two');
103
+ assert.equal(fs.readFileSync(f, 'utf8'), '## one\n## two\n');
104
+ delete process.env.GITHUB_STEP_SUMMARY;
105
+ p.stepSummary('## three'); // must not throw, must not write anywhere
106
+ assert.equal(fs.readFileSync(f, 'utf8'), '## one\n## two\n');
107
+ } finally {
108
+ if (prev === undefined) delete process.env.GITHUB_STEP_SUMMARY;
109
+ else process.env.GITHUB_STEP_SUMMARY = prev;
110
+ }
111
+ });
112
+
113
+ test('filingBlocker reports a sessionless runner instead of exiting with re-auth guidance', () => {
114
+ // The regression guarded here: cli-lib's apiCall answers 401 by PRINTING
115
+ // "run /builder-reauth" and calling process.exit(2). In a cron log that is
116
+ // indistinguishable from a crash, and it kills the run before the findings are
117
+ // ever printed. A subprocess with an empty HOME is the only honest way to
118
+ // simulate a runner with no session file — the suite's own machine has one.
119
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'patrol-home-'));
120
+ const r = spawnSync(process.execPath, ['-e',
121
+ `const p = require(${JSON.stringify(path.join(ROOT, 'scripts', 'gds', 'redteam-patrol.js'))});
122
+ console.log(JSON.stringify({ blocker: p.filingBlocker() }));`,
123
+ ], { env: { ...process.env, HOME: home, USERPROFILE: home }, encoding: 'utf8' });
124
+ assert.equal(r.status, 0, `expected a clean return, not an exit: ${r.stderr}`);
125
+ const out = JSON.parse(r.stdout.trim().split('\n').pop());
126
+ assert.equal(typeof out.blocker, 'string');
127
+ assert.match(out.blocker, /no GDS session|session unreadable/);
128
+ assert.doesNotMatch(r.stdout + r.stderr, /builder-reauth/,
129
+ 'the sessionless path must not print interactive re-auth guidance into a cron log');
130
+ });