@bongos/core 1.19.665 → 1.19.667

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.
@@ -44,6 +44,9 @@
44
44
  // 0 when a task was selected (a directive was emitted), 1 on a NO-GO / no work.
45
45
 
46
46
  const { execFileSync } = require('node:child_process');
47
+ // basename(cwd) is the worktree name a claim is bound to (task 1003834) — the
48
+ // same value claim.js records via --worktree.
49
+ const path = require('node:path');
47
50
  const { cliClient } = require('./cli-lib');
48
51
  const grader = require('../../modules/grading/grader');
49
52
  // Phase 4.5: reuse the $0 deterministic autonomy precheck (the same gate the
@@ -195,11 +198,58 @@ function diffStatsSince(base, untracked) {
195
198
  };
196
199
  }
197
200
 
201
+ // THE WORKTREE IS THE ANSWER, when there is one (task 1003834).
202
+ //
203
+ // A claim BINDS to a dedicated worktree — one claim per session, one worktree
204
+ // per claim (task 1642, ADR 0097) — and `claim.js --worktree` records that
205
+ // worktree's leaf-folder name on the claim row, which is exactly
206
+ // `path.basename(process.cwd())`. So when this dispatch runs inside a claimed
207
+ // worktree there is no inference to do: that worktree's claim IS the task being
208
+ // worked on. Everything below it is a heuristic; this is a fact.
209
+ //
210
+ // Claims from before the column existed carry a null worktree_name and are
211
+ // simply not matched here — the heuristic still covers them.
212
+ function claimForWorktree(claims, worktreeName) {
213
+ if (!Array.isArray(claims) || !worktreeName) return null;
214
+ return claims.find((c) => c && c.worktree_name && c.worktree_name === worktreeName) || null;
215
+ }
216
+
198
217
  // Pick the active claim whose touches[] best overlap the changed files — the
199
218
  // claim the current diff most plausibly belongs to. With multiple (possibly
200
219
  // stale) claims, blindly taking claims[0] grades the diff against the wrong
201
220
  // task: the Phase 4 dogfood's Narc FAILed because it graded Conductor code
202
221
  // against an unrelated stale offsite-backup claim. Best-overlap fixes that.
222
+ //
223
+ // SPECIFICITY, NOT VOLUME (task 1003834). A raw count of matched files rewards
224
+ // BREADTH, so the more honestly a claim declares its touches[] the more likely
225
+ // it loses. Observed 2026-09-10: reviewing task 1003829's diff, its own claim
226
+ // declared three exact files (`modules/hall-ui/public/studio.{js,html,css}`) and
227
+ // could therefore score at most 3, while a concurrent rename claim declaring
228
+ // `scripts/gds/`, `src/bongos/`, `modules/`, `clients/` matched most of the
229
+ // same diff and won — so the reviewer was briefed on an unrelated task. A
230
+ // pattern like `modules/` covers almost any diff in this repo and must not
231
+ // out-vote an exact path.
232
+ //
233
+ // So each matched file is worth the SPECIFICITY of the pattern that matched it:
234
+ // its path-segment depth, plus a bonus when the pattern is the file itself.
235
+ // Volume still counts — a genuinely broad claim matching fifty files beats a
236
+ // narrow one sharing a single file — but depth breaks the tie the right way.
237
+ function patternWeight(pattern, file) {
238
+ const p = String(pattern || '');
239
+ const depth = p.replace(/\/+$/, '').split('/').filter(Boolean).length;
240
+ return depth + (p === file ? EXACT_MATCH_BONUS : 0);
241
+ }
242
+ const EXACT_MATCH_BONUS = 1;
243
+
244
+ function claimOverlapScore(claim, files) {
245
+ let score = 0;
246
+ for (const f of files) {
247
+ const hit = matchOne(f, (claim && claim.touches) || []);
248
+ if (hit !== null) score += patternWeight(hit, f);
249
+ }
250
+ return score;
251
+ }
252
+
203
253
  function bestMatchingClaim(claims, changedFiles) {
204
254
  if (!Array.isArray(claims) || claims.length === 0) return null;
205
255
  const files = Array.isArray(changedFiles) ? changedFiles : [];
@@ -207,12 +257,29 @@ function bestMatchingClaim(claims, changedFiles) {
207
257
  let bestScore = -1;
208
258
  for (const c of claims) {
209
259
  // Use the canonical matcher; ties keep the earlier claim (strict >).
210
- const score = files.filter((f) => matchOne(f, c.touches || []) !== null).length;
260
+ const score = claimOverlapScore(c, files);
211
261
  if (score > bestScore) { bestScore = score; best = c; }
212
262
  }
213
263
  return best;
214
264
  }
215
265
 
266
+ // The claim this diff belongs to, and HOW that was decided — because the two
267
+ // answers deserve very different trust. 'worktree' is a fact; 'overlap' is a
268
+ // guess, and a guess that must be visible so a reader can discount a review
269
+ // that argues from the brief rather than from the diff.
270
+ //
271
+ // A claim with an EMPTY touches[] scores zero against any diff, which is most
272
+ // tasks in this repo — another reason the worktree signal leads rather than
273
+ // merely breaking ties.
274
+ function attributeClaim(claims, changedFiles, worktreeName) {
275
+ const byWorktree = claimForWorktree(claims, worktreeName);
276
+ if (byWorktree) return { claim: byWorktree, signal: 'worktree', worktree: worktreeName };
277
+ const byOverlap = bestMatchingClaim(claims, changedFiles);
278
+ if (!byOverlap) return { claim: null, signal: 'none', worktree: worktreeName || null };
279
+ const score = claimOverlapScore(byOverlap, Array.isArray(changedFiles) ? changedFiles : []);
280
+ return { claim: byOverlap, signal: score > 0 ? 'overlap' : 'fallback', worktree: worktreeName || null };
281
+ }
282
+
216
283
  // --- task context (best-effort) ---
217
284
  // Workers grade a diff against a task. For a mid-flight review we use the
218
285
  // best-matching active claim's task; otherwise we synthesize a minimal
@@ -224,7 +291,12 @@ async function resolveTaskContext(changedFiles) {
224
291
  if (me.ok) {
225
292
  const claims = me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []);
226
293
  if (claims.length > 0) {
227
- const c = bestMatchingClaim(claims, changedFiles);
294
+ // The worktree this dispatch was invoked in decides it when it can
295
+ // (task 1003834); the touches[] heuristic is the fallback, and which
296
+ // one answered is reported to the reader rather than hidden.
297
+ const attribution = attributeClaim(claims, changedFiles, path.basename(process.cwd()));
298
+ const c = attribution.claim;
299
+ if (!c) throw new Error('no claim attributed');
228
300
  // Pull the full description for richer context (best-effort). Coerce
229
301
  // task_id to an integer before interpolating it into the URL path —
230
302
  // it's a trusted internal value, but a guard costs nothing and closes
@@ -243,6 +315,11 @@ async function resolveTaskContext(changedFiles) {
243
315
  description: description || '(no description fetched — mid-flight review; judge the diff on its own merits)',
244
316
  kind: 'unclassified',
245
317
  version_id: c.version_id,
318
+ // How this task was attributed to this diff, carried so the header can
319
+ // say it (task 1003834). 'worktree' is a fact; anything else is a
320
+ // guess the reader should weigh.
321
+ attribution: attribution.signal,
322
+ claim_count: claims.length,
246
323
  };
247
324
  }
248
325
  }
@@ -257,6 +334,22 @@ async function resolveTaskContext(changedFiles) {
257
334
 
258
335
  // --- rendering ---
259
336
 
337
+ // Say how the task context was chosen, but only when saying it changes what a
338
+ // reader should do (task 1003834). Attribution by WORKTREE is certain, and a
339
+ // single claim leaves nothing to confuse, so both stay silent. A guess made
340
+ // while several claims were open is the case that misled a reader before, and
341
+ // it says so out loud.
342
+ function attributionNote(task) {
343
+ if (!task || !task.id) return '';
344
+ const n = Number(task.claim_count) || 0;
345
+ if (task.attribution === 'worktree' || n < 2) return '';
346
+ const how = task.attribution === 'overlap'
347
+ ? 'guessed from touches[] overlap'
348
+ : 'no signal at all — first claim taken';
349
+ return `\n ⚠ attribution: ${how}, across ${n} open claims. This worktree is bound to no claim, so the brief`
350
+ + '\n may belong to different work: weigh findings about the FILES, discount findings about intent.';
351
+ }
352
+
260
353
  function renderWorker(w) {
261
354
  const lines = [];
262
355
  const status = w.error ? `ERROR (${w.error})` : w.verdict.toUpperCase();
@@ -382,7 +475,7 @@ async function main() {
382
475
  const routeRank = grader.routeRankPrePass(files);
383
476
 
384
477
  console.log(`🎼 Conductor dispatch → ${kinds.join(', ')} (model=${model}, ${files.length} changed file(s), base=${base.slice(0, 8)})`);
385
- console.log(` task context: ${task.id ? '#' + task.id + ' ' + task.title : task.title}`);
478
+ console.log(` task context: ${task.id ? '#' + task.id + ' ' + task.title : task.title}${attributionNote(task)}`);
386
479
  if (routeRank) console.log(' + deterministic route-rank check (route file in diff)');
387
480
  console.log('');
388
481
 
@@ -423,4 +516,9 @@ if (require.main === module) {
423
516
  });
424
517
  }
425
518
 
426
- module.exports = { pickSpecialists, bestMatchingClaim, selectAutonomousDispatch, autoDispatch };
519
+ module.exports = {
520
+ pickSpecialists, bestMatchingClaim, selectAutonomousDispatch, autoDispatch,
521
+ // task 1003834 — the attribution layer, exported so its decisions are pinned
522
+ // rather than only observed through a dispatch run.
523
+ claimForWorktree, claimOverlapScore, attributeClaim, attributionNote,
524
+ };
@@ -5,7 +5,7 @@
5
5
  // The art pipeline (modules/art-pipeline/pipeline/gen_api.py) runs LOCALLY and can't call the API
6
6
  // per generation, so SessionStart runs this once: GET /api/gds/me/art-key and,
7
7
  // based on the builder's live rank, write or remove the `shared_gemini_api_key`
8
- // field in ~/.config/otb/gds-session.json. gen_api.py reads that field as a
8
+ // field in ~/.config/otb/bongos-session.json. gen_api.py reads that field as a
9
9
  // fallback BELOW the builder's own key — so own key always wins, and a newcomer
10
10
  // with no own key falls back to the shared one we pay for.
11
11
  //
@@ -254,7 +254,7 @@ function buildBrandingConfig(spec) {
254
254
  // Every instance gets its OWN session/config dir (task 1002626). Without
255
255
  // this, instance-config.js falls back to the shared 'cloudbongos' dir, so
256
256
  // every un-branded instance on a machine reads/writes the SAME
257
- // ~/.config/cloudbongos/gds-session.json — signing in to one silently
257
+ // ~/.config/cloudbongos/bongos-session.json — signing in to one silently
258
258
  // evicts the CLI session of every other, including the hub. Same slug
259
259
  // family as the package name + provisioning slug.
260
260
  configDir: spec.configDir ? String(spec.configDir).trim() : slugify(productName, 'instance'),
@@ -2,7 +2,7 @@
2
2
  // scripts/gds/paste-token.js — V3.R91 / #401.
3
3
  //
4
4
  // Accept a CLI bearer minted by the builders' hall (POST /api/gds/auth/cli-token/issue)
5
- // and write it into ~/.config/otb/gds-session.json. Preserves any existing
5
+ // and write it into ~/.config/otb/bongos-session.json. Preserves any existing
6
6
  // fields (notably gemini_api_key registered via /builder-setup) so a token
7
7
  // paste never silently un-registers a builder's Gemini key.
8
8
  //
@@ -24,10 +24,13 @@
24
24
  const fs = require('node:fs');
25
25
  const path = require('node:path');
26
26
  const ic = require('../../src/instance-config');
27
+ // Destructured for the same reason as cli-lib: a namespace-only `ic.X` read is
28
+ // invisible to knip, which then calls the export dead code.
29
+ const { SESSION_FILENAMES, readSessionConfigSync } = require('../../src/instance-config');
27
30
 
28
31
  // WRITE the session to the configured instance dir (OTB → ~/.config/otb,
29
32
  // unchanged; a fresh instance → its neutral dir). R61 / task 1200.
30
- const SESSION_PATH = ic.configPath('gds-session.json');
33
+ const SESSION_PATH = ic.configPath(SESSION_FILENAMES[0]);
31
34
  // env → the EXISTING session's api_base → this instance's branding default.
32
35
  // On a RE-auth a session file already exists, and its api_base is the best signal
33
36
  // of which instance the builder is on — this file used to hardcode the founder
@@ -91,9 +94,11 @@ async function main() {
91
94
  // way, treat as a fresh write.
92
95
  let existing = {};
93
96
  try {
94
- // Read across config dirs (configured + legacy otb) so a session written the
95
- // old way still has its fields preserved on the next paste.
96
- const raw = ic.readConfigFileSync('gds-session.json');
97
+ // Read across config dirs (configured + legacy otb) AND across every accepted
98
+ // filename, so a session written the old way still has its fields preserved on
99
+ // the next paste. This is the re-auth path: a miss here does not fail loudly,
100
+ // it silently drops the builder's gemini_api_key (task 1003704).
101
+ const raw = readSessionConfigSync();
97
102
  if (raw) existing = JSON.parse(raw);
98
103
  } catch (_) {
99
104
  /* fresh session; nothing to merge */
@@ -95,7 +95,7 @@ if [ -z "$REMOTE" ]; then bad "could not resolve origin remote for fresh-clone t
95
95
  # --- 2. the fresh clone leaks NO secrets / NO builder memory ------------
96
96
  LEAKS=0
97
97
  CFG_DIR=$(gds_node "process.stdout.write(require('../../src/instance-config').configDirName())" 2>/dev/null) || CFG_DIR=""
98
- for p in ".env.local" "spaces.env" ".config/${CFG_DIR:-otb}/gds-session.json" "etc/${CFG_DIR:-otb}"; do
98
+ for p in ".env.local" "spaces.env" ".config/${CFG_DIR:-otb}/bongos-session.json" ".config/${CFG_DIR:-otb}/gds-session.json" "etc/${CFG_DIR:-otb}"; do
99
99
  if [ -e "$TMP/clone/$p" ]; then echo " leak: $p present in clone"; LEAKS=$((LEAKS+1)); fi
100
100
  done
101
101
  # builder_memory is server-side only — no memory dir should ship in-repo
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // Prints the user_code and verification_uri, then polls until the user
16
16
  // completes auth (or expires/denies). On success, writes
17
- // ~/.config/otb/gds-session.json (resolved from os.homedir(), so this works for
17
+ // ~/.config/otb/bongos-session.json (resolved from os.homedir(), so this works for
18
18
  // any user on any machine; no path is hardcoded to a particular developer's
19
19
  // home directory) and prints the builder profile.
20
20
 
@@ -49,7 +49,7 @@ function dryRunStep(msg) {
49
49
  // pixel-art pipeline (gen_api.py). V3.R11 (#91): each builder pays their own
50
50
  // Gemini bill, so spend is attributable per-builder on the public dashboard.
51
51
  //
52
- // Stored as gemini_api_key in ~/.config/otb/gds-session.json (mode 600, same
52
+ // Stored as gemini_api_key in ~/.config/otb/bongos-session.json (mode 600, same
53
53
  // file as the GitHub session token). gen_api.py reads this location before
54
54
  // falling back to ~/.config/otb/env or .env.local.
55
55
  //
@@ -476,7 +476,7 @@ async function main() {
476
476
 
477
477
  // A valid session already exists on this machine. Don't silently adopt
478
478
  // it. The session file is a single global path
479
- // (~/.config/otb/gds-session.json), so a second person setting up on a
479
+ // (~/.config/otb/bongos-session.json), so a second person setting up on a
480
480
  // shared device — or the same person onboarding a different account,
481
481
  // e.g. for testing — would otherwise be invisibly bound to whoever set
482
482
  // up first. On an interactive shell, confirm before continuing; the gate
@@ -513,7 +513,7 @@ async function main() {
513
513
  console.log('');
514
514
  console.log('Setting up as a different builder — the saved session will be replaced once you finish signing in...');
515
515
  // Fall through to the Device Flow below, which calls saveSession() and
516
- // overwrites ~/.config/otb/gds-session.json with the new identity.
516
+ // overwrites ~/.config/otb/bongos-session.json with the new identity.
517
517
  } else {
518
518
  console.log(`Continuing as ${who}.`);
519
519
  process.exit(0);
@@ -2,7 +2,8 @@
2
2
  # GDS write-path smoke test (production). Renamed from PMS in V3.R01.
3
3
  #
4
4
  # Exercises the full claim → ship round-trip against the live API:
5
- # 1. Read session token from ~/.config/otb/gds-session.json (or legacy pms-session.json)
5
+ # 1. Read session token from ~/.config/otb/bongos-session.json (or the legacy
6
+ # gds-session.json / pms-session.json names, all still accepted on read)
6
7
  # 2. POST /api/gds/tasks → create a synthetic task titled '__smoke__ <ts>'
7
8
  # 3. POST /api/gds/claims → claim it
8
9
  # 4. POST /api/gds/claims/:id/resolve outcome=shipped
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // scripts/hall-preview/refresh-fixtures.js — snapshot real API responses into
3
3
  // the preview harness's fixtures/ (task 2167). Run with a valid GDS CLI session
4
- // (~/.config/otb/gds-session.json) whenever the fixtures drift from the live
4
+ // (~/.config/otb/bongos-session.json) whenever the fixtures drift from the live
5
5
  // API shapes: node scripts/hall-preview/refresh-fixtures.js
6
6
  //
7
7
  // Read-only: GETs only. Endpoints that 403/404 for the caller's rank are
@@ -62,6 +62,15 @@ const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, di
62
62
 
63
63
  const MAX_AUTH_TOKEN_CANDIDATES = 6;
64
64
 
65
+ // Every name the session cookie has ever had, NEWEST FIRST. A rename APPENDS to
66
+ // this list, it never replaces: at the instant the new code deploys, every
67
+ // signed-in browser is still sending the OLD name, so dropping a name signs that
68
+ // builder out mid-session. `pms_session` predates the gds_ rename and is still
69
+ // in the wild, so the list is three long, not two (task 1003704). The server
70
+ // SETS only the head; routes/auth.js clears the tail on the next write. Part 4
71
+ // (task 1003706) retires the tail behind a test of its absence.
72
+ const SESSION_COOKIE_NAMES = Object.freeze(['bongos_session', 'gds_session', 'pms_session']);
73
+
65
74
  function extractTokens(req) {
66
75
  const out = [];
67
76
  const push = (t) => {
@@ -70,18 +79,30 @@ function extractTokens(req) {
70
79
  const auth = req.headers['authorization'] || '';
71
80
  const m = /^Bearer\s+([a-zA-Z0-9]+)$/.exec(auth);
72
81
  if (m) push(m[1]);
73
- // Cookie parsing: minimal, no dep. Prefer new name, fall back to legacy.
82
+ // Cookie parsing: minimal, no dep. Group by NAME, then emit in
83
+ // SESSION_COOKIE_NAMES order, so a browser carrying both a fresh
84
+ // `bongos_session` and a stale `gds_session` tries the fresh name first
85
+ // instead of depending on header order.
86
+ //
87
+ // EVERY value under a name is kept, in header order — never just the first.
88
+ // One name can legitimately appear TWICE: a stale host-only cookie plus the
89
+ // current apex-scoped one (#851, #726/#734). Returning only the first let the
90
+ // stale duplicate shadow the live session and 401 the builder, so the grouping
91
+ // must be name → LIST. push() dedupes identical values and caps the total.
74
92
  const cookieHeader = req.headers['cookie'] || '';
75
- const legacy = [];
93
+ const found = new Map();
76
94
  for (const part of cookieHeader.split(';')) {
77
95
  const idx = part.indexOf('=');
78
96
  if (idx < 0) continue;
79
97
  const k = part.slice(0, idx).trim();
80
98
  const v = part.slice(idx + 1).trim();
81
- if (k === 'gds_session' && v) push(v);
82
- else if (k === 'pms_session' && v) legacy.push(v);
99
+ if (!v || !SESSION_COOKIE_NAMES.includes(k)) continue;
100
+ if (!found.has(k)) found.set(k, []);
101
+ found.get(k).push(v);
102
+ }
103
+ for (const name of SESSION_COOKIE_NAMES) {
104
+ for (const v of found.get(name) || []) push(v);
83
105
  }
84
- for (const v of legacy) push(v);
85
106
  return out;
86
107
  }
87
108
 
@@ -887,6 +908,7 @@ module.exports = {
887
908
  completeDeviceExchange,
888
909
  membershipKind,
889
910
  extractTokens,
911
+ SESSION_COOKIE_NAMES,
890
912
  resolveSession,
891
913
  allowBoxScope,
892
914
  sessionScope,
@@ -122,6 +122,19 @@ function pruneAndCount(bucket, now, windowMs) {
122
122
  return bucket.length;
123
123
  }
124
124
 
125
+ // Every name the session cookie has ever had, newest first, precompiled.
126
+ //
127
+ // One regex PER NAME rather than a single alternation: an alternation matches in
128
+ // COOKIE-HEADER order, not preference order, so a browser mid-rename — carrying
129
+ // both the new and the old name — would key on whichever the browser happened to
130
+ // send first. Precompiled at module load because this runs on every request.
131
+ //
132
+ // Deliberately duplicated from SESSION_COOKIE_NAMES in src/bongos/auth.js rather
133
+ // than imported: this middleware is dependency-free by design. The copy is pinned
134
+ // against the real list by tests/session_rename_fallback.mjs (task 1003704).
135
+ const SESSION_COOKIE_RES = ['bongos_session', 'gds_session', 'pms_session']
136
+ .map((n) => new RegExp('(?:^|;\\s*)' + n + '=([^;]+)'));
137
+
125
138
  function extractSessionKey(req) {
126
139
  // Bearer token first (CLI flow); cookie second (web flow). We use the
127
140
  // token AS-IS as the limiter key — it's already opaque + cheap to hash to
@@ -132,8 +145,10 @@ function extractSessionKey(req) {
132
145
  }
133
146
  const cookie = req.headers && req.headers.cookie;
134
147
  if (cookie) {
135
- const m = /(?:^|;\s*)(?:gds_session|pms_session)=([^;]+)/.exec(cookie);
136
- if (m) return 'c:' + m[1];
148
+ for (const re of SESSION_COOKIE_RES) {
149
+ const m = re.exec(cookie);
150
+ if (m) return 'c:' + m[1];
151
+ }
137
152
  }
138
153
  return null;
139
154
  }
@@ -204,12 +204,41 @@ function renderAccessPendingPage(login, startHref) {
204
204
  </body></html>`;
205
205
  }
206
206
 
207
- // Cookie names. The web flow writes the new `gds_session` (+ `gds_oauth_state`)
208
- // names; both flows accept either the new or the legacy `pms_session` /
209
- // `pms_oauth_state` cookies on read so a builder mid-flow doesn't have to
210
- // re-auth at the rename rollout.
211
- const SESSION_COOKIE = 'gds_session';
212
- const SESSION_COOKIE_LEGACY = 'pms_session';
207
+ // Cookie names. The web flow writes the new `bongos_session` (+ `gds_oauth_state`)
208
+ // names; both flows accept every older session name on read so a builder
209
+ // mid-flow doesn't have to re-auth at the rename rollout.
210
+ //
211
+ // The session read-list is NOT declared here. It lives in ../auth.js as
212
+ // SESSION_COOKIE_NAMES, because the request-level token extractor there has to
213
+ // accept exactly the names this router sets and clears: two lists would drift,
214
+ // and the only symptom of the drift is a builder being silently signed out.
215
+ // Head = the one name we SET. Tail = the names we accept and clear on write.
216
+ const SESSION_COOKIE = auth.SESSION_COOKIE_NAMES[0];
217
+ const SESSION_COOKIE_OLD = auth.SESSION_COOKIE_NAMES.slice(1);
218
+
219
+ // The session token off a request's cookies, trying each accepted name newest
220
+ // first. Replaces the hand-written `parseCookie(new) || parseCookie(legacy)`
221
+ // pairs, which accepted only two of the three live names.
222
+ function sessionCookieToken(req) {
223
+ for (const name of auth.SESSION_COOKIE_NAMES) {
224
+ const v = parseCookie(req, name);
225
+ if (v) return v;
226
+ }
227
+ return null;
228
+ }
229
+
230
+ // Clear every SUPERSEDED session cookie, in both the host-only and apex-scoped
231
+ // forms (a clearCookie only matches the cookie whose domain attribute it names).
232
+ // Called wherever we set the current cookie, so a browser that arrived with a
233
+ // `gds_session` leaves holding only `bongos_session` — otherwise the stale name
234
+ // lingers until its own expiry and every later request carries two tokens.
235
+ function clearSupersededSessionCookies(req, res) {
236
+ const domainOpts = sessionCookieDomainOpts(req);
237
+ for (const name of SESSION_COOKIE_OLD) {
238
+ res.clearCookie(name);
239
+ if (domainOpts.domain) res.clearCookie(name, domainOpts);
240
+ }
241
+ }
213
242
  const STATE_COOKIE = 'gds_oauth_state';
214
243
  const STATE_COOKIE_LEGACY = 'pms_oauth_state';
215
244
  const DISCORD_STATE_COOKIE = 'gds_discord_state';
@@ -650,6 +679,10 @@ module.exports = function buildAuthRouter() {
650
679
  // amazonprimea.com + builders. + status. (#726); host-only elsewhere.
651
680
  ...sessionCookieDomainOpts(req),
652
681
  });
682
+ // A browser arriving with an older session name must not keep it: two
683
+ // session cookies means every later request carries two candidate tokens,
684
+ // and the stale one outlives this sign-in.
685
+ clearSupersededSessionCookies(req, res);
653
686
  // Persist the wizard's repo token (task 1002099 / ADR 0143): encrypted at rest,
654
687
  // short-TTL, keyed to the builder — the repo picker/creator reads it back over
655
688
  // the next few minutes. Gated on secret-box being configured (upsertGithubToken
@@ -916,19 +949,17 @@ module.exports = function buildAuthRouter() {
916
949
  // rank: any-builder — own session, no rank gate (a Xenos can log themselves out).
917
950
  router.post('/auth/logout', auth.requireBuilder, async (req, res) => {
918
951
  const token = (req.headers['authorization'] || '').replace(/^Bearer\s+/, '')
919
- || parseCookie(req, SESSION_COOKIE)
920
- || parseCookie(req, SESSION_COOKIE_LEGACY);
952
+ || sessionCookieToken(req);
921
953
  if (token) await db.revokeSession(token);
922
954
  // Clear BOTH the host-only form (older sessions) and the apex-scoped form
923
955
  // (#726) — a clearCookie only matches the cookie whose domain attribute it
924
956
  // names, so sign-out on a production host must target the apex domain too.
925
957
  res.clearCookie(SESSION_COOKIE);
926
- res.clearCookie(SESSION_COOKIE_LEGACY);
927
958
  const domainOpts = sessionCookieDomainOpts(req);
928
- if (domainOpts.domain) {
929
- res.clearCookie(SESSION_COOKIE, domainOpts);
930
- res.clearCookie(SESSION_COOKIE_LEGACY, domainOpts);
931
- }
959
+ if (domainOpts.domain) res.clearCookie(SESSION_COOKIE, domainOpts);
960
+ // …and every superseded name, or a sign-out leaves the older cookie behind
961
+ // and the next request re-authenticates on it.
962
+ clearSupersededSessionCookies(req, res);
932
963
  // Federated single-logout (ADR 0144, task 1002238): announce the logout so the
933
964
  // hub (platform-identity, enabled only on cloudbongos.com) can fan a signed
934
965
  // back-channel logout out to every federated project this builder entered. A
@@ -1002,7 +1033,7 @@ module.exports = function buildAuthRouter() {
1002
1033
  // (a leaked CLI token shouldn't be able to extend its own lifetime; the
1003
1034
  // httpOnly browser cookie is the harder-to-steal credential).
1004
1035
  router.post('/auth/cli-token/issue', auth.requireBuilder, async (req, res) => {
1005
- const cookieToken = parseCookie(req, SESSION_COOKIE) || parseCookie(req, SESSION_COOKIE_LEGACY);
1036
+ const cookieToken = sessionCookieToken(req);
1006
1037
  if (!cookieToken) {
1007
1038
  // Name the instance's own sign-in host (from the branding pack), not a
1008
1039
  // hardcoded "amazonprimea.com" (ADR 0062 §3/§5).
@@ -1144,7 +1175,7 @@ module.exports = function buildAuthRouter() {
1144
1175
  // CLI bearer must not be able to approve pairings and mint itself siblings;
1145
1176
  // only the httpOnly browser cookie — the click in the hall — can.
1146
1177
  router.post('/auth/app-pair/approve', auth.requireBuilder, async (req, res) => {
1147
- const cookieToken = parseCookie(req, SESSION_COOKIE) || parseCookie(req, SESSION_COOKIE_LEGACY);
1178
+ const cookieToken = sessionCookieToken(req);
1148
1179
  if (!cookieToken) {
1149
1180
  return res.fail('cookie_required', { status: 403, message: 'Pairings are approved from an authenticated browser session in the builders’ hall, not from a bearer token.' });
1150
1181
  }
@@ -104,7 +104,7 @@ function resolveApiBase({ session, env = process.env, b = safeBrand() } = {}) {
104
104
  let s = session;
105
105
  if (s === undefined) {
106
106
  try {
107
- const raw = readConfigFileSync('gds-session.json', { b });
107
+ const raw = readSessionConfigSync({ b });
108
108
  s = raw ? JSON.parse(raw) : null;
109
109
  } catch (_) {
110
110
  s = null; // unreadable/corrupt session must never break base resolution
@@ -135,8 +135,32 @@ function configPath(name, b = safeBrand()) {
135
135
  return path.join(configHome(b), name);
136
136
  }
137
137
 
138
+ // Every name the CLI session file has ever had, NEWEST FIRST.
139
+ //
140
+ // Lives here, not in cli-lib.js which owns the session STORE, because this module
141
+ // is the lowest one that has to read the file (resolveApiBase below) and cli-lib
142
+ // imports this — so this is the only place both can share. A rename APPENDS a
143
+ // name; dropping one signs out every builder machine and every live dev box at
144
+ // once. Part 4 (task 1003706) retires the tail. (task 1003704)
145
+ const SESSION_FILENAMES = Object.freeze([
146
+ 'bongos-session.json',
147
+ 'gds-session.json',
148
+ 'pms-session.json',
149
+ ]);
150
+
151
+ // The first readable session file across every config dir and every accepted
152
+ // name, or null. FILENAME-major: the newest name in ANY read dir beats an older
153
+ // name in the configured dir, which is the precedence a rename wants.
154
+ function readSessionConfigSync({ b = safeBrand() } = {}) {
155
+ for (const name of SESSION_FILENAMES) {
156
+ const raw = readConfigFileSync(name, { b });
157
+ if (raw) return raw;
158
+ }
159
+ return null;
160
+ }
161
+
138
162
  // Every candidate path for a named config file, configured dir first then legacy
139
- // dirs — for read-with-fallback (the gds-session.json / cost_ledger pattern).
163
+ // dirs — for read-with-fallback (the session-file / cost_ledger pattern).
140
164
  function configReadPaths(name, b = safeBrand()) {
141
165
  return configReadDirs(b).map((d) => path.join(d, name));
142
166
  }
@@ -298,6 +322,8 @@ module.exports = {
298
322
  configPath,
299
323
  configReadPaths,
300
324
  readConfigFileSync,
325
+ SESSION_FILENAMES,
326
+ readSessionConfigSync,
301
327
  resolveEnv,
302
328
  resolveCoreRoot,
303
329
  resolveInstanceRoot,
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.665'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.667'; // 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');
@@ -10,7 +10,8 @@
10
10
  // • in-repo the session landed in ~/.config/<slug>/ where the standalone CLI could never find it.
11
11
  //
12
12
  // The store is keyed by instance host at a FIXED anchor, so the same instance resolves to the same
13
- // file from either direction. The per-brand gds-session.json stays the ACTIVE pointer, untouched,
13
+ // file from either direction. The per-brand session file (SESSION_FILENAMES[0]) stays the ACTIVE
14
+ // pointer, untouched,
14
15
  // so every existing reader behaves identically — that back-compat is asserted here too.
15
16
 
16
17
  import test from 'node:test';
@@ -157,7 +158,7 @@ test('the sandbox actually relocates os.homedir() — on THIS platform', () => {
157
158
  });
158
159
 
159
160
  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
+ const { out, status, home } = inSandbox(`console.log(JSON.stringify({ p: ic.configPath(ic.SESSION_FILENAMES[0]), d: lib.sessionStoreDir() }));`);
161
162
  assert.equal(status, 0, `sandbox child failed: ${out}`);
162
163
  const { p, d } = JSON.parse(out.trim().split('\n').pop());
163
164
  assert.ok(p.startsWith(home), `the active session pointer resolved OUTSIDE the sandbox: ${p}`);
@@ -176,7 +177,7 @@ test('sandboxEnv overrides every home variable a platform might read', () => {
176
177
  test('signing into a second instance PRESERVES the first — including one written before the store existed', () => {
177
178
  const { out } = inSandbox(`
178
179
  // A session from before this feature: an active pointer, nothing in the store.
179
- const active = ic.configPath('gds-session.json');
180
+ const active = ic.configPath(ic.SESSION_FILENAMES[0]);
180
181
  fs.mkdirSync(path.dirname(active), { recursive: true });
181
182
  fs.writeFileSync(active, JSON.stringify({ token:'TOKEN-A', api_base:'https://a.example.com',
182
183
  builder:{github_login:'someone'} }), { mode: 0o600 });
@@ -200,11 +201,11 @@ test('signing into a second instance PRESERVES the first — including one writt
200
201
  });
201
202
 
202
203
  test('the ACTIVE pointer still lands exactly where every existing reader looks', () => {
203
- // In-repo skills, hooks and the dev box all read gds-session.json in the configured dir. The
204
+ // In-repo skills, hooks and the dev box all read the active session file in the configured dir. The
204
205
  // store is additive; if this moved, every one of them would silently stop finding a session.
205
206
  const { out } = inSandbox(`
206
207
  await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
207
- console.log(JSON.stringify({ wroteActive: fs.existsSync(ic.configPath('gds-session.json')) }));`);
208
+ console.log(JSON.stringify({ wroteActive: fs.existsSync(ic.configPath(ic.SESSION_FILENAMES[0])) }));`);
208
209
  assert.equal(JSON.parse(out.trim().split('\n').pop()).wroteActive, true);
209
210
  });
210
211