@bongos/core 1.19.847 → 1.19.849

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.
@@ -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
+ });
@@ -816,6 +816,78 @@ t('installedCorePackDir: a registry-pinned consumer with an installed core → i
816
816
  [reg, filePinned, uninstalled, bare2, alt2].forEach((d) => rmSync(d, { recursive: true, force: true }));
817
817
  });
818
818
 
819
+ // ── the git-less core checkout: rung 4 (task 1003995) ────────────────────────────
820
+ // The shipped self-host image is a complete core source tree with NO .git (.dockerignore
821
+ // excludes it by design), so package-core.js's `git ls-tree` enumeration has nothing to
822
+ // read and the scaffold inside the container could never build the tarball it vendors
823
+ // into a child. Rung 4 packs the working tree instead.
824
+ t('coreCheckoutHasGit: a clone (.git dir) and a linked worktree (.git file) both have history; the image does not', () => {
825
+ const clone = mkdtempSync(join(tmpdir(), 'cchg-clone-'));
826
+ mkdirSync(join(clone, '.git'));
827
+ assert.equal(P.coreCheckoutHasGit({ repoRoot: clone }), true, 'a clone carries a .git DIRECTORY');
828
+
829
+ // A linked worktree's .git is a FILE pointing at the real gitdir — every claim in this
830
+ // project is worked in one, so treating it as history-less would take the wrong rung on
831
+ // the machine that runs these tests.
832
+ const wt = mkdtempSync(join(tmpdir(), 'cchg-worktree-'));
833
+ writeFileSync(join(wt, '.git'), 'gitdir: /somewhere/.git/worktrees/task-1\n');
834
+ assert.equal(P.coreCheckoutHasGit({ repoRoot: wt }), true, 'a linked worktree carries a .git FILE');
835
+
836
+ const image = mkdtempSync(join(tmpdir(), 'cchg-image-'));
837
+ assert.equal(P.coreCheckoutHasGit({ repoRoot: image }), false, 'the self-host image has no .git at all');
838
+
839
+ [clone, wt, image].forEach((d) => rmSync(d, { recursive: true, force: true }));
840
+ });
841
+
842
+ t('resolveVendorableCoreTarball: the ladder picks file: pin → registry pack → git source → worktree, in that order', () => {
843
+ // One helper per rung so the ONLY thing varying between cases is which shape the
844
+ // control-plane root presents. Asserting on the recorded commands is what proves the
845
+ // ORDER: a rung that fires early leaves the later rung's command unrun.
846
+ const run = (instanceRoot, repoRoot) => {
847
+ const cmds = [];
848
+ const res = P.resolveVendorableCoreTarball({
849
+ instanceRoot, repoRoot, exec: (c) => { cmds.push(c); }, log: () => {}, coreV: '9.9.9',
850
+ });
851
+ return { cmds, ...res };
852
+ };
853
+ const gitRoot = mkdtempSync(join(tmpdir(), 'rvct-git-'));
854
+ mkdirSync(join(gitRoot, '.git'));
855
+ const bareRoot = mkdtempSync(join(tmpdir(), 'rvct-bare-')); // no .git → the image shape
856
+
857
+ // Rung 1 — a file: pin wins even from a git-less root: nothing is rebuilt at all.
858
+ const filePinned = mkdtempSync(join(tmpdir(), 'rvct-file-'));
859
+ mkdirSync(join(filePinned, 'vendor'));
860
+ writeFileSync(join(filePinned, 'vendor', 'bongos-core-1.2.3.tgz'), 'x');
861
+ writeFileSync(join(filePinned, 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': 'file:vendor/bongos-core-1.2.3.tgz' } }));
862
+ const r1 = run(filePinned, bareRoot);
863
+ assert.equal(r1.tgz, join(filePinned, 'vendor', 'bongos-core-1.2.3.tgz'));
864
+ assert.deepEqual(r1.cmds, [], 'rung 1 runs no command — it reuses bytes already on disk');
865
+
866
+ // Rung 2 — a registry-installed core packs, still without touching package-core.js.
867
+ const reg = mkdtempSync(join(tmpdir(), 'rvct-reg-'));
868
+ mkdirSync(join(reg, 'node_modules', '@bongos', 'core'), { recursive: true });
869
+ writeFileSync(join(reg, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.19.56' }));
870
+ writeFileSync(join(reg, 'package.json'), JSON.stringify({ dependencies: { '@bongos/core': '1.19.56' } }));
871
+ const r2 = run(reg, bareRoot);
872
+ assert.ok(r2.cmds.some((c) => c.startsWith('npm pack ')), 'rung 2 packs the installed core');
873
+ assert.ok(!r2.cmds.some((c) => /package-core\.js/.test(c)), 'rung 2 never reaches package-core.js');
874
+ assert.ok(r2.packDest, 'rung 2 returns the temp pack dir for the caller to clean up');
875
+
876
+ // Rung 3 — a source checkout WITH history rebuilds reproducibly from the ref.
877
+ const source = mkdtempSync(join(tmpdir(), 'rvct-src-'));
878
+ const r3 = run(source, gitRoot);
879
+ assert.deepEqual(r3.cmds, ['node scripts/gds/package-core.js'], 'history present → the ref build, NOT --from-worktree');
880
+ assert.equal(r3.tgz, join(gitRoot, 'dist', 'bongos-core-9.9.9.tgz'));
881
+ assert.equal(r3.packDest, null);
882
+
883
+ // Rung 4 — the same source checkout with its history stripped: the self-host image.
884
+ const r4 = run(source, bareRoot);
885
+ assert.deepEqual(r4.cmds, ['node scripts/gds/package-core.js --from-worktree'], 'no history → pack the working tree');
886
+ assert.equal(r4.tgz, join(bareRoot, 'dist', 'bongos-core-9.9.9.tgz'), 'the child still pins the running CORE_VERSION');
887
+
888
+ [gitRoot, bareRoot, filePinned, reg, source].forEach((d) => rmSync(d, { recursive: true, force: true }));
889
+ });
890
+
819
891
  await ta('scaffoldStandaloneRepo: a REGISTRY-pinned consumer control plane PACKS its installed core — current version, no source rebuild (task 1002706)', async () => {
820
892
  const cp = mkdtempSync(join(tmpdir(), 'cpdir-cp-'));
821
893
  mkdirSync(join(cp, 'node_modules', '@bongos', 'core'), { recursive: true });