@bongos/core 1.19.1033 → 1.19.1034
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/.bongos-core.json +186 -86
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/grader-health/SKILL.md +4 -0
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +1 -1
- package/docs/architecture.md +3 -2
- package/docs/canonical-permissions.md +2 -2
- package/docs/copy-inventory.md +34 -30
- package/docs/copy-registry.json +69 -33
- package/docs/module-api-changelog.md +3 -1
- package/docs/recipes/local-dev.md +45 -3
- package/docs/recipes/packaging-the-core.md +1 -1
- package/modules/autonomy/cadence.js +52 -0
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/grading/grade-engine.js +267 -0
- package/modules/grading/grader-subagent.js +4 -3
- package/modules/grading/grader-workers/index.js +67 -11
- package/modules/grading/grader-workers/worker-verdict.js +32 -2
- package/modules/grading/grader.js +109 -88
- package/modules/hall-ui/public/deploy.js +35 -0
- package/modules/hall-ui/public/project-settings.css +7 -0
- package/modules/lifecycle/routes/tasks.js +19 -4
- package/modules/provisioning/control-plane-guard.js +14 -13
- package/modules/provisioning/core-upgrade.js +30 -17
- package/modules/provisioning/public-refusal.js +44 -0
- package/modules/provisioning/routes/disconnect.js +2 -1
- package/modules/provisioning/routes/env-manifest.js +5 -4
- package/modules/provisioning/routes/provisioning.js +16 -15
- package/modules/provisioning/routes/repo-private.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +70 -0
- package/scripts/gds/autobongos-run.js +10 -2
- package/scripts/gds/backfill-cost.js +20 -9
- package/scripts/gds/claim.js +2 -2
- package/scripts/gds/credential-presence.js +281 -0
- package/scripts/gds/deploy-main-audit-gate.sh +11 -4
- package/scripts/gds/dev-lib.js +212 -5
- package/scripts/gds/dev.js +102 -50
- package/scripts/gds/doctor.js +3 -2
- package/scripts/gds/grade-correlation-audit.js +280 -0
- package/scripts/gds/grade-replay.js +237 -0
- package/scripts/gds/hooks/pre-push.js +7 -3
- package/scripts/gds/main-audit.js +19 -10
- package/scripts/gds/memory-map.js +3 -1
- package/scripts/gds/onboarding-config.js +3 -2
- package/scripts/gds/plain-cards.js +4 -0
- package/scripts/gds/preflight.js +12 -4
- package/scripts/gds/provision-core-upgrade.js +155 -4
- package/scripts/gds/provision-repo-auth.js +15 -0
- package/scripts/gds/provision.js +1 -1
- package/scripts/gds/publish-credential-check.js +3 -0
- package/scripts/gds/push-main.js +14 -3
- package/scripts/gds/push-path-brief.js +3 -2
- package/scripts/gds/run-routine.js +18 -5
- package/scripts/gds/run-unit-tests.js +14 -3
- package/scripts/gds/session-cost.js +12 -3
- package/scripts/gds/ship-deploy-target.js +11 -6
- package/scripts/gds/ship-grade-steps.js +33 -0
- package/scripts/gds/ship-land.js +157 -11
- package/scripts/gds/ship-merge.js +3 -2
- package/scripts/gds/ship-preflight-steps.js +30 -8
- package/scripts/gds/ship.js +1 -1
- package/scripts/gds/upgrade.js +3 -3
- package/scripts/hall-preview/README.md +20 -3
- package/scripts/hall-preview/live-identity.js +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +71 -11
- package/scripts/hall-preview/scrub-identity.js +296 -0
- package/src/bongos/routes/security.js +3 -3
- package/src/build-info.js +4 -2
- package/src/module-api.js +1 -1
- package/tests/adr_0089_scope.mjs +70 -0
- package/tests/autobongos_cadence.mjs +74 -0
- package/tests/consumer_layout_boot.mjs +363 -31
- package/tests/core_upgrade_door.mjs +40 -6
- package/tests/core_upgrade_runner.mjs +240 -4
- package/tests/credential_presence.mjs +153 -0
- package/tests/deploy_audit_gate.mjs +5 -2
- package/tests/deploy_page_projects.mjs +63 -2
- package/tests/dev_lib.mjs +2 -1
- package/tests/dev_windows.mjs +324 -0
- package/tests/env_knobs_neutral_prefix.mjs +203 -0
- package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
- package/tests/government_protected_surfaces.mjs +10 -0
- package/tests/grade_correlation_audit.mjs +159 -0
- package/tests/grade_engine.mjs +249 -0
- package/tests/grade_engine_replay.mjs +99 -0
- package/tests/grader_prompt_window_bound.mjs +132 -0
- package/tests/grader_unbacked_score.mjs +194 -0
- package/tests/hall_preview_scrub.mjs +214 -0
- package/tests/integration_parked_ratchet.mjs +24 -1
- package/tests/provisioning_public_refusal.mjs +195 -0
- package/tests/publish_status_branch.mjs +128 -0
- package/tests/ship_land_red_pr.mjs +236 -0
- package/tests/ship_preflight.mjs +67 -1
- package/tests/start_env_guard.mjs +16 -0
- package/tests/upgrade.mjs +20 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/grade-replay.js — the differential replay over RECORDED grades
|
|
5
|
+
// (task 1002839, ADR 0089 §1). Read-only: it never grades, never spawns a model
|
|
6
|
+
// and never writes a row.
|
|
7
|
+
//
|
|
8
|
+
// WHY. How a grade is aggregated and gated IS the live ship gate, so a change to
|
|
9
|
+
// either is only behaviour-preserving if real recorded grades still come out the
|
|
10
|
+
// way production recorded them. Every persisted panel grade carries its judges'
|
|
11
|
+
// verdicts (signals.workers, the deterministic pseudo-judges included), so the
|
|
12
|
+
// grade engine's aggregate + gate stages can be re-run over them at zero cost and
|
|
13
|
+
// compared field by field with what the server stored.
|
|
14
|
+
//
|
|
15
|
+
// WHAT IS COMPARED — the decision, not the prose: panel_outcome, the four verdict
|
|
16
|
+
// counts, the worker count, the panel's own cost, the forwarded confidence (skipped
|
|
17
|
+
// when the server-side guards downgraded it — that number is theirs, not the
|
|
18
|
+
// panel's), and the five rubric axes the server re-records.
|
|
19
|
+
//
|
|
20
|
+
// Usage:
|
|
21
|
+
// node scripts/gds/grade-replay.js replay the committed corpus
|
|
22
|
+
// node scripts/gds/grade-replay.js --file <corpus> replay another corpus file
|
|
23
|
+
// node scripts/gds/grade-replay.js --live [--recent N]
|
|
24
|
+
// pull the N most recently finished tasks' grades from the API (default 150)
|
|
25
|
+
// ... --write-fixture <path> write the AGREEING replays, trimmed (issue text and
|
|
26
|
+
// stderr hashed), one per panel shape — how the test corpus is made
|
|
27
|
+
// ... --json machine-readable summary
|
|
28
|
+
// Exit 0 when every replayed grade agrees, 1 when any does not. A report, not a
|
|
29
|
+
// gate: nothing reads the exit code but the person who ran it.
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
const crypto = require('node:crypto');
|
|
34
|
+
const { arg, hasFlag, cliClient } = require('./cli-lib');
|
|
35
|
+
// Through the module facade: requiring grader.js is what registers the code panel
|
|
36
|
+
// as the engine's default work-type.
|
|
37
|
+
const engine = require('../../modules/grading/grader').gradeEngine;
|
|
38
|
+
|
|
39
|
+
const DEFAULT_CORPUS = path.join(__dirname, '..', '..', 'tests', 'fixtures', 'grade-replay', 'recorded-panel-grades.json');
|
|
40
|
+
const COUNT_FIELDS = ['pass_count', 'concerns_count', 'fail_count', 'error_count', 'worker_count'];
|
|
41
|
+
const RUBRIC_AXES = ['functional_fidelity', 'code_quality', 'economy', 'security_score', 'efficiency_score'];
|
|
42
|
+
const PER_SHAPE = 1;
|
|
43
|
+
|
|
44
|
+
const num = (v) => (v == null || v === '' ? null : Number(v));
|
|
45
|
+
|
|
46
|
+
// The decision a stored grade row records. The rubric axes are the server's
|
|
47
|
+
// re-derivation (validateAndScore) of the panel's medians — equal to them for
|
|
48
|
+
// every grade since the null-axis fixes (tasks 1002653 / 1003318).
|
|
49
|
+
function recordedDecision(grade) {
|
|
50
|
+
const s = (grade && grade.signals) || {};
|
|
51
|
+
const guard = s.server_guard || {};
|
|
52
|
+
const out = { panel_outcome: s.panel_outcome ?? null };
|
|
53
|
+
for (const k of COUNT_FIELDS) out[k] = s[k] ?? null;
|
|
54
|
+
out.panel_cost_usd = num(s.panel_cost_usd);
|
|
55
|
+
out.confidence = guard.downgraded_to ? undefined : num(s.confidence);
|
|
56
|
+
out.rubric = {};
|
|
57
|
+
for (const a of RUBRIC_AXES) out.rubric[a] = num(grade && grade.rubric_scores ? grade.rubric_scores[a] : null);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The same decision, read off a replayed grade-result.
|
|
62
|
+
function replayedDecision(result) {
|
|
63
|
+
const s = (result && result.signals) || {};
|
|
64
|
+
const out = { panel_outcome: s.panel_outcome ?? null };
|
|
65
|
+
for (const k of COUNT_FIELDS) out[k] = s[k] ?? null;
|
|
66
|
+
out.panel_cost_usd = num(s.panel_cost_usd);
|
|
67
|
+
out.confidence = num(s.confidence);
|
|
68
|
+
out.rubric = {};
|
|
69
|
+
for (const a of RUBRIC_AXES) out.rubric[a] = num(result && result.rubric_scores ? result.rubric_scores[a] : null);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const same = (a, b) => a === b || (typeof a === 'number' && typeof b === 'number' && Math.abs(a - b) < 1e-9);
|
|
74
|
+
|
|
75
|
+
// Every field on which a replay disagrees with the record, as
|
|
76
|
+
// { field, recorded, replayed }. An `undefined` recorded value is not compared.
|
|
77
|
+
//
|
|
78
|
+
// One pairing is NOT a disagreement: a rubric axis the panel left null (no judge
|
|
79
|
+
// reported it) that the row stores as 0. That 0 is the server's, not the panel's —
|
|
80
|
+
// validateAndScore coerced a missing axis with Number(null) === 0 until tasks
|
|
81
|
+
// 1002653 / 1003318 made it record null. A real 0/10 median replays as 0, so the
|
|
82
|
+
// pairing can only mean that coercion; any other value against a null still counts.
|
|
83
|
+
function compareDecision(recorded, replayed) {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const k of ['panel_outcome', ...COUNT_FIELDS, 'panel_cost_usd', 'confidence']) {
|
|
86
|
+
if (recorded[k] === undefined) continue;
|
|
87
|
+
if (!same(recorded[k], replayed[k])) out.push({ field: k, recorded: recorded[k], replayed: replayed[k] });
|
|
88
|
+
}
|
|
89
|
+
for (const a of RUBRIC_AXES) {
|
|
90
|
+
const r = (recorded.rubric || {})[a];
|
|
91
|
+
const p = (replayed.rubric || {})[a];
|
|
92
|
+
if (r === undefined || same(r, p) || (r === 0 && p === null)) continue;
|
|
93
|
+
out.push({ field: `rubric.${a}`, recorded: r, replayed: p });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const shortHash = (s) => `h:${crypto.createHash('sha256').update(String(s)).digest('hex').slice(0, 10)}`;
|
|
99
|
+
|
|
100
|
+
// A worker, reduced to what aggregate + gate read. Issue summaries, their file
|
|
101
|
+
// paths and stderr are hashed: the gate reads a summary and a file only to
|
|
102
|
+
// de-duplicate (the hash of the trimmed text preserves exactly that), and a
|
|
103
|
+
// fixture should not carry verbatim review prose.
|
|
104
|
+
function trimWorker(w) {
|
|
105
|
+
const t = {
|
|
106
|
+
kind: w.kind,
|
|
107
|
+
verdict: w.verdict,
|
|
108
|
+
error: w.error ?? null,
|
|
109
|
+
scores: w.scores || {},
|
|
110
|
+
confidence: w.confidence ?? null,
|
|
111
|
+
model: w.model ?? null,
|
|
112
|
+
cost_usd: w.cost_usd ?? null,
|
|
113
|
+
retried: !!w.retried,
|
|
114
|
+
issues: (Array.isArray(w.issues) ? w.issues : []).filter((i) => i && typeof i === 'object').map((i) => ({
|
|
115
|
+
severity: i.severity ?? null,
|
|
116
|
+
file: i.file == null ? null : shortHash(i.file),
|
|
117
|
+
summary: shortHash(String(i.summary ?? '').trim()),
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
if (w.exit_code != null) t.exit_code = w.exit_code;
|
|
121
|
+
if (w.stderr_tail) t.stderr_tail = shortHash(w.stderr_tail);
|
|
122
|
+
return t;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// One recorded grade → a corpus row, or null when it is not a panel grade.
|
|
126
|
+
function trimRecordedGrade({ task_id, graded_at = null, grade }) {
|
|
127
|
+
const s = (grade && grade.signals) || {};
|
|
128
|
+
if (!Array.isArray(s.workers) || s.workers.length === 0) return null;
|
|
129
|
+
return { task_id: String(task_id), graded_at, recorded: recordedDecision(grade), workers: s.workers.map(trimWorker) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The panel's shape — outcome plus each judge's kind/verdict/error/retry — so
|
|
133
|
+
// the fixture keeps variety instead of 200 copies of an all-pass panel.
|
|
134
|
+
function shapeKey(row) {
|
|
135
|
+
return [row.recorded.panel_outcome, ...row.workers.map((w) => `${w.kind}:${w.verdict}${w.error ? `!${w.error}` : ''}${w.retried ? '~r' : ''}`)].join('|');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Replay one corpus row (or one raw recorded grade) through the engine.
|
|
139
|
+
function replayRow(row) {
|
|
140
|
+
const result = engine.replayRecorded({ signals: { workers: row.workers } });
|
|
141
|
+
return { result, mismatches: compareDecision(row.recorded, replayedDecision(result)) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The most recently finished tasks' STORED grades, raw and newest first:
|
|
145
|
+
// [{ task_id, task_status, at, grade }]. Read-only GETs. Shared with the
|
|
146
|
+
// correlation audit (scripts/gds/grade-correlation-audit.js), which needs the
|
|
147
|
+
// task's fate beside its grade. The per-task reads run a few at a time — one
|
|
148
|
+
// after another made a 400-task pull take minutes for no reason.
|
|
149
|
+
const FETCH_CONCURRENCY = 6;
|
|
150
|
+
async function fetchRecentGrades({ recent = 150, statuses = ['shipped', 'completed', 'confirmed'], client = null } = {}) {
|
|
151
|
+
const api = client || await cliClient();
|
|
152
|
+
const tasks = [];
|
|
153
|
+
for (const status of statuses) {
|
|
154
|
+
const r = await api.tasks.getTasks({ query: { status, limit: 1000 } });
|
|
155
|
+
if (!r.ok) throw new Error(`GET /tasks?status=${status} → HTTP ${r.status}`);
|
|
156
|
+
for (const t of r.data.tasks || []) tasks.push({ id: String(t.id), status: t.status || status, at: t.shipped_at || t.updated_at || '' });
|
|
157
|
+
}
|
|
158
|
+
tasks.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
159
|
+
const picked = tasks.slice(0, recent);
|
|
160
|
+
const out = new Array(picked.length).fill(null);
|
|
161
|
+
for (let i = 0; i < picked.length; i += FETCH_CONCURRENCY) {
|
|
162
|
+
await Promise.all(picked.slice(i, i + FETCH_CONCURRENCY).map(async (t, j) => {
|
|
163
|
+
const r = await api.tasks.getTasksId({ id: t.id, query: { include: 'grade' } });
|
|
164
|
+
if (r.ok && r.data && r.data.grade) out[i + j] = { task_id: t.id, task_status: t.status, at: t.at, grade: r.data.grade };
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
return out.filter(Boolean);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function fetchLive(recent) {
|
|
171
|
+
const rows = [];
|
|
172
|
+
for (const g of await fetchRecentGrades({ recent })) {
|
|
173
|
+
const row = trimRecordedGrade({ task_id: g.task_id, graded_at: g.at, grade: g.grade });
|
|
174
|
+
if (row) rows.push(row);
|
|
175
|
+
}
|
|
176
|
+
return rows;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readCorpus(file) {
|
|
180
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
181
|
+
return Array.isArray(doc) ? doc : doc.grades;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function main() {
|
|
185
|
+
const live = hasFlag('--live');
|
|
186
|
+
const rows = live
|
|
187
|
+
? await fetchLive(Number(arg('--recent')) || 150)
|
|
188
|
+
: readCorpus(arg('--file') || DEFAULT_CORPUS);
|
|
189
|
+
const report = rows.map((row) => ({ row, ...replayRow(row) }));
|
|
190
|
+
const disagree = report.filter((r) => r.mismatches.length);
|
|
191
|
+
|
|
192
|
+
const fixturePath = arg('--write-fixture');
|
|
193
|
+
if (fixturePath) {
|
|
194
|
+
const perShape = new Map();
|
|
195
|
+
const keep = [];
|
|
196
|
+
for (const r of report) {
|
|
197
|
+
if (r.mismatches.length) continue;
|
|
198
|
+
const k = shapeKey(r.row);
|
|
199
|
+
const n = perShape.get(k) || 0;
|
|
200
|
+
if (n >= PER_SHAPE) continue;
|
|
201
|
+
perShape.set(k, n + 1);
|
|
202
|
+
keep.push(r.row);
|
|
203
|
+
}
|
|
204
|
+
const note = 'Recorded panel grades for the differential replay (tests/grade_engine_replay.mjs). Generated by scripts/gds/grade-replay.js --live --write-fixture: only grades whose replay agreed with the stored row, one per panel shape. Issue summaries, their files and stderr are hashed.';
|
|
205
|
+
fs.mkdirSync(path.dirname(fixturePath), { recursive: true });
|
|
206
|
+
// One grade per line: small in a diff, and a regenerated corpus diffs by row.
|
|
207
|
+
fs.writeFileSync(fixturePath, `{"note":${JSON.stringify(note)},\n"grades":[\n${keep.map((g) => JSON.stringify(g)).join(',\n')}\n]}\n`);
|
|
208
|
+
console.error(`wrote ${keep.length} grades (${perShape.size} panel shapes) → ${fixturePath}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (hasFlag('--json')) {
|
|
212
|
+
console.log(JSON.stringify({
|
|
213
|
+
replayed: report.length,
|
|
214
|
+
agree: report.length - disagree.length,
|
|
215
|
+
disagree: disagree.map((r) => ({ task_id: r.row.task_id, graded_at: r.row.graded_at, mismatches: r.mismatches })),
|
|
216
|
+
}, null, 2));
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`grade replay: ${report.length} recorded panel grade(s), ${report.length - disagree.length} agree, ${disagree.length} disagree`);
|
|
219
|
+
for (const r of disagree) {
|
|
220
|
+
const what = r.mismatches.map((m) => `${m.field} recorded=${JSON.stringify(m.recorded)} replayed=${JSON.stringify(m.replayed)}`).join('; ');
|
|
221
|
+
console.log(` task ${r.row.task_id}${r.row.graded_at ? ` (${r.row.graded_at})` : ''}: ${what}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
process.exitCode = disagree.length ? 1 : 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
compareDecision,
|
|
229
|
+
fetchRecentGrades,
|
|
230
|
+
replayRow,
|
|
231
|
+
shapeKey,
|
|
232
|
+
trimRecordedGrade,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
if (require.main === module) {
|
|
236
|
+
main().catch((err) => { console.error(`grade-replay: ${err.message}`); process.exit(2); });
|
|
237
|
+
}
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
// 1. NOBODY pushes refs/heads/main directly. Under Option A (ADR 0043) the
|
|
19
19
|
// GDS lands graded work; a builder pushing main bypasses the grader, the
|
|
20
20
|
// confirm gate, parallel-safety, and credits — exactly the #863 incident.
|
|
21
|
-
// Override for the server/operator deploy identity only:
|
|
21
|
+
// Override for the server/operator deploy identity only: <PREFIX>_ALLOW_MAIN_PUSH=1
|
|
22
|
+
// (BONGOS_ on a vanilla instance; the legacy OTB_ALLOW_MAIN_PUSH is still read).
|
|
22
23
|
// 2. A builder BELOW Metic may not push a diff that touches a permission-
|
|
23
24
|
// sensitive path (rank / permission / ship-grade / deploy / migration
|
|
24
25
|
// machinery — see src/bongos/permission-path-check.js).
|
|
@@ -33,6 +34,9 @@ const path = require('node:path');
|
|
|
33
34
|
|
|
34
35
|
const ppc = require(path.join(__dirname, '..', '..', '..', 'src', 'bongos', 'permission-path-check.js'));
|
|
35
36
|
const { apiCall, productName } = require(path.join(__dirname, '..', 'cli-lib.js'));
|
|
37
|
+
// The main-push override resolves through the branded prefix, the same reader
|
|
38
|
+
// push-main.js's canonical spelling is written for (task 1001441).
|
|
39
|
+
const { resolveEnv, envPrefix } = require(path.join(__dirname, '..', '..', '..', 'src', 'instance-config.js'));
|
|
36
40
|
|
|
37
41
|
const ZERO = /^0+$/;
|
|
38
42
|
const MAIN_REF = 'refs/heads/main';
|
|
@@ -122,7 +126,7 @@ async function run() {
|
|
|
122
126
|
// The operator / server deploy identity is the ONLY thing allowed to push
|
|
123
127
|
// main, and it sets this env explicitly. (Belt-and-braces; under full Option A
|
|
124
128
|
// builders never push main at all.)
|
|
125
|
-
const allowMain =
|
|
129
|
+
const allowMain = resolveEnv('ALLOW_MAIN_PUSH') === '1';
|
|
126
130
|
|
|
127
131
|
const stdin = require('node:fs').readFileSync(0, 'utf8');
|
|
128
132
|
const refs = stdin.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => {
|
|
@@ -141,7 +145,7 @@ async function run() {
|
|
|
141
145
|
'A direct push to main skips the grader, the confirm gate, parallel-safety, and',
|
|
142
146
|
'credits — this is exactly the breach SEC #863 closes (ADR 0043).',
|
|
143
147
|
'',
|
|
144
|
-
|
|
148
|
+
`(Operator/deploy identity only: set ${envPrefix()}_ALLOW_MAIN_PUSH=1 to override.)`,
|
|
145
149
|
]);
|
|
146
150
|
}
|
|
147
151
|
}
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
const { execSync } = require('node:child_process');
|
|
51
51
|
const ppc = require(require('node:path').join(__dirname, '..', '..', 'src', 'bongos', 'permission-path-check.js'));
|
|
52
52
|
const { cliClient, cliExit, API_BASE } = require('./cli-lib');
|
|
53
|
+
const { resolveEnv, envPrefix } = require(require('node:path').join(__dirname, '..', '..', 'src', 'instance-config.js'));
|
|
53
54
|
|
|
54
55
|
// ---------- pure analyzer (tested in tests/main_audit.mjs) ----------
|
|
55
56
|
|
|
@@ -314,23 +315,31 @@ function arg(name, fallback = null) {
|
|
|
314
315
|
return i >= 0 ? (process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : true) : fallback;
|
|
315
316
|
}
|
|
316
317
|
|
|
318
|
+
// requestedRange — the range this run was asked to audit, or null for the default
|
|
319
|
+
// window. An explicit --range wins; otherwise the <PREFIX>_AUDIT_RANGE env knob
|
|
320
|
+
// (BONGOS_AUDIT_RANGE on a vanilla instance; the legacy OTB_AUDIT_RANGE is still
|
|
321
|
+
// read, and warned about). The deploy gate and the CI workflow hand the range over
|
|
322
|
+
// as --range, because neither can compute the branded env prefix from a shell
|
|
323
|
+
// (task 1001441).
|
|
324
|
+
function requestedRange(argValue, env = process.env) {
|
|
325
|
+
if (argValue && argValue !== true) return argValue;
|
|
326
|
+
return resolveEnv('AUDIT_RANGE', { env }) || null;
|
|
327
|
+
}
|
|
328
|
+
|
|
317
329
|
async function main() {
|
|
318
330
|
const verifyHead = process.argv.includes('--verify-head');
|
|
319
331
|
const doRevert = process.argv.includes('--revert');
|
|
320
332
|
const strict = process.argv.includes('--strict');
|
|
321
333
|
const useDb = process.argv.includes('--db');
|
|
322
|
-
let range = arg('--range');
|
|
323
334
|
// #863-followup (findings #8/#14): the verify-head window was a fixed
|
|
324
335
|
// HEAD~30..HEAD — a poisoned commit buried under >30 commits (merges inflate
|
|
325
336
|
// the count) or landed before a rebase/force-push could escape it. The deploy
|
|
326
|
-
// gate now passes the EXACT previously-deployed SHA
|
|
327
|
-
// (deploy-main-audit-gate.sh
|
|
328
|
-
//
|
|
337
|
+
// gate now passes the EXACT previously-deployed SHA as --range
|
|
338
|
+
// <prev-deployed-sha>..HEAD (deploy-main-audit-gate.sh), so the gate audits
|
|
339
|
+
// precisely what this deploy introduces — no window to slip under.
|
|
329
340
|
// Fallbacks widen to HEAD~200 (verify-head) / a deeper origin scan otherwise.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
if (!range || range === true) {
|
|
341
|
+
let range = requestedRange(arg('--range'));
|
|
342
|
+
if (!range) {
|
|
334
343
|
const ref = verifyHead ? 'HEAD' : 'origin/main';
|
|
335
344
|
if (!verifyHead) {
|
|
336
345
|
// Default: recent origin/main. Fetch first so we're auditing the truth.
|
|
@@ -344,7 +353,7 @@ async function main() {
|
|
|
344
353
|
console.error(
|
|
345
354
|
`main-audit: DID NOT RUN — cannot build a default range for ${ref}: its first-parent history is `
|
|
346
355
|
+ `${Number.isFinite(depth) ? `${depth} commit(s)` : 'unreadable'} deep. `
|
|
347
|
-
+
|
|
356
|
+
+ `Pass --range <A..B> or set ${envPrefix()}_AUDIT_RANGE. This is a check that did not execute, not a clean audit.`
|
|
348
357
|
);
|
|
349
358
|
cliExit(2);
|
|
350
359
|
return;
|
|
@@ -411,7 +420,7 @@ if (require.main === module) {
|
|
|
411
420
|
|
|
412
421
|
module.exports = {
|
|
413
422
|
analyzeCommits, resolveRank, parseCommits, rowsToMap,
|
|
414
|
-
clampWindow, defaultRange, firstParentDepth, DEFAULT_WINDOW,
|
|
423
|
+
clampWindow, defaultRange, firstParentDepth, DEFAULT_WINDOW, requestedRange,
|
|
415
424
|
// Exported so a test can assert the address is LIVE without a database. The --db
|
|
416
425
|
// path was dead for as long as the db.js carve has been landed precisely because
|
|
417
426
|
// nothing checked that this require target still exports what it is called for.
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
'use strict';
|
|
23
23
|
const fs = require('node:fs');
|
|
24
24
|
const path = require('node:path');
|
|
25
|
+
const { resolveEnv } = require('../../src/instance-config');
|
|
25
26
|
|
|
26
27
|
// Budgets, in bytes of MEMORY.md (~4 chars/token for markdown prose).
|
|
27
28
|
const WARN_BYTES = 16000; // ~4K tok — drifting up; warn so it gets tidied.
|
|
@@ -157,7 +158,8 @@ module.exports = {
|
|
|
157
158
|
if (require.main === module) {
|
|
158
159
|
const args = process.argv.slice(2);
|
|
159
160
|
const dirArg = args.indexOf('--dir');
|
|
160
|
-
|
|
161
|
+
// <PREFIX>_MEMORY_DIR (BONGOS_ on a vanilla instance; the legacy OTB_ still read).
|
|
162
|
+
const memoryDir = dirArg >= 0 ? args[dirArg + 1] : resolveEnv('MEMORY_DIR');
|
|
161
163
|
if (!memoryDir) { console.error('usage: memory-map.js --dir <memoryDir> [--apply]'); process.exit(2); }
|
|
162
164
|
const mapPath = path.join(memoryDir, 'MEMORY.md');
|
|
163
165
|
let text; try { text = fs.readFileSync(mapPath, 'utf8'); } catch { console.error('no MEMORY.md at', mapPath); process.exit(0); }
|
|
@@ -27,11 +27,12 @@ const MANIFEST_PATH = path.join(SPOOL_DIR, 'manifest.json');
|
|
|
27
27
|
// How long the onboarding window stays open after the consent decision.
|
|
28
28
|
// Friction-mining is for the first stretch of a builder's life; after that
|
|
29
29
|
// they're not "onboarding" and shipping their transcripts would be noise (and
|
|
30
|
-
// a privacy overreach). Override for tests via
|
|
30
|
+
// a privacy overreach). Override for tests via <PREFIX>_ONBOARDING_WINDOW_DAYS
|
|
31
|
+
// (BONGOS_ on a vanilla instance; the legacy OTB_ spelling is still read).
|
|
31
32
|
const DEFAULT_WINDOW_DAYS = 14;
|
|
32
33
|
|
|
33
34
|
function windowDays() {
|
|
34
|
-
const env = Number(
|
|
35
|
+
const env = Number(ic.resolveEnv('ONBOARDING_WINDOW_DAYS'));
|
|
35
36
|
return Number.isFinite(env) && env > 0 ? env : DEFAULT_WINDOW_DAYS;
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -281,6 +281,10 @@ const CARDS = Object.freeze({
|
|
|
281
281
|
'Your work touches a protected part of the project that your rank may not change, so the server would not accept it.',
|
|
282
282
|
'Asking again will be refused again. I will take the protected files out and hand in the rest, or we pass the task to someone with the rank.',
|
|
283
283
|
'A builder with a high enough rank, for the protected part.', RULE),
|
|
284
|
+
grade_unbacked_score: card(
|
|
285
|
+
'One of the automatic reviewers gave your work a low mark without saying what is wrong, so there is nothing listed to fix. It is not accepted yet, but it was not turned down for a named problem either.',
|
|
286
|
+
'I will ask for the review again. If the low mark comes back still without a reason, I will ask for your work to be accepted by hand, with an explanation.',
|
|
287
|
+
'Only if it keeps happening — an Archon decides whether to accept it by hand.', FAULT),
|
|
284
288
|
grade_failed: card(
|
|
285
289
|
'The automatic reviewers found problems in your work, so it is not accepted yet.',
|
|
286
290
|
'I will fix what they listed, save it, and ask for the review again. That costs nothing extra.',
|
package/scripts/gds/preflight.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
const nodeFs = require('node:fs');
|
|
29
29
|
const nodePath = require('node:path');
|
|
30
30
|
const { spawnSync } = require('node:child_process');
|
|
31
|
+
const { resolveEnv, envPrefix, LEGACY_ENV_PREFIXES } = require('../../src/instance-config');
|
|
31
32
|
|
|
32
33
|
const REPO_ROOT = nodePath.resolve(__dirname, '..', '..');
|
|
33
34
|
const MIN_NODE_MAJOR = 22;
|
|
@@ -155,9 +156,16 @@ function preflightChecks({
|
|
|
155
156
|
// /builder-start caller via depsFinding() (task 1002403).
|
|
156
157
|
out.push(depsFinding({ repoRoot, fs }));
|
|
157
158
|
|
|
158
|
-
// 3. `claude` CLI launchable — same resolution order the grader uses
|
|
159
|
-
|
|
160
|
-
const
|
|
159
|
+
// 3. `claude` CLI launchable — same resolution order the grader uses:
|
|
160
|
+
// <PREFIX>_CLAUDE_BIN (legacy spellings still read), then CLAUDE_BIN, then `claude`.
|
|
161
|
+
const prefixed = resolveEnv('CLAUDE_BIN', { env });
|
|
162
|
+
const bin = prefixed || env.CLAUDE_BIN || 'claude';
|
|
163
|
+
// The label names the spelling that actually supplied the value, so a builder who
|
|
164
|
+
// set the legacy OTB_CLAUDE_BIN is told so rather than shown a name they never set.
|
|
165
|
+
const viaName = prefixed
|
|
166
|
+
? [envPrefix(), ...LEGACY_ENV_PREFIXES].map((p) => `${p}_CLAUDE_BIN`).find((n) => env[n] === prefixed)
|
|
167
|
+
: env.CLAUDE_BIN ? 'CLAUDE_BIN' : null;
|
|
168
|
+
const via = viaName ? ` (via ${viaName})` : '';
|
|
161
169
|
let r;
|
|
162
170
|
try {
|
|
163
171
|
r = spawn(bin, ['--version'], { encoding: 'utf8', shell: platform === 'win32', windowsHide: true, timeout: 15000 });
|
|
@@ -174,7 +182,7 @@ function preflightChecks({
|
|
|
174
182
|
level: 'fail',
|
|
175
183
|
title: 'claude CLI',
|
|
176
184
|
detail: `not launchable (${bin}: ${why}) — the grader spawns it per worker, so ships fail grading with SUBAGENT_SPAWN_FAILED.`,
|
|
177
|
-
fix: 'Run `npm i -g @anthropic-ai/claude-code` (puts a real claude on PATH), or set
|
|
185
|
+
fix: 'Run `npm i -g @anthropic-ai/claude-code` (puts a real claude on PATH), or set ' + envPrefix() + '_CLAUDE_BIN to its full path.',
|
|
178
186
|
});
|
|
179
187
|
}
|
|
180
188
|
|
|
@@ -28,12 +28,13 @@
|
|
|
28
28
|
// Its own file because scripts/gds/provision.js sits at the 1500-line oversized-file
|
|
29
29
|
// ratchet, the same reason provision-net.js and provision-units.js were carved out.
|
|
30
30
|
|
|
31
|
+
const crypto = require('crypto');
|
|
31
32
|
const fs = require('fs');
|
|
32
33
|
const os = require('os');
|
|
33
34
|
const path = require('path');
|
|
34
35
|
const { spawnSync } = require('child_process');
|
|
35
36
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
36
|
-
const { appUser, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
37
|
+
const { appUser, dbName, instanceUser, parseTargetRef, resolveOwnerGithubToken, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
37
38
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
38
39
|
const { RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, parseChangeRecords, notesBetween } = require('./release-notes.js');
|
|
39
40
|
|
|
@@ -273,14 +274,161 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
273
274
|
// reaching here means the two disagree. Report what was SEEN rather than a verdict.
|
|
274
275
|
return { ok: false, error: `the box is serving core ${served}, not the requested ${to} — the move did not take` };
|
|
275
276
|
}
|
|
277
|
+
|
|
278
|
+
// ---- 5. the pin reaches the project's own repo, pushed from HERE (task 1004065) ----
|
|
279
|
+
// Only now: a pin worth saving is one the box is serving. upgrade.js was told
|
|
280
|
+
// --pin-no-push for this shape, so it committed and stopped; see pushUpgradePin.
|
|
281
|
+
const repo = hubPushesPin(inst) ? await pushUpgradePin(inst, deps, { instanceDir }) : null;
|
|
282
|
+
const unsaved = repo && !repo.pushed ? `; GitHub was not updated (${repo.code})` : '';
|
|
276
283
|
await provisioning.recordEvent(db, {
|
|
277
284
|
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'core-upgraded',
|
|
278
|
-
detail: served ? `core pin moved to ${served}` : `core pin moved to ${to}; the box did not answer /version, so this is unconfirmed
|
|
285
|
+
detail: (served ? `core pin moved to ${served}` : `core pin moved to ${to}; the box did not answer /version, so this is unconfirmed`) + unsaved,
|
|
279
286
|
actor: intent.requested_by || 'runner',
|
|
280
287
|
}).catch(() => {});
|
|
281
288
|
if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id, 'core-upgrade').catch(() => {});
|
|
282
289
|
log(served ? ` ✓ ${inst.slug} serves core ${served}` : ` ⚠ ${inst.slug} upgraded to ${to}, but did not answer /version — unconfirmed`);
|
|
283
|
-
|
|
290
|
+
// `doneNote`: the drain loop resolves the intent DONE and stores this beside it, which
|
|
291
|
+
// is how the deploy page learns the upgrade landed but GitHub did not get it.
|
|
292
|
+
return { ok: true, served, confirmed: served === to, repo, doneNote: repo && !repo.pushed ? repo.note : null };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── the pin reaches the project's GitHub repo, pushed from the HUB (task 1004065) ──
|
|
296
|
+
//
|
|
297
|
+
// THE BUG THIS EXISTS FOR. upgrade.js commits the new pin on the box and then pushes it.
|
|
298
|
+
// On a hosted project it runs connected to the PROJECT's database — it has to, for
|
|
299
|
+
// migrations — while the owner's GitHub token lives in the HUB's. Its token lookup found
|
|
300
|
+
// no provisioning table there (ADR 0281), fell back to `git push origin`, and origin is a
|
|
301
|
+
// read-only deploy key (ADR 0176). GitHub refused, the refusal reached only the journal
|
|
302
|
+
// and named the wrong cause, and the intent resolved `done`. The box ran the new core,
|
|
303
|
+
// GitHub still pinned the old one, and the box's local commit stopped its fast-forward
|
|
304
|
+
// pulls from taking the owner's new work: hermeslines-marketing served 1.19.994 against
|
|
305
|
+
// a repo pinning 1.19.881, and pages pushed to that repo 404'd on the live site.
|
|
306
|
+
//
|
|
307
|
+
// WHY THE RUNNER PUSHES rather than handing upgrade.js the token. This process already
|
|
308
|
+
// holds the hub's database, where the token is, and it runs as the account that owns the
|
|
309
|
+
// checkout (the project's own account only READS it — grantInstanceRepoReadCmd). Passing
|
|
310
|
+
// the token into the upgrade would put a credential that can write the repo inside the
|
|
311
|
+
// project's own process. So the move passes --pin-no-push, upgrade.js commits, and the
|
|
312
|
+
// push happens here, in the shape refreshStandaloneCorePin already uses for this repo.
|
|
313
|
+
//
|
|
314
|
+
// A FAILED PUSH IS NOT A FAILED UPGRADE. The site runs the new core, and rolling a good
|
|
315
|
+
// deploy back would be worse than the split. The move still resolves `done`, carrying a
|
|
316
|
+
// note the deploy page reads: `repo_not_updated:<code>: <sentence>`, parsed by
|
|
317
|
+
// parseRepoNote in modules/provisioning/core-upgrade.js (re-stated there because a
|
|
318
|
+
// module may not require a script; tests/core_upgrade_runner.mjs round-trips the two).
|
|
319
|
+
//
|
|
320
|
+
// NOTHING IS FORCED OR REBASED. When GitHub has commits the box does not, putting the pin
|
|
321
|
+
// on top of them would change what the live box runs with no restart and no health check.
|
|
322
|
+
// That is reported for an operator to reconcile, never resolved here.
|
|
323
|
+
//
|
|
324
|
+
// THE LIMIT, stated so nobody mistakes this for durable: the owner's token is kept for an
|
|
325
|
+
// hour after they sign in with repository access (GITHUB_TOKEN_TTL_MS in src/bongos/db.js
|
|
326
|
+
// — one wizard sitting). An upgrade days later usually finds none; the box's own remote
|
|
327
|
+
// is then tried, and it can only read a private repo. The note says so in words.
|
|
328
|
+
|
|
329
|
+
const REPO_NOTE_PREFIX = 'repo_not_updated';
|
|
330
|
+
const REPO_NOTES = Object.freeze({
|
|
331
|
+
no_repo: 'This project has no GitHub repository on record, so the new version was saved on the server only.',
|
|
332
|
+
no_branch: 'The server\'s copy of the project is not on a branch, so there was nothing to save to GitHub.',
|
|
333
|
+
no_access: 'The hub had no permission to write to the project\'s GitHub repository: the owner\'s GitHub access is only kept for an hour after they connect GitHub, and the server\'s own key can only read the repository.',
|
|
334
|
+
refused: 'GitHub turned down the owner\'s GitHub access — it may have expired, or lack permission to write to the repository.',
|
|
335
|
+
behind: 'GitHub has changes the server does not have, so the new version could not be added on top of them. An operator needs to bring the two back in line; the site itself is running normally.',
|
|
336
|
+
failed: 'The hub could not save the new version to GitHub.',
|
|
337
|
+
});
|
|
338
|
+
// A branch name that is safe to interpolate. Narrower than git's own ref grammar on
|
|
339
|
+
// purpose: a name outside it is refused, never quoted.
|
|
340
|
+
const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/;
|
|
341
|
+
|
|
342
|
+
/** The note a done-but-unsaved move carries, bounded for the DOM it reaches. PURE. */
|
|
343
|
+
function repoNote(code, extra = '') {
|
|
344
|
+
const known = Object.prototype.hasOwnProperty.call(REPO_NOTES, code) ? code : 'failed';
|
|
345
|
+
return `${REPO_NOTE_PREFIX}:${known}: ${REPO_NOTES[known]}${extra ? ` ${extra}` : ''}`.slice(0, 600);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Which shapes get their pin pushed from here: a hosted project, whose repo is the
|
|
349
|
+
// owner's own. The platform's own row pushes to a repo its key CAN write, and a
|
|
350
|
+
// co-tenant shares the platform's checkout, so neither changes.
|
|
351
|
+
function hubPushesPin(inst) { return !!inst && inst.hosting_shape === 'cloud-host'; }
|
|
352
|
+
|
|
353
|
+
// Why a push did not happen, from what git said. `behind` is checked first: a rejected
|
|
354
|
+
// update means the credential WORKED and the history is what stopped it.
|
|
355
|
+
function pushFault(r) {
|
|
356
|
+
const said = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
|
|
357
|
+
if (/\[rejected\]|non-fast-forward|fetch first|Updates were rejected/i.test(said)) return 'behind';
|
|
358
|
+
if (/\b403\b|Authentication failed|Invalid username or password|Write access to repository not granted|Permission to \S+ denied|marked as read only|Permission denied \(publickey\)|could not read Username/i.test(said)) return 'refused';
|
|
359
|
+
return 'failed';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// GitHub's own line beats git's closing `fatal:`, which names no reason (upgrade.js
|
|
363
|
+
// gitSaid has the same preference).
|
|
364
|
+
function gitLine(r) {
|
|
365
|
+
const lines = String((r && r.stderr) || '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
366
|
+
const server = lines.filter((l) => /^(ERROR:|remote:|!)/i.test(l));
|
|
367
|
+
const said = server.length ? server[server.length - 1] : lines[lines.length - 1];
|
|
368
|
+
return said ? `(git: ${said.slice(0, 200)})` : '';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Push a confirmed upgrade's committed pin to the project's repo. NEVER THROWS: every
|
|
373
|
+
* miss returns `{ pushed:false, code, note }`, because the drain loop turns a throw into
|
|
374
|
+
* a broken instance and this instance is running fine.
|
|
375
|
+
*
|
|
376
|
+
* The token rides a chmod-600 credential file that is removed whatever the push did —
|
|
377
|
+
* never argv (ps, the journal), never a log line. The helper list is reset first
|
|
378
|
+
* (helpers are cumulative, so a global one would answer with some other account) and
|
|
379
|
+
* hooks are off, so nothing in the project's checkout runs as this account.
|
|
380
|
+
*/
|
|
381
|
+
async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
382
|
+
const log = deps.log || (() => {});
|
|
383
|
+
// The CONTROL PLANE's executor and writer: the checkout, the hub db and the token are
|
|
384
|
+
// all here, never on a remote co-hosting box (refreshStandaloneCorePin's routing).
|
|
385
|
+
const exec = deps.controlExec || deps.exec;
|
|
386
|
+
const writeFile = deps.controlWriteFile || deps.writeFile || ((p, body, o) => fs.writeFileSync(p, body, o));
|
|
387
|
+
const failedRun = (r) => !r || r.softFailed === true || r.ok === false;
|
|
388
|
+
const miss = (code, extra = '') => {
|
|
389
|
+
log(` ⚠ ${inst.slug}: GitHub was not updated — ${REPO_NOTES[code] || REPO_NOTES.failed}${extra ? ` ${extra}` : ''}`);
|
|
390
|
+
return { pushed: false, code, note: repoNote(code, extra) };
|
|
391
|
+
};
|
|
392
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
393
|
+
if (!repo) return miss('no_repo');
|
|
394
|
+
const head = exec(`git -C ${instanceDir} symbolic-ref --short HEAD`, { allowFail: true });
|
|
395
|
+
const branch = failedRun(head) ? '' : String(head.stdout || '').trim();
|
|
396
|
+
if (!BRANCH_RE.test(branch)) return miss('no_branch');
|
|
397
|
+
const at = `${repo.owner}/${repo.name}@${branch}`;
|
|
398
|
+
const hardened = `git -C ${instanceDir} -c core.hooksPath=/dev/null`;
|
|
399
|
+
const env = { GIT_TERMINAL_PROMPT: '0' }; // a refused credential fails; it never waits at a prompt
|
|
400
|
+
|
|
401
|
+
const token = await resolveOwnerGithubToken(inst, deps, { tag: '[core-upgrade]' });
|
|
402
|
+
if (token) {
|
|
403
|
+
const credFile = path.join(os.tmpdir(), `bongos-gitcred-upgrade-${inst.slug}-${process.pid}-${crypto.randomBytes(6).toString('hex')}`);
|
|
404
|
+
let r = null;
|
|
405
|
+
try {
|
|
406
|
+
writeFile(credFile, `https://x-access-token:${token}@github.com\n`, { mode: 0o600 });
|
|
407
|
+
r = exec(`${hardened} -c credential.helper= -c credential.helper='store --file=${credFile}' push https://x-access-token@github.com/${repo.owner}/${repo.name}.git HEAD:${branch}`,
|
|
408
|
+
{ allowFail: true, env });
|
|
409
|
+
} catch (e) {
|
|
410
|
+
return miss('failed', `(could not stage the owner's credential: ${String((e && e.message) || e).slice(0, 160)})`);
|
|
411
|
+
} finally {
|
|
412
|
+
exec(`rm -f ${credFile}`, { allowFail: true });
|
|
413
|
+
}
|
|
414
|
+
if (!failedRun(r)) {
|
|
415
|
+
log(` ✓ pushed the pin to ${at} from the hub, over the owner's GitHub token`);
|
|
416
|
+
return { pushed: true, via: 'owner-token', branch };
|
|
417
|
+
}
|
|
418
|
+
return miss(pushFault(r), gitLine(r));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// No token stored — which is the ordinary case (see THE LIMIT above). The box's own
|
|
422
|
+
// remote is still tried: a checkout wired with a key that can write is not wrong to use.
|
|
423
|
+
const r = exec(`${hardened} push origin HEAD:${branch}`, { allowFail: true, env });
|
|
424
|
+
if (!failedRun(r)) {
|
|
425
|
+
log(` ✓ pushed the pin to ${at} from the hub, over the checkout's own remote`);
|
|
426
|
+
return { pushed: true, via: 'origin', branch };
|
|
427
|
+
}
|
|
428
|
+
const fault = pushFault(r);
|
|
429
|
+
// A refusal here is not the owner's access being turned down — there was none to turn
|
|
430
|
+
// down. It is the read-only key, which is the no-access case.
|
|
431
|
+
return miss(fault === 'refused' ? 'no_access' : fault, gitLine(r));
|
|
284
432
|
}
|
|
285
433
|
|
|
286
434
|
// The pre-flight lines a dry run emits, and the refusal when it has one. upgrade.js writes
|
|
@@ -404,6 +552,9 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
|
|
|
404
552
|
`--health-url http://127.0.0.1:${inst.port}/healthz`,
|
|
405
553
|
`--version-url http://127.0.0.1:${inst.port}/version`,
|
|
406
554
|
'--commit-pin',
|
|
555
|
+
// A hosted project's pin is pushed by the RUNNER, which can reach the owner's
|
|
556
|
+
// token; the upgrade process cannot (task 1004065, pushUpgradePin).
|
|
557
|
+
hubPushesPin(inst) ? '--pin-no-push' : '',
|
|
407
558
|
requestedBy ? `--requested-by ${requestedBy}` : '',
|
|
408
559
|
requestedBy ? '--upgrade-source owner-control' : ''];
|
|
409
560
|
return { cmd: [...shared, ...tail].filter(Boolean).join(' '), runDir: REPO_ROOT, instanceDir, runAs: account };
|
|
@@ -831,4 +982,4 @@ async function snapshotAllCoreVersions(deps) {
|
|
|
831
982
|
return { seen };
|
|
832
983
|
}
|
|
833
984
|
|
|
834
|
-
module.exports = { UPGRADE_SHAPES, CHANGE_CAP, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
985
|
+
module.exports = { UPGRADE_SHAPES, CHANGE_CAP, REPO_NOTES, repoNote, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
@@ -140,10 +140,25 @@ async function lookupInstanceRow(instanceDir, deps = {}) {
|
|
|
140
140
|
} catch { return null; }
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// What upgrade.js's pin push says when lookupInstanceRow found NO row on a checkout wired
|
|
144
|
+
// with a read-only deploy key (task 1004065). PURE.
|
|
145
|
+
//
|
|
146
|
+
// That pairing is NOT the origin path the header above describes. The key says this is a
|
|
147
|
+
// hosted private project (ADR 0176), and its own database never has the provisioning row
|
|
148
|
+
// (ADR 0281) — so the owner's token cannot be looked up here, and the remote the push
|
|
149
|
+
// falls back to cannot write. Silence made that indistinguishable from a misconfiguration.
|
|
150
|
+
// A missing row anywhere else IS the origin path and stays quiet, which is why the caller
|
|
151
|
+
// only asks for this line when the key is present. Lives here rather than beside its
|
|
152
|
+
// caller because upgrade.js sits exactly at the oversized-file ratchet.
|
|
153
|
+
function missingRowNote(key, remote = 'origin') {
|
|
154
|
+
return ` • pin push: no provisioning row is readable from this database, so the owner's GitHub token cannot be looked up here — and ${remote} is the read-only deploy key at ${key}. A hosted project's own database never has that row (ADR 0281); the deploy door pushes the pin from the hub instead (task 1004065).`;
|
|
155
|
+
}
|
|
156
|
+
|
|
143
157
|
module.exports = {
|
|
144
158
|
instanceRepoRemote,
|
|
145
159
|
instanceRepoRemotes,
|
|
146
160
|
lookupInstanceRow,
|
|
161
|
+
missingRowNote,
|
|
147
162
|
parseTargetRef,
|
|
148
163
|
resolveOwnerGithubToken,
|
|
149
164
|
};
|