@bongos/core 1.19.333 → 1.19.335
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 +24 -14
- package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
- package/docs/adr/README.md +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/auth.js +54 -10
- package/src/module-api.js +5 -1
- package/tests/auth_web_recovery.mjs +63 -0
- package/tests/lib_sh_resolution.mjs +142 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.335",
|
|
6
|
+
"core_contract": "1.19.335",
|
|
7
|
+
"source_commit": "b5f2b884a9011be30bffe5bbd62c272a7497314a",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-25T00:
|
|
9
|
+
"built_at": "2026-08-25T00:51:04.860Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 325,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1490,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1837,
|
|
20
|
+
"tree_sha256": "9792847e3608bc598acfe751efe68d1e8cbb5b0ab10a19490cf2f325db36a44c",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1414,10 +1414,15 @@
|
|
|
1414
1414
|
"mode": "0000644",
|
|
1415
1415
|
"sha256": "749040f16b59cc1cd1c49ca40453fed838f31b36af8c8eed2b5de7db00ddb0d0"
|
|
1416
1416
|
},
|
|
1417
|
+
{
|
|
1418
|
+
"path": "docs/adr/0187-oauth-handshake-cookie-lifetime.md",
|
|
1419
|
+
"mode": "0000644",
|
|
1420
|
+
"sha256": "d59906a447f2459efdef6c3eb97cf8629813ec74776ead700017f02ef76aed9e"
|
|
1421
|
+
},
|
|
1417
1422
|
{
|
|
1418
1423
|
"path": "docs/adr/README.md",
|
|
1419
1424
|
"mode": "0000644",
|
|
1420
|
-
"sha256": "
|
|
1425
|
+
"sha256": "87606f8e07f470f0ff55d7ac733de9d1004d3d15eddf1003a8958566b4147077"
|
|
1421
1426
|
},
|
|
1422
1427
|
{
|
|
1423
1428
|
"path": "docs/api-reference.md",
|
|
@@ -4717,12 +4722,12 @@
|
|
|
4717
4722
|
{
|
|
4718
4723
|
"path": "package-lock.json",
|
|
4719
4724
|
"mode": "0000644",
|
|
4720
|
-
"sha256": "
|
|
4725
|
+
"sha256": "c3a2cfd45a78396f4f7186492cf625a2351f4dfd78c0d82dbffc4217db97a68d"
|
|
4721
4726
|
},
|
|
4722
4727
|
{
|
|
4723
4728
|
"path": "package.json",
|
|
4724
4729
|
"mode": "0000644",
|
|
4725
|
-
"sha256": "
|
|
4730
|
+
"sha256": "9896b1417474da8bf8b09e8dd188455d6d75b5e95602c7409a81ea17ee6e4cc3"
|
|
4726
4731
|
},
|
|
4727
4732
|
{
|
|
4728
4733
|
"path": "public-docs/index.html",
|
|
@@ -6462,7 +6467,7 @@
|
|
|
6462
6467
|
{
|
|
6463
6468
|
"path": "src/bongos/routes/auth.js",
|
|
6464
6469
|
"mode": "0000644",
|
|
6465
|
-
"sha256": "
|
|
6470
|
+
"sha256": "e2195c0f80c1a7ae3a07e2234d2b97e6a20366b740fe3d0329dd8d8d6f9c1969"
|
|
6466
6471
|
},
|
|
6467
6472
|
{
|
|
6468
6473
|
"path": "src/bongos/routes/backup.js",
|
|
@@ -6572,7 +6577,7 @@
|
|
|
6572
6577
|
{
|
|
6573
6578
|
"path": "src/module-api.js",
|
|
6574
6579
|
"mode": "0000644",
|
|
6575
|
-
"sha256": "
|
|
6580
|
+
"sha256": "490504c9d10309b811026a035d5999360cbe00b8eb912bb8c4d0873c8271cff2"
|
|
6576
6581
|
},
|
|
6577
6582
|
{
|
|
6578
6583
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6794,6 +6799,11 @@
|
|
|
6794
6799
|
"mode": "0000644",
|
|
6795
6800
|
"sha256": "92b7033ab16d9d4472fcca4695d9df47938a45bdc62feda87b8771f5a2d59ff2"
|
|
6796
6801
|
},
|
|
6802
|
+
{
|
|
6803
|
+
"path": "tests/auth_web_recovery.mjs",
|
|
6804
|
+
"mode": "0000644",
|
|
6805
|
+
"sha256": "84b20e0bd1d014ebec22d0ed9b50c45d9b79e26fe509af1444a2fa46a52f03ec"
|
|
6806
|
+
},
|
|
6797
6807
|
{
|
|
6798
6808
|
"path": "tests/authorship_audit.mjs",
|
|
6799
6809
|
"mode": "0000644",
|
|
@@ -8062,7 +8072,7 @@
|
|
|
8062
8072
|
{
|
|
8063
8073
|
"path": "tests/lib_sh_resolution.mjs",
|
|
8064
8074
|
"mode": "0000644",
|
|
8065
|
-
"sha256": "
|
|
8075
|
+
"sha256": "97630ec5029469ec99c6a87160822f319833857f12a79e42b4a465e362836ef0"
|
|
8066
8076
|
},
|
|
8067
8077
|
{
|
|
8068
8078
|
"path": "tests/lifecycle-card.mjs",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# 0187 — The OAuth web-handshake cookies live 30 minutes, and move together
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-08-24
|
|
5
|
+
- **Tasks:** [#1003234](https://cloudbongos.com/builders#/task/1003234) (goal 1000058 — the non-cloudbongos project lifecycle; a walk finding from the 2026-08-24 stranger's-walk review, task 1003232)
|
|
6
|
+
- **Builds on:** [ADR 0143](<redacted>.md) (the `gds_oauth_repos` opt-in cookie), #726/#734 (the `gds_return` return cookie and the apex-scoped state)
|
|
7
|
+
- **Related:** [ADR 0141](<redacted>.md) (the federated leg shares the same state cookie)
|
|
8
|
+
|
|
9
|
+
## 1. Context
|
|
10
|
+
|
|
11
|
+
`GET /api/gds/auth/web/start` sets three cookies before bouncing the visitor to
|
|
12
|
+
GitHub: the CSRF **state** (`gds_oauth_state`), the validated **return target**
|
|
13
|
+
(`gds_return`) and the **repo-scope opt-in** (`gds_oauth_repos`). All three were
|
|
14
|
+
`maxAge: 10 minutes` — written for a hall builder who already has a GitHub
|
|
15
|
+
account and is back in seconds.
|
|
16
|
+
|
|
17
|
+
A founder's first sign-in is not that. The project wizard's gate sends them to
|
|
18
|
+
GitHub with `?repos=1`; a stranger with no GitHub account signs up there first,
|
|
19
|
+
verifies an email, sometimes sets up 2FA, and comes back. Past ten minutes the
|
|
20
|
+
callback found no state cookie and answered **"State mismatch"** — and because
|
|
21
|
+
the return and repos cookies had expired with it, the page's "try signing in
|
|
22
|
+
again" door was a bare `/auth/web/start`: the founder re-entered without the
|
|
23
|
+
repository scope and landed on the default post-login landing, nowhere near
|
|
24
|
+
the wizard. The review filed it as a founder-shaped dead end.
|
|
25
|
+
|
|
26
|
+
The state cookie is CSRF protection for the OAuth callback. Lengthening it is a
|
|
27
|
+
security-posture change, not a copy tweak, and the task said so; hence this
|
|
28
|
+
record.
|
|
29
|
+
|
|
30
|
+
## 2. Decision
|
|
31
|
+
|
|
32
|
+
1. **One lifetime, `HANDSHAKE_COOKIE_MS = 30 * 60 * 1000`, for all three
|
|
33
|
+
cookies.** They MUST move together: a state that outlives its return cookie
|
|
34
|
+
lands a founder on the default landing with no repo scope; a return that
|
|
35
|
+
outlives its state lands them on "State mismatch". The constant lives beside
|
|
36
|
+
the cookie names in `src/bongos/routes/auth.js`; the State-mismatch copy
|
|
37
|
+
reads it rather than hardcoding a number.
|
|
38
|
+
2. **Every OAuth error page rebuilds its retry door from whatever cookies are
|
|
39
|
+
still valid** (`recoveryStartHref(req)`: `return` through the same
|
|
40
|
+
open-redirect allowlist as the callback, `repos=1` from the opt-in cookie),
|
|
41
|
+
and so does the access-pending page's auto-walk-in. The lifetime change
|
|
42
|
+
makes the dead end rare; the door change makes it recoverable when it
|
|
43
|
+
still happens.
|
|
44
|
+
3. The Discord account-link state cookie is a different flow (an already
|
|
45
|
+
signed-in builder linking an account) and keeps its 10 minutes.
|
|
46
|
+
|
|
47
|
+
## 3. What did NOT change — the posture, stated plainly
|
|
48
|
+
|
|
49
|
+
- The state is still **single-use**: the callback clears it (apex-scoped and
|
|
50
|
+
host-only forms) on every path, and GitHub's authorization code is itself
|
|
51
|
+
single-use, so a captured state cannot be replayed into a session.
|
|
52
|
+
- Still `httpOnly`, `sameSite=lax`, `secure` behind TLS, apex-scoped — a
|
|
53
|
+
cross-site page cannot read or send it.
|
|
54
|
+
- The redirect_uri is still pinned to the canonical origin (#734), and
|
|
55
|
+
`safeReturnTo` still bounds every return target.
|
|
56
|
+
|
|
57
|
+
What grew is only the **window in which a legitimate handshake may complete**:
|
|
58
|
+
an attacker who could fixate a victim's state cookie (already requiring a
|
|
59
|
+
foothold this cookie's flags deny) has thirty minutes instead of ten to
|
|
60
|
+
complete a login-CSRF against a browser that has not itself finished the
|
|
61
|
+
handshake. Thirty was chosen as the smallest round number that comfortably
|
|
62
|
+
covers a GitHub signup with email verification; sixty was not needed.
|
|
63
|
+
|
|
64
|
+
## 4. Rejected
|
|
65
|
+
|
|
66
|
+
- **Keep the state at 10 minutes and lengthen only `return` + `repos`.** That
|
|
67
|
+
makes the "State mismatch" page recoverable (its door would still carry the
|
|
68
|
+
return + scope) but keeps the dead-end page itself as the founder's
|
|
69
|
+
experience — and it breaks the one-lifetime invariant in the other
|
|
70
|
+
direction, so a builder who *did* come back inside ten minutes could see a
|
|
71
|
+
return cookie from an older, abandoned start. The three move together.
|
|
72
|
+
- **A server-side state store with a long TTL.** Correct in the abstract, a new
|
|
73
|
+
table and a sweep for a problem a cookie lifetime solves; not for this task.
|
|
74
|
+
- **Sixty minutes.** Wider than the failure needs.
|
|
75
|
+
|
|
76
|
+
## 5. Consequences
|
|
77
|
+
|
|
78
|
+
- `tests/auth_web_recovery.mjs` pins the constant, the three `maxAge` sites in
|
|
79
|
+
`/auth/web/start`, the copy that reads it, and the recovery helper by value
|
|
80
|
+
(including the allowlist dropping foreign, protocol-relative and
|
|
81
|
+
`javascript:` returns).
|
|
82
|
+
- If GitHub's signup flow ever grows past thirty minutes for a typical
|
|
83
|
+
founder, the constant is the one place to move — and the invariant is that
|
|
84
|
+
all three cookies move with it.
|
package/docs/adr/README.md
CHANGED
|
@@ -260,5 +260,6 @@ This keeps the decision history honest and traceable.
|
|
|
260
260
|
| 0184 | [**A ship must account for itself: `shipTask` requires a land proof or a declared no-artifact** ([task 1002483](https://cloudbongos.com/builders#/task/1002483), goal 1000024). `db.shipTask` is the confirmed→shipped flip and, since [ADR 0120](<redacted>.md), where the CREDIT is booked — yet its only precondition was `WHERE status = 'confirmed'`, nothing about whether code reached main. The two AUTOMATED paths each proved the land first; the MANUAL route passed nothing, so a task confirmed with no pushed code could be shipped by its claim holder **or by any Metic+ sweeping `/merge-mode`**, booking its `task.shipped` credit. It stayed untracked for two months because its tracking task was closed in June asserting task 1001547 had delivered the gate — it had not, blocker 1000047's own final update says so, and the code agreed with the blocker rather than the closure note: **a hole hidden by a record nobody checked**, which is the same failure the gate is about. Decision: the gate lives at the WRITE, not the route (a route gate leaves the invariant unenforced at the layer that moves the ledger, so the next caller reopens it silently), and `shipTask` is FAIL-CLOSED on a required `landAssurance`. TWO kinds, and the second is why this does not make things worse: `proof` (this task's commit is on main) and **`no_artifact`** — a deliberate DB-only ship, an explicitly supported shape (CLAUDE.md "Direct DB writes are work too") that [task 1002478](https://cloudbongos.com/builders#/task/1002478) documents as a dead end below Archon. A naive proof-only gate would have made 1002478 strictly worse; naming no-artifact as a KIND turns it from an absence the gate must be talked out of into a recorded answer. Both carry a reason, audited post-commit and best-effort (an audit miss must never roll back a landed ship). `ship.js` now declares its own `--db-only`/`--allow-empty` lands, without which this fix would have regressed the very flow CLAUDE.md calls supported. Rejected: (b) gate only the route — leaves the write unguarded; (c) record the residual as tolerable — defensible on blast radius and genuinely close, but it would have been the FOURTH record asserting this was handled with nothing checkable behind it. Does NOT gate confirm: a codeless confirm is still accepted, it simply cannot reach `shipped` or pay.](<redacted>.md) | lifecycle / integrity |
|
|
261
261
|
| 0185 | [**The spark-handoff credit split: 70% developer, 30% spark author** ([task 1002923](https://cloudbongos.com/builders#/task/1002923), BV1.R147, goal 1000062 — completing the chain [task 1002920](https://cloudbongos.com/builders#/task/1002920) mark → [task 1002921](https://cloudbongos.com/builders#/task/1002921) queue → [task 1002922](https://cloudbongos.com/builders#/task/1002922) pickup). [ADR 0172](<redacted>.md) built the ideator streams and paid `captured_by`, the only person an idea then had. The spark handoff created a second: R146 develops a spark IN PLACE, preserving the author while recording a `developed_by`. **R146 therefore shipped paying NOTHING on purpose** — not an oversight but a refusal to guess, because `awardIdeaFullPassCredit`'s idempotency predicate is `(builder_id, reason)`, so paying the DEVELOPER would not have blocked a later payment to the AUTHOR and one idea would have paid twice; the cost of that caution was that a developed Full Idea received stream 2 **never** unless its author resubmitted. Owner decision (2026-08-22): **70/30 favouring the developer**, on BOTH existing streams, one ratio so there is one rule to explain. The reasoning is behavioural, not sentimental — developing someone else's spark is the scarce act the queue depends on, and at an even split it pays HALF what authoring your own Full Idea pays for the same writing work, so the rational move is always to write your own and the R145 queue stays a list nobody reads. The author keeps 30% plus their share of stream 1 on every task shipped under the idea, forever. `IDEA_DEVELOPER_SHARE = 0.70` is a **policy constant in `credits.js`** beside the pass bar and quick factor (ADR 0172's own consequence, the ADR 0023 precedent): a share of someone's earnings deserves a review and a commit, not a runtime edit. **The structural half is what retires R146's caution:** the economy module now derives the payees itself (`ideaCreditParties` reads `captured_by` + `developed_by`), so the caller can no longer choose a recipient — the develop route hands over an idea and a score, never a payee, and books the split like any other path (fail-open; the development has already committed). Three arithmetic properties, each a real bug if missed: the total is **conserved** (developer rounded, author gets the REMAINDER — rounding both pays 6 out of a 5-credit payout); a **self-developed** idea collapses to ONE payee, because two rows for one builder would silently UNDER-pay under the per-builder idempotency predicate, not double-pay (the comparison normalises via `String()` since `bigint` arrives as a string, so a type mismatch cannot become a phantom payee); and **zero-value rows are dropped**. Ids otherwise pass through unconverted — `Number()`-ing a `bigint` risks precision. `builder_id` in the idempotency predicate, which entered as an index optimisation (the R143/IC2 grader finding), is now **load-bearing for correctness**: two payees share one `reason`, so a reason-only predicate would refuse the second, and a partially-booked split completes on the next call. **Not decided here:** stream 3's split (ships with the Board Room, task 1002999), and whether `origin='develop'` in `ideas_task_lineage` belongs to whoever promotes a DEVELOPED idea into a task — ADR 0172 §5 says the row is "set at promotion and at develop (R146)", but R146 creates no task, so that origin stays deliberately unwired rather than invented; it changes who stream 1 pays.](<redacted>.md) | economy / ideator lane |
|
|
262
262
|
| 0186 | [**Per-project idea templates: the seam is already built; the economics are the open problem** ([task 1002932](https://cloudbongos.com/builders#/task/1002932), BV1.R156, goal 1000062 — a SPIKE, output is this ADR plus a task list, no implementation). The literal question — how does a founder define their own idea shape without a schema change or forking the scorer — is **already answered by shipped work**: R141 gave every template function an optional `registry` defaulting to the built-in one, so validation and scoring both read the declaration as data; `ideas_grades.template_key` is per-row with a format CHECK whose own comment says a project key "stays addressable"; and `provisioning_instances.settings jsonb` is an existing per-project home. **No schema change, no fork.** Decision A: a **`project-settings` kernel port**, sibling of `builder-settings`, read via `resolveOptional` so a vanilla instance with no provisioning module degrades to the built-in registry; `base-v1` stays the default and the fallback for an unresolvable key. Rejected a new `ideas_templates` table (second home for data that has one) and a config file (per-instance, but one instance hosts many projects). **Decision B is the finding that matters.** Completeness is normalised 0–100 WITHIN its template (mean of per-field ratios), while karma reads it against an absolute bar (`IDEA_FULL_PASS_BAR = 60`, `IDEA_FULL_PASS_CREDIT = 5`) — so template choice becomes a credit-minting lever: `base-v1` needs ~200 characters for 5 credits, a 1-field template with `substanceMin` 10 needs ~10, a ~20× arbitrage handed to exactly the projects this ADR empowers. Shipping A without B would make the feature a way to print credits. The fix separates the two questions one number is answering: **completeness stays relative** (it is the right signal for triage ordering and the hall meter) and **karma stops reading it**, switching to an absolute substance total the scorer already computes per field and currently discards after averaging. An interim guard (≥3 required fields, ≥120 total `substanceMin`) bounds the arbitrage without fixing it. Decision C: an optional per-field `weight` (default 1) turning the unweighted mean into a weighted one — today's behaviour is the all-weights-1 case, so it is a data addition and one arithmetic line. Decision D: the five base fields are **not** mandatory (forcing them would make "define your own" cosmetic), replaced by two constraints — a template must declare ≥1 required field, and a stored template is validated at WRITE time so a malformed one is refused there rather than discovered inside the capture path, which must never throw. **Boundary note:** goal 1000062's wall is `[ideas, hall-ui]`, so the port task (provisioning + kernel) and the karma task (economy) cannot be claimed under it.](<redacted>.md) | ideas / templates |
|
|
263
|
+
| 0187 | [**The OAuth web-handshake cookies live 30 minutes, and move together** ([task 1003234](https://cloudbongos.com/builders#/task/1003234), goal 1000058). The three cookies `/auth/web/start` sets — the CSRF state, the validated return target, the repo-scope opt-in — were 10 minutes each, a hall builder's timescale; a founder's first sign-in can include a GitHub SIGNUP with email verification and came back past ten minutes to "State mismatch" with the return + repo scope gone, and the page's retry door was a bare start. Decision: one `HANDSHAKE_COOKIE_MS = 30 min` for all three (they MUST move together — a state outliving its return cookie lands on the default landing with no scope, a return outliving its state lands on State mismatch), and every OAuth error page plus the access-pending auto-walk-in rebuilds its door from whatever cookies are still valid (`recoveryStartHref`, through the same open-redirect allowlist). What did NOT change: the state is still single-use, still `httpOnly`/`sameSite=lax`/apex-scoped, the code still the bearer — only the window for a legitimate handshake grew. Rejected: lengthening only return + repos (keeps the dead end and breaks the one-lifetime invariant the other way), a server-side state store, sixty minutes. The Discord-link state cookie is a different flow and keeps 10.](<redacted>.md) | auth / security posture |
|
|
263
264
|
|
|
264
265
|
> ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.335",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.335",
|
|
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.
|
|
3
|
+
"version": "1.19.335",
|
|
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",
|
|
@@ -21,6 +21,9 @@ function escapeHtml(s) {
|
|
|
21
21
|
// subtitle — replaces the default header sub-line
|
|
22
22
|
// cta — { href, label } for the primary button (default: retry sign-in)
|
|
23
23
|
// note — replaces the default troubleshooting footnote
|
|
24
|
+
// startHref — the retry sign-in URL (default: a bare /auth/web/start); the
|
|
25
|
+
// callback passes recoveryStartHref(req) so the founder's return
|
|
26
|
+
// target + repo-scope opt-in survive the error (task 1003234)
|
|
24
27
|
// The instance's own word for the admitting rank, for per-branch error strings
|
|
25
28
|
// (task 1002616, the 1439 follow-up): core error surfaces never hardcode
|
|
26
29
|
// "Archon" — a non-OTB instance names its own role (branding rankLabels.archon;
|
|
@@ -51,7 +54,7 @@ function renderAuthErrorPage(heading, detail, opts = {}) {
|
|
|
51
54
|
catch { return AUTH_ERROR_FALLBACK; }
|
|
52
55
|
})();
|
|
53
56
|
const subtitle = opts.subtitle || copy.subtitle;
|
|
54
|
-
const cta = opts.cta || { href: '/api/gds/auth/web/start', label: copy.ctaLabel };
|
|
57
|
+
const cta = opts.cta || { href: opts.startHref || '/api/gds/auth/web/start', label: copy.ctaLabel };
|
|
55
58
|
const note = opts.note || "If this keeps happening, the GitHub OAuth app's authorized callback URL may be misconfigured. See docs/recipes/ops-gotchas.md (GitHub OAuth section).";
|
|
56
59
|
// The instance name in the tab title comes from the branding pack, never a
|
|
57
60
|
// hardcoded host brand — a non-OTB instance sees its own name (ADR 0062 §3).
|
|
@@ -100,7 +103,7 @@ function renderAuthErrorPage(heading, detail, opts = {}) {
|
|
|
100
103
|
//
|
|
101
104
|
// Branding resolves defensively, exactly as renderAuthErrorPage does — a
|
|
102
105
|
// branding hiccup must degrade to neutral copy, never crash the page.
|
|
103
|
-
function renderAccessPendingPage(login) {
|
|
106
|
+
function renderAccessPendingPage(login, startHref) {
|
|
104
107
|
const worldName = (() => { try { return branding().identity?.worldName || 'The city'; } catch { return 'The city'; } })();
|
|
105
108
|
const epigraph = (() => { try { return branding().copy?.authError?.epigraph || '— access —'; } catch { return '— access —'; } })();
|
|
106
109
|
// The probe takes the login as a query param; everything interpolated into
|
|
@@ -124,7 +127,7 @@ function renderAccessPendingPage(login) {
|
|
|
124
127
|
</header>
|
|
125
128
|
<main>
|
|
126
129
|
<section class="scroll">
|
|
127
|
-
<div class="landing" id="pending" data-login="${safeLogin}">
|
|
130
|
+
<div class="landing" id="pending" data-login="${safeLogin}" data-start="${escapeHtml(startHref || '/api/gds/auth/web/start')}">
|
|
128
131
|
<p>We're checking your access now. You'll be signed in automatically as soon as it clears — no need to refresh.</p>
|
|
129
132
|
<p class="landing__note" id="pending-note">Checking…</p>
|
|
130
133
|
</div>
|
|
@@ -135,11 +138,14 @@ function renderAccessPendingPage(login) {
|
|
|
135
138
|
var el = document.getElementById('pending');
|
|
136
139
|
var note = document.getElementById('pending-note');
|
|
137
140
|
var login = el ? el.getAttribute('data-login') : '';
|
|
141
|
+
/* server-built from the still-valid handshake cookies (task 1003234): the
|
|
142
|
+
fresh sign-in keeps the wizard return + repo scope a founder started with */
|
|
143
|
+
var start = (el && el.getAttribute('data-start')) || '/api/gds/auth/web/start';
|
|
138
144
|
var EVERY_MS = 3000;
|
|
139
145
|
var GIVE_UP_MS = 5 * 60 * 1000;
|
|
140
146
|
var started = Date.now();
|
|
141
147
|
|
|
142
|
-
function land() { window.location.href =
|
|
148
|
+
function land() { window.location.href = start; }
|
|
143
149
|
|
|
144
150
|
function tick() {
|
|
145
151
|
if (Date.now() - started > GIVE_UP_MS) {
|
|
@@ -180,6 +186,18 @@ const RETURN_COOKIE = 'gds_return';
|
|
|
180
186
|
// include repo") means an ordinary hall sign-in never stores a token, even for a
|
|
181
187
|
// user who previously granted the broader scope (task 1002099 / ADR 0143).
|
|
182
188
|
const REPOS_COOKIE = 'gds_oauth_repos';
|
|
189
|
+
// How long the three /auth/web/start handshake cookies live — the CSRF state,
|
|
190
|
+
// the return target and the repo-scope opt-in — and they MUST move together:
|
|
191
|
+
// a state that outlives its return cookie lands a founder on the default
|
|
192
|
+
// landing with no repo scope, and a return that outlives its state lands them
|
|
193
|
+
// on "State mismatch". They were 10 minutes each, the hall builder's
|
|
194
|
+
// timescale; a founder's first sign-in can include a GitHub SIGNUP (email
|
|
195
|
+
// verification and all) and came back past 10 minutes with all three gone
|
|
196
|
+
// (walk finding, task 1003234). 30 minutes is a security-posture change,
|
|
197
|
+
// deliberately modest: the state is still single-use (the callback clears it),
|
|
198
|
+
// still httpOnly + sameSite=lax, and the code GitHub hands back is still the
|
|
199
|
+
// bearer — only the window in which a legitimate handshake may complete grew.
|
|
200
|
+
const HANDSHAKE_COOKIE_MS = 30 * 60 * 1000;
|
|
183
201
|
|
|
184
202
|
// The apex domain the instance is served under, read from the instance branding
|
|
185
203
|
// pack's cookieDomain (".amazonprimea.com" → "amazonprimea.com",
|
|
@@ -253,6 +271,21 @@ function safeReturnTo(raw) {
|
|
|
253
271
|
return null;
|
|
254
272
|
}
|
|
255
273
|
|
|
274
|
+
// The sign-in-again door on every OAuth error page (and the pending page's
|
|
275
|
+
// auto-walk-in), rebuilt from the handshake cookies that are still valid: the
|
|
276
|
+
// return target and the repo-scope opt-in that /auth/web/start stashed. A bare
|
|
277
|
+
// /auth/web/start dropped both, so a founder who hit "State mismatch" from the
|
|
278
|
+
// wizard came back with no repository access and landed on the default
|
|
279
|
+
// landing (task 1003234). Same open-redirect allowlist as the callback.
|
|
280
|
+
function recoveryStartHref(req) {
|
|
281
|
+
const params = new URLSearchParams();
|
|
282
|
+
const returnTo = safeReturnTo(parseCookie(req, RETURN_COOKIE));
|
|
283
|
+
if (returnTo) params.set('return', returnTo);
|
|
284
|
+
if (parseCookie(req, REPOS_COOKIE) === '1') params.set('repos', '1');
|
|
285
|
+
const q = params.toString();
|
|
286
|
+
return '/api/gds/auth/web/start' + (q ? '?' + q : '');
|
|
287
|
+
}
|
|
288
|
+
|
|
256
289
|
// The canonical sign-in origin, read from the instance branding pack (oauthOrigin,
|
|
257
290
|
// else publicOrigin) so a non-OTB Cloud Bongos instance pins its OAuth redirects to
|
|
258
291
|
// ITS OWN apex instead of a hardcoded "amazonprimea.com" (ADR 0062 §3/§5; the
|
|
@@ -422,7 +455,7 @@ module.exports = function buildAuthRouter() {
|
|
|
422
455
|
httpOnly: true,
|
|
423
456
|
sameSite: 'lax',
|
|
424
457
|
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
|
|
425
|
-
maxAge:
|
|
458
|
+
maxAge: HANDSHAKE_COOKIE_MS,
|
|
426
459
|
// Apex-scoped (#734) so the state survives the host switch: start may run
|
|
427
460
|
// on the subdomain but the callback always lands on the apex.
|
|
428
461
|
...sessionCookieDomainOpts(req),
|
|
@@ -438,7 +471,7 @@ module.exports = function buildAuthRouter() {
|
|
|
438
471
|
httpOnly: true,
|
|
439
472
|
sameSite: 'lax',
|
|
440
473
|
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
|
|
441
|
-
maxAge:
|
|
474
|
+
maxAge: HANDSHAKE_COOKIE_MS,
|
|
442
475
|
...sessionCookieDomainOpts(req),
|
|
443
476
|
});
|
|
444
477
|
}
|
|
@@ -453,7 +486,7 @@ module.exports = function buildAuthRouter() {
|
|
|
453
486
|
httpOnly: true,
|
|
454
487
|
sameSite: 'lax',
|
|
455
488
|
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
|
|
456
|
-
maxAge:
|
|
489
|
+
maxAge: HANDSHAKE_COOKIE_MS,
|
|
457
490
|
...sessionCookieDomainOpts(req),
|
|
458
491
|
});
|
|
459
492
|
}
|
|
@@ -519,12 +552,14 @@ module.exports = function buildAuthRouter() {
|
|
|
519
552
|
return res.status(400).type('text/html').send(renderAuthErrorPage(
|
|
520
553
|
'GitHub refused the handshake',
|
|
521
554
|
`GitHub returned the error code "${error}". This usually means the OAuth app's authorized callback URL doesn't include this server's /api/gds/auth/web/callback path.`,
|
|
555
|
+
{ startHref: recoveryStartHref(req) },
|
|
522
556
|
));
|
|
523
557
|
}
|
|
524
558
|
if (!code || !state || state !== expectedState) {
|
|
525
559
|
return res.status(400).type('text/html').send(renderAuthErrorPage(
|
|
526
560
|
'State mismatch',
|
|
527
|
-
|
|
561
|
+
`The handshake-cookie did not match the state GitHub returned. This usually means the sign-in took too long (the cookie expires after ${HANDSHAKE_COOKIE_MS / 60000} minutes) or the request crossed an origin boundary. Start over and sign in again.`,
|
|
562
|
+
{ startHref: recoveryStartHref(req) },
|
|
528
563
|
));
|
|
529
564
|
}
|
|
530
565
|
try {
|
|
@@ -604,15 +639,21 @@ module.exports = function buildAuthRouter() {
|
|
|
604
639
|
console.error('[gds] web/callback admission classify', probeErr);
|
|
605
640
|
}
|
|
606
641
|
if (expectedSoon) {
|
|
607
|
-
return res.status(202).type('text/html').send(renderAccessPendingPage(err.login));
|
|
642
|
+
return res.status(202).type('text/html').send(renderAccessPendingPage(err.login, recoveryStartHref(req)));
|
|
608
643
|
}
|
|
644
|
+
// The founder-shaped branch (only reachable with open enrollment OFF):
|
|
645
|
+
// a stranger who was on their way to create a project is told the
|
|
646
|
+
// approval brings them back to it — the return cookie says where.
|
|
647
|
+
const founderReturn = safeReturnTo(parseCookie(req, RETURN_COOKIE)) || '';
|
|
648
|
+
const onWayToProject = founderReturn.startsWith('/projects');
|
|
609
649
|
return res.status(403).type('text/html').send(renderAuthErrorPage(
|
|
610
650
|
'This account is not approved yet',
|
|
611
651
|
`Signing in with GitHub is only for builders the ${admitterLabel()} has already approved. If you are new, request access at the builders’ hall — once the ${admitterLabel()} approves you, this sign-in will let you in. If you were just approved, it may take a moment to be marked invited; try again shortly.`,
|
|
612
652
|
{
|
|
613
653
|
subtitle: 'Sign-in is for approved builders. New here? Request access first.',
|
|
614
654
|
cta: { href: '/builders', label: 'Go to the hall & request access' },
|
|
615
|
-
note:
|
|
655
|
+
note: (onWayToProject ? 'You were on your way to creating a project — once approved, sign in again and you land back in it. ' : '') +
|
|
656
|
+
`Already approved and still blocked? Ask the ${admitterLabel()} to confirm your request was marked “invited.”`,
|
|
616
657
|
},
|
|
617
658
|
));
|
|
618
659
|
}
|
|
@@ -620,6 +661,7 @@ module.exports = function buildAuthRouter() {
|
|
|
620
661
|
res.status(500).type('text/html').send(renderAuthErrorPage(
|
|
621
662
|
'The handshake threw an error',
|
|
622
663
|
`The server tried to exchange the auth code for a session and something went wrong: ${err.message}. Try again; if it persists, ask the ${admitterLabel()} to investigate the server logs.`,
|
|
664
|
+
{ startHref: recoveryStartHref(req) },
|
|
623
665
|
));
|
|
624
666
|
}
|
|
625
667
|
});
|
|
@@ -1080,6 +1122,8 @@ module.exports = function buildAuthRouter() {
|
|
|
1080
1122
|
// cookie-domain decision — worth locking down independently of a running server.
|
|
1081
1123
|
module.exports.safeReturnTo = safeReturnTo;
|
|
1082
1124
|
module.exports.postLoginLanding = postLoginLanding;
|
|
1125
|
+
module.exports.recoveryStartHref = recoveryStartHref;
|
|
1126
|
+
module.exports.HANDSHAKE_COOKIE_MS = HANDSHAKE_COOKIE_MS;
|
|
1083
1127
|
module.exports.sessionCookieDomainOpts = sessionCookieDomainOpts;
|
|
1084
1128
|
module.exports.reqHost = reqHost;
|
|
1085
1129
|
// The auth-error page renderer (task 1439): pure over the branding pack, so a
|
package/src/module-api.js
CHANGED
|
@@ -1093,8 +1093,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1093
1093
|
// landed since 1.19.331 with no explicit bump. run 32793033453. (task 1002620)
|
|
1094
1094
|
// 1.19.333 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1095
1095
|
// landed since 1.19.332 with no explicit bump. run 32793666555. (task 1002620)
|
|
1096
|
+
// 1.19.334 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1097
|
+
// landed since 1.19.333 with no explicit bump. run 32794305277. (task 1002620)
|
|
1098
|
+
// 1.19.335 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1099
|
+
// landed since 1.19.334 with no explicit bump. run 32795232997. (task 1002620)
|
|
1096
1100
|
// ---------------------------------------------------------------------------
|
|
1097
|
-
const CORE_VERSION = '1.19.
|
|
1101
|
+
const CORE_VERSION = '1.19.335'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1098
1102
|
|
|
1099
1103
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1100
1104
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// tests/auth_web_recovery.mjs — founder-shaped OAuth recovery (task 1003234,
|
|
2
|
+
// goal 1000058). The walk found that every OAuth error page's "try again" door
|
|
3
|
+
// was a bare /auth/web/start — dropping the wizard's return target and the
|
|
4
|
+
// repo-scope opt-in — and that the three handshake cookies expired at 10
|
|
5
|
+
// minutes, i.e. mid GitHub signup. Both are pinned here: the pure helper by
|
|
6
|
+
// value, the call sites and the shared lifetime by reading the source.
|
|
7
|
+
|
|
8
|
+
import { strict as assert } from 'node:assert';
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const authRoutes = require(path.join(ROOT, 'src/bongos/routes/auth.js'));
|
|
18
|
+
const { recoveryStartHref, HANDSHAKE_COOKIE_MS } = authRoutes;
|
|
19
|
+
const SRC = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/auth.js'), 'utf8').replace(/\r\n/g, '\n');
|
|
20
|
+
const req = (cookie) => ({ headers: cookie ? { cookie } : {} });
|
|
21
|
+
|
|
22
|
+
test('the retry door carries the wizard return and the repo-scope opt-in while their cookies are valid', () => {
|
|
23
|
+
assert.equal(recoveryStartHref(req('gds_return=%2Fprojects%3Fview%3Dnew; gds_oauth_repos=1')),
|
|
24
|
+
'/api/gds/auth/web/start?return=%2Fprojects%3Fview%3Dnew&repos=1');
|
|
25
|
+
assert.equal(recoveryStartHref(req('gds_return=%2Fprojects%3Fview%3Dnew')),
|
|
26
|
+
'/api/gds/auth/web/start?return=%2Fprojects%3Fview%3Dnew', 'a hall sign-in never asks for repos');
|
|
27
|
+
assert.equal(recoveryStartHref(req('gds_oauth_repos=1')), '/api/gds/auth/web/start?repos=1');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('with nothing left to rebuild from, the door is the bare start — never a guess', () => {
|
|
31
|
+
assert.equal(recoveryStartHref(req('')), '/api/gds/auth/web/start');
|
|
32
|
+
assert.equal(recoveryStartHref(req('gds_session=abc')), '/api/gds/auth/web/start');
|
|
33
|
+
assert.equal(recoveryStartHref(req('gds_oauth_repos=0')), '/api/gds/auth/web/start', 'only the literal opt-in counts');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('the door is under the same open-redirect allowlist as the callback', () => {
|
|
37
|
+
assert.equal(recoveryStartHref(req('gds_return=https%3A%2F%2Fevil.example%2Fx')), '/api/gds/auth/web/start');
|
|
38
|
+
assert.equal(recoveryStartHref(req('gds_return=%2F%2Fevil.example')), '/api/gds/auth/web/start');
|
|
39
|
+
assert.equal(recoveryStartHref(req('gds_return=javascript%3Aalert(1)')), '/api/gds/auth/web/start');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('every OAuth error page and the pending page rebuild their door from the cookies', () => {
|
|
43
|
+
const start = SRC.indexOf("router.get('/auth/web/callback'");
|
|
44
|
+
const end = SRC.indexOf("router.get('/auth/discord/start'");
|
|
45
|
+
const callback = SRC.slice(start, end);
|
|
46
|
+
assert.equal((callback.match(/\{ startHref: recoveryStartHref\(req\) \}/g) || []).length, 3,
|
|
47
|
+
'GitHub refused · State mismatch · handshake threw — three pages, three doors');
|
|
48
|
+
assert.match(callback, /renderAccessPendingPage\(err\.login, recoveryStartHref\(req\)\)/, 'the auto-walk-in lands where the founder was going');
|
|
49
|
+
assert.match(callback, /const onWayToProject = founderReturn\.startsWith\('\/projects'\);/, 'the founder-shaped NOT_APPROVED note');
|
|
50
|
+
assert.match(SRC, /const cta = opts\.cta \|\| \{ href: opts\.startHref \|\| '\/api\/gds\/auth\/web\/start', label: copy\.ctaLabel \};/);
|
|
51
|
+
assert.match(SRC, /data-start="\$\{escapeHtml\(startHref \|\| '\/api\/gds\/auth\/web\/start'\)\}"/, 'the pending page reads its start from a data attribute, never string-built into script');
|
|
52
|
+
assert.match(SRC, /function land\(\) \{ window\.location\.href = start; \}/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the three handshake cookies share one lifetime, long enough for a GitHub signup', () => {
|
|
56
|
+
assert.equal(HANDSHAKE_COOKIE_MS, 30 * 60 * 1000);
|
|
57
|
+
const start = SRC.indexOf("router.get('/auth/web/start'");
|
|
58
|
+
const end = SRC.indexOf("router.get('/auth/web/admission-status'");
|
|
59
|
+
const block = SRC.slice(start, end);
|
|
60
|
+
assert.equal((block.match(/maxAge: HANDSHAKE_COOKIE_MS,/g) || []).length, 3, 'state · return · repos');
|
|
61
|
+
assert.doesNotMatch(block, /maxAge: 10 \* 60 \* 1000/, 'no handshake cookie is left on the old clock');
|
|
62
|
+
assert.match(SRC, /the cookie expires after \$\{HANDSHAKE_COOKIE_MS \/ 60000\} minutes/, 'the State-mismatch copy reads the same constant');
|
|
63
|
+
});
|
|
@@ -26,6 +26,7 @@ import { test } from 'node:test';
|
|
|
26
26
|
import { createRequire } from 'node:module';
|
|
27
27
|
import { spawnSync } from 'node:child_process';
|
|
28
28
|
import fs from 'node:fs';
|
|
29
|
+
import os from 'node:os';
|
|
29
30
|
import path from 'node:path';
|
|
30
31
|
import { fileURLToPath } from 'node:url';
|
|
31
32
|
import { fitnessSource } from './helpers.mjs';
|
|
@@ -152,3 +153,144 @@ test('the whole host-identity check is green on this tree', () => {
|
|
|
152
153
|
const r = fitness.checkKernelPromptsNoHardcodedIdentity();
|
|
153
154
|
assert.equal(r.ok, true, `violations:\n${r.violations.join('\n')}`);
|
|
154
155
|
});
|
|
156
|
+
|
|
157
|
+
// ---- 4. the SAME rule applies to the baked box scripts (task 1003252) ------
|
|
158
|
+
//
|
|
159
|
+
// THE SECOND BUG, same class, different file group. The sweep above fixed
|
|
160
|
+
// scripts/gds/*.sh but never looked at infra/box-*.sh — the scripts baked onto a
|
|
161
|
+
// dev box. Those kept reading a hardcoded ~/.config/otb/gds-session.json while
|
|
162
|
+
// paste-token.js writes to ~/.config/<branding.configDir>/. On this instance
|
|
163
|
+
// (configDir "cloudbongos") that meant every /builder-reauth silently stranded the
|
|
164
|
+
// box: the 5-min heartbeat, the terminal/host-key/version reporters and the
|
|
165
|
+
// GDS-backed git credential helper all kept presenting a DEAD token and failing
|
|
166
|
+
// with a misleading "session expired — re-auth on the box", which is exactly what
|
|
167
|
+
// the builder had just done. Observed live: four reporters 401ing every tick and a
|
|
168
|
+
// checkout 311 commits behind origin/main.
|
|
169
|
+
//
|
|
170
|
+
// The fix mirrors instance-config.js's read-side fallback in bash: prefer the most
|
|
171
|
+
// recently written gds-session.json across ~/.config/*/, since branding is not
|
|
172
|
+
// readable from a script that must also run before any clone exists.
|
|
173
|
+
|
|
174
|
+
const BOX_SCRIPTS = [
|
|
175
|
+
'box-heartbeat.sh',
|
|
176
|
+
'box-report-terminal.sh',
|
|
177
|
+
'box-report-host-keys.sh',
|
|
178
|
+
'box-report-version.sh',
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
const INFRA = path.join(ROOT, 'infra');
|
|
182
|
+
const boxCode = (f) => fitness.stripCommentsForIdentity(fs.readFileSync(path.join(INFRA, f), 'utf8'), '.sh');
|
|
183
|
+
|
|
184
|
+
test('every baked box script resolves the session across config dirs, not just the legacy one', () => {
|
|
185
|
+
for (const f of BOX_SCRIPTS) {
|
|
186
|
+
const src = boxCode(f);
|
|
187
|
+
assert.match(src, /\.config\/\*\/gds-session\.json/,
|
|
188
|
+
`${f} must fall back across ~/.config/*/ — without it a /builder-reauth into a `
|
|
189
|
+
+ 'non-"otb" configDir strands the box on a dead token');
|
|
190
|
+
// The legacy literal may survive ONLY as the last-resort default, never as the
|
|
191
|
+
// sole source — i.e. it must be preceded by the glob scan.
|
|
192
|
+
// The scan widens what these scripts will read, so each candidate must be owned
|
|
193
|
+
// by the box user -- otherwise a file dropped by another local account could win
|
|
194
|
+
// simply by being newest.
|
|
195
|
+
assert.match(src, /\[ -O "\$_sess" \]/,
|
|
196
|
+
`${f} must require candidate session files to be owned by the current user`);
|
|
197
|
+
const globAt = src.indexOf('.config/*/gds-session.json');
|
|
198
|
+
const legacyAt = src.indexOf('.config/otb/gds-session.json');
|
|
199
|
+
if (legacyAt !== -1) {
|
|
200
|
+
assert.ok(globAt < legacyAt,
|
|
201
|
+
`${f} reaches the legacy path before scanning ~/.config/*/ — the fallback must come first`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('every baked box script passes bash -n', (t) => {
|
|
207
|
+
const probe = bash(['-n', 'infra/box-heartbeat.sh']);
|
|
208
|
+
if (!probe) return t.skip('bash not available on this machine');
|
|
209
|
+
for (const f of BOX_SCRIPTS) {
|
|
210
|
+
const r = bash(['-n', `infra/${f}`]);
|
|
211
|
+
assert.equal(r.status, 0, `bash -n infra/${f} failed:\n${r.stderr}`);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Pull the resolver straight out of the committed file so this exercises the real
|
|
216
|
+
// code, not a copy that could drift away from it.
|
|
217
|
+
function resolverBlock(f) {
|
|
218
|
+
const src = fs.readFileSync(path.join(INFRA, f), 'utf8');
|
|
219
|
+
const start = src.indexOf('if [ -z "${GDS_SESSION:-}" ]; then');
|
|
220
|
+
const endMark = 'GDS_SESSION="${GDS_SESSION:-$HOME/.config/otb/gds-session.json}"';
|
|
221
|
+
const end = src.indexOf(endMark, start);
|
|
222
|
+
assert.ok(start !== -1 && end !== -1, `could not locate the resolver block in ${f}`);
|
|
223
|
+
return src.slice(start, end + endMark.length);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
test('the resolver prefers the most recently written session across config dirs', (t) => {
|
|
227
|
+
const probe = bash(['-n', 'infra/box-report-version.sh']);
|
|
228
|
+
if (!probe) return t.skip('bash not available on this machine');
|
|
229
|
+
|
|
230
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'boxsess-'));
|
|
231
|
+
const legacy = path.join(home, '.config', 'otb');
|
|
232
|
+
const configured = path.join(home, '.config', 'cloudbongos');
|
|
233
|
+
fs.mkdirSync(legacy, { recursive: true });
|
|
234
|
+
fs.mkdirSync(configured, { recursive: true });
|
|
235
|
+
fs.writeFileSync(path.join(legacy, 'gds-session.json'), '{"token":"stale"}');
|
|
236
|
+
fs.writeFileSync(path.join(configured, 'gds-session.json'), '{"token":"fresh"}');
|
|
237
|
+
// Make the legacy copy unambiguously older than the freshly re-authed one.
|
|
238
|
+
const old = new Date(Date.now() - 60_000);
|
|
239
|
+
fs.utimesSync(path.join(legacy, 'gds-session.json'), old, old);
|
|
240
|
+
|
|
241
|
+
const block = resolverBlock('box-report-version.sh');
|
|
242
|
+
const run = (env) => spawnSync('bash', ['-c', `set -uo pipefail\n${block}\nprintf '%s' "$GDS_SESSION"`],
|
|
243
|
+
{ encoding: 'utf8', env: { PATH: process.env.PATH, HOME: home, ...env } });
|
|
244
|
+
|
|
245
|
+
// 1. the freshly written session wins over the stale legacy one
|
|
246
|
+
assert.equal(run({}).stdout, path.join(configured, 'gds-session.json'));
|
|
247
|
+
|
|
248
|
+
// 2. an explicit override still beats the scan
|
|
249
|
+
assert.equal(run({ GDS_SESSION: '/explicit/path.json' }).stdout, '/explicit/path.json');
|
|
250
|
+
|
|
251
|
+
// 3. with nothing on disk it falls back to the historical path, so the caller's
|
|
252
|
+
// "no session at ..." message still names somewhere sensible
|
|
253
|
+
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'boxsess-empty-'));
|
|
254
|
+
const r3 = spawnSync('bash', ['-c', `set -uo pipefail\n${block}\nprintf '%s' "$GDS_SESSION"`],
|
|
255
|
+
{ encoding: 'utf8', env: { PATH: process.env.PATH, HOME: empty } });
|
|
256
|
+
assert.equal(r3.stdout, path.join(empty, '.config', 'otb', 'gds-session.json'));
|
|
257
|
+
|
|
258
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
259
|
+
fs.rmSync(empty, { recursive: true, force: true });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// box-heartbeat.sh is the odd one out: its override variable (OTB_GDS_SESSION) is
|
|
263
|
+
// NOT the variable it resolves into (SF). A first cut of the fix guarded on the
|
|
264
|
+
// override but assigned to SF, so a caller that SET the override had it silently
|
|
265
|
+
// dropped and fell through to the legacy default — the exact bug being fixed,
|
|
266
|
+
// reintroduced through the back door. Pin all three branches.
|
|
267
|
+
test('box-heartbeat.sh honors OTB_GDS_SESSION, scans, then falls back', (t) => {
|
|
268
|
+
const probe = bash(['-n', 'infra/box-heartbeat.sh']);
|
|
269
|
+
if (!probe) return t.skip('bash not available on this machine');
|
|
270
|
+
|
|
271
|
+
const src = fs.readFileSync(path.join(INFRA, 'box-heartbeat.sh'), 'utf8');
|
|
272
|
+
const start = src.indexOf('SF="${OTB_GDS_SESSION:-}"');
|
|
273
|
+
const endMark = 'SF="${SF:-$HOME/.config/otb/gds-session.json}"';
|
|
274
|
+
const end = src.indexOf(endMark, start);
|
|
275
|
+
assert.ok(start !== -1 && end !== -1, 'could not locate the SF resolver in box-heartbeat.sh');
|
|
276
|
+
const block = src.slice(start, end + endMark.length);
|
|
277
|
+
|
|
278
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'hbsess-'));
|
|
279
|
+
const configured = path.join(home, '.config', 'cloudbongos');
|
|
280
|
+
fs.mkdirSync(configured, { recursive: true });
|
|
281
|
+
fs.writeFileSync(path.join(configured, 'gds-session.json'), '{"token":"fresh"}');
|
|
282
|
+
|
|
283
|
+
const run = (env, h = home) => spawnSync('bash',
|
|
284
|
+
['-c', `set -euo pipefail\n${block}\nprintf '%s' "$SF"`],
|
|
285
|
+
{ encoding: 'utf8', env: { PATH: process.env.PATH, HOME: h, ...env } }).stdout;
|
|
286
|
+
|
|
287
|
+
assert.equal(run({ OTB_GDS_SESSION: '/explicit/override.json' }), '/explicit/override.json',
|
|
288
|
+
'the OTB_GDS_SESSION override must win — guarding on it but assigning elsewhere drops it');
|
|
289
|
+
assert.equal(run({}), path.join(configured, 'gds-session.json'));
|
|
290
|
+
|
|
291
|
+
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'hbsess-empty-'));
|
|
292
|
+
assert.equal(run({}, empty), path.join(empty, '.config', 'otb', 'gds-session.json'));
|
|
293
|
+
|
|
294
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
295
|
+
fs.rmSync(empty, { recursive: true, force: true });
|
|
296
|
+
});
|