@bongos/core 1.19.1039 → 1.19.1040

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.
Files changed (37) hide show
  1. package/.bongos-core.json +79 -34
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +39 -1
  8. package/docs/api-reference.md +7 -1
  9. package/docs/copy-inventory.md +2 -2
  10. package/docs/copy-registry.json +2 -2
  11. package/docs/file-map.md +1 -0
  12. package/docs/module-api-changelog.md +2 -0
  13. package/modules/hall-ui/public/deploy.html +9 -0
  14. package/modules/hall-ui/public/deploy.js +14 -1
  15. package/modules/hall-ui/public/deploy.states.json +14 -0
  16. package/modules/lifecycle/conflict-resolve.js +9 -1
  17. package/modules/npm-release/module.json +19 -0
  18. package/modules/npm-release/public/work.css +8 -0
  19. package/modules/npm-release/public/work.js +261 -0
  20. package/modules/npm-release/registry.js +271 -0
  21. package/modules/npm-release/routes/work.js +46 -0
  22. package/modules/npm-release/work.js +252 -0
  23. package/modules/provisioning/starter-bundles.js +6 -2
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/release-notes.json +14 -0
  27. package/scripts/gds/exec-path-guard.js +1 -1
  28. package/scripts/gds/install-git-hooks.js +18 -2
  29. package/scripts/gds/publish-manifest.js +1 -0
  30. package/src/bongos/module-scope-map.js +5 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/conflict_resolve.mjs +15 -1
  33. package/tests/merge_driver_register.mjs +28 -0
  34. package/tests/module_loader.mjs +1 -1
  35. package/tests/npm_release_page.mjs +173 -0
  36. package/tests/npm_release_work.mjs +350 -0
  37. package/tests/render_check.mjs +3 -2
@@ -0,0 +1,252 @@
1
+ 'use strict';
2
+
3
+ // modules/npm-release/work.js — "where is my work?", answered for the deploy page (task 1002622).
4
+ //
5
+ // THE COMPLAINT THIS EXISTS FOR. The owner, 2026-09-26, over a /deploy that read "Serving
6
+ // 1.19.1033 … Core 1.19.1039 is published": "I did so many tasks and changes yesterday and
7
+ // it's not showing anything that's been done." Fifteen tasks marked shipped sat between those
8
+ // two numbers, and the page named none of them. A version number is the least useful thing a
9
+ // deploy page can show; the work is the thing its reader did.
10
+ //
11
+ // SO EVERY RECENT TASK IS PLACED IN THE STAGE IT HAS ACTUALLY REACHED, in the order work
12
+ // travels:
13
+ // not_merged the ledger says done (completed / confirmed), the code is not on main
14
+ // unversioned merged after the newest version the notes reach was published
15
+ // not_live in a version newer than the one this hall runs
16
+ // live_unreleased running here, but not yet what other projects are offered
17
+ // released running here and offered to other projects
18
+ // no_version shipped, but no version names it — a ledger-only ship (a scope edit, a
19
+ // verify task) or a merge whose note could not be tied to a task
20
+ //
21
+ // THE JOIN. A version's work comes from the release notes the package was packed with, where
22
+ // each merge that carried `task <id>: <summary>` is `{ id, text }`. A hand-landed merge names
23
+ // no id, so an entry without one is matched on its text against the task's own ship summary —
24
+ // the same sentence ship.js wrote into the merge. Nothing is inferred from the changelog,
25
+ // whose CI lines ("carrier for merges landed since …") name no work at all.
26
+ //
27
+ // THE PAGE KNOWS NOTHING (deploy.js's rule, kept). Every version and verdict here is a reading
28
+ // with its source and time; a reading that failed is reported as failed, never as empty.
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const api = require('../../src/module-api');
33
+ const { readRegistry, compareVersions } = require('./registry');
34
+
35
+ // The package this server runs — the one whose "live" version is this process's own core.
36
+ // Any other package has no "live on this hall" stage: nothing here runs it.
37
+ const CORE_PACKAGE = '@bongos/core';
38
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
39
+ const TEXT_MAX = 400;
40
+ const DEFAULT_DAYS = 14;
41
+ const MAX_DAYS = 60;
42
+ const DEFAULT_LIMIT = 30;
43
+ const MAX_LIMIT = 100;
44
+ // The core carries its notes at its package root: <root>/release-notes.json, beside
45
+ // package.json. From modules/npm-release/ that is two levels up.
46
+ const INSTALLED_NOTES = path.join(__dirname, '..', '..', 'release-notes.json');
47
+
48
+ /** The package this project publishes. Config, not code: another project names its own. */
49
+ function configuredPackage(env = process.env) {
50
+ const v = String(env.NPM_RELEASE_PACKAGE || '').trim();
51
+ return v || CORE_PACKAGE;
52
+ }
53
+
54
+ function readInstalledNotes() {
55
+ try { return JSON.parse(fs.readFileSync(INSTALLED_NOTES, 'utf8')); }
56
+ catch { return null; }
57
+ }
58
+
59
+ function boundText(text) {
60
+ const t = String(text || '').trim();
61
+ return t.length > TEXT_MAX ? `${t.slice(0, TEXT_MAX - 1)}…` : t;
62
+ }
63
+
64
+ /** A notes file's versions, oldest first, each entry re-checked (it may have come off the network). */
65
+ function versionsIn(notes) {
66
+ if (!notes || typeof notes !== 'object' || Array.isArray(notes)) return [];
67
+ return Object.keys(notes)
68
+ .filter((v) => VERSION_RE.test(v) && Array.isArray(notes[v]))
69
+ .sort(compareVersions)
70
+ .map((v) => ({
71
+ version: v,
72
+ entries: notes[v]
73
+ .filter((e) => e && typeof e.text === 'string' && e.text.trim())
74
+ .map((e) => (/^\d+$/.test(String(e.id || '')) ? { id: String(e.id), text: boundText(e.text) } : { text: boundText(e.text) })),
75
+ }));
76
+ }
77
+
78
+ /**
79
+ * Where each task stands. PURE — every input injected.
80
+ *
81
+ * `tasks`: ledger rows `{ id, title, status, at, builder, summary }`.
82
+ * `versions`: `versionsIn` output. `live` / `released`: versions or null. `coveredAt`: when
83
+ * the newest version the notes reach was published (ISO) or null when unknown.
84
+ */
85
+ function placeTasks({ tasks, versions, live, released, coveredAt }) {
86
+ const byId = new Map();
87
+ const byText = new Map();
88
+ for (const { version, entries } of versions) {
89
+ for (const e of entries) {
90
+ if (e.id && !byId.has(e.id)) byId.set(e.id, version);
91
+ if (!e.id && !byText.has(e.text)) byText.set(e.text, version);
92
+ }
93
+ }
94
+ const covered = coveredAt ? Date.parse(coveredAt) : NaN;
95
+ const stages = { not_merged: [], unversioned: [], not_live: [], live_unreleased: [], released: [], no_version: [] };
96
+ for (const t of tasks) {
97
+ const row = { id: String(t.id), title: t.title, status: t.status, builder: t.builder || null, at: t.at };
98
+ if (t.status !== 'shipped') { stages.not_merged.push(row); continue; }
99
+ const version = byId.get(row.id) || (t.summary ? byText.get(boundText(t.summary)) : undefined);
100
+ if (!version) {
101
+ // Merged after the notes' newest version went out: it is waiting for the next one.
102
+ // Before it (or with no way to tell): no version names it.
103
+ const at = Date.parse(t.at);
104
+ if (Number.isFinite(covered) && Number.isFinite(at) && at > covered) stages.unversioned.push(row);
105
+ else stages.no_version.push(row);
106
+ continue;
107
+ }
108
+ row.version = version;
109
+ const offered = released ? compareVersions(version, released) <= 0 : null;
110
+ if (live && compareVersions(version, live) > 0) {
111
+ // Offered to other projects before this hall ran it — the order the Release step
112
+ // exists to prevent. Said, not hidden.
113
+ if (offered) row.offered = true;
114
+ stages.not_live.push(row);
115
+ } else if (offered === false) stages.live_unreleased.push(row);
116
+ else stages.released.push(row);
117
+ }
118
+ return stages;
119
+ }
120
+
121
+ /** The version history, newest first, from `before` (exclusive) down, `limit` long. PURE. */
122
+ function history({ versions, published, live, released, newest, before, limit, titles }) {
123
+ const known = new Map(versions.map((v) => [v.version, v.entries]));
124
+ // Versions the registry lists but the notes do not reach are shown as unread, never
125
+ // dropped: a history with a silent hole in it reads as "nothing happened there".
126
+ const all = new Set(versions.map((v) => v.version));
127
+ for (const v of Object.keys(published || {})) if (!newest || compareVersions(v, newest) <= 0) all.add(v);
128
+ let list = [...all].sort(compareVersions).reverse();
129
+ if (before && VERSION_RE.test(before)) list = list.filter((v) => compareVersions(v, before) < 0);
130
+ const page = list.slice(0, limit);
131
+ return {
132
+ more: list.length > page.length,
133
+ versions: page.map((v) => ({
134
+ version: v,
135
+ published_at: (published && published[v]) || null,
136
+ live: live === v,
137
+ released: released === v,
138
+ entries: known.has(v)
139
+ ? known.get(v).map((e) => (e.id ? { id: e.id, title: titles.get(e.id) || null, text: e.text } : { text: e.text }))
140
+ : null,
141
+ })),
142
+ };
143
+ }
144
+
145
+ async function recentTasks(pool, days) {
146
+ // Who did it: the shipper once shipped, the last claimant before that.
147
+ const { rows } = await pool.query(
148
+ `SELECT t.id, t.title, t.status, t.value_summary,
149
+ COALESCE(t.shipped_at, t.updated_at) AS at,
150
+ COALESCE(sb.display_name, sb.github_login, cb.display_name, cb.github_login) AS builder
151
+ FROM tasks t
152
+ LEFT JOIN builders sb ON sb.id = t.shipped_by
153
+ LEFT JOIN LATERAL (
154
+ SELECT c.builder_id FROM claims c WHERE c.task_id = t.id ORDER BY c.claimed_at DESC LIMIT 1
155
+ ) lc ON true
156
+ LEFT JOIN builders cb ON cb.id = lc.builder_id
157
+ WHERE t.status IN ('completed', 'confirmed', 'shipped')
158
+ AND COALESCE(t.shipped_at, t.updated_at) >= now() - make_interval(days => $1::int)
159
+ ORDER BY COALESCE(t.shipped_at, t.updated_at) DESC
160
+ LIMIT 500`,
161
+ [days],
162
+ );
163
+ return rows.map((r) => ({
164
+ id: String(r.id),
165
+ title: r.title,
166
+ status: r.status,
167
+ summary: r.value_summary || null,
168
+ builder: r.builder || null,
169
+ at: r.at instanceof Date ? r.at.toISOString() : r.at,
170
+ }));
171
+ }
172
+
173
+ async function taskTitles(pool, ids) {
174
+ if (!ids.length) return new Map();
175
+ const { rows } = await pool.query('SELECT id, title FROM tasks WHERE id = ANY($1::bigint[])', [ids]);
176
+ return new Map(rows.map((r) => [String(r.id), r.title]));
177
+ }
178
+
179
+ function clampInt(v, dflt, max) {
180
+ const n = Number.parseInt(v, 10);
181
+ return Number.isInteger(n) && n > 0 ? Math.min(n, max) : dflt;
182
+ }
183
+
184
+ /**
185
+ * The deploy page's whole reading, in one call — the route's only dependency.
186
+ *
187
+ * `days` bounds the ledger window, `before` + `limit` page the version history. `get`,
188
+ * `now`, `store` and `installedNotes` are the seams tests drive; `live` defaults to this
189
+ * process's own core version when the package is the core.
190
+ */
191
+ async function readWork({
192
+ pool, pkg = configuredPackage(), live, days, before, limit,
193
+ installedNotes = readInstalledNotes, get, now, store,
194
+ } = {}) {
195
+ const windowDays = clampInt(days, DEFAULT_DAYS, MAX_DAYS);
196
+ const pageLimit = clampInt(limit, DEFAULT_LIMIT, MAX_LIMIT);
197
+ const running = live !== undefined ? live : (pkg === CORE_PACKAGE ? api.CORE_VERSION : null);
198
+ const liveVersion = VERSION_RE.test(String(running || '')) ? running : null;
199
+
200
+ const local = pkg === CORE_PACKAGE ? installedNotes() : null;
201
+ const localVersions = versionsIn(local);
202
+ const localTop = localVersions.length ? localVersions[localVersions.length - 1].version : null;
203
+
204
+ const { registry, notes } = await readRegistry({
205
+ pkg, get, now, store,
206
+ haveNotesFor: (v) => !!localTop && compareVersions(localTop, v) >= 0,
207
+ });
208
+
209
+ // The newest notes win; the installed copy stands in when they could not be read.
210
+ let versions = localVersions;
211
+ let source = localTop ? { from: 'installed', version: localTop } : null;
212
+ let notesError = null;
213
+ if (notes.ok) {
214
+ versions = versionsIn(notes.notes);
215
+ source = { from: 'package', version: notes.version };
216
+ } else if (!notes.skipped) {
217
+ notesError = notes.error || 'the release notes could not be read';
218
+ }
219
+ const coversTo = versions.length ? versions[versions.length - 1].version : null;
220
+ const published = registry.ok ? registry.published : {};
221
+ const released = registry.ok ? registry.latest : null;
222
+
223
+ const tasks = await recentTasks(pool, windowDays);
224
+ const stages = placeTasks({ tasks, versions, live: liveVersion, released, coveredAt: coversTo ? published[coversTo] || null : null });
225
+
226
+ const known = new Map(tasks.map((t) => [t.id, t.title]));
227
+ const hist = history({
228
+ versions, published, live: liveVersion, released,
229
+ newest: registry.ok ? registry.newest : coversTo,
230
+ before, limit: pageLimit, titles: known,
231
+ });
232
+ const missing = [...new Set(hist.versions.flatMap((v) => (v.entries || []).filter((e) => e.id && !e.title).map((e) => e.id)))];
233
+ if (missing.length) {
234
+ const more = await taskTitles(pool, missing);
235
+ for (const v of hist.versions) for (const e of v.entries || []) if (e.id && !e.title) e.title = more.get(e.id) || null;
236
+ }
237
+
238
+ return {
239
+ package: pkg,
240
+ checked_at: new Date(now ? now() : Date.now()).toISOString(),
241
+ live: liveVersion,
242
+ released,
243
+ newest: registry.ok ? registry.newest : null,
244
+ registry: registry.ok ? { ok: true } : { ok: false, error: registry.error },
245
+ notes: { source, covers_to: coversTo, error: notesError },
246
+ window_days: windowDays,
247
+ stages,
248
+ history: hist,
249
+ };
250
+ }
251
+
252
+ module.exports = { readWork };
@@ -68,8 +68,12 @@ const ALWAYS_ON_CORE = Object.freeze([
68
68
  // provider ("enabled ONLY on the hub instance") and `provisioning` is the surface
69
69
  // that stands other instances up — a project created THROUGH the hub is a
70
70
  // consumer of both, never a second copy of them. Named so the integrity test can
71
- // refuse them by construction rather than by anyone remembering.
72
- const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning']);
71
+ // refuse them by construction rather than by anyone remembering. `npm-release` is the
72
+ // deploy page of the one project that PUBLISHES the core (task 1002622): every project
73
+ // the hub creates receives those releases, so none is offered the publisher's page. It
74
+ // is package-agnostic by construction, so offering it to a project that publishes its
75
+ // own npm package is a later, deliberate removal from this list — with picker copy.
76
+ const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release']);
73
77
 
74
78
  // Modules that were once offered and are being WITHDRAWN. A retired key is still
75
79
  // a module on disk — its manifest, routes and tables are deleted by later tasks —
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1039",
3
+ "version": "1.19.1040",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1039",
9
+ "version": "1.19.1040",
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.1039",
3
+ "version": "1.19.1040",
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",
@@ -7523,5 +7523,19 @@
7523
7523
  "id": "1004290",
7524
7524
  "text": "A new release of the core is now published only after the check that it can set up a brand-new empty database has passed, so a release that cannot install cleanly can no longer reach projects."
7525
7525
  }
7526
+ ],
7527
+ "1.19.1040": [
7528
+ {
7529
+ "id": "1002622",
7530
+ "text": "The Deploy page now lists every recently finished task by name and shows how far each one has got — not merged yet, published, running on Bongos, or released — plus what each version carried."
7531
+ },
7532
+ {
7533
+ "id": "1004282",
7534
+ "text": "Wrote up tonight's work on accounts and community: 12 changes finished, the site upgraded, and your page-placement decisions turned into tasks. New code is paused until the GitHub build budget resets next month."
7535
+ },
7536
+ {
7537
+ "id": "1003368",
7538
+ "text": "Closed a security hole where a builder's branch could choose the program the server runs while auto-resolving merge conflicts; the server now always uses its own trusted copy."
7539
+ }
7526
7540
  ]
7527
7541
  }
@@ -64,7 +64,7 @@ const STATIC_ROOTS = new Set([
64
64
  // brittle — a line shift re-opens the review rather than carrying a stale approval.
65
65
  const EXEMPTIONS = [
66
66
  {
67
- at: 'modules/lifecycle/conflict-resolve.js:382',
67
+ at: 'modules/lifecycle/conflict-resolve.js:390',
68
68
  why: 'THE site this check was written for (audit ref B1), and the one place a runtime path is '
69
69
  + 'REQUIRED rather than tolerated. Each generator derives its repo root from its own '
70
70
  + '__dirname, so running the SERVER\'s copy would regenerate the deploy tree instead of the '
@@ -49,14 +49,30 @@ const MERGE_DRIVER_NAME = 'otb-regen';
49
49
  // - `.name` alone is the fatal state above.
50
50
  // So writing `.driver` FIRST makes the intermediate state safe, and removing the whole
51
51
  // section on failure makes even a mid-write abort leave no trace.
52
- function registerMergeDriver(root, { run = execFileSync } = {}) {
52
+ //
53
+ // `driverScript` (task 1003368, audit B2) pins the command to an ABSOLUTE path. The default
54
+ // is the RELATIVE `scripts/gds/git-merge-regen.js`, which git resolves against the merge's
55
+ // cwd — the checked-out tree — so on a clone of a builder's branch it names the BRANCH's copy
56
+ // of the script. The server's conflict resolver passes its own checkout's copy so the command
57
+ // git runs is never one the branch could have written. Only the server-side caller may pass
58
+ // it; a builder's own clone keeps the relative form, which is what they want to run.
59
+ function mergeDriverCommand(driverScript) {
60
+ if (!driverScript) return 'node scripts/gds/git-merge-regen.js %O %A %B %P';
61
+ // Forward slashes + double quotes: git hands this to `sh -c`, and a path with spaces or a
62
+ // Windows backslash would otherwise split or be eaten as an escape.
63
+ const abs = path.resolve(driverScript).replace(/\\/g, '/');
64
+ if (/["$`]/.test(abs)) throw new Error(`unsafe merge driver path: ${abs}`);
65
+ return `node "${abs}" %O %A %B %P`;
66
+ }
67
+
68
+ function registerMergeDriver(root, { run = execFileSync, driverScript } = {}) {
53
69
  // execFileSync (arg array, no shell) so the literal %O/%A/%B/%P placeholders and
54
70
  // the spaces in the command survive verbatim on every platform (a shell would
55
71
  // mangle %VARS on Windows cmd).
56
72
  try {
57
73
  // .driver FIRST — a section carrying only .driver is valid and works.
58
74
  run('git', ['config', `merge.${MERGE_DRIVER_NAME}.driver`,
59
- 'node scripts/gds/git-merge-regen.js %O %A %B %P'], { cwd: root, stdio: 'ignore' });
75
+ mergeDriverCommand(driverScript)], { cwd: root, stdio: 'ignore' });
60
76
  // .name SECOND — cosmetic (it is what `git config --list` shows); if this throws,
61
77
  // the section left behind is the safe driver-only one.
62
78
  run('git', ['config', `merge.${MERGE_DRIVER_NAME}.name`,
@@ -72,6 +72,7 @@ const PUBLISH_ALLOWLIST = [
72
72
  'modules/discord/',
73
73
  'modules/provisioning/', // instance-hosting control plane — CORE like its sibling dev-box (same do-api/cf-tunnel toolchain, all core). Reclassified IN so the core CLI scripts/gds/provision.js resolves it from the core package post-split, not a MODULE_NOT_FOUND (task 2135). default:false — shipped ≠ enabled.
74
74
  'modules/agents/', // the declarative agent registry + spawn seam (goal 1000038) — CORE: its own manifest declares maintenance.status 'core-maintained', and it consumes the core `grade` port, so a released core without it cannot run the MAS machinery the methodology describes. default:false — shipped is not enabled (ADR 0083), exactly like provisioning + platform-identity below. Omitted at module-creation time (tasks 1002485/1002486): the omission was invisible until tests/agents_spawn.mjs became the first PUBLISHABLE file to import it and the R89 boundary guard caught it (task 1002489).
75
+ 'modules/npm-release/', // the deploy page's "where is the work" + version history (task 1002622). default:false — on only in the hall that publishes the core. MUST be here: modules/hall-ui/ ships as one subtree, so a released core without this carries deploy.js's loader and the #nr-work mounts with no routes behind them — the copy-desk asymmetry above.
75
76
  'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
76
77
  // --- neutral (customer-0) config starters + their schemas (the filled-in *.json twins are denied) ---
77
78
  'config/branding.neutral.json',
@@ -335,6 +335,11 @@ const MODULE_GLOBS = {
335
335
  // The instance-side CLIENT half of federation is woven into core auth.js and is
336
336
  // kernel territory (ADR 0141 §Packaging), NOT this key.
337
337
  'platform-identity': ['modules/platform-identity/'],
338
+ // --- the npm-release module (task 1002622): the deploy page's "where the work is" +
339
+ // version history, and later its Release step. Its tests live at the repo root
340
+ // (tests/npm_release_*.mjs) because a default-off module's own tests/ is skipped
341
+ // by the unit runner. The /deploy page shell stays hall-ui's (one key per surface).
342
+ 'npm-release': ['modules/npm-release/', 'tests/npm_release_*.mjs'],
338
343
  // --- instance provisioning: the web-tier REQUEST + STATE surface (module home)
339
344
  // plus the control-plane runner CLI — a command travels with its domain (the
340
345
  // lifecycle/dev-box precedent). Covers the projects-surface work (goal
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.1039'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1040'; // 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');
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { strict as assert } from 'node:assert';
18
18
  import { createRequire } from 'node:module';
19
+ import path from 'node:path';
19
20
 
20
21
  const require = createRequire(import.meta.url);
21
22
  const cr = require('../modules/lifecycle/conflict-resolve.js');
@@ -90,6 +91,7 @@ function mkExec(scn = {}) {
90
91
 
91
92
  function mkDeps(scn = {}, over = {}) {
92
93
  const reg = [];
94
+ const regOpts = [];
93
95
  const exec = mkExec(scn);
94
96
  const genCalls = [];
95
97
  return {
@@ -105,12 +107,13 @@ function mkDeps(scn = {}, over = {}) {
105
107
  buildAuthUrl: () => 'https://x-access-token:REDACTED@github.com/o/r.git',
106
108
  },
107
109
  repoInfo: over.repoInfo === undefined ? { owner: 'o', repo: 'r' } : over.repoInfo,
108
- registerMergeDriver: (root) => { reg.push(root); },
110
+ registerMergeDriver: (root, opts) => { reg.push(root); regOpts.push(opts); },
109
111
  repoRoot: '/srv/repo',
110
112
  mkdtemp: () => '/tmp/otb-resolve-test',
111
113
  rmtmp: () => {},
112
114
  _exec: exec,
113
115
  _reg: reg,
116
+ _regOpts: regOpts,
114
117
  _gen: genCalls,
115
118
  };
116
119
  }
@@ -129,6 +132,17 @@ test('clean generated/index merge → resolved + pushed; driver registered in th
129
132
  assert.ok(ran(deps._exec, 'push'), 'the resolved branch is pushed');
130
133
  });
131
134
 
135
+ // Task 1003368 (audit B2): the driver command must name the SERVER's copy of the script,
136
+ // never the clone's (which is the builder's branch).
137
+ test('the otb-regen driver is pinned to the server checkout, not the clone', async () => {
138
+ const deps = mkDeps({ merge: 'clean', before: 'oldsha1', after: 'newsha2' });
139
+ await cr.resolveBranchConflicts({ branch: 'claude/wt-1' }, deps);
140
+ const script = deps._regOpts[0] && deps._regOpts[0].driverScript;
141
+ assert.ok(script, 'registration must pass a driverScript');
142
+ assert.equal(path.resolve(script), path.resolve('/srv/repo', 'scripts', 'gds', 'git-merge-regen.js'));
143
+ assert.ok(!path.resolve(script).startsWith(path.resolve('/tmp/otb-resolve-test')), 'never inside the clone');
144
+ });
145
+
132
146
  test('genuine hand-written conflict → NOT resolved, merge aborted, never pushed', async () => {
133
147
  const deps = mkDeps({ merge: 'conflict', conflicted: ['src/bongos/db.js'] });
134
148
  const r = await cr.resolveBranchConflicts({ branch: 'claude/wt-2' }, deps);
@@ -99,6 +99,34 @@ test('registerMergeDriver writes .driver BEFORE .name', () => {
99
99
  'order is the fix: the intermediate state after write #1 must be the SAFE one');
100
100
  });
101
101
 
102
+ // Task 1003368 (audit B2): the default command is RELATIVE, which git resolves against the
103
+ // checked-out tree. The server passes an absolute driverScript so the command never names a
104
+ // file the branch could have written.
105
+ test('registerMergeDriver: default command stays relative, driverScript pins an absolute path', () => {
106
+ const commands = [];
107
+ const run = (_bin, args) => { if (args[1] === `merge.${MERGE_DRIVER_NAME}.driver`) commands.push(args[2]); };
108
+ registerMergeDriver('/nowhere', { run });
109
+ assert.equal(commands[0], 'node scripts/gds/git-merge-regen.js %O %A %B %P');
110
+
111
+ const script = path.join(REPO_ROOT, 'scripts', 'gds', 'git-merge-regen.js');
112
+ registerMergeDriver('/nowhere', { run, driverScript: script });
113
+ const pinned = commands[1];
114
+ const m = pinned.match(/^node "([^"]+)" %O %A %B %P$/);
115
+ assert.ok(m, `pinned command has the quoted-absolute shape, got: ${pinned}`);
116
+ assert.ok(path.isAbsolute(m[1]), 'the pinned path must be absolute');
117
+ assert.ok(!m[1].includes('\\'), 'backslashes would be eaten by sh -c');
118
+ assert.equal(path.resolve(m[1]), script);
119
+ });
120
+
121
+ test('registerMergeDriver: a path that could break out of the quotes is refused, leaving no trace', () => {
122
+ const calls = [];
123
+ const run = (_bin, args) => { calls.push(args); };
124
+ const res = registerMergeDriver('/nowhere', { run, driverScript: '/srv/x"; touch pwned; "/a.js' });
125
+ assert.equal(res.ok, false);
126
+ assert.match(res.error, /unsafe merge driver path/);
127
+ assert.ok(!calls.some((a) => a[1] === `merge.${MERGE_DRIVER_NAME}.driver`), 'nothing may be written');
128
+ });
129
+
102
130
  test('a failure mid-registration removes the whole section, leaving no trace', () => {
103
131
  const calls = [];
104
132
  const run = (_bin, args) => {
@@ -276,7 +276,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
276
276
  // That is precisely how the agents regression reached an open PR (task 1002485).
277
277
  loader._reset();
278
278
  const snap = loader.loaderSnapshot();
279
- assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'public-landing', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
279
+ assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'npm-release', 'onboarding', 'platform-identity', 'provisioning', 'public-landing', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
280
280
  assert.deepEqual(snap.errors, []);
281
281
  assert.deepEqual(snap.skipped, []);
282
282
  loader._reset();