@bongos/core 1.19.618 → 1.19.619

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.618",
6
- "core_contract": "1.19.618",
7
- "source_commit": "1e344743e60d4921a3e5d73b32817388928e62a0",
5
+ "core_version": "1.19.619",
6
+ "core_contract": "1.19.619",
7
+ "source_commit": "b3a494e84958a264492cf55b3525d70680f7bbda",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-09T06:20:10.684Z",
9
+ "built_at": "2026-09-09T06:39:30.103Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 462,
12
+ "docs_redacted": 463,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2085,
14
+ "functional_verbatim": 2086,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2571,
20
- "tree_sha256": "725579642848cf49184292a5271ac418e2ea288cac2beaaa80b49468625abc14",
19
+ "file_count": 2573,
20
+ "tree_sha256": "f0ad9c8d1e70405ab669163d2d2153cd7f6ae97a699036bb8e4278f1e3a435d5",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -1864,10 +1864,15 @@
1864
1864
  "mode": "0000644",
1865
1865
  "sha256": "cec61252a02d5da416c77088dbf2adf99c138cb10b654fa9fef949a0848a7964"
1866
1866
  },
1867
+ {
1868
+ "path": "docs/adr/0269-the-cli-session-store-is-host-keyed-at-a-fixed-anchor.md",
1869
+ "mode": "0000644",
1870
+ "sha256": "cc44045ea59f76dabc62fe43993ffab6004add88c7f6142bfe9215f7f2c33c99"
1871
+ },
1867
1872
  {
1868
1873
  "path": "docs/adr/README.md",
1869
1874
  "mode": "0000644",
1870
- "sha256": "7ab74c04d47b89328aedfe3fca3f2fd18c049c2fe5aa6c98bc1072d26bc12c68"
1875
+ "sha256": "d660b4bc0bc3306f5fcf583118ca9149ba3deafe82dfe3894fe0b1c11b60cc2f"
1871
1876
  },
1872
1877
  {
1873
1878
  "path": "docs/api-reference.md",
@@ -2752,7 +2757,7 @@
2752
2757
  {
2753
2758
  "path": "docs/module-api-changelog.md",
2754
2759
  "mode": "0000644",
2755
- "sha256": "ec11a2ed9d50d4558aee3aca03f217dc0dde1f717859eb7a77c706535324fd32"
2760
+ "sha256": "1fe02fc79e2952efb61450bbd45e6b09e41e8cf73453c6e0a2fbbf2944c1cdab"
2756
2761
  },
2757
2762
  {
2758
2763
  "path": "docs/modules-contract.md",
@@ -7652,12 +7657,12 @@
7652
7657
  {
7653
7658
  "path": "package-lock.json",
7654
7659
  "mode": "0000644",
7655
- "sha256": "44a869111a69105fd5f1778cb749ad2c40a067db90fee6e6bbd19fcd76acd2ab"
7660
+ "sha256": "41aca5e97b1cb72e389656c14ddd1bb35a3d5fdce8145f05e294c3fa3f52dc15"
7656
7661
  },
7657
7662
  {
7658
7663
  "path": "package.json",
7659
7664
  "mode": "0000644",
7660
- "sha256": "b974dffa8dcde89358258e710e0f0b2c9fbd560ff5ba4c505cc57ced1809ab0e"
7665
+ "sha256": "2084b4c59fd9b3857bb83b5a4198d43c308f0180edc0e21a698e4285809323fd"
7661
7666
  },
7662
7667
  {
7663
7668
  "path": "public-docs/index.html",
@@ -7862,7 +7867,7 @@
7862
7867
  {
7863
7868
  "path": "scripts/gds/build-cli-package.js",
7864
7869
  "mode": "0000644",
7865
- "sha256": "81ad7fbc99f5c85219f1385cdd6e6954d27a0e2943f285676f060611a4878f4e"
7870
+ "sha256": "de1cdede17761775722509b1e1edb4bb43fbbf336450556f22119b18b93e43d3"
7866
7871
  },
7867
7872
  {
7868
7873
  "path": "scripts/gds/bump-version.js",
@@ -7922,7 +7927,7 @@
7922
7927
  {
7923
7928
  "path": "scripts/gds/cli-lib.js",
7924
7929
  "mode": "0000644",
7925
- "sha256": "44b8c997a25d9fcc68ba573d33391546ff98a871c7d99e9aa6d982c69a703824"
7930
+ "sha256": "eaa4c3a172009d5ce825e528f5077a480e034658501a7d0c532a7807886beb9e"
7926
7931
  },
7927
7932
  {
7928
7933
  "path": "scripts/gds/client-baseurl-guard.js",
@@ -8302,7 +8307,7 @@
8302
8307
  {
8303
8308
  "path": "scripts/gds/login.js",
8304
8309
  "mode": "0000644",
8305
- "sha256": "f16497d7eae23657e83f631b420f82a55ed8216480f3cd5488609d3b93274dee"
8310
+ "sha256": "bdc7fcc0876a1185fb15339740793944c755d716d998bb68533ab3a721d39ab2"
8306
8311
  },
8307
8312
  {
8308
8313
  "path": "scripts/gds/main-audit.js",
@@ -9387,7 +9392,7 @@
9387
9392
  {
9388
9393
  "path": "src/module-api.js",
9389
9394
  "mode": "0000644",
9390
- "sha256": "8d4f44cdde9eecac4f1d8294dcd8d1f1283d536c2adab2d9e49b74c8891a40e8"
9395
+ "sha256": "983d456cff4042137990a5baacb002919c3d213a33f663d6e8fcc7e9f5945ae6"
9391
9396
  },
9392
9397
  {
9393
9398
  "path": "src/module-loader/catalog.js",
@@ -9994,6 +9999,11 @@
9994
9999
  "mode": "0000644",
9995
10000
  "sha256": "a57516ed9e904cf1c86fe99688415f834dcae1a19f01b22e986f199fa5473258"
9996
10001
  },
10002
+ {
10003
+ "path": "tests/cli_sessions.mjs",
10004
+ "mode": "0000644",
10005
+ "sha256": "6ec406bf2bcbae261ab37ecaf820cfbe196c11007b0d8d3ee47cf21b6a3919ca"
10006
+ },
9997
10007
  {
9998
10008
  "path": "tests/cli_surface.mjs",
9999
10009
  "mode": "0000644",
@@ -0,0 +1,104 @@
1
+ # ADR 0269 — The CLI's session store is host-keyed at a fixed anchor; the per-brand file stays the active pointer
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-09
5
+ - **Task:** [task 1003741](https://cloudbongos.com/builders#/task/1003741) (BONGOS-V2, goal 1000090 — *Working area 4, Bongos Core distribution*)
6
+ - **Deciders:** Claude, under the Archon's standing scope
7
+ - **Related:** [ADR 0258](<redacted>.md) (the public CLI, which is where this bites) · [ADR 0108](<redacted>.md) (each instance its own checkout) · [ADR 0083](<redacted>.md) (why the store directory may not be called `sessions`)
8
+
9
+ ---
10
+
11
+ ## Context
12
+
13
+ The CLI resolved its session path from the **branding pack**, which is read out of whatever
14
+ checkout the process happens to be standing in. That gave it exactly **one session slot**, and the
15
+ slot moved depending on where you ran from:
16
+
17
+ - **Standalone** — the public `@cloudbongos/cli`'s entire situation — no checkout means no brand,
18
+ so every instance shared `~/.config/cloudbongos/gds-session.json`. Signing into a second
19
+ instance **destroyed** the first. Measured on the published 0.1.1: `configHome()` resolved to
20
+ `<HOME>/.config/cloudbongos` whichever instance was named.
21
+ - **In-repo** the brand resolved and the session landed in `~/.config/<slug>/`, where the
22
+ standalone CLI could never find it. `npx @cloudbongos/cli api GET /api/gds/me` from a bare
23
+ directory returned the cloudbongos.com builder while a valid hermeslines session sat on the same
24
+ machine.
25
+
26
+ This was not theoretical. The owner's own config dir carries a hand-made
27
+ `gds-session.<redacted>.bak.json`, alongside four more rescues of the same
28
+ shape. For a newcomer with one project it is invisible; it bites precisely the people running more
29
+ than one, which is the direction the platform is going.
30
+
31
+ ## Decision
32
+
33
+ **Keep one session file per instance, keyed by the instance's host, under a FIXED anchor —
34
+ `~/.config/<FALLBACK_DIR>/instances/<host>.json` — and leave the per-brand `gds-session.json`
35
+ exactly where it is, as the ACTIVE pointer.**
36
+
37
+ Four parts are load-bearing.
38
+
39
+ ### 1. The anchor is fixed, never brand-derived
40
+
41
+ A brand-derived anchor is the bug. `sessionStoreDir()` is built from `FALLBACK_DIR` and must not
42
+ read `configHome()`, `configDirName()` or the pack — so the same instance resolves to the same file
43
+ whether you run from a checkout or from `npx`. A test asserts the function body names none of them,
44
+ because "helpfully" routing it back through the brand would silently reinstate the split.
45
+
46
+ It lives in `scripts/gds/cli-lib.js`, **not** `src/instance-config.js`. That module is generic
47
+ config-path machinery shared with the server, and a CLI session store is not its concern; cli-lib
48
+ already owns `SESSION_PATH` and `SESSION_READ_PATHS`, so one file now describes where a session
49
+ lives. (The first cut put it in instance-config and the knip dead-code ratchet caught it: knip
50
+ cannot trace that module's `require()` consumers, so every export it gains counts as dead — three
51
+ new ones broke the gate. The right answer was better placement, not a bigger baseline.)
52
+
53
+ ### 2. The active pointer does not move
54
+
55
+ Every existing reader — in-repo skills, the card hook, the dev box, `readSessionToken` — looks at
56
+ `gds-session.json` in the configured dir. The store is **additive**: `saveSession` still writes that
57
+ file last and unchanged. Moving it would have been the tidier design and would have broken every one
58
+ of those readers at once, for no gain a builder can see.
59
+
60
+ ### 3. The OUTGOING session is archived before the incoming one lands
61
+
62
+ Order matters, and this step is the one that is easy to omit. A session written *before* the store
63
+ existed is not in it; without archiving the session being replaced, the very first login after
64
+ upgrading would still lose it — the exact bug, surviving its own fix. Archiving is best-effort and
65
+ never blocks a real sign-in.
66
+
67
+ ### 4. A stored token is verified before it is trusted
68
+
69
+ `bongos login <url>` now switches back to an instance you are already signed into with no browser
70
+ flow — but it calls `/api/gds/me` with the stored token first. Reinstating an expired or revoked
71
+ token unchecked would leave a builder "signed in" to a session every later command then fails on.
72
+ An unverified token falls straight through to the real device flow; `--force` signs in as somebody
73
+ else.
74
+
75
+ Because sessions are now kept rather than overwritten, `login` also names the other instances this
76
+ machine knows and how to move between them — silence would leave them unfindable.
77
+
78
+ ## Consequences
79
+
80
+ - Signing into a second instance is non-destructive, and moving between two projects costs no
81
+ browser round-trip.
82
+ - Session files are `0600` in a `0700` directory: they hold bearer tokens.
83
+ - An `api_base` that names no host is **not stored** rather than stored under a guessed name, so a
84
+ malformed or hostile base cannot write outside the store directory.
85
+ - **The directory is `instances/`, not `sessions/`.** `sessions` is a live module key, and
86
+ ADR 0083 §Decision #4 forbids kernel machinery from naming one — `src/instance-config.js` is a
87
+ kernel file and the fitness gate caught it. `instances/` is also the truer name: one file per
88
+ instance is exactly what it holds.
89
+ - A corrupt entry in the store is skipped, never fatal.
90
+ - Still open: the store is per-machine and holds no notion of which instance a *directory* belongs
91
+ to, so running in an instance repo does not automatically select that instance — the active
92
+ pointer is whatever you last signed into. That is a smaller, separable question.
93
+
94
+ ## Rejected
95
+
96
+ - **Moving the active pointer into the store** — breaks every existing reader at once for no
97
+ visible gain.
98
+ - **One file holding a map of instances** — changes the on-disk format that in-repo readers,
99
+ hooks and the dev box all parse today.
100
+ - **Keying the store under `configHome()`** — brand-derived, which is the bug being fixed.
101
+ - **Restoring a stored session without verifying it** — hands the builder a dead session that
102
+ looks live.
103
+ - **A new `bongos switch` verb** — `login <url>` is the command a builder already reaches for, and
104
+ the public CLI's verb surface is deliberately small (ADR 0258).
@@ -360,3 +360,4 @@ This keeps the decision history honest and traceable.
360
360
  | 0266 | [**The Board Room is its own surface, reachable by whoever may vote** ([task 1003734](https://cloudbongos.com/builders#/task/1003734) · goal 1000111 — *Working area 7, Government*, criterion `wa7-government`; owner decision 2026-09-08). The hall's only **upward-pointing** gate ([ADR 0175](<redacted>.md)) shipped as a hash tab inside `/government`, and two things about that did not survive inspection. **The nav never followed the page:** R16 correctly widened the PAGE gate from `government.manage` (archon) to `page.view.government` (metic+) when the page became three rooms — its own comment says *"the shell follows the widest legitimate audience"* — but `shell.js` still declares that item as label Permissions with gate archon, so a Metic who SITS on the board has no nav link at all and the only word in the nav is Permissions. Watch, Harbor, Gate and Sessions each own a nav item; the voting room was reachable only by knowing a URL fragment. **The surface was narrower than the franchise:** `board.vote.cast` floors at XENOS deliberately (`board.js:149` — the floor is low *so that widening the board works*) while the shell demands metic+, so the moment a constitution widens membership below Metic — the exact act [ADR 0175](<redacted>.md) §6 exists to make a CONFIG change — those members may vote by API and cannot load the page they would vote on, reintroducing the code-path change §6 removed. Decision: **its own page at `/board-room`, gated on the same atom the vote route checks.** The floor stays coarse — membership is still checked in-handler per item against that item's own snapshotted constitution — so a builder who clears the floor but sits on no board sees the room and no ballot, which is honest (§9 made the ballot open on purpose). **The migration is client-side because a fragment never reaches the server:** `#board-room` cannot be caught by any server route, so `government.js` redirects on load, preserving `?item=N` — recorded as a decision because adding a regex to `serve-internal.js` looks like the obvious fix and silently never fires. Three writers move (Discord `board-broadcast.js`, the `board_votes` need href, docs), and the redirect stays a release regardless because announcements already in channel history carry the old link forever. A **waiting-vote count** goes on the nav item: the data already exists (`boardVotesNeed` carries a live count pre-filtered to what the vote route would accept, served on `GET /me`), but `shell.js` has NO badge/bell/dot mechanism at all, so one is built once, generically, zero-is-silent, visible to exactly whoever the item is. Found while scoping and filed separately ([task 1003738](https://cloudbongos.com/builders#/task/1003738)): the hall renders needs as a SINGLE slot and `computeNeeds` sorts only by state with a stable sort, so registry order decides inside the `action_needed` bucket and `boardVotesNeed` sits fourth behind `artKeyNeed` — any builder with an unresolved image key never sees the board notice, which was live for this decision's own owner at the moment they asked for the bell. Rejected: a nav item merely deep-linking to the existing tab (cheaper, keeps the hash links free, but leaves the voting room inside a page whose gate answers a different question); a bell in the global header (a second inbox, needing its own read/unread and dismissal semantics); and a SOUND (`alert.wav`/`chime.wav` already ship, so it would be easy — it was not asked for).](<redacted>.md) | government / the board / hall nav |
361
361
  | 0267 | [**Unanimity, and the revise-and-re-sit loop** ([task 1003733](https://cloudbongos.com/builders#/task/1003733) · goal 1000111 — *Government*, owner decision 2026-09-08). A Full Idea is ratified by **unanimous** agreement from round one; a sitting that does not carry RETURNS to its author, who revises and re-sits it, unbounded, until the board is unanimous. `unanimous` joins `PASS_RULES` as a fourth answer on the axis these rules actually differ on — **what silence means**: it is two conditions, not one, because "nobody objected" is true of an empty room. Everyone who spoke must have said yes AND somebody must have spoken, so an unvoted sitting RETURNS, which is exactly what makes the clock the owner asked for safe under it ([ADR 0191](<redacted>.md) §4: a deadline may only be given to a rule whose expiry means return). **There is deliberately no membership denominator** — that is the whole difference from `majority`, and it is the owner's "silence does not block" as code: an absent member is not in the reckoning, so one yes out of four carries. The stated cost is that an attentive minority can ratify on a distracted board; the alternative is one person on holiday stopping the pipeline. Trigger changes follow from the rule: a single yes does NOT close it (a later member must still be able to object — the sitting runs its clock, full turnout closes early), while a reasoned objection closes it AT ONCE (no vote-changing in v1 fixes the outcome, and the author needs the feedback to revise). ADR 0191 §3's author rule rides both unchanged. **`BOARD_DEFAULTS` is NOT moved**: a fresh instance stays the day-one monarchy, because under any non-author-yes rule a solo founder can never ratify their own ideas (0191 §5) — adoption is a board amendment, and no env var can change a constitution. The revise-and-re-sit loop already existed (`POST /inbox/:id/resubmit` re-grades and re-fires the window hook); what the board owes it is now pinned by test — a return CLOSES the item so the next window may open, and pays nothing. Named limits: a zero-vote return carries no human objection (the author gets the mechanical weakest-section flag), and this changes nothing live until the separate defect where a ratified amendment does not survive to the next read is fixed.](<redacted>.md) | government / board room |
362
362
  | 0268 | [**The constitution comes from two roots, and a decision that did not take must say so** ([task 1003739](https://cloudbongos.com/builders#/task/1003739) · goal 1000111 — *Government*). On 2026-08-25 cloudbongos.com's board RATIFIED an amendment (rank:metic+ · consent · a 1440-minute sitting) and the constitution never changed — for two weeks `GET /government/constitution` answered the day-one monarchy with the passed amendment sitting in its own `history` array directly beneath the contradicting `board` block. Every sitting since was decided under a rule the board had voted to replace, and it silently un-shipped [ADR 0191](<redacted>.md) (majority) and would have un-shipped [ADR 0267](<redacted>.md) the same way. **Cause:** `modules/government/config.js` resolved BOTH its neutral starter and its instance pack from one `path.resolve(__dirname, '..', '..')`, under a comment saying the roots coincided *today* — true in a single checkout, false on a STANDALONE instance ([ADR 0108](<redacted>.md) §1), where the server runs `node_modules/@cloudbongos/core` with `WorkingDirectory=<instance repo>`. The neutral path stayed right (it really is core content); the INSTANCE path resolved inside the core package, so the host's own `config/government.json` was never read and `applyBoardAmendment` wrote the ratified amendment into `node_modules`, where the nightly core upgrade ([ADR 0161](<redacted>.md) cuts a release per merge) erased it within a day. **Nothing threw and nothing could have** — both paths exist, both are writable, and an absent instance pack is a legal state, so the read fell through to the neutral monarchy exactly as designed. `src/branding.js` had the pattern right three files away. **Fix:** neutral from `resolveCoreRoot()`, instance from `resolveInstanceRoot()`, reached through `src/module-api` — which had exposed NEITHER resolver, and that absence is precisely why the module re-derived the wrong one ([ADR 0083](<redacted>.md) forbids requiring a core internal). Resolved at load, not per call, because `resolveInstanceRoot()` falls back to `process.cwd()` and a lazy resolve would let a `chdir` move the constitution; `applyBoardAmendment` now mkdirs, since the write targets a directory the core does not own. Single-checkout behaviour is byte-identical, asserted rather than claimed. **The detector, which is the durable half:** `constitutionView` carries a `divergence` block comparing the newest PASSED amendment (sanitized as an apply would write it) field-by-field against what is in force, rendered above the hall's dials in words rather than config keys. It REPORTS and never HEALS — the instance pack is also the file a human editing the constitution touches, so a silent re-apply would revert a legitimate hand edit with no way to tell the two apart. A RETURNED amendment is never compared (that is what *returned* means). Does NOT put the 2026-08-25 amendment back in force — that is a board act. Sibling sweep RUN, not promised: one real hit filed as [task 1003745](https://cloudbongos.com/builders#/task/1003745) (quarantine.js's repo-containment refusal is blind to the instance repo).](<redacted>.md) | government / instance roots |
363
+ | 0269 | [**The CLI session store is host-keyed at a fixed anchor; the per-brand file stays the active pointer** ([task 1003741](https://cloudbongos.com/builders#/task/1003741) · goal 1000090 — *Working area 4, Bongos Core distribution*). The CLI resolved its session path from the BRANDING PACK, read out of whatever checkout the process stood in — one slot, that moved. Standalone (the public CLI's whole situation) no checkout means no brand, so every instance shared `~/.config/cloudbongos/gds-session.json` and signing into a second DESTROYED the first; in-repo it landed in `~/.config/<slug>/` where the standalone CLI could never find it (`npx … api GET /me` from a bare dir returned the cloudbongos builder while a valid hermeslines session sat on the same machine). Not theoretical — the owner's config dir carries a hand-made `gds-session.<redacted>.bak.json` and four more of the same shape. **Decision: one file per instance keyed by HOST at a FIXED anchor (`~/.config/<FALLBACK_DIR>/instances/<host>.json`), with the per-brand `gds-session.json` left exactly where it is as the ACTIVE pointer.** Four load-bearing parts. (1) The anchor must never read `configHome()`/`configDirName()`/the pack — a test asserts the function body names none, since routing it back through the brand silently reinstates the split. (2) The active pointer does NOT move: every existing reader (in-repo skills, the card hook, the dev box, `readSessionToken`) looks there, and the store is purely additive. (3) The OUTGOING session is archived BEFORE the incoming one lands — a session written before the store existed is not in it, so without this the first login after upgrading still loses it, the bug surviving its own fix. (4) A stored token is VERIFIED against `/api/gds/me` before being reinstated; unverified falls through to the real device flow, `--force` signs in as somebody else. `login` also names the other instances and how to switch, since sessions are now kept rather than overwritten. Files 0600 in a 0700 dir; an `api_base` naming no host is not stored rather than stored under a guess, so nothing can write outside the store. The dir is `instances/` NOT `sessions/` — `sessions` is a live module key and [ADR 0083](<redacted>.md) §Decision #4 forbids kernel machinery naming one; the fitness gate caught it. Rejected: moving the active pointer (breaks every reader for no visible gain); one file holding a map (changes a format hooks and the dev box parse today); keying under `configHome()` (the bug); restoring without verifying (hands over a dead session that looks live); a new `bongos switch` verb (login is the command already reached for, and the public CLI's surface is deliberately small).](<redacted>.md) | cli / auth / distribution |
@@ -1685,5 +1685,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1685
1685
  landed since 1.19.616 with no explicit bump. run 34317034024. (task 1002620)
1686
1686
  1.19.618 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1687
1687
  landed since 1.19.617 with no explicit bump. run 34318552090. (task 1002620)
1688
+ 1.19.619 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1689
+ landed since 1.19.618 with no explicit bump. run 34320024204. (task 1002620)
1688
1690
  ---------------------------------------------------------------------------
1689
1691
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.618",
3
+ "version": "1.19.619",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.618",
9
+ "version": "1.19.619",
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.618",
3
+ "version": "1.19.619",
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",
@@ -35,7 +35,7 @@ const REPO_ROOT = path.join(__dirname, '..', '..');
35
35
 
36
36
  // The package's OWN version — deliberately independent of the core's. The core moves many times
37
37
  // a day (CI auto-patch, ADR 0161); the CLI's public contract should not.
38
- const PACKAGE_VERSION = '0.1.2';
38
+ const PACKAGE_VERSION = '0.1.3';
39
39
  const PACKAGE_NAME = '@cloudbongos/cli';
40
40
 
41
41
  // ── What the package carries ────────────────────────────────────────────────────────────────
@@ -229,12 +229,117 @@ async function loadSession() {
229
229
  return null;
230
230
  }
231
231
 
232
+ // ---- the shared session store (task 1003741, ADR 0269) ---------------------
233
+ // ONE FILE PER INSTANCE, at a FIXED anchor — deliberately NOT configHome().
234
+ //
235
+ // WHY A FIXED ANCHOR. configHome() is brand-derived, and the brand is read out of whatever checkout
236
+ // the process is standing in. That gave the CLI exactly one session slot, and the slot MOVED: run
237
+ // standalone (the public @cloudbongos/cli's whole situation) the brand cannot resolve, so every
238
+ // instance shared one gds-session.json and signing into a second DESTROYED the first; run from
239
+ // inside an instance repo and the session landed in ~/.config/<slug>/ where the standalone CLI
240
+ // could never find it. The owner's own config dir carries a hand-made `hermeslines-clobber` backup
241
+ // from the first mode.
242
+ //
243
+ // WHY HERE AND NOT instance-config.js. That module is generic config-path machinery shared with the
244
+ // server; a CLI session store is not its concern, and cli-lib already owns SESSION_PATH and
245
+ // SESSION_READ_PATHS. Keeping the two together means one file describes where a session lives.
246
+ function sessionStoreDir() {
247
+ // Directory name deliberately NOT 'sessions': that is a live module key, and ADR 0083
248
+ // §Decision #4 forbids naming one in shared machinery. 'instances' is the truer name anyway —
249
+ // one file per instance is exactly what this holds.
250
+ return path.join(os.homedir(), '.config', ic.FALLBACK_DIR, 'instances');
251
+ }
252
+
253
+ // The store's filename for an instance, derived from its api_base host. Returns null when the base
254
+ // names no host — an unkeyable session is simply not stored, never stored under a guessed name.
255
+ function sessionHostKey(apiBase) {
256
+ const raw = String(apiBase || '').trim();
257
+ if (!raw) return null;
258
+ let host = null;
259
+ try { host = new URL(raw).host; } catch (_) { host = null; }
260
+ if (!host) return null;
261
+ // Filename-safe, and collision-free for real hosts: ':' (a port) is the only character a valid
262
+ // host adds beyond the safe set, and it maps to '_' which cannot otherwise appear in one.
263
+ const safe = host.replace(/[^A-Za-z0-9._-]/g, '_');
264
+ return safe && safe !== '.' && safe !== '..' ? safe : null;
265
+ }
266
+
267
+ function sessionStorePath(apiBase) {
268
+ const key = sessionHostKey(apiBase);
269
+ return key ? path.join(sessionStoreDir(), `${key}.json`) : null;
270
+ }
271
+
272
+ // Write one session into the shared, host-keyed store. No-op when the session names no host —
273
+ // an unkeyable session is skipped, never filed under a guessed name.
274
+ async function writeSessionToStore(session) {
275
+ const target = session && sessionStorePath(session.api_base);
276
+ if (!target) return null;
277
+ await fsp.mkdir(path.dirname(target), { recursive: true });
278
+ await fsp.chmod(path.dirname(target), 0o700).catch(() => {});
279
+ await fsp.writeFile(target, JSON.stringify(session, null, 2), { mode: 0o600 });
280
+ return target;
281
+ }
282
+
283
+ // Every instance's session is kept, not just the last one (task 1003741).
284
+ //
285
+ // This used to write ONE file, so `bongos login <another-instance>` silently DESTROYED the session
286
+ // you already had — the owner's own config dir carries a hand-made `hermeslines-clobber` backup
287
+ // from exactly that. Three steps now, and the order matters:
288
+ //
289
+ // 1. archive the OUTGOING session first. That is what rescues a session written before the store
290
+ // existed: it was never in the store, so without this step the very first login after
291
+ // upgrading would still lose it.
292
+ // 2. store the incoming one under its own host.
293
+ // 3. write the ACTIVE pointer exactly where it has always gone, so every existing reader —
294
+ // in-repo skills, hooks, the dev box — behaves identically.
232
295
  async function saveSession(session) {
233
296
  const dir = path.dirname(SESSION_PATH);
234
297
  await fsp.mkdir(dir, { recursive: true });
298
+
299
+ try {
300
+ const prev = await loadSession();
301
+ const prevKey = prev && prev.token ? sessionHostKey(prev.api_base) : null;
302
+ const nextKey = sessionHostKey(session && session.api_base);
303
+ if (prevKey && prevKey !== nextKey) await writeSessionToStore(prev);
304
+ } catch (_) {
305
+ // Archiving is best-effort — a lost previous session must never block a real sign-in.
306
+ }
307
+
308
+ await writeSessionToStore(session).catch(() => null);
235
309
  await fsp.writeFile(SESSION_PATH, JSON.stringify(session, null, 2), { mode: 0o600 });
236
310
  }
237
311
 
312
+ // The stored session for one instance, or null. Used by `bongos login` to switch back to an
313
+ // instance you are already signed into without a fresh device flow.
314
+ async function loadStoredSession(apiBase) {
315
+ const target = sessionStorePath(apiBase);
316
+ if (!target) return null;
317
+ return readSessionFile(target);
318
+ }
319
+
320
+ // Every instance this machine has a stored session for — so the CLI can say which one it is
321
+ // targeting and what else it remembers. Sorted for a stable listing; never throws.
322
+ function listStoredSessions() {
323
+ let names = [];
324
+ try { names = fs.readdirSync(sessionStoreDir()); } catch (_) { return []; }
325
+ const out = [];
326
+ for (const name of names) {
327
+ if (!name.endsWith('.json')) continue;
328
+ try {
329
+ const j = JSON.parse(fs.readFileSync(path.join(sessionStoreDir(), name), 'utf8'));
330
+ if (j && j.token) {
331
+ out.push({
332
+ host: name.slice(0, -5),
333
+ api_base: j.api_base || null,
334
+ login: (j.builder && j.builder.github_login) || null,
335
+ instance: (j.instance && j.instance.name) || null,
336
+ });
337
+ }
338
+ } catch (_) { /* a corrupt entry is skipped, never fatal */ }
339
+ }
340
+ return out.sort((a, b) => a.host.localeCompare(b.host));
341
+ }
342
+
238
343
  function loadSessionSync() {
239
344
  for (const p of SESSION_READ_PATHS) {
240
345
  if (!fs.existsSync(p)) continue;
@@ -1006,6 +1111,11 @@ module.exports = {
1006
1111
  loadSession,
1007
1112
  loadSessionSync,
1008
1113
  saveSession,
1114
+ sessionStoreDir,
1115
+ sessionHostKey,
1116
+ sessionStorePath,
1117
+ loadStoredSession,
1118
+ listStoredSessions,
1009
1119
  arg,
1010
1120
  argText,
1011
1121
  hasFlag,
@@ -22,7 +22,7 @@
22
22
 
23
23
  const readline = require('node:readline');
24
24
  const { spawnSync } = require('node:child_process');
25
- const { saveSession } = require('./cli-lib');
25
+ const { saveSession, loadStoredSession, listStoredSessions } = require('./cli-lib');
26
26
  const { branding } = require('../../src/branding');
27
27
 
28
28
  // --- pure helpers (unit-tested) ---------------------------------------------
@@ -204,6 +204,30 @@ async function main() {
204
204
  const idp = (man.data.auth && man.data.auth.idp) || null;
205
205
  log(` Signing in to ${instanceName}${doorWord}${idp ? ' — via Cloud Bongos platform sign-in' : ''}`);
206
206
 
207
+ // 1b. Already signed in to THIS instance? Switch back instead of re-running a browser flow
208
+ // (task 1003741). Sessions are now kept per instance, so moving between two projects should
209
+ // cost nothing. The stored token is VERIFIED against the instance before it is trusted — an
210
+ // expired or revoked one falls straight through to the real flow below.
211
+ if (!process.argv.includes('--force')) {
212
+ let stored = null;
213
+ try { stored = await loadStoredSession(base); } catch (_) { stored = null; }
214
+ if (stored && stored.token) {
215
+ const me = await fetchJson(`${base}/api/gds/me`, { headers: { Authorization: `Bearer ${stored.token}` } });
216
+ if (me.ok && me.data && me.data.builder) {
217
+ await saveSession({ ...stored, builder: me.data.builder, api_base: base,
218
+ instance: { name: instanceName, origin: base } });
219
+ const back = me.data.builder.github_login || me.data.builder.display_name || 'you';
220
+ log('');
221
+ log(` ✓ Already signed in to ${instanceName} as ${back} — switched, no browser needed.`);
222
+ logOtherInstances(base, log);
223
+ log(' Next: bongos start (see what you can claim)');
224
+ log(' (Re-run with --force to sign in as somebody else.)');
225
+ return;
226
+ }
227
+ log(' Your saved session for this instance has expired — signing in again.');
228
+ }
229
+ }
230
+
207
231
  // 2. Device flow.
208
232
  let flow = await runDeviceFlow(base, { log, idp });
209
233
 
@@ -280,9 +304,23 @@ async function main() {
280
304
  const who = (flow.builder && (flow.builder.github_login || flow.builder.display_name)) || 'you';
281
305
  log('');
282
306
  log(` ✓ Signed in to ${instanceName} as ${who}.`);
307
+ logOtherInstances(base, log);
283
308
  log(' Next: bongos start (see what you can claim) · bongos code / bongos shell (get on your dev box)');
284
309
  }
285
310
 
311
+ // Name the OTHER instances this machine is signed into, and how to move between them (task
312
+ // 1003741). Signing in used to destroy the session you already had, so there was never anything
313
+ // to say; now that every instance is kept, silence would leave a builder unable to find them.
314
+ function logOtherInstances(base, log) {
315
+ let others = [];
316
+ try {
317
+ others = listStoredSessions().filter((s) => s.api_base && s.api_base !== base);
318
+ } catch (_) { return; }
319
+ if (!others.length) return;
320
+ const names = others.map((s) => s.instance || s.host);
321
+ log(` Also signed in to ${names.join(', ')} — switch with \`bongos login <url>\` (no browser needed).`);
322
+ }
323
+
286
324
  if (require.main === module) {
287
325
  main().catch((err) => {
288
326
  console.error(`✖ ${err.message}`);
@@ -290,4 +328,4 @@ if (require.main === module) {
290
328
  });
291
329
  }
292
330
 
293
- module.exports = { normalizeInstanceBase, pollDecision, runDeviceFlow };
331
+ module.exports = { normalizeInstanceBase, pollDecision, runDeviceFlow, logOtherInstances };
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.618'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.619'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,168 @@
1
+ // tests/cli_sessions.mjs — one session slot became one per instance (task 1003741).
2
+ //
3
+ // THE BUG. The CLI resolved its session path from the BRANDING PACK, which is read out of whatever
4
+ // checkout you happen to be standing in. That gave it exactly one slot, with two failure modes:
5
+ //
6
+ // • standalone (the public @cloudbongos/cli's whole situation) the brand cannot resolve, so every
7
+ // instance shared ~/.config/cloudbongos/gds-session.json and `bongos login <other>` DESTROYED
8
+ // the session you already had. The owner's own config dir carries a hand-made
9
+ // `gds-session.hermeslines-clobber-2026-08-12.bak.json` from exactly this.
10
+ // • in-repo the session landed in ~/.config/<slug>/ where the standalone CLI could never find it.
11
+ //
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,
14
+ // so every existing reader behaves identically — that back-compat is asserted here too.
15
+
16
+ import test from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ import fs from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { createRequire } from 'node:module';
23
+ import { spawnSync } from 'node:child_process';
24
+
25
+ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const require = createRequire(import.meta.url);
27
+ const ic = require(path.join(REPO_ROOT, 'src', 'instance-config.js'));
28
+ const lib = require(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'));
29
+
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.
32
+ function inSandbox(body) {
33
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-sess-'));
34
+ try {
35
+ const code = `const R=${JSON.stringify(REPO_ROOT)};
36
+ const lib=require(R+'/scripts/gds/cli-lib.js');
37
+ const ic=require(R+'/src/instance-config.js');
38
+ const fs=require('node:fs'), path=require('node:path');
39
+ (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 } });
41
+ return { home, out: `${r.stdout || ''}${r.stderr || ''}`, status: r.status };
42
+ } finally {
43
+ fs.rmSync(home, { recursive: true, force: true });
44
+ }
45
+ }
46
+
47
+ // ── 1. The key and the anchor ───────────────────────────────────────────────────────────────
48
+
49
+ test('a session is keyed by the instance host', () => {
50
+ assert.equal(lib.sessionHostKey('https://cloudbongos.com'), 'cloudbongos.com');
51
+ assert.equal(lib.sessionHostKey('https://hermeslines-marketing.cloudbongos.com'), 'hermeslines-marketing.cloudbongos.com');
52
+ assert.equal(lib.sessionHostKey('http://localhost:3000'), 'localhost_3000');
53
+ });
54
+
55
+ test('an unkeyable base is stored nowhere, never under a guess', () => {
56
+ for (const bad of ['', null, undefined, 'not a url', '/etc/passwd', '../../escape']) {
57
+ assert.equal(lib.sessionHostKey(bad), null, `should refuse ${JSON.stringify(bad)}`);
58
+ assert.equal(lib.sessionStorePath(bad), null);
59
+ }
60
+ });
61
+
62
+ test('no host can escape the store directory', () => {
63
+ // The key becomes a filename, so a traversal in a hostile api_base must not reach outside.
64
+ for (const b of ['https://cloudbongos.com', 'http://localhost:3000']) {
65
+ const p = lib.sessionStorePath(b);
66
+ assert.equal(path.dirname(p), lib.sessionStoreDir(), `${b} escaped the store dir`);
67
+ assert.ok(!path.basename(p).includes('/') && !path.basename(p).includes('\\'));
68
+ }
69
+ });
70
+
71
+ test('the store anchor is FIXED, not brand-derived — that is the whole fix', () => {
72
+ // A brand-derived anchor is what split in-repo from standalone in the first place. If someone
73
+ // "helpfully" routes this through configHome() again, the two diverge and the bug is back.
74
+ assert.equal(lib.sessionStoreDir(), path.join(os.homedir(), '.config', ic.FALLBACK_DIR, 'instances'));
75
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'), 'utf8');
76
+ const body = src.slice(src.indexOf('function sessionStoreDir()'));
77
+ const fn = body.slice(0, body.indexOf('\n}') + 2);
78
+ assert.ok(!/configHome|configDirName|safeBrand/.test(fn), `sessionStoreDir must not read the brand:\n${fn}`);
79
+ });
80
+
81
+ // ── 2. A login never destroys another instance's session ────────────────────────────────────
82
+
83
+ test('signing into a second instance PRESERVES the first — including one written before the store existed', () => {
84
+ const { out } = inSandbox(`
85
+ // A session from before this feature: an active pointer, nothing in the store.
86
+ const active = ic.configPath('gds-session.json');
87
+ fs.mkdirSync(path.dirname(active), { recursive: true });
88
+ fs.writeFileSync(active, JSON.stringify({ token:'TOKEN-A', api_base:'https://a.example.com',
89
+ builder:{github_login:'someone'} }), { mode: 0o600 });
90
+
91
+ await lib.saveSession({ token:'TOKEN-B', api_base:'https://b.example.com', builder:{github_login:'someone'} });
92
+
93
+ const rescued = await lib.loadStoredSession('https://a.example.com');
94
+ const stored = await lib.loadStoredSession('https://b.example.com');
95
+ const pointer = JSON.parse(fs.readFileSync(active,'utf8'));
96
+ console.log(JSON.stringify({
97
+ firstSurvived: !!(rescued && rescued.token === 'TOKEN-A'),
98
+ secondStored: !!(stored && stored.token === 'TOKEN-B'),
99
+ activeIsSecond: pointer.api_base === 'https://b.example.com',
100
+ hosts: lib.listStoredSessions().map(s => s.host),
101
+ }));`);
102
+ const r = JSON.parse(out.trim().split('\n').pop());
103
+ assert.equal(r.firstSurvived, true, 'the first instance\'s session was destroyed — the bug is back');
104
+ assert.equal(r.secondStored, true);
105
+ assert.equal(r.activeIsSecond, true, 'the active pointer must follow the newest sign-in');
106
+ assert.deepEqual(r.hosts, ['a.example.com', 'b.example.com']);
107
+ });
108
+
109
+ test('the ACTIVE pointer still lands exactly where every existing reader looks', () => {
110
+ // In-repo skills, hooks and the dev box all read gds-session.json in the configured dir. The
111
+ // store is additive; if this moved, every one of them would silently stop finding a session.
112
+ const { out } = inSandbox(`
113
+ await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
114
+ console.log(JSON.stringify({ wroteActive: fs.existsSync(ic.configPath('gds-session.json')) }));`);
115
+ assert.equal(JSON.parse(out.trim().split('\n').pop()).wroteActive, true);
116
+ });
117
+
118
+ test('re-saving the SAME instance is idempotent and files nothing extra', () => {
119
+ const { out } = inSandbox(`
120
+ const s = { token:'T1', api_base:'https://a.example.com', builder:{github_login:'x'} };
121
+ await lib.saveSession(s);
122
+ await lib.saveSession({ ...s, token:'T2' }); // e.g. fetch-art-key rewriting one field
123
+ const cur = await lib.loadStoredSession('https://a.example.com');
124
+ console.log(JSON.stringify({ hosts: lib.listStoredSessions().map(h=>h.host), token: cur.token }));`);
125
+ const r = JSON.parse(out.trim().split('\n').pop());
126
+ assert.deepEqual(r.hosts, ['a.example.com'], 'a same-instance re-save must not fan out');
127
+ assert.equal(r.token, 'T2');
128
+ });
129
+
130
+ test('session files are owner-only', () => {
131
+ const { out } = inSandbox(`
132
+ 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;
134
+ const d = fs.statSync(lib.sessionStoreDir()).mode & 0o777;
135
+ console.log(JSON.stringify({ file: f.toString(8), dir: d.toString(8) }));`);
136
+ const r = JSON.parse(out.trim().split('\n').pop());
137
+ assert.equal(r.file, '600', 'a session file holds a bearer token');
138
+ assert.equal(r.dir, '700');
139
+ });
140
+
141
+ test('a corrupt store entry is skipped, never fatal', () => {
142
+ const { out } = inSandbox(`
143
+ await lib.saveSession({ token:'T', api_base:'https://a.example.com', builder:{github_login:'x'} });
144
+ fs.writeFileSync(path.join(lib.sessionStoreDir(),'broken.json'), '{ not json');
145
+ console.log(JSON.stringify({ hosts: lib.listStoredSessions().map(h=>h.host) }));`);
146
+ assert.deepEqual(JSON.parse(out.trim().split('\n').pop()).hosts, ['a.example.com']);
147
+ });
148
+
149
+ // ── 3. Switching back ───────────────────────────────────────────────────────────────────────
150
+
151
+ test('login VERIFIES a stored token before trusting it, and can be forced past', () => {
152
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'login.js'), 'utf8');
153
+ // A stored token may be expired or revoked. Reinstating one unchecked would leave the builder
154
+ // "signed in" to a session every later command then fails on.
155
+ assert.match(src, /loadStoredSession\(base\)/);
156
+ assert.match(src, /\/api\/gds\/me`, \{ headers: \{ Authorization/);
157
+ assert.match(src, /if \(me\.ok && me\.data && me\.data\.builder\)/);
158
+ assert.match(src, /--force/, 'there must be a way to sign in as somebody else');
159
+ });
160
+
161
+ test('login names the other instances and how to move between them', () => {
162
+ const src = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'login.js'), 'utf8');
163
+ assert.match(src, /function logOtherInstances/);
164
+ assert.match(src, /Also signed in to/);
165
+ // Terminal reader: no slash command may appear in that guidance (task 1003730's rule).
166
+ const fnStart = src.indexOf('function logOtherInstances');
167
+ assert.ok(!/\/builder-/.test(src.slice(fnStart, fnStart + 700)), 'no slash command in terminal guidance');
168
+ });