@bongos/core 1.19.623 → 1.19.625

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.
@@ -28,16 +28,79 @@ const ic = require(path.join(REPO_ROOT, 'src', 'instance-config.js'));
28
28
  const lib = require(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'));
29
29
 
30
30
  // SESSION_PATH is frozen at module load from os.homedir(), so anything touching the real save path
31
- // runs in a child with its own HOME.
31
+ // runs in a child with its own home directory.
32
+ //
33
+ // SETTING `HOME` ALONE IS NOT ISOLATION, AND THE MISS COST REAL CREDENTIALS (task 1003760).
34
+ // On Windows os.homedir() reads USERPROFILE and never consults HOME, so the original
35
+ // `env: { ...process.env, HOME: home }` isolated nothing there: every child below wrote
36
+ // its fixtures into the developer's ACTUAL ~/.config/<instance>/, overwriting the live
37
+ // CLI session with `token:'TOKEN-A', api_base:'https://a.example.com'` and leaving
38
+ // a.example.com.json / b.example.com.json / broken.json in the real session store. The
39
+ // owner had to re-issue a CLI token to recover, and because a claim is bound to the
40
+ // session that created it, the re-auth then orphaned an in-flight claim too.
41
+ //
42
+ // CI is Linux, where HOME *is* honoured — so this can never go red there. Two layers,
43
+ // because the platform list is not knowable in advance:
44
+ // 1. Override every variable a platform might read (USERPROFILE is the Windows one;
45
+ // HOMEDRIVE/HOMEPATH are the legacy fallback os.homedir() tries next).
46
+ // 2. FAIL CLOSED IN THE CHILD: prove os.homedir() actually landed in the sandbox
47
+ // BEFORE running any body, and abort if it did not. A future platform that reads
48
+ // some third variable then fails this test loudly instead of silently eating the
49
+ // developer's credentials — which is the only outcome that is acceptable for a
50
+ // test whose whole job is writing to a session store.
51
+ // THE AUDIT THIS FIX OWED, RECORDED HERE RATHER THAN IN A SHIP NOTE. `grep -rn "HOME:"
52
+ // tests/ scripts/` at the time of the fix (task 1003760) found the pattern in ten files.
53
+ // Three already override the Windows variable and are correct: tests/memory_pull.mjs:438,
54
+ // tests/redteam_patrol.mjs:123, tests/setup_device_flow_fallback.mjs:130 — so the
55
+ // convention existed and THIS file simply missed it. Two still set HOME alone and can go
56
+ // FALSE-GREEN on Windows, tracked as task 1003763: tests/cli_package.mjs:140+149 (whose own
57
+ // comment says the temp HOME exists "so a real session file on this machine cannot make a
58
+ // verb look healthier than it is" — on Windows it can) and tests/cli_surface.mjs:174 (which
59
+ // points HOME at a nonexistent dir to simulate "no session", and on Windows sees the real
60
+ // one). Neither DESTROYS anything — this file was the only one writing a session store — so
61
+ // they were filed rather than folded in. Two uses are legitimately HOME-only and must stay:
62
+ // tests/scrubber_corpus.mjs:228 uses HOME:'/root' as corpus DATA, and
63
+ // tests/lib_sh_resolution.mjs exercises POSIX shell resolution where HOME is the subject.
64
+ // They are why closing this class needs an allowlist, not a blanket rule (1003763).
65
+ //
66
+ // Also done off-repo when this landed, and unverifiable from a diff, so stated plainly: the
67
+ // three fixture files this bug left in the REAL session store — a.example.com.json,
68
+ // b.example.com.json, broken.json under ~/.config/<instance>/instances/ — were deleted, and
69
+ // the live session + auth were re-verified afterwards.
70
+ //
71
+ // KNOWN GAP, deliberately left: on Windows this suite now asserts no permission-tightness
72
+ // property at all (see the owner-only test below). NTFS has no POSIX triad, so the real
73
+ // property is the per-user profile ACL; an icacls-style assertion would restore the signal
74
+ // and does not exist here.
75
+ function sandboxEnv(home) {
76
+ const { root } = path.parse(home);
77
+ return {
78
+ ...process.env,
79
+ HOME: home,
80
+ USERPROFILE: home, // Windows: what os.homedir() actually reads
81
+ HOMEDRIVE: root.replace(/[\\/]+$/, ''), // legacy Windows fallback pair
82
+ HOMEPATH: home.slice(root.length - 1) || '\\',
83
+ };
84
+ }
85
+
32
86
  function inSandbox(body) {
33
87
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-sess-'));
34
88
  try {
35
89
  const code = `const R=${JSON.stringify(REPO_ROOT)};
90
+ const SANDBOX=${JSON.stringify(home)};
91
+ const os=require('node:os');
92
+ // Layer 2: refuse to touch a session store outside the sandbox. Runs before the
93
+ // requires below so not even a module-load side effect can reach the real home.
94
+ if (os.homedir() !== SANDBOX) {
95
+ console.error('ERR sandbox-escape: os.homedir()=' + os.homedir() + ' but the sandbox is ' + SANDBOX
96
+ + ' — this platform does not take the home override, and writing here would clobber the real session store.');
97
+ process.exit(2);
98
+ }
36
99
  const lib=require(R+'/scripts/gds/cli-lib.js');
37
100
  const ic=require(R+'/src/instance-config.js');
38
101
  const fs=require('node:fs'), path=require('node:path');
39
102
  (async()=>{ ${body} })().catch((e)=>{ console.error('ERR '+e.message); process.exit(1); });`;
40
- const r = spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', env: { ...process.env, HOME: home } });
103
+ const r = spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', env: sandboxEnv(home) });
41
104
  return { home, out: `${r.stdout || ''}${r.stderr || ''}`, status: r.status };
42
105
  } finally {
43
106
  fs.rmSync(home, { recursive: true, force: true });
@@ -78,6 +141,36 @@ test('the store anchor is FIXED, not brand-derived — that is the whole fix', (
78
141
  assert.ok(!/configHome|configDirName|safeBrand/.test(fn), `sessionStoreDir must not read the brand:\n${fn}`);
79
142
  });
80
143
 
144
+ // ── 1b. The sandbox is really a sandbox ─────────────────────────────────────────────────────
145
+ //
146
+ // These guard the guard (task 1003760). Every test below writes to a session store, so if
147
+ // the isolation is a no-op they write to the DEVELOPER'S store — which is exactly what
148
+ // happened on Windows, where os.homedir() reads USERPROFILE and the original helper set
149
+ // only HOME. A test suite that can delete credentials must prove it cannot before it runs.
150
+
151
+ test('the sandbox actually relocates os.homedir() — on THIS platform', () => {
152
+ const { out, status, home } = inSandbox(`console.log(JSON.stringify({ h: require('node:os').homedir() }));`);
153
+ assert.equal(status, 0, `sandbox child failed: ${out}`);
154
+ const { h } = JSON.parse(out.trim().split('\n').pop());
155
+ assert.equal(h, home,
156
+ 'os.homedir() inside the sandbox must BE the sandbox — otherwise every test here writes to the real session store');
157
+ });
158
+
159
+ test('a session store path resolved inside the sandbox stays inside it', () => {
160
+ const { out, status, home } = inSandbox(`console.log(JSON.stringify({ p: ic.configPath('gds-session.json'), d: lib.sessionStoreDir() }));`);
161
+ assert.equal(status, 0, `sandbox child failed: ${out}`);
162
+ const { p, d } = JSON.parse(out.trim().split('\n').pop());
163
+ assert.ok(p.startsWith(home), `the active session pointer resolved OUTSIDE the sandbox: ${p}`);
164
+ assert.ok(d.startsWith(home), `the session store dir resolved OUTSIDE the sandbox: ${d}`);
165
+ });
166
+
167
+ test('sandboxEnv overrides every home variable a platform might read', () => {
168
+ const env = sandboxEnv(path.join(os.tmpdir(), 'probe-home'));
169
+ assert.equal(env.HOME, path.join(os.tmpdir(), 'probe-home'));
170
+ assert.equal(env.USERPROFILE, env.HOME, 'USERPROFILE is the one Windows reads — the original miss');
171
+ assert.ok('HOMEDRIVE' in env && 'HOMEPATH' in env, 'the legacy Windows fallback pair must be set too');
172
+ });
173
+
81
174
  // ── 2. A login never destroys another instance's session ────────────────────────────────────
82
175
 
83
176
  test('signing into a second instance PRESERVES the first — including one written before the store existed', () => {
@@ -127,13 +220,25 @@ test('re-saving the SAME instance is idempotent and files nothing extra', () =>
127
220
  assert.equal(r.token, 'T2');
128
221
  });
129
222
 
223
+ // POSIX MODE BITS DO NOT EXIST ON WINDOWS, so this assertion could never pass there
224
+ // (task 1003760). NTFS has no owner/group/other triad: Node reports 0666 for any
225
+ // writable file and 0444 for a read-only one, and chmod(0o600) is silently a no-op —
226
+ // measured on win32, both before and after an explicit chmod. Asserting '600' there
227
+ // made the unit suite permanently red on every Windows checkout, which is the exact
228
+ // harm task 1003754 names: a test that always fails is how a genuinely failing suite
229
+ // stops being read. So the mode claim is asserted where the platform can express it,
230
+ // and on Windows the file's EXISTENCE is still asserted — the security property has
231
+ // to be met by NTFS ACLs (the per-user profile directory), not by a mode integer.
130
232
  test('session files are owner-only', () => {
131
233
  const { out } = inSandbox(`
132
234
  await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
133
- const f = fs.statSync(lib.sessionStorePath('https://a.example.com')).mode & 0o777;
235
+ const p = lib.sessionStorePath('https://a.example.com');
236
+ const f = fs.statSync(p).mode & 0o777;
134
237
  const d = fs.statSync(lib.sessionStoreDir()).mode & 0o777;
135
- console.log(JSON.stringify({ file: f.toString(8), dir: d.toString(8) }));`);
238
+ console.log(JSON.stringify({ file: f.toString(8), dir: d.toString(8), exists: fs.existsSync(p) }));`);
136
239
  const r = JSON.parse(out.trim().split('\n').pop());
240
+ assert.equal(r.exists, true, 'the session file must be written wherever we run');
241
+ if (process.platform === 'win32') return; // no POSIX triad to assert — see above
137
242
  assert.equal(r.file, '600', 'a session file holds a bearer token');
138
243
  assert.equal(r.dir, '700');
139
244
  });
@@ -0,0 +1,250 @@
1
+ // tests/session_earnings_mirror_db.mjs
2
+ //
3
+ // task 1003634 — the session earnings mirror, against a REAL Postgres.
4
+ //
5
+ // WHAT IS BEING PROVEN, and why a fake cannot prove it. The defect was never in
6
+ // JavaScript arithmetic; it was in which credit_log rows a WHERE clause can
7
+ // reach. credit_log pays a builder by two different keys — the per-task streams
8
+ // key on `task_id` and leave session_id NULL, the cost-plus session reward
9
+ // (ADR 0054) keys on `session_id` and leaves task_id NULL — and the mirror
10
+ // summed only the first. A stubbed client would answer whatever the stub was
11
+ // written to answer, which is exactly the assumption under test. So the SQL has
12
+ // to meet a real planner over real columns.
13
+ //
14
+ // NON-DESTRUCTIVE BY CONSTRUCTION: every fixture and credit row is written inside
15
+ // ONE transaction that is ALWAYS rolled back. It never TRUNCATEs, never commits,
16
+ // and is safe to point at a dev database holding real rows. (The precedent and
17
+ // the schema-driven insertRow helper are tests/idea_credit_streams_db.mjs's.)
18
+ //
19
+ // Real-DB (self-skips without Postgres; belongs in the INTEGRATION set).
20
+ //
21
+ // Run: DATABASE_URL=postgres://... node tests/session_earnings_mirror_db.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const { Pool } = require('pg');
28
+ const credits = require('../modules/economy/credits.js');
29
+
30
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
31
+
32
+ let passed = 0;
33
+ let failed = 0;
34
+ // Each test runs inside its own SAVEPOINT: a failure rolls back to it, so one
35
+ // broken assertion cannot abort the transaction and cascade into every test
36
+ // after it.
37
+ let client;
38
+ async function test(name, fn) {
39
+ await client.query('SAVEPOINT t');
40
+ try {
41
+ await fn();
42
+ passed++; console.log(` ok ${name}`);
43
+ await client.query('RELEASE SAVEPOINT t');
44
+ } catch (err) {
45
+ failed++; console.error(` FAIL ${name}\n ${err.message}`);
46
+ await client.query('ROLLBACK TO SAVEPOINT t');
47
+ }
48
+ }
49
+
50
+ const schemaCache = new Map();
51
+ async function columnsOf(client, table) {
52
+ if (schemaCache.has(table)) return schemaCache.get(table);
53
+ const { rows } = await client.query(
54
+ `SELECT column_name, data_type, is_nullable, column_default, is_identity
55
+ FROM information_schema.columns
56
+ WHERE table_schema = 'public' AND table_name = $1`,
57
+ [table]
58
+ );
59
+ schemaCache.set(table, rows);
60
+ return rows;
61
+ }
62
+
63
+ function placeholderFor(dataType, columnName) {
64
+ switch (dataType) {
65
+ case 'integer': case 'bigint': case 'smallint':
66
+ case 'numeric': case 'real': case 'double precision':
67
+ return 0;
68
+ case 'boolean': return false;
69
+ case 'timestamp with time zone': case 'timestamp without time zone': case 'date':
70
+ return new Date();
71
+ case 'json': case 'jsonb': return {};
72
+ case 'ARRAY': return [];
73
+ default: return `sem-${columnName}`;
74
+ }
75
+ }
76
+
77
+ // Identifiers are interpolated because SQL cannot parameterise them. Both are
78
+ // trusted by construction: `table` is a literal from this file, and the column
79
+ // names come from information_schema for that table — never from input.
80
+ async function insertRow(client, table, explicit = {}) {
81
+ const cols = await columnsOf(client, table);
82
+ if (cols.length === 0) throw new Error(`table ${table} not found — is this database migrated?`);
83
+ const values = { ...explicit };
84
+ for (const c of cols) {
85
+ if (c.column_name in values) continue;
86
+ if (c.is_nullable === 'YES') continue;
87
+ if (c.column_default !== null) continue;
88
+ if (c.is_identity === 'YES') continue;
89
+ values[c.column_name] = placeholderFor(c.data_type, c.column_name);
90
+ }
91
+ const names = Object.keys(values);
92
+ const params = names.map((_, i) => `$${i + 1}`);
93
+ const { rows } = await client.query(
94
+ `INSERT INTO ${table} (${names.map((n) => `"${n}"`).join(', ')})
95
+ VALUES (${params.join(', ')}) RETURNING *`,
96
+ names.map((n) => values[n])
97
+ );
98
+ return rows[0];
99
+ }
100
+
101
+ // The query the mirror used BEFORE this fix — sumCreditsForTasks's predicate,
102
+ // inlined so the bug itself is asserted rather than described. It cannot use
103
+ // credits.sumCreditsForTasks() directly: that reads the module's own pool and so
104
+ // cannot see this transaction's uncommitted rows.
105
+ async function taskKeyedSumOnly(client, builderId, taskIds) {
106
+ const { rows } = await client.query(
107
+ `SELECT COALESCE(SUM(delta), 0)::int AS total
108
+ FROM credit_log WHERE builder_id = $1 AND task_id = ANY($2::bigint[])`,
109
+ [builderId, taskIds]
110
+ );
111
+ return rows[0].total;
112
+ }
113
+
114
+ // One builder, one shipped task, one session_record linking them.
115
+ async function fixture(client) {
116
+ const tag = `sem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
117
+ const builder = await insertRow(client, 'builders', {
118
+ github_id: `${tag}-gh`,
119
+ github_login: tag,
120
+ display_name: tag,
121
+ total_credits: 0,
122
+ });
123
+ const version = await insertRow(client, 'versions', { id: `${tag}-v`, status: 'building' });
124
+ const task = await insertRow(client, 'tasks', {
125
+ title: `${tag} task`,
126
+ status: 'shipped',
127
+ version_id: version.id,
128
+ });
129
+ const sessionId = `${tag}-session`;
130
+ await insertRow(client, 'session_records', {
131
+ session_id: sessionId,
132
+ builder_id: builder.id,
133
+ task_ids: [task.id],
134
+ drachmae_earned: 0,
135
+ total_tokens: 7823777,
136
+ });
137
+ return { builder, task, sessionId };
138
+ }
139
+
140
+ // Book a cost-plus session reward the way credits.js does: keyed on session_id,
141
+ // with task_id deliberately NULL.
142
+ function sessionReward(client, builderId, sessionId, delta, basis) {
143
+ return client.query(
144
+ `INSERT INTO credit_log (builder_id, task_id, delta, reason, description, session_id, reward_basis)
145
+ VALUES ($1, NULL, $2, 'session.token_reward', $3, $4, $5)`,
146
+ [builderId, delta, `$${delta} test`, sessionId, basis]
147
+ );
148
+ }
149
+
150
+ async function main() {
151
+ try {
152
+ client = await pool.connect();
153
+ } catch {
154
+ console.log('session_earnings_mirror_db: no Postgres (DATABASE_URL unset/unreachable) — skipping');
155
+ process.exit(0);
156
+ }
157
+
158
+ await client.query('BEGIN');
159
+ try {
160
+ // 1. THE BUG, executable. A session paid purely on cost-plus reads as zero
161
+ // through the old predicate — the exact state a builder reported as
162
+ // missing pay while the ledger showed them paid.
163
+ await test('the task-keyed sum alone cannot see a cost-plus session reward (the bug)', async () => {
164
+ const { builder, task, sessionId } = await fixture(client);
165
+ await sessionReward(client, builder.id, sessionId, 7, 581);
166
+
167
+ assert.equal(await taskKeyedSumOnly(client, builder.id, [task.id]), 0,
168
+ 'the old predicate should report 0 — this is the defect being fixed');
169
+ assert.equal(
170
+ await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 7,
171
+ 'the two-key sum must find the 7 drachmae actually paid');
172
+ });
173
+
174
+ // 2. Neither key may be lost when both streams pay the same session.
175
+ await test('both attribution keys are summed together', async () => {
176
+ const { builder, task, sessionId } = await fixture(client);
177
+ await sessionReward(client, builder.id, sessionId, 7, 581);
178
+ await client.query(
179
+ `INSERT INTO credit_log (builder_id, task_id, delta, reason, session_id)
180
+ VALUES ($1, $2, 60, 'task.ship', NULL)`,
181
+ [builder.id, task.id]
182
+ );
183
+ assert.equal(
184
+ await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 67,
185
+ 'a session paid on both streams must total both');
186
+ });
187
+
188
+ // 3. The OR is one predicate over one scan. A row carrying BOTH keys must be
189
+ // summed once — if it were summed per-key, every fix would over-report.
190
+ await test('a row carrying both keys is counted once, not twice', async () => {
191
+ const { builder, task, sessionId } = await fixture(client);
192
+ await client.query(
193
+ `INSERT INTO credit_log (builder_id, task_id, delta, reason, session_id)
194
+ VALUES ($1, $2, 5, 'idea.credit.task_ship:1:2', $3)`,
195
+ [builder.id, task.id, sessionId]
196
+ );
197
+ assert.equal(
198
+ await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 5,
199
+ 'the doubly-keyed row must contribute 5, not 10');
200
+ });
201
+
202
+ // 4. The figure is SET from ledger state, so re-deriving is a no-op. This is
203
+ // what lets every pay-on-land trigger call the sync and lets a re-attempt
204
+ // repair a stale row instead of inflating a correct one.
205
+ await test('re-deriving is idempotent across a top-up', async () => {
206
+ const { builder, task, sessionId } = await fixture(client);
207
+ await sessionReward(client, builder.id, sessionId, 18, 1496);
208
+ const first = await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId);
209
+ const again = await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId);
210
+ assert.equal(first, 18);
211
+ assert.equal(again, 18, 'the same ledger state must yield the same figure');
212
+
213
+ // A second ship in the same session tops the reward up at a higher basis.
214
+ await sessionReward(client, builder.id, sessionId, 35, 4403);
215
+ assert.equal(
216
+ await credits.sumSessionEarnings(client, builder.id, [task.id], sessionId), 53,
217
+ 'a top-up must be reflected in full, not added to a stale total');
218
+ });
219
+
220
+ // 5. Degenerate inputs must not become an unfiltered scan of the ledger.
221
+ await test('no keys means zero, never an unscoped sum', async () => {
222
+ const { builder, sessionId } = await fixture(client);
223
+ await sessionReward(client, builder.id, sessionId, 9, 750);
224
+ assert.equal(await credits.sumSessionEarnings(client, builder.id, [], null), 0);
225
+ assert.equal(await credits.sumSessionEarnings(client, builder.id, null, null), 0);
226
+ // A session key alone is enough — the cost-plus-only case.
227
+ assert.equal(await credits.sumSessionEarnings(client, builder.id, [], sessionId), 9);
228
+ });
229
+
230
+ // 6. Another builder's rows are never reachable, whichever key matches.
231
+ await test('the sum stays scoped to one builder', async () => {
232
+ const a = await fixture(client);
233
+ const b = await fixture(client);
234
+ await sessionReward(client, a.builder.id, a.sessionId, 7, 581);
235
+ await sessionReward(client, b.builder.id, b.sessionId, 99, 8250);
236
+ assert.equal(
237
+ await credits.sumSessionEarnings(client, a.builder.id, [a.task.id, b.task.id], a.sessionId), 7,
238
+ "another builder's credits must not leak in via a shared task id");
239
+ });
240
+ } finally {
241
+ await client.query('ROLLBACK');
242
+ client.release();
243
+ await pool.end();
244
+ }
245
+
246
+ console.log(`\nsession_earnings_mirror_db: ${passed} passed, ${failed} failed`);
247
+ process.exit(failed === 0 ? 0 : 1);
248
+ }
249
+
250
+ main().catch((err) => { console.error(err); process.exit(1); });