@bongos/core 1.24.26 → 1.24.28
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 +164 -109
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +12 -0
- package/docs/copy-inventory.md +242 -223
- package/docs/copy-registry.json +559 -358
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +18 -6
- package/docs/page-readings.json +4399 -4315
- package/docs/recipes/ui-look-before-you-ship.md +1 -0
- package/modules/autonomy/fence.js +16 -1
- package/modules/government/protected-surfaces.json +2 -0
- package/modules/hall-ui/public/ai-studio.html +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/core-move-notice.js +9 -6
- package/modules/hall-ui/public/deploy-handoff.js +203 -0
- package/modules/hall-ui/public/deploy.html +4 -2
- package/modules/hall-ui/public/deploy.js +7 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +1 -1
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.css +72 -1
- package/modules/hall-ui/public/my-agents.html +31 -7
- package/modules/hall-ui/public/my-agents.js +63 -54
- package/modules/hall-ui/public/my-agents.states.json +18 -0
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-software-update.js +22 -8
- package/modules/hall-ui/public/settings.css +2 -132
- package/modules/hall-ui/public/settings.html +15 -53
- package/modules/hall-ui/public/shell.js +25 -73
- package/modules/hall-ui/public/speciality-tools.css +140 -0
- package/modules/hall-ui/public/{settings-specialities.js → speciality-tools.js} +83 -6
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +119 -39
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/update-pill.js +623 -0
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/my-agents.md +4 -2
- package/modules/hall-ui/records/panel-family.md +2 -0
- package/modules/hall-ui/records/update-pill.md +12 -0
- package/modules/lifecycle/github-push.js +1 -0
- package/modules/lifecycle/routes/tasks.js +2 -2
- package/modules/lifecycle/runner-self-guard.js +122 -0
- package/modules/lifecycle/runner-self.js +154 -0
- package/modules/public-landing/public/account.html +3 -3
- package/modules/public-landing/public/assets/cosmos.css +45 -0
- package/modules/public-landing/public/community.html +4 -4
- package/modules/public-landing/public/connections.html +1 -1
- package/modules/public-landing/public/guild.html +1 -1
- package/modules/public-landing/public/invitations.html +4 -4
- package/modules/public-landing/public/projects.html +26 -26
- package/modules/status-ui/public/index.html +2 -2
- package/modules/status-ui/public/index.states.json +12 -0
- package/modules/status-ui/public/status.js +10 -2
- package/modules/status-ui/public/style.css +57 -0
- package/modules/ui-design/kit/README.md +1 -1
- package/modules/ui-design/kit/serve.js +8 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/bongos/autobongos-loop.js +4 -0
- package/scripts/bongos/autobongos-run.js +21 -4
- package/scripts/bongos/autobongos-tweak-lane.js +5 -3
- package/scripts/bongos/ship-flow.js +20 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/autobongos_host_access.mjs +5 -3
- package/tests/autobongos_self_modification.mjs +420 -0
- package/tests/core_update_banner.mjs +6 -92
- package/tests/core_upgrade_owner_door.mjs +4 -56
- package/tests/deploy_handoff.mjs +206 -0
- package/tests/government_protected_surfaces.mjs +15 -0
- package/tests/hall_loading.mjs +3 -1
- package/tests/hall_my_agents.mjs +104 -2
- package/tests/helpers.mjs +161 -0
- package/tests/hub_connections_page.mjs +1 -1
- package/tests/hub_invitations_page.mjs +1 -1
- package/tests/loading_mark.mjs +203 -0
- package/tests/nav_permission_atoms.mjs +3 -2
- package/tests/software_update.mjs +6 -2
- package/tests/speciality_offer_ui.mjs +182 -149
- package/tests/speciality_walkthrough_ui.mjs +180 -161
- package/tests/update_pill.mjs +431 -0
|
@@ -195,6 +195,10 @@ const CANNED_WRITES = [
|
|
|
195
195
|
// the Market Watch weekly ask's answer (task 1004940), so its "sent." state renders.
|
|
196
196
|
['POST', /^market-watch\/ask\/answer$/, 'market-watch__ask__answer.post'],
|
|
197
197
|
['PATCH', /^specialities\/\d+$/, 'specialities__12.patch'],
|
|
198
|
+
// the rest of the speciality tools, now on My agents too (task 1005052): stop
|
|
199
|
+
// using one, and train your own, so their toasts and redraws render here.
|
|
200
|
+
['POST', /^specialities\/\d+\/abandon$/, 'specialities__12__abandon.post'],
|
|
201
|
+
['POST', /^specialities\/\d+\/documents$/, 'specialities__12__documents.post'],
|
|
198
202
|
// a vote cast from the street's matter card (task 1004528), so the card's
|
|
199
203
|
// answer (the toast, the building lighting) renders here too.
|
|
200
204
|
['POST', /^government\/board\/items\/\d+\/votes$/, 'government__board__items__votes.post'],
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.28'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -48,9 +48,11 @@ const T1003980 = {
|
|
|
48
48
|
};
|
|
49
49
|
const CODE_TASK = {
|
|
50
50
|
id: 9002, goal_id: 1000119, kind: 'feature', status: 'ready', requires_rank: 'xenos',
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
// Not the picker itself: since task 1004546 sequence.js is on the runner's own
|
|
52
|
+
// list, so a task editing it is passed over as self_modification.
|
|
53
|
+
touches: ['modules/hall-ui/public/goals.js'], manual_degree: null, automation_tag: null,
|
|
54
|
+
title: 'Show a new column on the goals page',
|
|
55
|
+
description: 'Add a column to modules/hall-ui/public/goals.js and cover it with a tests/*.mjs case. '.repeat(4)
|
|
54
56
|
+ 'DONE WHEN: the new case passes and the old ones still do.',
|
|
55
57
|
};
|
|
56
58
|
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// tests/autobongos_self_modification.mjs — an Autobongos runner never changes its
|
|
2
|
+
// own supervisor, safety checks or kill switch (task 1004546, carrying task
|
|
3
|
+
// 1004540's work; ADR 0307 §6).
|
|
4
|
+
//
|
|
5
|
+
// The done-when, case by case:
|
|
6
|
+
// 1. a task DECLARING modules/autonomy/fence.js is refused before the claim
|
|
7
|
+
// (the picker skips it, and decideTask refuses it if it is handed one);
|
|
8
|
+
// 2. an UNDECLARED edit to an autobongos-* file in the real diff is caught, by
|
|
9
|
+
// ship.js inside the worker and by the server at publish, which reads the
|
|
10
|
+
// pull request's own files and closes it;
|
|
11
|
+
// plus the three findings that failed task 1004540's second grade:
|
|
12
|
+
// - the guard's own implementation is on the list it enforces, and the server
|
|
13
|
+
// guard requires its matcher directly rather than through the module doorway;
|
|
14
|
+
// - the wiring in the publish route is held by driving the REAL route here, so
|
|
15
|
+
// deleting that line turns this file red (and this file is on the list);
|
|
16
|
+
// - the runner-worktree shape is written once.
|
|
17
|
+
// No DB, no network: fake pools, the real router, stubbed GitHub reads.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/autobongos_self_modification.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import http from 'node:http';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { makeRunner } from './helpers.mjs';
|
|
28
|
+
|
|
29
|
+
process.env.NODE_ENV = 'test';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
32
|
+
const { test, summary } = makeRunner();
|
|
33
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
34
|
+
|
|
35
|
+
const runnerSelf = require('../modules/lifecycle/runner-self.js');
|
|
36
|
+
const guard = require('../modules/lifecycle/runner-self-guard.js');
|
|
37
|
+
const fence = require('../modules/autonomy/fence.js');
|
|
38
|
+
const runner = require('../scripts/bongos/autobongos-run.js');
|
|
39
|
+
const pcheck = require('../src/bongos/permission-path-check.js');
|
|
40
|
+
|
|
41
|
+
const onList = (f) => runnerSelf.matchRunnerSelf([f]).length === 1;
|
|
42
|
+
|
|
43
|
+
// ---- the list ---------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
await test('every file that implements the self-edit checks is on the list it enforces', () => {
|
|
46
|
+
const implementation = [
|
|
47
|
+
'modules/lifecycle/runner-self.js', // the list
|
|
48
|
+
'modules/lifecycle/runner-self-guard.js', // the server refusal
|
|
49
|
+
'modules/autonomy/fence.js', // decideTask
|
|
50
|
+
'scripts/bongos/autobongos-run.js', // the picker skip + the worker env
|
|
51
|
+
'scripts/bongos/ship-flow.js', // the ship-time check
|
|
52
|
+
'scripts/bongos/ship.js',
|
|
53
|
+
'tests/autobongos_self_modification.mjs', // this file, which holds the route wiring
|
|
54
|
+
];
|
|
55
|
+
for (const f of implementation) assert.ok(onList(f), `${f} must be on the runner's own list`);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await test('everything the server guard requires is on the list too, and the runner-list matcher is never the doorway\'s', () => {
|
|
59
|
+
const src = read('modules/lifecycle/runner-self-guard.js');
|
|
60
|
+
const required = [...src.matchAll(/require\('(\.\/[^']+)'\)/g)].map((m) => m[1]);
|
|
61
|
+
assert.ok(required.includes('./runner-self'), 'the runner-list matcher is required directly, as a sibling');
|
|
62
|
+
for (const rel of required) {
|
|
63
|
+
const file = `modules/lifecycle/${rel.replace(/^\.\//, '')}.js`;
|
|
64
|
+
assert.ok(onList(file), `${file} decides what the guard sees, so a runner may not change it`);
|
|
65
|
+
}
|
|
66
|
+
// The registry matcher can only come through the doorway; it is verified before use.
|
|
67
|
+
assert.match(src, /verifiedMatcher\(deps\.matchProtected \|\| \(\(files\) => api\.permissionPathCheck\.matchProtected\(files\)\)\)/);
|
|
68
|
+
assert.doesNotMatch(src, /api\.pathMatch/);
|
|
69
|
+
// And the list itself requires nothing at all.
|
|
70
|
+
assert.doesNotMatch(read('modules/lifecycle/runner-self.js'), /require\(/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await test('the route line that calls the server guard is itself a protected file', () => {
|
|
74
|
+
// The owner kept tasks.js off the runner's own list; the registry covers it, and
|
|
75
|
+
// the real-diff checks read the registry, so a runner cannot publish its removal.
|
|
76
|
+
assert.equal(pcheck.matchProtected(['modules/lifecycle/routes/tasks.js']).length, 1);
|
|
77
|
+
assert.equal(runnerSelf.diffRefusal({ files: ['modules/lifecycle/routes/tasks.js'], protectedHits: pcheck.matchProtected(['modules/lifecycle/routes/tasks.js']) }).code, 'runner_protected_path');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('the kill switch, the gauge, the grader and the registry are on the list', () => {
|
|
81
|
+
for (const f of [
|
|
82
|
+
'modules/autonomy/routes/autonomy.js', 'modules/autonomy/gauge.js', 'modules/autonomy/cadence.js',
|
|
83
|
+
'modules/government/catalog.js', 'modules/government/protected-surfaces.json', 'src/bongos/permission-path-check.js',
|
|
84
|
+
'modules/grading/grader.js', 'scripts/bongos/autobongos-loop.js', 'scripts/bongos/autobongos-verify.js',
|
|
85
|
+
'scripts/bongos/autobongos-grade-cap.js', 'scripts/gds/autobongos-run.js', 'scripts/bongos/autonomy-gauge.js',
|
|
86
|
+
'scripts/bongos/sequence.js', 'modules/lifecycle/autopickup-files.js',
|
|
87
|
+
]) assert.ok(onList(f), f);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('the module doorway is on the list: the server guard reaches its matcher and pool through it', () => {
|
|
91
|
+
// Owner ruling 2026-11-03 (grade round 2 of this task): a runner that could rewrite
|
|
92
|
+
// the doorway could swap what the server guard checks with.
|
|
93
|
+
assert.ok(onList('src/module-api.js'));
|
|
94
|
+
assert.match(read('modules/lifecycle/runner-self-guard.js'), /require\('\.\.\/\.\.\/src\/module-api'\)/, 'the guard does reach it, so it must stay listed');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await test('the tweak lane asks the runner-list question too', () => {
|
|
98
|
+
const lane = require('../scripts/bongos/autobongos-tweak-lane.js');
|
|
99
|
+
const d = lane.decideRound({ task: { id: 7, goal_id: 1, updated_at: 'x' }, claims: [], files: ['modules/autonomy/fence.js'], matchProtected: () => [] });
|
|
100
|
+
assert.equal(d.code, 'self_modification');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('ordinary work is not on the list (the owner ruled tasks.js out)', () => {
|
|
104
|
+
for (const f of [
|
|
105
|
+
'modules/lifecycle/routes/tasks.js', 'modules/hall-ui/public/settings.js',
|
|
106
|
+
'scripts/bongos/claim.js', 'docs/recipes/autobongos-windows-host.md', 'modules/lifecycle/db-claims.js',
|
|
107
|
+
'scripts/bongos/autobongos-lib/nested.js', // a '*' pattern stays inside its directory
|
|
108
|
+
]) assert.ok(!onList(f), f);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('every entry on the list says why it is there, and none is listed twice', () => {
|
|
112
|
+
const patterns = runnerSelf.RUNNER_SELF_SURFACES.map((s) => s.pattern);
|
|
113
|
+
assert.equal(new Set(patterns).size, patterns.length);
|
|
114
|
+
for (const s of runnerSelf.RUNNER_SELF_SURFACES) {
|
|
115
|
+
assert.ok(typeof s.why === 'string' && s.why.length > 5, `${s.pattern} carries a reason`);
|
|
116
|
+
assert.match(s.pattern, /^[a-z0-9_./-]+(\/|\*)?$/, `${s.pattern} is an exact path, a 'dir/' or a 'prefix*'`);
|
|
117
|
+
}
|
|
118
|
+
assert.ok(Object.isFrozen(runnerSelf.RUNNER_SELF_SURFACES), 'nothing at run time can shorten it');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await test('a declared touch cannot slip past on its spelling', () => {
|
|
122
|
+
for (const f of ['scripts\\bongos\\autobongos-run.js', './modules/autonomy/fence.js', 'Modules/Autonomy/Fence.js', '/modules/autonomy/fence.js']) {
|
|
123
|
+
assert.ok(onList(f), f);
|
|
124
|
+
}
|
|
125
|
+
assert.deepEqual(runnerSelf.matchRunnerSelf(['', null, 42]), []);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('both guard files are on the ADR 0043 registry, at the pipeline floor', () => {
|
|
129
|
+
const hits = pcheck.matchProtected(['modules/lifecycle/runner-self.js', 'modules/lifecycle/runner-self-guard.js']);
|
|
130
|
+
assert.equal(hits.length, 2);
|
|
131
|
+
for (const h of hits) assert.equal(h.permission, 'path.protected.modify');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---- one shape for a runner's claim -----------------------------------------
|
|
135
|
+
|
|
136
|
+
await test('the runner-worktree shape is written once, and the runner uses that one', () => {
|
|
137
|
+
assert.equal(runner.RUNNER_TREE_RE, runnerSelf.RUNNER_TREE_RE, 'the supervisor re-exports the shared regex');
|
|
138
|
+
assert.equal(runner.isRunnerTree, runnerSelf.isRunnerTree);
|
|
139
|
+
for (const id of [1, 4242, 1004546]) assert.ok(runnerSelf.isRunnerTree(runner.worktreeName(id)), `worktreeName(${id})`);
|
|
140
|
+
assert.equal(runnerSelf.isRunnerTree('autobongos-resume-1003904-d29f25'), false, "a person's readable name is not the runner's");
|
|
141
|
+
// No other file spells the shape: a second copy is how two checks drift apart.
|
|
142
|
+
const spelling = String.raw`autobongos-\d+-[0-9a-f]{6}`;
|
|
143
|
+
const copies = [];
|
|
144
|
+
for (const dir of ['modules', 'scripts', 'src']) {
|
|
145
|
+
const walk = (d) => {
|
|
146
|
+
for (const e of fs.readdirSync(path.join(ROOT, d), { withFileTypes: true })) {
|
|
147
|
+
const rel = `${d}/${e.name}`;
|
|
148
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(rel); continue; }
|
|
149
|
+
if (/\.js$/.test(e.name) && read(rel).includes(spelling)) copies.push(rel);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
walk(dir);
|
|
153
|
+
}
|
|
154
|
+
assert.deepEqual(copies, ['modules/lifecycle/runner-self.js']);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---- 1. declared: refused before the claim ----------------------------------
|
|
158
|
+
|
|
159
|
+
await test('decideTask refuses a task declaring modules/autonomy/fence.js, and names it', () => {
|
|
160
|
+
const task = { id: 7, goal_id: 1000118, touches: ['modules/autonomy/fence.js'] };
|
|
161
|
+
const d = fence.decideTask({ task, goals: [1000118], protectedHits: [], selfHits: runnerSelf.matchRunnerSelf(task.touches) });
|
|
162
|
+
assert.equal(d.go, false);
|
|
163
|
+
assert.equal(d.code, 'self_modification');
|
|
164
|
+
assert.deepEqual(d.files, ['modules/autonomy/fence.js']);
|
|
165
|
+
assert.match(d.reason, /supervisor, safety checks or kill switch/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await test('decideTask keeps its order: a protected path is still reported as one', () => {
|
|
169
|
+
const task = { id: 7, goal_id: 1, touches: ['scripts/gds/autobongos-run.js'] };
|
|
170
|
+
const d = fence.decideTask({ task, goals: [1], protectedHits: pcheck.matchProtected(task.touches), selfHits: runnerSelf.matchRunnerSelf(task.touches) });
|
|
171
|
+
assert.equal(d.code, 'protected_path');
|
|
172
|
+
assert.equal(fence.decideTask({ task: { id: 8, goal_id: 1, touches: [] }, goals: [1] }).go, true, 'no hits, no refusal');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
function fakeApi(tasks) {
|
|
176
|
+
return {
|
|
177
|
+
me: { getMe: async () => ({ ok: true, data: { builder: { rank: 'archon' } } }) },
|
|
178
|
+
tasks: {
|
|
179
|
+
getTasks: async ({ query }) => ({ ok: true, data: { tasks: query.offset ? [] : tasks.map((t) => ({ goal_id: query.goal_id, ...t })) } }),
|
|
180
|
+
getTasksId: async ({ id }) => {
|
|
181
|
+
const t = tasks.find((x) => Number(x.id) === Number(id));
|
|
182
|
+
return t ? { ok: true, data: { task: { goal_id: 1000118, ...t } } } : { ok: false };
|
|
183
|
+
},
|
|
184
|
+
getTasksClaimable: async () => ({ ok: true, data: { claimable: tasks.map((t) => ({ id: t.id })) } }),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const ready = (over) => ({ title: 'A change', status: 'ready', kind: 'bug', description: 'x'.repeat(400), dependencies: [], touches: [], requires_rank: 'xenos', ...over });
|
|
189
|
+
|
|
190
|
+
await test('the picker passes over a self-editing task and takes the next one, listing why', async () => {
|
|
191
|
+
const api = fakeApi([ready({ id: 9001, touches: ['modules/autonomy/fence.js'] }), ready({ id: 9002 })]);
|
|
192
|
+
const got = await runner.pickTask([1000118], { api, rank: 'archon' });
|
|
193
|
+
assert.equal(got.task.id, 9002, 'the runner must not keep re-picking work it may never do');
|
|
194
|
+
const sk = got.skipped.find((s) => s.id === 9001);
|
|
195
|
+
assert.ok(sk, 'the passed-over task is listed');
|
|
196
|
+
assert.equal(sk.reasons[0].code, 'self_modification');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await test('a goal of nothing but self-editing work reads as an unworkable queue, not idle', async () => {
|
|
200
|
+
const got = await runner.pickTask([1000118], { api: fakeApi([ready({ id: 9001, touches: ['scripts/bongos/autobongos-loop.js'] })]), rank: 'archon' });
|
|
201
|
+
assert.equal(got.none, true);
|
|
202
|
+
assert.deepEqual(got.skipped.map((s) => s.reasons[0].code), ['self_modification']);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await test('an iteration handed a self-editing task makes no tree, no claim and no worker', async () => {
|
|
206
|
+
const calls = [];
|
|
207
|
+
const rows = [];
|
|
208
|
+
const deps = {
|
|
209
|
+
gauge: () => ({ decision: { go: true, unknown: false, sleepUntil: null, reason: 'headroom' } }),
|
|
210
|
+
readFence: async () => ({ raw: { enabled: true, paused_reason: null, goals: [{ goal_id: 1000118 }] }, graderBypassed: false }),
|
|
211
|
+
api: {},
|
|
212
|
+
state: runner.newRunnerState(),
|
|
213
|
+
pickTask: async () => ({ task: { id: 4242, title: 't', kind: 'bug', description: 'd'.repeat(300), goal_id: 1000118, touches: ['modules/autonomy/fence.js'] }, goalId: 1000118, skipped: [] }),
|
|
214
|
+
worktreeName: () => 'autobongos-4242-abc123',
|
|
215
|
+
record: (r) => { rows.push(r); return r; },
|
|
216
|
+
run: async (bin, args) => { calls.push(args[0]); return { ok: true, code: 0, stdout: '', stderr: '' }; },
|
|
217
|
+
spawnWorker: async () => { calls.push('WORKER'); return { envelope: {}, exitCode: 0 }; },
|
|
218
|
+
};
|
|
219
|
+
const row = await runner.iteration({ goals: [1000118], maxTasks: 1, dryRun: false, model: 'm', timeoutMs: 1000 }, deps);
|
|
220
|
+
assert.equal(row.event, 'fenced');
|
|
221
|
+
assert.equal(row.code, 'self_modification');
|
|
222
|
+
assert.deepEqual(row.files, ['modules/autonomy/fence.js'], 'the log row names the file');
|
|
223
|
+
assert.deepEqual(calls, []);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ---- 2. undeclared: caught in the real diff ---------------------------------
|
|
227
|
+
|
|
228
|
+
await test('every worker the supervisor spawns carries the runner flag', () => {
|
|
229
|
+
const env = runner.workerEnv({ PATH: '/usr/bin' });
|
|
230
|
+
assert.equal(env[runnerSelf.RUNNER_WORKER_ENV], '1');
|
|
231
|
+
assert.equal(runnerSelf.RUNNER_WORKER_ENV, 'AUTOBONGOS_WORKER');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await test('ship.js inside a worker refuses an undeclared autobongos-* edit; outside one it does nothing', () => {
|
|
235
|
+
const files = ['modules/hall-ui/public/x.js', 'scripts/bongos/autobongos-loop.js'];
|
|
236
|
+
const worker = { AUTOBONGOS_WORKER: '1' };
|
|
237
|
+
assert.deepEqual(runnerSelf.selfEditRefusal({ files, env: worker, matchProtected: pcheck.matchProtected }), { code: 'self_modification', files: ['scripts/bongos/autobongos-loop.js'], protected: [] });
|
|
238
|
+
assert.equal(runnerSelf.selfEditRefusal({ files, env: {}, matchProtected: pcheck.matchProtected }), null, 'a person shipping is never refused');
|
|
239
|
+
assert.equal(runnerSelf.selfEditRefusal({ files: ['modules/hall-ui/public/x.js'], env: worker, matchProtected: pcheck.matchProtected }), null);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await test('ship.js inside a worker refuses an undeclared protected file, and only asks the matcher inside a worker', () => {
|
|
243
|
+
const worker = { AUTOBONGOS_WORKER: '1' };
|
|
244
|
+
const r = runnerSelf.selfEditRefusal({ files: ['migrations/999_x.sql', 'docs/a.md'], env: worker, matchProtected: pcheck.matchProtected });
|
|
245
|
+
assert.deepEqual(r, { code: 'runner_protected_path', files: [], protected: ['migrations/999_x.sql'] });
|
|
246
|
+
assert.match(runnerSelf.selfEditMessage(r), /protected permission, migration, infrastructure or pipeline file \(migrations\/999_x\.sql\)/);
|
|
247
|
+
let asked = 0;
|
|
248
|
+
runnerSelf.selfEditRefusal({ files: ['migrations/999_x.sql'], env: {}, matchProtected: () => { asked++; return []; } });
|
|
249
|
+
assert.equal(asked, 0, 'a person\'s ship never runs the check at all');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await test('ship.js runs that check on the committed diff, before the grade, on both the ship and the re-grade', () => {
|
|
253
|
+
const src = read('scripts/bongos/ship-flow.js');
|
|
254
|
+
const sites = src.match(/const committedFiles = computeChangedFiles\(graderBaseline\);\n\s*refuseRunnerSelfEdit\(committedFiles\);/g) || [];
|
|
255
|
+
assert.equal(sites.length, 2, 'shipMain and regradeMain each check before anything is resolved or graded');
|
|
256
|
+
assert.match(src, /function refuseRunnerSelfEdit\(files\) \{\n\s*const refusal = runnerSelf\.selfEditRefusal\(\{ files, matchProtected \}\);/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await test('the worker is told not to add a session log, which would make the ship touch the protected CLAUDE.md', () => {
|
|
260
|
+
const { buildWorkerPrompt } = require('../scripts/bongos/autobongos-loop.js');
|
|
261
|
+
assert.match(buildWorkerPrompt({ task: { id: 7, title: 't', description: 'd' } }), /Do not add a session-log file under docs\/session-logs\//);
|
|
262
|
+
assert.equal(pcheck.matchProtected(['CLAUDE.md']).length, 1, 'still protected, so the instruction is load-bearing');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const RUNNER_CLAIM = { worktree_name: 'autobongos-4242-abc123', requested_worktree_name: 'autobongos-4242-abc123' };
|
|
266
|
+
const files = (...p) => p.map((x) => ({ path: x }));
|
|
267
|
+
const refusal = (claim, prFiles, extra = {}) => guard.runnerSelfEditRefusal({ taskId: 4242, prNumber: 77 }, {
|
|
268
|
+
readLatestClaim: async () => claim, getPullRequestFiles: async () => prFiles, ...extra,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await test('the server refuses a runner\'s pull request that edits an autobongos-* file it never declared', async () => {
|
|
272
|
+
const r = await refusal(RUNNER_CLAIM, files('modules/hall-ui/public/x.js', 'scripts/bongos/autobongos-loop.js'));
|
|
273
|
+
assert.deepEqual(r, { code: 'self_modification', files: ['scripts/bongos/autobongos-loop.js'], protected: [] });
|
|
274
|
+
assert.match(guard.refusalMessage(r), /autobongos-loop\.js.*pull request was closed/s);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await test('the server refuses a runner\'s pull request that touches a protected file, the route itself included', async () => {
|
|
278
|
+
const r = await refusal(RUNNER_CLAIM, files('modules/lifecycle/routes/tasks.js'));
|
|
279
|
+
assert.equal(r.code, 'runner_protected_path');
|
|
280
|
+
assert.deepEqual(r.protected, ['modules/lifecycle/routes/tasks.js']);
|
|
281
|
+
assert.match(guard.refusalMessage(r), /protected permission, migration, infrastructure or pipeline file/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await test('a registry matcher that does not flag the paths it always has is not trusted', async () => {
|
|
285
|
+
for (const matchProtected of [() => [], () => { throw new Error('swapped'); }, () => null]) {
|
|
286
|
+
const r = await refusal(RUNNER_CLAIM, files('docs/a.md'), { matchProtected });
|
|
287
|
+
assert.equal(r.code, 'self_modification_unreadable', 'a neutered matcher refuses rather than waving everything through');
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await test('moving a guard away is an edit to it', async () => {
|
|
292
|
+
const r = await refusal(RUNNER_CLAIM, [{ path: 'modules/elsewhere/fence.js', previous_path: 'modules/autonomy/fence.js', status: 'renamed' }]);
|
|
293
|
+
assert.equal(r.code, 'self_modification');
|
|
294
|
+
assert.deepEqual(r.files, ['modules/autonomy/fence.js']);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await test('a person\'s claim is never checked, and its files are never read', async () => {
|
|
298
|
+
let read = 0;
|
|
299
|
+
const r = await refusal({ worktree_name: 'my-branch', requested_worktree_name: null }, files('modules/autonomy/fence.js'), {
|
|
300
|
+
getPullRequestFiles: async () => { read++; return []; },
|
|
301
|
+
});
|
|
302
|
+
assert.equal(r, null);
|
|
303
|
+
assert.equal(read, 0);
|
|
304
|
+
assert.equal(await refusal(null, files('modules/autonomy/fence.js')), null, 'no claim row, no check');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await test('a disambiguated name is still the runner when the requested one is', async () => {
|
|
308
|
+
const r = await refusal({ worktree_name: 'autobongos-4242-abc123-2', requested_worktree_name: 'autobongos-4242-abc123' }, files('modules/autonomy/gauge.js'));
|
|
309
|
+
assert.equal(r.code, 'self_modification');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await test('a runner\'s files that cannot be read in full are refused, never waved through', async () => {
|
|
313
|
+
assert.equal((await refusal(RUNNER_CLAIM, null, { getPullRequestFiles: async () => { throw new Error('502'); } })).code, 'self_modification_unreadable');
|
|
314
|
+
const cap = require('../modules/lifecycle/ship-files.js').PR_FILES_CAP;
|
|
315
|
+
const many = Array.from({ length: cap }, (_, i) => ({ path: `docs/n${i}.md` }));
|
|
316
|
+
assert.equal((await refusal(RUNNER_CLAIM, many)).code, 'self_modification_unreadable');
|
|
317
|
+
assert.equal(await refusal(RUNNER_CLAIM, files('docs/a.md')), null, 'a clean runner diff publishes');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await test('a claim read that fails lets an ordinary publish through, but not a runner\'s branch', async () => {
|
|
321
|
+
const failing = { query: async () => { throw new Error('db down'); } };
|
|
322
|
+
const claim = await guard.readLatestClaim(4242, failing);
|
|
323
|
+
assert.equal(claim && claim.unreadable, true, 'a failed read is not the same answer as no claim');
|
|
324
|
+
const viaFailedRead = (branch, prFiles) => guard.runnerSelfEditRefusal({ taskId: 4242, prNumber: 77, branch }, {
|
|
325
|
+
readLatestClaim: (id) => guard.readLatestClaim(id, failing), getPullRequestFiles: async () => prFiles,
|
|
326
|
+
});
|
|
327
|
+
assert.equal(await viaFailedRead('fix-the-fence', files('modules/autonomy/fence.js')), null, 'an ordinary publish never depends on the read');
|
|
328
|
+
assert.equal((await viaFailedRead('autobongos-4242-abc123', files('modules/autonomy/fence.js'))).code, 'self_modification', 'a runner branch is still checked');
|
|
329
|
+
const seen = [];
|
|
330
|
+
await guard.readLatestClaim(4242, { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } });
|
|
331
|
+
assert.match(seen[0].sql, /FROM claims c WHERE c\.task_id = \$1\s+ORDER BY c\.claimed_at DESC, c\.id DESC LIMIT 1/);
|
|
332
|
+
assert.deepEqual(seen[0].params, [4242]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---- the real publish route --------------------------------------------------
|
|
336
|
+
|
|
337
|
+
const express = require('express');
|
|
338
|
+
const api = require('../src/module-api.js');
|
|
339
|
+
const db = require('../modules/lifecycle/db.js');
|
|
340
|
+
const gp = require('../modules/lifecycle/github-push.js');
|
|
341
|
+
const prAssign = require('../modules/lifecycle/pr-assign.js');
|
|
342
|
+
const prClose = require('../modules/lifecycle/github-pr-close.js');
|
|
343
|
+
const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
|
|
344
|
+
|
|
345
|
+
let claim = RUNNER_CLAIM;
|
|
346
|
+
let prFiles = files('docs/a.md');
|
|
347
|
+
let armCalls = 0;
|
|
348
|
+
let closed = [];
|
|
349
|
+
api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon', github_login: 'the-archon' }; next(); };
|
|
350
|
+
db.getLastClaimHolder = async () => ({ builder_id: 3, github_login: 'some-builder' });
|
|
351
|
+
db.setClaimPublishedSha = async () => {};
|
|
352
|
+
api.pool.query = async (sql) => {
|
|
353
|
+
if (/c\.auto_pickup/.test(sql)) return { rows: [{ id: 4242, source: 'planning-session', source_ref: null, auto_pickup: null }] };
|
|
354
|
+
if (/c\.worktree_name, c\.requested_worktree_name/.test(sql)) return { rows: claim ? [claim] : [] };
|
|
355
|
+
return { rows: [] };
|
|
356
|
+
};
|
|
357
|
+
gp.isConfigured = () => true;
|
|
358
|
+
gp.pushBundle = async () => ({ head_sha: 'a'.repeat(40) });
|
|
359
|
+
gp.openOrFindPullRequest = async () => ({ number: 77, url: 'https://github.com/o/r/pull/77', node_id: 'N' });
|
|
360
|
+
gp.enableAutoMergeDetailed = async () => { armCalls++; return { ok: true }; };
|
|
361
|
+
let fileReads = 0;
|
|
362
|
+
gp.getPullRequestFiles = async () => { fileReads++; return prFiles; };
|
|
363
|
+
gp.setCommitStatus = async () => true;
|
|
364
|
+
prAssign.assignPullRequest = async () => ({ assigned: true });
|
|
365
|
+
prClose.closePullRequest = async ({ number }) => { closed.push(number); return { ok: true }; };
|
|
366
|
+
pageTweakReads.heldForArtistFailClosed = async () => false;
|
|
367
|
+
|
|
368
|
+
const app = express();
|
|
369
|
+
app.use((req, res, next) => { res.fail = (code, status, extra) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code, ...(extra || {}) }); next(); });
|
|
370
|
+
app.use(require('../modules/lifecycle/routes/tasks.js')());
|
|
371
|
+
const server = http.createServer(app);
|
|
372
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
373
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
374
|
+
const publish = async () => {
|
|
375
|
+
const res = await fetch(`${base}/tasks/4242/publish-branch`, {
|
|
376
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
377
|
+
body: JSON.stringify({ branch: 'autobongos-4242-abc123', head_sha: 'a'.repeat(40), bundle_b64: Buffer.from('b').toString('base64'), value_summary: 'A fix.' }),
|
|
378
|
+
});
|
|
379
|
+
return { status: res.status, body: await res.json() };
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
await test('publish: a runner\'s undeclared self-edit is REFUSED 409, its PR closed, never armed', async () => {
|
|
383
|
+
claim = RUNNER_CLAIM; prFiles = files('modules/hall-ui/public/x.js', 'scripts/bongos/autobongos-run.js'); armCalls = 0; closed = [];
|
|
384
|
+
const r = await publish();
|
|
385
|
+
assert.equal(r.status, 409, JSON.stringify(r.body));
|
|
386
|
+
assert.equal(r.body.error, 'self_modification');
|
|
387
|
+
assert.deepEqual(r.body.files, ['scripts/bongos/autobongos-run.js']);
|
|
388
|
+
assert.match(r.body.message, /supervisor, safety checks or kill switch/);
|
|
389
|
+
assert.deepEqual(closed, [77]);
|
|
390
|
+
assert.equal(armCalls, 0);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
await test('publish: a runner\'s clean change publishes and arms as usual', async () => {
|
|
394
|
+
claim = RUNNER_CLAIM; prFiles = files('modules/hall-ui/public/x.js'); armCalls = 0; closed = [];
|
|
395
|
+
const r = await publish();
|
|
396
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
397
|
+
assert.deepEqual(closed, []);
|
|
398
|
+
assert.equal(armCalls, 1);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await test('publish: a runner\'s auto-pickup round reads its pull request\'s files from GitHub once, for both checks', async () => {
|
|
402
|
+
const round = { id: 4242, source: 'page-tweak', source_ref: 'page-tweak/landing:index/r2', auto_pickup: true };
|
|
403
|
+
const before = api.pool.query;
|
|
404
|
+
api.pool.query = async (sql) => (/c\.auto_pickup/.test(sql) ? { rows: [round] } : before(sql));
|
|
405
|
+
claim = RUNNER_CLAIM; prFiles = files('modules/public-landing/public/index.html'); fileReads = 0; closed = [];
|
|
406
|
+
try {
|
|
407
|
+
await publish();
|
|
408
|
+
assert.equal(fileReads, 1, 'the auto-pickup check and the runner check share one read');
|
|
409
|
+
} finally { api.pool.query = before; }
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
await test('publish: a person\'s change to the same files is not checked', async () => {
|
|
413
|
+
claim = { worktree_name: 'fix-the-fence', requested_worktree_name: null }; prFiles = files('modules/autonomy/fence.js'); closed = [];
|
|
414
|
+
const r = await publish();
|
|
415
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
416
|
+
assert.deepEqual(closed, []);
|
|
417
|
+
});
|
|
418
|
+
server.close();
|
|
419
|
+
|
|
420
|
+
summary();
|
|
@@ -237,7 +237,7 @@ test('a real request through the assembled router returns the verdict, not a 500
|
|
|
237
237
|
}
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
-
// ── the
|
|
240
|
+
// ── the shell's half: who is asked, and how often ───────────────────────────────
|
|
241
241
|
|
|
242
242
|
function boot({ permissions, update, status = 200, dismissed = null, provisioning = true, pathname = '/settings' } = {}) {
|
|
243
243
|
const els = new Map();
|
|
@@ -295,15 +295,6 @@ function boot({ permissions, update, status = 200, dismissed = null, provisionin
|
|
|
295
295
|
|
|
296
296
|
const BEHIND = { running: '1.19.861', latest: '1.19.871', behind: true, reading: 'ok' };
|
|
297
297
|
|
|
298
|
-
test('an owner who is behind sees the banner, naming both versions', async () => {
|
|
299
|
-
const b = boot({ permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
300
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
301
|
-
const el = b.banner();
|
|
302
|
-
assert.ok(el, 'banner rendered');
|
|
303
|
-
assert.match(el.innerHTML, /1\.19\.871/);
|
|
304
|
-
assert.match(el.innerHTML, /1\.19\.861/);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
298
|
test('a builder without the atom is never even asked about it', async () => {
|
|
308
299
|
const b = boot({ permissions: { 'task.create': true }, update: BEHIND });
|
|
309
300
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -311,33 +302,6 @@ test('a builder without the atom is never even asked about it', async () => {
|
|
|
311
302
|
assert.ok(!b.requested.includes('GET /api/bongos/core-update'), 'no request is made');
|
|
312
303
|
});
|
|
313
304
|
|
|
314
|
-
test('no answer renders no banner — not a false "up to date"', async () => {
|
|
315
|
-
const b = boot({ permissions: { 'core.pin.move': true }, update: { running: '1.19.861', latest: null, behind: false, reading: 'no_answer' } });
|
|
316
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
317
|
-
assert.equal(b.banner(), undefined);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
test('a dismissal silences THAT version only', async () => {
|
|
321
|
-
const same = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, dismissed: '1.19.871' });
|
|
322
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
323
|
-
assert.equal(same.banner(), undefined, 'the dismissed version stays dismissed');
|
|
324
|
-
|
|
325
|
-
const newer = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, dismissed: '1.19.800' });
|
|
326
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
327
|
-
assert.ok(newer.banner(), 'a newer release re-announces itself');
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
test('the Upgrade action appears only where the deploy door exists', async () => {
|
|
331
|
-
const on = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, provisioning: true });
|
|
332
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
333
|
-
assert.match(on.banner().innerHTML, /href="\/deploy"/);
|
|
334
|
-
|
|
335
|
-
const off = boot({ permissions: { 'core.pin.move': true }, update: BEHIND, provisioning: false });
|
|
336
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
337
|
-
assert.ok(off.banner(), 'a tenant still hears the news');
|
|
338
|
-
assert.doesNotMatch(off.banner().innerHTML, /href="\/deploy"/, 'but is not offered a 404');
|
|
339
|
-
});
|
|
340
|
-
|
|
341
305
|
test('the banner is asked for ONCE, not on every access settle', async () => {
|
|
342
306
|
const b = boot({ permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
343
307
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -345,61 +309,11 @@ test('the banner is asked for ONCE, not on every access settle', async () => {
|
|
|
345
309
|
assert.equal(first, 1);
|
|
346
310
|
});
|
|
347
311
|
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
// builder on every instance — while this suite stayed green throughout.
|
|
354
|
-
//
|
|
355
|
-
// So this case does NOT write the map. It DERIVES it from me.js's own declaration,
|
|
356
|
-
// the way the server does, and grants everything in it. If the atom the banner needs
|
|
357
|
-
// is not part of that projection, there is nothing here to grant and the banner
|
|
358
|
-
// cannot render — which is the real bug, reproduced as a failure.
|
|
359
|
-
test('the banner renders from the projection /me ACTUALLY sends, not one written by this test', async () => {
|
|
360
|
-
const meSrc = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
|
|
361
|
-
const m = meSrc.match(/NAV_PERMISSION_ATOMS = Object\.freeze\(\[([^\]]*)\]\)/);
|
|
362
|
-
assert.ok(m, 'NAV_PERMISSION_ATOMS is readable');
|
|
363
|
-
const atoms = m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
|
|
364
|
-
// A builder who holds everything the projection can report — the most generous real
|
|
365
|
-
// answer the server is capable of giving.
|
|
366
|
-
const permissions = Object.fromEntries(atoms.map((a) => [a, true]));
|
|
367
|
-
const b = boot({ permissions, update: BEHIND });
|
|
368
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
369
|
-
assert.ok(
|
|
370
|
-
b.banner(),
|
|
371
|
-
'the banner did not render for a builder holding EVERY atom /me can report — so the atom it ' +
|
|
372
|
-
'gates on is outside NAV_PERMISSION_ATOMS and the feature is dead for everyone (task 1004118)',
|
|
373
|
-
);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
// ── the banner must not offer the page you are on (task 1004145) ──────────────
|
|
377
|
-
// The owner pressed this on /deploy and reported "I clicked update, but I didn't see
|
|
378
|
-
// anything happen". Nothing had: it is an <a href="/deploy"> styled as a button and
|
|
379
|
-
// labelled with the verb, so on /deploy it navigates to the current page and does
|
|
380
|
-
// nothing at all. No request was ever made — verified against the live box, which had
|
|
381
|
-
// no core-upgrade POST in three hours and no queued intent from that press.
|
|
382
|
-
|
|
383
|
-
test('on /deploy the banner points at the control instead of linking to /deploy', async () => {
|
|
384
|
-
const b = boot({ pathname: '/deploy', permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
385
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
386
|
-
const html = b.banner().innerHTML;
|
|
387
|
-
assert.doesNotMatch(html, /href="\/deploy"/, 'no link to the page already open');
|
|
388
|
-
assert.match(html, /control below/i, 'it says where the real control is');
|
|
389
|
-
assert.match(html, /1\.19\.871/, 'and still announces the release');
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
test('everywhere else the banner still offers the link', async () => {
|
|
393
|
-
const b = boot({ pathname: '/settings', permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
394
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
395
|
-
assert.match(b.banner().innerHTML, /href="\/deploy"/, 'the action is real from any other page');
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
test('a trailing slash is still /deploy', async () => {
|
|
399
|
-
const b = boot({ pathname: '/deploy/', permissions: { 'core.pin.move': true }, update: BEHIND });
|
|
400
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
401
|
-
assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/);
|
|
402
|
-
});
|
|
312
|
+
// The pill (task 1005041) replaced the banner, and its DOM half — said only from an answer,
|
|
313
|
+
// rendered from the projection /me really sends (task 1004118), never a link to the page you
|
|
314
|
+
// are on (task 1004145), never offering a held version (task 1004730) — is pinned against the
|
|
315
|
+
// real shell.js and update-pill.js in tests/update_pill.mjs. What stays here is the verdict,
|
|
316
|
+
// the route, and the shell's half: who is asked, and how often.
|
|
403
317
|
|
|
404
318
|
test('the banner asks with the project\'s stored update rule (task 1004535)', async () => {
|
|
405
319
|
const r2 = createRequire(import.meta.url);
|