@bongos/core 1.19.848 → 1.19.850
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 +29 -14
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/paste-token.js +19 -2
- package/scripts/gds/ship-escalation.js +148 -0
- package/scripts/gds/ship-flow.js +34 -4
- package/src/module-api.js +1 -1
- package/tests/no_artifact_escalation.mjs +248 -0
- package/tests/paste_token_session_store.mjs +188 -0
- package/tests/reward_gate.mjs +22 -8
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.850",
|
|
6
|
+
"core_contract": "1.19.850",
|
|
7
|
+
"source_commit": "232e375d3248cfc923d287f61d29cb6c5f59c5d9",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-17T20:32:54.381Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 496,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2276,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2798,
|
|
20
|
+
"tree_sha256": "12391d158894c3d0cb4e4073c3939f9c3e83705fa0406663fe5a30859fba4af4",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2582,7 +2582,7 @@
|
|
|
2582
2582
|
{
|
|
2583
2583
|
"path": "docs/module-api-changelog.md",
|
|
2584
2584
|
"mode": "0000644",
|
|
2585
|
-
"sha256": "
|
|
2585
|
+
"sha256": "6c3496a323791d9b6bfc8e86faba4e433a51091e5a9d12b85ede0b207d8057a5"
|
|
2586
2586
|
},
|
|
2587
2587
|
{
|
|
2588
2588
|
"path": "docs/modules-contract.md",
|
|
@@ -7907,12 +7907,12 @@
|
|
|
7907
7907
|
{
|
|
7908
7908
|
"path": "package-lock.json",
|
|
7909
7909
|
"mode": "0000644",
|
|
7910
|
-
"sha256": "
|
|
7910
|
+
"sha256": "1e217b4d7975b9c295c2806a83a5817f93fc38a0786a1976fe8ec19aac141b50"
|
|
7911
7911
|
},
|
|
7912
7912
|
{
|
|
7913
7913
|
"path": "package.json",
|
|
7914
7914
|
"mode": "0000644",
|
|
7915
|
-
"sha256": "
|
|
7915
|
+
"sha256": "6943316e63bef1a867ea04e705e42a9aa28a50e2cded10e16874899c61f69f03"
|
|
7916
7916
|
},
|
|
7917
7917
|
{
|
|
7918
7918
|
"path": "public-docs/index.html",
|
|
@@ -8772,7 +8772,7 @@
|
|
|
8772
8772
|
{
|
|
8773
8773
|
"path": "scripts/gds/paste-token.js",
|
|
8774
8774
|
"mode": "0000644",
|
|
8775
|
-
"sha256": "
|
|
8775
|
+
"sha256": "9c3fd12318b04062382d8f1309d2da64de7436c93ff7bb25d2b1f5ac5cec7795"
|
|
8776
8776
|
},
|
|
8777
8777
|
{
|
|
8778
8778
|
"path": "scripts/gds/plain-cards.js",
|
|
@@ -9189,6 +9189,11 @@
|
|
|
9189
9189
|
"mode": "0000644",
|
|
9190
9190
|
"sha256": "96ad972d929f3733f871d45df1a9ff7013f9f74154f9e37d5e2df55e7959c1a5"
|
|
9191
9191
|
},
|
|
9192
|
+
{
|
|
9193
|
+
"path": "scripts/gds/ship-escalation.js",
|
|
9194
|
+
"mode": "0000644",
|
|
9195
|
+
"sha256": "500c73254cb071f9e9ef08e4a3fc934f6a49a25193be9975c39704fb19e67429"
|
|
9196
|
+
},
|
|
9192
9197
|
{
|
|
9193
9198
|
"path": "scripts/gds/ship-finish.js",
|
|
9194
9199
|
"mode": "0000644",
|
|
@@ -9197,7 +9202,7 @@
|
|
|
9197
9202
|
{
|
|
9198
9203
|
"path": "scripts/gds/ship-flow.js",
|
|
9199
9204
|
"mode": "0000644",
|
|
9200
|
-
"sha256": "
|
|
9205
|
+
"sha256": "4b8179e5c1aae3fc4845457a4089c57f6cf105c33d03d21d3ade99776371e50f"
|
|
9201
9206
|
},
|
|
9202
9207
|
{
|
|
9203
9208
|
"path": "scripts/gds/ship-git.js",
|
|
@@ -9847,7 +9852,7 @@
|
|
|
9847
9852
|
{
|
|
9848
9853
|
"path": "src/module-api.js",
|
|
9849
9854
|
"mode": "0000644",
|
|
9850
|
-
"sha256": "
|
|
9855
|
+
"sha256": "d2a5ba427ce014f7940e2757f06541e25275e0c4f59c0b9c5026396c795e41f2"
|
|
9851
9856
|
},
|
|
9852
9857
|
{
|
|
9853
9858
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12354,6 +12359,11 @@
|
|
|
12354
12359
|
"mode": "0000644",
|
|
12355
12360
|
"sha256": "c57c0211709ab041ee895bbb4ade9f4b146b43147570bf282e6d6a808560db76"
|
|
12356
12361
|
},
|
|
12362
|
+
{
|
|
12363
|
+
"path": "tests/no_artifact_escalation.mjs",
|
|
12364
|
+
"mode": "0000644",
|
|
12365
|
+
"sha256": "3d2b03a39cbefc02565a190dee0612e14576edbf32ea0fccf798d6ed28fee8f1"
|
|
12366
|
+
},
|
|
12357
12367
|
{
|
|
12358
12368
|
"path": "tests/oauth_secret.mjs",
|
|
12359
12369
|
"mode": "0000644",
|
|
@@ -12444,6 +12454,11 @@
|
|
|
12444
12454
|
"mode": "0000644",
|
|
12445
12455
|
"sha256": "e48806b48a9f7a4f9f9c010f45750e6b440d2de68feacdabacd9bd25d98f2046"
|
|
12446
12456
|
},
|
|
12457
|
+
{
|
|
12458
|
+
"path": "tests/paste_token_session_store.mjs",
|
|
12459
|
+
"mode": "0000644",
|
|
12460
|
+
"sha256": "9901e8e0b857c3061421909180bb1e34f1752a1ccfaca8150a52e7a79bf1227b"
|
|
12461
|
+
},
|
|
12447
12462
|
{
|
|
12448
12463
|
"path": "tests/patch_gate_ordering.mjs",
|
|
12449
12464
|
"mode": "0000644",
|
|
@@ -13007,7 +13022,7 @@
|
|
|
13007
13022
|
{
|
|
13008
13023
|
"path": "tests/reward_gate.mjs",
|
|
13009
13024
|
"mode": "0000644",
|
|
13010
|
-
"sha256": "
|
|
13025
|
+
"sha256": "f1cb5f34e9ecbd072b56840facdc57ca2b9d2fa6efc48427ce738cc65a72011f"
|
|
13011
13026
|
},
|
|
13012
13027
|
{
|
|
13013
13028
|
"path": "tests/reward_mode_cost_plus_only.mjs",
|
|
@@ -2170,5 +2170,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2170
2170
|
landed since 1.19.846 with no explicit bump. run 35266409194. (task 1002620)
|
|
2171
2171
|
1.19.848 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2172
2172
|
landed since 1.19.847 with no explicit bump. run 35267380720. (task 1002620)
|
|
2173
|
+
1.19.849 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2174
|
+
landed since 1.19.848 with no explicit bump. run 35269098215. (task 1002620)
|
|
2175
|
+
1.19.850 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2176
|
+
landed since 1.19.849 with no explicit bump. run 35271402682. (task 1002620)
|
|
2173
2177
|
---------------------------------------------------------------------------
|
|
2174
2178
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.850",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.850",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.850",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -28,6 +28,13 @@ const ic = require('../../src/instance-config');
|
|
|
28
28
|
// Destructured for the same reason as cli-lib: a namespace-only `ic.X` read is
|
|
29
29
|
// invisible to knip, which then calls the export dead code.
|
|
30
30
|
const { SESSION_FILENAMES, readSessionConfigSync } = require('../../src/instance-config');
|
|
31
|
+
// SESSIONS ARE PER INSTANCE (ADR 0269), and saveSession is what makes that true — see
|
|
32
|
+
// step 4. Requiring cli-lib here was weighed rather than assumed: it costs ~13ms and
|
|
33
|
+
// prints nothing at load (undici and the surface machinery are all lazy), and login.js
|
|
34
|
+
// — the sibling pre-session flow, shipped in the same standalone on-ramp bundle as this
|
|
35
|
+
// script — already requires it. So this adds no module to the package and no visible
|
|
36
|
+
// cost to a paste. (task 1004003)
|
|
37
|
+
const { saveSession } = require('./cli-lib');
|
|
31
38
|
|
|
32
39
|
// WRITE the session to the configured instance dir (OTB → ~/.config/otb,
|
|
33
40
|
// unchanged; a fresh instance → its neutral dir). R61 / task 1200.
|
|
@@ -160,10 +167,20 @@ async function main() {
|
|
|
160
167
|
api_base: API_BASE,
|
|
161
168
|
};
|
|
162
169
|
|
|
163
|
-
// 4. Ensure dir exists with safe perms, then
|
|
170
|
+
// 4. Ensure dir exists with safe perms, then save 0600 THROUGH saveSession.
|
|
171
|
+
//
|
|
172
|
+
// WHY NOT A BARE writeFileSync HERE (task 1004003). This used to write SESSION_PATH
|
|
173
|
+
// directly, which is the ACTIVE POINTER and nothing else. Under ADR 0269 a machine has
|
|
174
|
+
// several instances, and saveSession is the only writer that knows it: it archives the
|
|
175
|
+
// OUTGOING session into the host-keyed store before overwriting the pointer, and files
|
|
176
|
+
// the incoming one there too. Writing the pointer directly skipped both, so pasting a
|
|
177
|
+
// token for instance B while signed in to instance A destroyed A's session with no
|
|
178
|
+
// archive — and `bongos login <A>`, which exists to switch back without a device flow,
|
|
179
|
+
// reads that store and found nothing, so the switch back cost a full re-auth. The state
|
|
180
|
+
// was unrecoverable, because the pointer is the file that was overwritten.
|
|
164
181
|
try {
|
|
165
182
|
fs.mkdirSync(path.dirname(SESSION_PATH), { recursive: true, mode: 0o700 });
|
|
166
|
-
|
|
183
|
+
await saveSession(next);
|
|
167
184
|
// chmod again in case the file already existed with looser perms (writeFile's
|
|
168
185
|
// mode arg is ignored when the file already exists on some platforms).
|
|
169
186
|
fs.chmodSync(SESSION_PATH, 0o600);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
//
|
|
3
|
+
// scripts/gds/ship-escalation.js — the exit for a no-artifact close (task 1003950)
|
|
4
|
+
//
|
|
5
|
+
// THE DEAD END THIS REPLACES. A task that is legitimately artifact-free — an ops
|
|
6
|
+
// verification, a DB-only close, or one whose premise turned out to be already-done —
|
|
7
|
+
// could not be closed by any builder below archon, and the two documented paths pointed
|
|
8
|
+
// at each other:
|
|
9
|
+
//
|
|
10
|
+
// ship.js NNNN --allow-empty → implies --skip-grade → 403 task.confirm.grade_bypass
|
|
11
|
+
// → "re-run WITHOUT --skip-grade and use --regrade"
|
|
12
|
+
// ship.js NNNN --regrade → "Nothing to grade: the committed diff is empty"
|
|
13
|
+
// → "if this task is DELIBERATELY artifact-free, use --allow-empty"
|
|
14
|
+
//
|
|
15
|
+
// Neither exit is reachable at metic. The task parks at `completed` with the claim
|
|
16
|
+
// released — the strand shape the reconciler cannot heal (no branch, no diff, and
|
|
17
|
+
// `completed` sits BEFORE the confirm gate the reconciler sweeps from). Reproduced live
|
|
18
|
+
// 2026-09-16 on task 1003822.
|
|
19
|
+
//
|
|
20
|
+
// WHAT THIS DOES, AND DELIBERATELY DOES NOT DO. It does not move the gate. The owner
|
|
21
|
+
// decision recorded on task 1003950 scoped this to the third of the three shapes the
|
|
22
|
+
// bug offered — "give the refusal a working escalation that does not require the builder
|
|
23
|
+
// to know an archon personally" — because `task.confirm.grade_bypass` is `system: true`
|
|
24
|
+
// in modules/government/catalog.js, one of the four trust-boundary keys ADR 0157 names
|
|
25
|
+
// in its escalation-chain argument. Lowering or forking it is a governance migration and
|
|
26
|
+
// an ADR, filed as task 1004010; grading the NOTES instead of the diff is task 1004011.
|
|
27
|
+
//
|
|
28
|
+
// So the close stays archon-gated. What stops is the LOOP: the refusal now files the
|
|
29
|
+
// escalation on the builder's behalf and terminates, instead of returning them to a flag
|
|
30
|
+
// that will send them back here.
|
|
31
|
+
//
|
|
32
|
+
// IDEMPOTENT BY TASK, not by run — the same reasoning artifact-audit.js carries.
|
|
33
|
+
// POST /blockers dedupes on (source, source_ref), so a builder who re-runs --allow-empty
|
|
34
|
+
// three times while waiting gets ONE blocker, not three. A check that spams is a check
|
|
35
|
+
// that gets muted, and this one is aimed at the surface a person actually reads.
|
|
36
|
+
|
|
37
|
+
const NO_ARTIFACT_SOURCE = 'no-artifact-close';
|
|
38
|
+
|
|
39
|
+
// One blocker per task. Deliberately NOT keyed on the builder or the run: two builders
|
|
40
|
+
// hitting the same stuck task should land on the same blocker, not race to file two.
|
|
41
|
+
function escalationSourceRef(taskId) {
|
|
42
|
+
return `${NO_ARTIFACT_SOURCE}:${taskId}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function escalationTitle(taskId, title) {
|
|
46
|
+
const t = String(title || '').slice(0, 90);
|
|
47
|
+
return `No-artifact close needs an archon: task ${taskId}${t ? ` — ${t}` : ''}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Pure so the wording is unit-testable without git or network. The body has to carry
|
|
51
|
+
// enough for the archon to make the call WITHOUT re-interviewing the builder — that is
|
|
52
|
+
// the whole point of filing it rather than telling them to go find someone.
|
|
53
|
+
function escalationBody({ taskId, title, floor, summary, notes, builder }) {
|
|
54
|
+
const reason = [summary, notes].filter(Boolean).join('\n\n');
|
|
55
|
+
const lines = [
|
|
56
|
+
`Task **${taskId}** is claimed to be legitimately artifact-free and cannot be closed by its builder.`,
|
|
57
|
+
'',
|
|
58
|
+
`- **Task:** ${title || '(untitled)'}`,
|
|
59
|
+
`- **Declared by:** ${builder || 'the claim holder'} (ran \`ship.js ${taskId} --allow-empty\`)`,
|
|
60
|
+
`- **Blocked on:** \`task.confirm.grade_bypass\`${floor ? ` (floor: ${floor})` : ''}`,
|
|
61
|
+
'',
|
|
62
|
+
'### Why it is stuck',
|
|
63
|
+
'',
|
|
64
|
+
'The committed diff is empty and the working tree is clean, so the subagent grader has',
|
|
65
|
+
'nothing to read. Confirming without a passing grade is archon-gated, and the builder',
|
|
66
|
+
'holds no route past it. Left alone the task parks at `completed` with the claim',
|
|
67
|
+
'released — the strand shape the publish reconciler cannot heal, because `completed`',
|
|
68
|
+
'sits before the `confirmed` gate it sweeps from.',
|
|
69
|
+
'',
|
|
70
|
+
'### The reason the builder gave',
|
|
71
|
+
'',
|
|
72
|
+
reason || '_(none given — the ship carried no --summary or --notes)_',
|
|
73
|
+
'',
|
|
74
|
+
'### What is being asked of you',
|
|
75
|
+
'',
|
|
76
|
+
'Decide whether this task is genuinely artifact-free. That claim is the thing worth',
|
|
77
|
+
'checking here; the absent diff is not evidence either way.',
|
|
78
|
+
'',
|
|
79
|
+
`- If yes: \`POST /api/bongos/tasks/${taskId}/confirm\`, or run \`node scripts/gds/ship.js ${taskId} --allow-empty\` yourself.`,
|
|
80
|
+
'- If no: the work was never done, or never committed — release the claim back to `ready` and say so here.',
|
|
81
|
+
'',
|
|
82
|
+
'Filed automatically by the ship path (task 1003950). Re-running `--allow-empty` will',
|
|
83
|
+
'not file a second copy.',
|
|
84
|
+
];
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The terminating refusal. THE ONE INVARIANT: these lines must never send the builder to
|
|
89
|
+
// --regrade, because --regrade on an empty diff sends them straight back to --allow-empty.
|
|
90
|
+
// That mutual advice IS the bug (task 1003950). Pure + exported so a test can assert the
|
|
91
|
+
// loop stays closed rather than trusting a reviewer to notice it reopening.
|
|
92
|
+
function noArtifactRefusalLines({ taskId, floor, escalation }) {
|
|
93
|
+
const lines = [
|
|
94
|
+
'',
|
|
95
|
+
'This task has no artifact to grade, and confirming without a passing grade is',
|
|
96
|
+
`permission-gated: task.confirm.grade_bypass${floor ? ` (floor: ${floor})` : ''}.`,
|
|
97
|
+
'This is an authorization wall, not a verdict on your work.',
|
|
98
|
+
'',
|
|
99
|
+
];
|
|
100
|
+
if (escalation && escalation.filed) {
|
|
101
|
+
lines.push(`Escalated for you: blocker ${escalation.id} is now on the archon review queue,`);
|
|
102
|
+
lines.push('carrying this task, the reason you gave, and what is being asked. You do not');
|
|
103
|
+
lines.push('need to know an archon personally, and there is nothing further to re-run.');
|
|
104
|
+
} else if (escalation && escalation.reason === 'already-filed') {
|
|
105
|
+
lines.push('Already escalated: a blocker for this task is open on the archon review queue.');
|
|
106
|
+
lines.push('Re-running will not speed it up. Nothing further to do here.');
|
|
107
|
+
} else {
|
|
108
|
+
// Fail-open: the close is still blocked, so the builder must not be told it worked.
|
|
109
|
+
const why = escalation && escalation.reason ? ` (${escalation.reason})` : '';
|
|
110
|
+
lines.push(`Could not file the escalation automatically${why}.`);
|
|
111
|
+
lines.push('File it by hand so this does not sit silently — post a blocker naming this task:');
|
|
112
|
+
lines.push(' node scripts/gds/api.js POST /blockers --body-file <path-to-json>');
|
|
113
|
+
}
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push(`Your claim on task ${taskId} is unchanged. Do NOT re-run with --regrade:`);
|
|
116
|
+
lines.push('the diff is empty, so a re-grade has nothing to read and will point you back here.');
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Best-effort by design: a failure to file must not turn a blocked close into a crash,
|
|
121
|
+
// but it must also never read as success — noArtifactRefusalLines prints the manual
|
|
122
|
+
// fallback when `filed` is false.
|
|
123
|
+
async function fileNoArtifactEscalation({ taskId, title, floor, summary, notes, builder }, { call } = {}) {
|
|
124
|
+
const post = call || require('./cli-lib').apiCall;
|
|
125
|
+
let r;
|
|
126
|
+
try {
|
|
127
|
+
r = await post('POST', '/api/bongos/blockers', {
|
|
128
|
+
title: escalationTitle(taskId, title),
|
|
129
|
+
body_md: escalationBody({ taskId, title, floor, summary, notes, builder }),
|
|
130
|
+
source: NO_ARTIFACT_SOURCE,
|
|
131
|
+
source_ref: escalationSourceRef(taskId),
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return { filed: false, reason: `POST /blockers threw: ${err && err.message}` };
|
|
135
|
+
}
|
|
136
|
+
if (!r || !r.ok) return { filed: false, reason: `POST /blockers -> ${r && r.status}` };
|
|
137
|
+
if (r.data && r.data.skipped) return { filed: false, reason: 'already-filed' };
|
|
138
|
+
return { filed: true, id: r.data && r.data.blocker && r.data.blocker.id };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
NO_ARTIFACT_SOURCE,
|
|
143
|
+
escalationSourceRef,
|
|
144
|
+
escalationTitle,
|
|
145
|
+
escalationBody,
|
|
146
|
+
noArtifactRefusalLines,
|
|
147
|
+
fileNoArtifactEscalation,
|
|
148
|
+
};
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -28,6 +28,7 @@ const { pushVia } = require('./ship-deploy-target.js');
|
|
|
28
28
|
|
|
29
29
|
const { diffstatFor, finishShip } = require('./ship-finish.js');
|
|
30
30
|
const { evaluateRegradeEligibility, evaluateResumeAction, resumeMerge, shouldConfirmWithoutGrade } = require('./ship-resume.js');
|
|
31
|
+
const { fileNoArtifactEscalation, noArtifactRefusalLines } = require('./ship-escalation.js');
|
|
31
32
|
const { noteServerReward } = require('./ship-state.js');
|
|
32
33
|
|
|
33
34
|
// ---------- Main flow ----------
|
|
@@ -70,6 +71,9 @@ async function shipMain() {
|
|
|
70
71
|
// empty diff is refused (it used to slip through under --skip-grade — the
|
|
71
72
|
// task-1363 phantom ship). Never overrides a dirty working tree.
|
|
72
73
|
const allowEmpty = args.includes('--allow-empty');
|
|
74
|
+
// task 1003950: set only when the empty-diff guard below classifies this ship as a
|
|
75
|
+
// deliberate no-artifact close. Gates the auto-escalation at the confirm wall.
|
|
76
|
+
let noArtifactClose = false;
|
|
73
77
|
// task 1002605 (G-18): explicit opt-in to continue past a FAILED production
|
|
74
78
|
// smoke run. Without it a smoke FAIL now ABORTS before the claim resolves
|
|
75
79
|
// (claim intact); with it, the resolve proceeds and the task deliberately
|
|
@@ -266,6 +270,12 @@ async function shipMain() {
|
|
|
266
270
|
// There is no committed diff for the subagent grader to read, so confirm
|
|
267
271
|
// directly (this is the DB-only path the old --skip-grade route served).
|
|
268
272
|
if (!skipGrade) skipGrade = true;
|
|
273
|
+
// task 1003950: mark WHY the grader is being skipped. A --skip-grade ship past a
|
|
274
|
+
// failing grade and a genuinely artifact-free close both reach the same archon
|
|
275
|
+
// gate below, but only the second one may be escalated automatically — the first
|
|
276
|
+
// has a diff the grader DID read and reject, and auto-filing a blocker for it
|
|
277
|
+
// would be a builder routing their own rejected work to an override queue.
|
|
278
|
+
noArtifactClose = true;
|
|
269
279
|
console.log('--allow-empty: no committed diff (no-artifact / DB-only ship) — confirming directly without the subagent grader.');
|
|
270
280
|
}
|
|
271
281
|
|
|
@@ -570,6 +580,19 @@ async function shipMain() {
|
|
|
570
580
|
const details = (errObj && typeof errObj === 'object' && errObj.details) || {};
|
|
571
581
|
const floor = details.floors && details.floors['task.confirm.grade_bypass'];
|
|
572
582
|
const floorNote = floor ? ` (floor: ${floor})` : '';
|
|
583
|
+
// task 1003950: these two cases used to print the same advice, and for a
|
|
584
|
+
// no-artifact close that advice was a LOOP — --regrade on an empty diff
|
|
585
|
+
// refuses and points back at --allow-empty, which lands here again. Split
|
|
586
|
+
// them: a rejected-grade bypass still has a real diff and a real remedy
|
|
587
|
+
// (fix the findings, re-grade); an artifact-free close has neither, so it
|
|
588
|
+
// gets the escalation filed for it and terminates.
|
|
589
|
+
if (noArtifactClose) {
|
|
590
|
+
const escalation = await fileNoArtifactEscalation({
|
|
591
|
+
taskId, title: ac.title, floor, summary, notes, builder: (me.data.builder || {}).github_login,
|
|
592
|
+
});
|
|
593
|
+
for (const line of noArtifactRefusalLines({ taskId, floor, escalation })) console.error(line);
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
573
596
|
console.error('');
|
|
574
597
|
console.error(`✖ Confirming without a passing grade is permission-gated: task.confirm.grade_bypass${floorNote}.`);
|
|
575
598
|
console.error(' This is an authorization wall, not a verdict on your work.');
|
|
@@ -907,10 +930,17 @@ async function regradeMain() {
|
|
|
907
930
|
if (confirm.status === 403 && errCode === 'permission_forbidden') {
|
|
908
931
|
const details = (errObj && typeof errObj === 'object' && errObj.details) || {};
|
|
909
932
|
const floor = details.floors && details.floors['task.confirm.grade_bypass'];
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
933
|
+
// task 1003950: this branch is reached ONLY via shouldConfirmWithoutGrade,
|
|
934
|
+
// which requires both an empty committed diff and an explicit --allow-empty —
|
|
935
|
+
// so it is always a no-artifact close, and never a bypass of a failing grade.
|
|
936
|
+
// It used to end at "ask a builder whose role holds it", which is the exact
|
|
937
|
+
// dead end the bug names: a builder who knows no archon had nowhere to go.
|
|
938
|
+
const escalation = await fileNoArtifactEscalation({
|
|
939
|
+
// no `notes` on this path by design: regradeMain REFUSES --notes (see
|
|
940
|
+
// proseFlagPresent above), so the summary is the only reason the builder gave.
|
|
941
|
+
taskId, title: task && task.title, floor, summary, builder: builder.github_login,
|
|
942
|
+
});
|
|
943
|
+
for (const line of noArtifactRefusalLines({ taskId, floor, escalation })) console.error(line);
|
|
914
944
|
process.exit(1);
|
|
915
945
|
}
|
|
916
946
|
console.error(`manual-confirm failed: ${apiErrorLine(confirm.data, confirm.status)}`);
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.850'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// tests/no_artifact_escalation.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003950 — a no-artifact close must TERMINATE, not loop.
|
|
4
|
+
//
|
|
5
|
+
// The bug: a legitimately artifact-free task (an ops verification, a DB-only close, one
|
|
6
|
+
// whose premise turned out already-done) could not be closed below archon, and the two
|
|
7
|
+
// documented exits advised each other:
|
|
8
|
+
//
|
|
9
|
+
// --allow-empty → 403 task.confirm.grade_bypass → "use --regrade"
|
|
10
|
+
// --regrade → "the committed diff is empty" → "use --allow-empty"
|
|
11
|
+
//
|
|
12
|
+
// The fix keeps the gate where it is (`task.confirm.grade_bypass` is `system: true` —
|
|
13
|
+
// one of the four trust-boundary keys ADR 0157 names; moving it is task 1004010) and
|
|
14
|
+
// instead FILES the escalation for the builder, then stops.
|
|
15
|
+
//
|
|
16
|
+
// What these tests protect, in order of how easily each would rot:
|
|
17
|
+
// 1. the loop stays closed — no refusal line may advise a command that comes back here;
|
|
18
|
+
// 2. the escalation is actually WIRED at both 403 sites, not merely exported;
|
|
19
|
+
// 3. only a no-artifact close is auto-escalated — a --skip-grade bypass of a FAILING
|
|
20
|
+
// grade must not be, or a builder could route their own rejected work to the
|
|
21
|
+
// override queue;
|
|
22
|
+
// 4. filing is idempotent per task, and a filing failure never reads as success.
|
|
23
|
+
//
|
|
24
|
+
// Run: node tests/no_artifact_escalation.mjs
|
|
25
|
+
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
|
|
29
|
+
import { shipCliSource } from './helpers.mjs';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const esc = require('../scripts/gds/ship-escalation.js');
|
|
33
|
+
const {
|
|
34
|
+
escalationSourceRef, escalationTitle, escalationBody,
|
|
35
|
+
noArtifactRefusalLines, fileNoArtifactEscalation, NO_ARTIFACT_SOURCE,
|
|
36
|
+
} = esc;
|
|
37
|
+
|
|
38
|
+
let passed = 0;
|
|
39
|
+
let failed = 0;
|
|
40
|
+
function test(name, fn) {
|
|
41
|
+
try {
|
|
42
|
+
const r = fn();
|
|
43
|
+
if (r && typeof r.then === 'function') {
|
|
44
|
+
return r.then(() => { passed++; console.log(` ok ${name}`); },
|
|
45
|
+
(e) => { failed++; console.error(`FAIL ${name}\n ${e.message}`); });
|
|
46
|
+
}
|
|
47
|
+
passed++; console.log(` ok ${name}`);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
failed++; console.error(`FAIL ${name}\n ${e.message}`);
|
|
50
|
+
}
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const text = (o) => noArtifactRefusalLines(o).join('\n');
|
|
55
|
+
|
|
56
|
+
// ---- 1. THE LOOP INVARIANT ---------------------------------------------------------
|
|
57
|
+
// This is the whole point of the task. Asserted as "no COMMAND that loops", not "the
|
|
58
|
+
// string --regrade never appears" — the refusal deliberately says *Do NOT* re-run with
|
|
59
|
+
// --regrade, and a naive absence check would forbid that warning and pass a version
|
|
60
|
+
// that quietly went back to recommending it.
|
|
61
|
+
|
|
62
|
+
await test('the refusal never tells the builder to run a command that returns them here', () => {
|
|
63
|
+
for (const escalation of [
|
|
64
|
+
{ filed: true, id: 42 },
|
|
65
|
+
{ filed: false, reason: 'already-filed' },
|
|
66
|
+
{ filed: false, reason: 'POST /blockers -> 500' },
|
|
67
|
+
undefined,
|
|
68
|
+
]) {
|
|
69
|
+
const out = text({ taskId: 1003822, floor: 'archon', escalation });
|
|
70
|
+
assert.ok(!/node scripts\/gds\/ship\.js\s+\d+\s+--regrade/.test(out),
|
|
71
|
+
'a runnable --regrade command is the exact advice that loops');
|
|
72
|
+
assert.ok(!/node scripts\/gds\/ship\.js\s+\d+\s+--allow-empty/.test(out),
|
|
73
|
+
'and re-running --allow-empty lands on this same refusal');
|
|
74
|
+
// The refusal deliberately contains "Do NOT re-run with --regrade". Drop that
|
|
75
|
+
// negated sentence, then assert nothing REMAINING recommends it. A blanket
|
|
76
|
+
// absence check would forbid the warning while still passing a version that
|
|
77
|
+
// quietly went back to advising the loop.
|
|
78
|
+
const withoutWarning = out.replace(/Do NOT re-run with --regrade[^]*/i, '');
|
|
79
|
+
assert.ok(!/(use|try|re-run with|switch to)\s+`?--regrade/i.test(withoutWarning),
|
|
80
|
+
'nor may it advise --regrade in prose');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await test('the refusal explicitly warns AGAINST the old advice, so the loop is closed on the record', () => {
|
|
85
|
+
const out = text({ taskId: 1003822, floor: 'archon', escalation: { filed: true, id: 42 } });
|
|
86
|
+
assert.match(out, /Do NOT re-run with --regrade/,
|
|
87
|
+
'the builder who read the old message needs to be told it was wrong');
|
|
88
|
+
assert.match(out, /nothing to read/, 'and told why, or they will assume it is a fluke');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await test('every refusal shape names what happens next, so none of them dead-ends', () => {
|
|
92
|
+
const filed = text({ taskId: 7, escalation: { filed: true, id: 42 } });
|
|
93
|
+
assert.match(filed, /blocker 42/, 'a filed escalation must name the blocker it filed');
|
|
94
|
+
assert.match(filed, /nothing further to re-run/i, 'and say the builder is done');
|
|
95
|
+
|
|
96
|
+
const dup = text({ taskId: 7, escalation: { filed: false, reason: 'already-filed' } });
|
|
97
|
+
assert.match(dup, /Already escalated/, 'a second run must not read as a failure');
|
|
98
|
+
assert.ok(!/Could not file/.test(dup), 'a dedupe is a success, not an error');
|
|
99
|
+
|
|
100
|
+
const broke = text({ taskId: 7, escalation: { filed: false, reason: 'POST /blockers -> 500' } });
|
|
101
|
+
assert.match(broke, /Could not file the escalation/, 'a real failure must say so');
|
|
102
|
+
assert.match(broke, /500/, 'and name the cause');
|
|
103
|
+
assert.match(broke, /by hand/, 'and give the manual fallback — the close is still blocked');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await test('the claim is stated as intact — a builder who thinks they lost it will not retry', () => {
|
|
107
|
+
const out = text({ taskId: 1003822, escalation: { filed: true, id: 1 } });
|
|
108
|
+
assert.match(out, /claim on task 1003822 is unchanged/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('a missing floor degrades to a clean sentence rather than "floor: undefined"', () => {
|
|
112
|
+
const out = text({ taskId: 7, floor: undefined, escalation: { filed: true, id: 1 } });
|
|
113
|
+
assert.ok(!/undefined/.test(out), out);
|
|
114
|
+
assert.match(out, /task\.confirm\.grade_bypass\./, 'still names the atom');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---- 2. WIRED, NOT MERELY EXPORTED -------------------------------------------------
|
|
118
|
+
// A pure unit can be perfect and unreachable. These read the real ship CLI source.
|
|
119
|
+
|
|
120
|
+
await test('both confirm walls call the escalation — not just the one that is easy to find', () => {
|
|
121
|
+
const src = shipCliSource();
|
|
122
|
+
// `await` is what makes this count CALLS: shipCliSource globs ship-*.js, so the
|
|
123
|
+
// module's own `async function fileNoArtifactEscalation({` is in this source too.
|
|
124
|
+
const calls = src.match(/await fileNoArtifactEscalation\(\{/g) || [];
|
|
125
|
+
assert.equal(calls.length, 2,
|
|
126
|
+
`expected the escalation at BOTH 403 sites (first-ship and --regrade), found ${calls.length}`);
|
|
127
|
+
assert.match(src, /noArtifactRefusalLines\(\{ taskId, floor, escalation \}\)/,
|
|
128
|
+
'and each must print the terminating refusal, not roll its own');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await test('ONLY a no-artifact close is auto-escalated, never a bypass of a FAILING grade', () => {
|
|
132
|
+
// The first-ship wall is reached by --skip-grade too, where the grader DID read a real
|
|
133
|
+
// diff and rejected it. Auto-filing a blocker there would let a builder route their own
|
|
134
|
+
// rejected work onto the override queue. The `noArtifactClose` guard is what separates
|
|
135
|
+
// them, so its absence is a security-shaped regression, not a cosmetic one.
|
|
136
|
+
const src = shipCliSource();
|
|
137
|
+
assert.match(src, /if \(noArtifactClose\) \{\s*\n\s*const escalation = await fileNoArtifactEscalation/,
|
|
138
|
+
'the first-ship escalation must sit behind the noArtifactClose guard');
|
|
139
|
+
assert.match(src, /noArtifactClose = true;/,
|
|
140
|
+
'and something must actually SET it, or the branch is unreachable and the guard is a lie');
|
|
141
|
+
// Reachability: the assignment has to live in the allow-empty branch specifically.
|
|
142
|
+
const allowEmptyBranch = src.slice(src.indexOf("verdict.reason === 'allow-empty'"));
|
|
143
|
+
assert.ok(allowEmptyBranch.indexOf('noArtifactClose = true;') > -1
|
|
144
|
+
&& allowEmptyBranch.indexOf('noArtifactClose = true;') < 1200,
|
|
145
|
+
'noArtifactClose must be set in the allow-empty branch, not somewhere it can never run');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('the --regrade call site passes no `notes` — that variable does not exist there', () => {
|
|
149
|
+
// Caught live while building this: regradeMain REFUSES --notes (proseFlagPresent), so
|
|
150
|
+
// `notes` is undeclared in that scope. Passing it parses fine and throws a
|
|
151
|
+
// ReferenceError at the exact moment a blocked builder needs the escalation — the one
|
|
152
|
+
// moment nothing else is going right. node --check cannot see this; only this can.
|
|
153
|
+
const src = shipCliSource();
|
|
154
|
+
const site = src.slice(src.indexOf('title: task && task.title'));
|
|
155
|
+
const call = site.slice(0, site.indexOf('})'));
|
|
156
|
+
assert.ok(!/\bnotes\b/.test(call),
|
|
157
|
+
'the --regrade escalation must not reference `notes`: ' + call);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---- 3. IDEMPOTENCY + THE FILING CONTRACT ------------------------------------------
|
|
161
|
+
|
|
162
|
+
await test('the source_ref is per-TASK, so re-running files one blocker and not three', () => {
|
|
163
|
+
assert.equal(escalationSourceRef(1003822), 'no-artifact-close:1003822');
|
|
164
|
+
assert.equal(escalationSourceRef(1003822), escalationSourceRef('1003822'),
|
|
165
|
+
'a numeric and string id must dedupe to the same ref, or a re-run files a twin');
|
|
166
|
+
assert.notEqual(escalationSourceRef(1), escalationSourceRef(2), 'different tasks stay distinct');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await test('fileNoArtifactEscalation: posts a well-formed blocker and returns its id', async () => {
|
|
170
|
+
let seen = null;
|
|
171
|
+
const r = await fileNoArtifactEscalation(
|
|
172
|
+
{ taskId: 1003822, title: 'a stale premise', floor: 'archon', summary: 'already fixed by 1003529', builder: 'a-builder' },
|
|
173
|
+
{ call: async (method, path, body) => { seen = { method, path, body }; return { ok: true, data: { blocker: { id: 77 } } }; } },
|
|
174
|
+
);
|
|
175
|
+
assert.deepEqual(r, { filed: true, id: 77 });
|
|
176
|
+
assert.equal(seen.method, 'POST');
|
|
177
|
+
assert.equal(seen.path, '/api/bongos/blockers');
|
|
178
|
+
assert.equal(seen.body.source, NO_ARTIFACT_SOURCE);
|
|
179
|
+
assert.equal(seen.body.source_ref, 'no-artifact-close:1003822');
|
|
180
|
+
assert.match(seen.body.title, /task 1003822/);
|
|
181
|
+
assert.match(seen.body.body_md, /already fixed by 1003529/, "the builder's reason must reach the archon");
|
|
182
|
+
assert.match(seen.body.body_md, /a-builder/, 'and so must who declared it');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await test('a dedupe response is reported as already-filed, not as a failure', async () => {
|
|
186
|
+
const r = await fileNoArtifactEscalation({ taskId: 1 },
|
|
187
|
+
{ call: async () => ({ ok: true, data: { skipped: true } }) });
|
|
188
|
+
assert.deepEqual(r, { filed: false, reason: 'already-filed' });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await test('a filing failure NEVER reads as filed — the close is still blocked', async () => {
|
|
192
|
+
const bad = await fileNoArtifactEscalation({ taskId: 1 }, { call: async () => ({ ok: false, status: 500 }) });
|
|
193
|
+
assert.equal(bad.filed, false);
|
|
194
|
+
assert.match(bad.reason, /500/);
|
|
195
|
+
|
|
196
|
+
const threw = await fileNoArtifactEscalation({ taskId: 1 },
|
|
197
|
+
{ call: async () => { throw new Error('ECONNREFUSED'); } });
|
|
198
|
+
assert.equal(threw.filed, false, 'a network error must not crash the ship path');
|
|
199
|
+
assert.match(threw.reason, /ECONNREFUSED/);
|
|
200
|
+
|
|
201
|
+
const nullish = await fileNoArtifactEscalation({ taskId: 1 }, { call: async () => null });
|
|
202
|
+
assert.equal(nullish.filed, false, 'a null response is not a success');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await test('the blocker body carries the decision the archon has to make, both ways', () => {
|
|
206
|
+
const body = escalationBody({ taskId: 9, title: 't', floor: 'archon', summary: 'ops verify only' });
|
|
207
|
+
assert.match(body, /If yes/, 'confirm path');
|
|
208
|
+
assert.match(body, /If no/, 'and the reject path — a one-sided ask gets rubber-stamped');
|
|
209
|
+
assert.match(body, /tasks\/9\/confirm/, 'with the actual route to run');
|
|
210
|
+
assert.match(body, /not file a second copy/, 'and reassurance that re-running is harmless');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await test('a reasonless ship still files a legible blocker rather than a blank one', () => {
|
|
214
|
+
const body = escalationBody({ taskId: 9 });
|
|
215
|
+
assert.match(body, /none given/, 'the absent reason must be visible, not an empty section');
|
|
216
|
+
assert.ok(!/undefined|\[object Object\]/.test(body), body);
|
|
217
|
+
assert.match(escalationTitle(9, null), /task 9/, 'and the title must survive a null title');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await test('the call sites resolve the builder login from me.data.builder, not me.data', () => {
|
|
221
|
+
// The grade caught this: `me.data.github_login` is ALWAYS undefined — /me returns
|
|
222
|
+
// { builder, ... } (src/bongos/routes/me.js), and ship-flow.js already proves the
|
|
223
|
+
// right pattern twice elsewhere. The failure is silent: escalationBody falls back to
|
|
224
|
+
// 'the claim holder', so the blocker still looks fine while naming nobody — and the
|
|
225
|
+
// whole reason it is filed is to spare the archon an interview.
|
|
226
|
+
//
|
|
227
|
+
// Asserted against SOURCE because both call sites sit behind a live confirm 403; the
|
|
228
|
+
// direct-call tests below cannot see the field path at all, which is exactly how 14
|
|
229
|
+
// green tests shipped the bug.
|
|
230
|
+
const src = shipCliSource();
|
|
231
|
+
assert.ok(!/me\.data\.github_login/.test(src),
|
|
232
|
+
'me.data.github_login is always undefined — the login is nested under me.data.builder');
|
|
233
|
+
assert.match(src, /builder: \(me\.data\.builder \|\| \{\}\)\.github_login/,
|
|
234
|
+
'the first-ship call site must reach through me.data.builder');
|
|
235
|
+
assert.match(src, /builder: builder\.github_login/,
|
|
236
|
+
'the --regrade call site must use the `builder` binding already in scope there');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await test('an unresolved builder degrades to a legible line, not "undefined"', () => {
|
|
240
|
+
// The fallback above is what the bug hid behind, so it has to stay honest.
|
|
241
|
+
const body = escalationBody({ taskId: 9, builder: undefined });
|
|
242
|
+
assert.match(body, /the claim holder/, 'a missing login must read as a role, not a blank');
|
|
243
|
+
assert.ok(!/undefined/.test(body), body);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
console.log('');
|
|
247
|
+
console.log(`no_artifact_escalation: ${passed} passed, ${failed} failed`);
|
|
248
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// tests/paste_token_session_store.mjs — a paste must not destroy the OTHER instance's
|
|
2
|
+
// session (task 1004003, ADR 0269).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG. `scripts/gds/paste-token.js` wrote the session with a bare
|
|
5
|
+
// `fs.writeFileSync(SESSION_PATH, ...)`. SESSION_PATH is the ACTIVE POINTER and nothing
|
|
6
|
+
// else. Under ADR 0269 a machine has several instances, and `saveSession()` in cli-lib is
|
|
7
|
+
// the only writer that knows it: it archives the OUTGOING session into the host-keyed
|
|
8
|
+
// store before overwriting the pointer, and files the incoming one there too. Writing the
|
|
9
|
+
// pointer directly skipped both steps, so pasting a token for instance B while signed in
|
|
10
|
+
// to instance A overwrote A with no archive. `bongos login <A>` exists precisely to switch
|
|
11
|
+
// back without a fresh device flow — it reads that store, found no entry, and the switch
|
|
12
|
+
// back cost a full re-auth. Nothing was recoverable, because the pointer is the very file
|
|
13
|
+
// that was overwritten.
|
|
14
|
+
//
|
|
15
|
+
// WHY THIS IS END-TO-END AND NOT A SOURCE SCAN. The divergence between the two write paths
|
|
16
|
+
// went unnoticed for a release precisely because no test ever ran a paste and then looked
|
|
17
|
+
// for the other instance. A grep for `saveSession` would pass on a call that is there and
|
|
18
|
+
// wrong; running the script and reading the store is the only assertion that cannot.
|
|
19
|
+
// paste-token verifies the token against `<base>/api/bongos/me` before it writes anything,
|
|
20
|
+
// so the "instance" below is a real local HTTP server and the flow is the real one.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test tests/paste_token_session_store.mjs
|
|
23
|
+
|
|
24
|
+
import test from 'node:test';
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
33
|
+
|
|
34
|
+
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const ic = require(path.join(REPO_ROOT, 'src', 'instance-config.js'));
|
|
37
|
+
const PASTE_TOKEN = path.join(REPO_ROOT, 'scripts', 'gds', 'paste-token.js');
|
|
38
|
+
|
|
39
|
+
// SETTING `HOME` ALONE IS NOT ISOLATION (task 1003760, learned the expensive way in
|
|
40
|
+
// tests/cli_sessions.mjs): on Windows os.homedir() reads USERPROFILE and never consults
|
|
41
|
+
// HOME, so a HOME-only override writes the fixtures into the developer's REAL session
|
|
42
|
+
// store and overwrites their live credentials. Override every variable a platform might
|
|
43
|
+
// read, and — because the child here is paste-token itself and cannot carry an inline
|
|
44
|
+
// guard — prove the relocation with a probe child BEFORE anything writes (see `sandbox`).
|
|
45
|
+
function sandboxEnv(home) {
|
|
46
|
+
const { root } = path.parse(home);
|
|
47
|
+
return {
|
|
48
|
+
...process.env,
|
|
49
|
+
HOME: home,
|
|
50
|
+
USERPROFILE: home, // Windows: what os.homedir() actually reads
|
|
51
|
+
HOMEDRIVE: root.replace(/[\\/]+$/, ''), // legacy Windows fallback pair
|
|
52
|
+
HOMEPATH: home.slice(root.length - 1) || '\\',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A throwaway home plus the two paths paste-token will resolve inside it. Both are read
|
|
57
|
+
// from the child, not computed here: the parent's own SESSION_PATH froze at module load
|
|
58
|
+
// against the REAL home, so any path this process derives would name the wrong file.
|
|
59
|
+
function sandbox() {
|
|
60
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-paste-'));
|
|
61
|
+
const probe = spawnSync(process.execPath, ['-e', `
|
|
62
|
+
const os = require('node:os');
|
|
63
|
+
if (os.homedir() !== ${JSON.stringify(home)}) { console.error('ESCAPE ' + os.homedir()); process.exit(2); }
|
|
64
|
+
const ic = require(${JSON.stringify(path.join(REPO_ROOT, 'src', 'instance-config.js').replace(/\\/g, '/'))});
|
|
65
|
+
console.log(JSON.stringify({
|
|
66
|
+
active: ic.configPath(ic.SESSION_FILENAMES[0]),
|
|
67
|
+
store: require('node:path').join(os.homedir(), '.config', ic.FALLBACK_DIR, 'instances'),
|
|
68
|
+
}));`], { encoding: 'utf8', env: sandboxEnv(home) });
|
|
69
|
+
if (probe.status !== 0) {
|
|
70
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
71
|
+
// Loud, never skipped: a test whose whole job is writing a session store must prove
|
|
72
|
+
// it cannot reach the real one rather than quietly running anyway.
|
|
73
|
+
throw new Error(`sandbox did not relocate os.homedir() on this platform: ${probe.stdout}${probe.stderr}`);
|
|
74
|
+
}
|
|
75
|
+
const { active, store } = JSON.parse(probe.stdout.trim().split('\n').pop());
|
|
76
|
+
assert.ok(active.startsWith(home), `the active pointer resolved OUTSIDE the sandbox: ${active}`);
|
|
77
|
+
assert.ok(store.startsWith(home), `the session store resolved OUTSIDE the sandbox: ${store}`);
|
|
78
|
+
return { home, active, store };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// One instance, as far as paste-token can tell: it only ever calls GET /api/bongos/me.
|
|
82
|
+
async function withInstance(body) {
|
|
83
|
+
const server = http.createServer((req, res) => {
|
|
84
|
+
if (!req.url.startsWith('/api/bongos/me')) { res.writeHead(404).end('{}'); return; }
|
|
85
|
+
if (req.headers.authorization !== 'Bearer ' + 'B'.repeat(64)) { res.writeHead(401).end('{}'); return; }
|
|
86
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
87
|
+
res.end(JSON.stringify({ builder: { id: 42, github_login: 'paster', rank: 'metic' } }));
|
|
88
|
+
});
|
|
89
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
90
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
91
|
+
try { return await body(base); } finally { await new Promise((r) => server.close(r)); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ASYNC ON PURPOSE, and it must stay that way: spawnSync blocks this process's event
|
|
95
|
+
// loop, so the instance server above — which lives in THIS process — can never accept the
|
|
96
|
+
// child's verify call, and the paste hangs until the suite times out. The two deadlock
|
|
97
|
+
// each other, which reads exactly like a hung test rather than a wrong one.
|
|
98
|
+
function runPaste(home, args) {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
const child = spawn(process.execPath, [PASTE_TOKEN, ...args], { env: sandboxEnv(home) });
|
|
101
|
+
let out = '';
|
|
102
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
103
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
104
|
+
child.on('close', (status) => resolve({ status, out }));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readJson(p) {
|
|
109
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test('pasting a token for instance B PRESERVES instance A — the bug this closes', async () => {
|
|
113
|
+
const { home, active, store } = sandbox();
|
|
114
|
+
try {
|
|
115
|
+
await withInstance(async (base) => {
|
|
116
|
+
// Signed in to A, the way a builder actually is: an active pointer and nothing else
|
|
117
|
+
// — the pre-store shape saveSession's archive step exists to rescue.
|
|
118
|
+
fs.mkdirSync(path.dirname(active), { recursive: true });
|
|
119
|
+
fs.writeFileSync(active, JSON.stringify({
|
|
120
|
+
token: 'TOKEN-A', api_base: 'https://a.example.com',
|
|
121
|
+
builder: { github_login: 'someone' }, gemini_api_key: 'KEY-KEEP-ME',
|
|
122
|
+
}), { mode: 0o600 });
|
|
123
|
+
|
|
124
|
+
const r = await runPaste(home, ['B'.repeat(64), '--api-base', base]);
|
|
125
|
+
assert.equal(r.status, 0, `paste failed: ${r.out}`);
|
|
126
|
+
|
|
127
|
+
// 1. THE REGRESSION. A is archived under its own host and is still readable, so
|
|
128
|
+
// `bongos login https://a.example.com` can switch back with no device flow.
|
|
129
|
+
const archived = readJson(path.join(store, 'a.example.com.json'));
|
|
130
|
+
assert.ok(archived, 'instance A was not archived — a paste destroyed it, the bug is back');
|
|
131
|
+
assert.equal(archived.token, 'TOKEN-A');
|
|
132
|
+
assert.equal(archived.api_base, 'https://a.example.com');
|
|
133
|
+
|
|
134
|
+
// 2. B is filed in the store too, not only at the pointer — otherwise the NEXT
|
|
135
|
+
// paste has nothing of B's to archive and the same loss happens one step later.
|
|
136
|
+
const key = `127.0.0.1_${new URL(base).port}.json`;
|
|
137
|
+
const stored = readJson(path.join(store, key));
|
|
138
|
+
assert.ok(stored, `instance B was not filed in the store (looked for ${key})`);
|
|
139
|
+
assert.equal(stored.token, 'B'.repeat(64));
|
|
140
|
+
|
|
141
|
+
// 3. The active pointer still follows the paste, in the same place every existing
|
|
142
|
+
// reader (skills, hooks, the dev box) looks. The fix is additive or it is a
|
|
143
|
+
// different, quieter outage.
|
|
144
|
+
const pointer = readJson(active);
|
|
145
|
+
assert.equal(pointer.api_base, base, 'the active pointer must follow the paste');
|
|
146
|
+
assert.equal(pointer.token, 'B'.repeat(64));
|
|
147
|
+
assert.equal(pointer.builder.github_login, 'paster');
|
|
148
|
+
|
|
149
|
+
// 4. The merge contract paste-token already owed (task 1003704) survives the
|
|
150
|
+
// reroute: a re-auth must never silently un-register a builder's art key.
|
|
151
|
+
assert.equal(pointer.gemini_api_key, 'KEY-KEEP-ME', 'the paste dropped a preserved field');
|
|
152
|
+
});
|
|
153
|
+
} finally {
|
|
154
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a rejected token writes nothing at all — not the pointer, not the store', async () => {
|
|
159
|
+
// The verify-before-write contract is what protects the session a bad paste was meant to
|
|
160
|
+
// REPLACE. Routing through saveSession must not move the write ahead of the check: that
|
|
161
|
+
// would turn a typo into the very cross-instance loss the first test closes.
|
|
162
|
+
const { home, active, store } = sandbox();
|
|
163
|
+
try {
|
|
164
|
+
await withInstance(async (base) => {
|
|
165
|
+
fs.mkdirSync(path.dirname(active), { recursive: true });
|
|
166
|
+
fs.writeFileSync(active, JSON.stringify({
|
|
167
|
+
token: 'TOKEN-A', api_base: 'https://a.example.com', builder: { github_login: 'someone' },
|
|
168
|
+
}), { mode: 0o600 });
|
|
169
|
+
|
|
170
|
+
const r = await runPaste(home, ['W'.repeat(64), '--api-base', base]);
|
|
171
|
+
assert.equal(r.status, 3, `a 401 must exit 3, got ${r.status}: ${r.out}`);
|
|
172
|
+
assert.equal(readJson(active).token, 'TOKEN-A', 'a rejected paste overwrote the live session');
|
|
173
|
+
assert.ok(!fs.existsSync(store), 'a rejected paste filed something in the store');
|
|
174
|
+
});
|
|
175
|
+
} finally {
|
|
176
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('paste-token routes the write through saveSession, not a bare writeFileSync', () => {
|
|
181
|
+
// The behavioural tests above are the real assertion. This one names the mechanism, so a
|
|
182
|
+
// future edit that "simplifies" the call back to a direct write fails on the line it
|
|
183
|
+
// changed rather than on a store path three files away.
|
|
184
|
+
const src = fs.readFileSync(PASTE_TOKEN, 'utf8');
|
|
185
|
+
assert.match(src, /await saveSession\(next\)/, 'the session write must go through saveSession');
|
|
186
|
+
assert.ok(!/writeFileSync\(\s*SESSION_PATH/.test(src),
|
|
187
|
+
'SESSION_PATH is the ACTIVE POINTER — writing it directly skips the per-instance archive (ADR 0269)');
|
|
188
|
+
});
|
package/tests/reward_gate.mjs
CHANGED
|
@@ -162,14 +162,28 @@ t('promote route AUTO-ASSIGNS then proceeds (no 409 refusal for missing reward)'
|
|
|
162
162
|
t('claim path AUTO-ASSIGNS inside the FOR UPDATE transaction (never refuses)', () => {
|
|
163
163
|
const start = dbSrc.indexOf('async function claimTask');
|
|
164
164
|
assert.ok(start >= 0, 'claimTask not found');
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// the
|
|
171
|
-
//
|
|
172
|
-
|
|
165
|
+
// Scan the REAL claimTask body — from its declaration to the next top-level
|
|
166
|
+
// function (claimTasksBatch) — rather than a fixed byte window, the same fix the
|
|
167
|
+
// createTask and POST /tasks siblings below already carry.
|
|
168
|
+
//
|
|
169
|
+
// The byte window was widened four times for four UNRELATED features: 5000 -> 6000
|
|
170
|
+
// in BV1.R106 (the permission-expressed claim gate), 6000 -> 7000 in task 1002613
|
|
171
|
+
// (the enriched rank_too_low_for_task denial), 7000 -> 8500 in task 1003137 (the
|
|
172
|
+
// goal-membership gate). Each time, an edit to claimTask's PREFLIGHT pushed the
|
|
173
|
+
// reward block past the edge and red a test about rewards — and the message,
|
|
174
|
+
// "claimTask must consult the auto-assign decision", pointed the reader at the
|
|
175
|
+
// reward gate instead of at their own change. Task 1003294 paid the tax again with
|
|
176
|
+
// a five-line comment (margin was down to ~125 chars) and had to trim prose to get
|
|
177
|
+
// green. The comment above USED to name claimTasksBatch as the real bound; it was
|
|
178
|
+
// simply never what the code measured. Now it is, so the window grows with the
|
|
179
|
+
// function and this line stops needing maintenance.
|
|
180
|
+
//
|
|
181
|
+
// A missing anchor fails LOUDLY rather than falling back to a byte count: a silent
|
|
182
|
+
// fallback would reintroduce exactly the failure mode being removed here.
|
|
183
|
+
const end = dbSrc.indexOf('async function claimTasksBatch', start);
|
|
184
|
+
assert.ok(end > start,
|
|
185
|
+
'claimTasksBatch boundary not found — reward_gate can no longer bound claimTask; re-anchor this slice');
|
|
186
|
+
const body = dbSrc.slice(start, end);
|
|
173
187
|
assert.match(body, /rewardGateAssignment\(task\)/, 'claimTask must consult the auto-assign decision');
|
|
174
188
|
assert.match(body, /UPDATE tasks SET credits_reward/, 'claimTask must persist the assigned reward in-tx');
|
|
175
189
|
// it must SELECT the columns the gate needs.
|