@bongos/core 1.19.996 → 1.19.998
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 +25 -20
- package/.claude/skills/read-session-export/SKILL.md +11 -35
- package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +14 -5
- package/docs/api/openapi.json +2 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/routes/goal-route-authz.js +15 -1
- package/modules/lifecycle/routes/goals.js +12 -12
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/prove-strict-versioning.js +1 -1
- package/scripts/gds/provision-core-upgrade.js +127 -15
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_runner.mjs +123 -20
- package/tests/goal_archive_disposition.mjs +2 -2
- package/tests/goal_owner_manager_powers.mjs +90 -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.998",
|
|
6
|
+
"core_contract": "1.19.998",
|
|
7
|
+
"source_commit": "18eb9f5bf0ce34f16c214da16a3b095c3bffb919",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-24T00:50:38.644Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 520,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2406,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2952,
|
|
20
|
+
"tree_sha256": "674cb32d9c86b0bad374632422a694d65919b84b97f553a856b7ba66baa23d81",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -222,7 +222,7 @@
|
|
|
222
222
|
{
|
|
223
223
|
"path": ".claude/skills/read-session-export/SKILL.md",
|
|
224
224
|
"mode": "0000644",
|
|
225
|
-
"sha256": "
|
|
225
|
+
"sha256": "594d32ef63be2b45c360210ad004fc14e6dae889af97d47d72aac6a738fe1878"
|
|
226
226
|
},
|
|
227
227
|
{
|
|
228
228
|
"path": ".claude/skills/recall/SKILL.md",
|
|
@@ -2132,7 +2132,7 @@
|
|
|
2132
2132
|
{
|
|
2133
2133
|
"path": "docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md",
|
|
2134
2134
|
"mode": "0000644",
|
|
2135
|
-
"sha256": "
|
|
2135
|
+
"sha256": "9ebd9e4b3cb71f3ee4c5e06cdad04471ab07988744263fef7cd40a403b296b0b"
|
|
2136
2136
|
},
|
|
2137
2137
|
{
|
|
2138
2138
|
"path": "docs/adr/0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md",
|
|
@@ -2157,7 +2157,7 @@
|
|
|
2157
2157
|
{
|
|
2158
2158
|
"path": "docs/api/openapi.json",
|
|
2159
2159
|
"mode": "0000644",
|
|
2160
|
-
"sha256": "
|
|
2160
|
+
"sha256": "734016d0505821010e06c197ea1987e3eb4e9700d45ad32d982a5b9cd9da5aed"
|
|
2161
2161
|
},
|
|
2162
2162
|
{
|
|
2163
2163
|
"path": "docs/architecture.md",
|
|
@@ -2717,7 +2717,7 @@
|
|
|
2717
2717
|
{
|
|
2718
2718
|
"path": "docs/module-api-changelog.md",
|
|
2719
2719
|
"mode": "0000644",
|
|
2720
|
-
"sha256": "
|
|
2720
|
+
"sha256": "ce30c687aa2730d3e13dc5bb9ea21d180b7272bbd080f8e564818c0f2b43c1cd"
|
|
2721
2721
|
},
|
|
2722
2722
|
{
|
|
2723
2723
|
"path": "docs/modules-contract.md",
|
|
@@ -6087,12 +6087,12 @@
|
|
|
6087
6087
|
{
|
|
6088
6088
|
"path": "modules/lifecycle/routes/goal-route-authz.js",
|
|
6089
6089
|
"mode": "0000644",
|
|
6090
|
-
"sha256": "
|
|
6090
|
+
"sha256": "89d282e6c5110ae698484ddab698a72f27c6fe509fc97ff628326fe42e79483d"
|
|
6091
6091
|
},
|
|
6092
6092
|
{
|
|
6093
6093
|
"path": "modules/lifecycle/routes/goals.js",
|
|
6094
6094
|
"mode": "0000644",
|
|
6095
|
-
"sha256": "
|
|
6095
|
+
"sha256": "8bd3b013fa96d062f15386e53f43dcdb918baf619c2beb5cf5333ef7052475f3"
|
|
6096
6096
|
},
|
|
6097
6097
|
{
|
|
6098
6098
|
"path": "modules/lifecycle/routes/help-requests.js",
|
|
@@ -8187,12 +8187,12 @@
|
|
|
8187
8187
|
{
|
|
8188
8188
|
"path": "package-lock.json",
|
|
8189
8189
|
"mode": "0000644",
|
|
8190
|
-
"sha256": "
|
|
8190
|
+
"sha256": "badffa260a3d5d830fea0ed1e1beb2950d9c03f0c0dd5c05998603953b710050"
|
|
8191
8191
|
},
|
|
8192
8192
|
{
|
|
8193
8193
|
"path": "package.json",
|
|
8194
8194
|
"mode": "0000644",
|
|
8195
|
-
"sha256": "
|
|
8195
|
+
"sha256": "82c6009c6ab97ab286b0fca8b3e3cd82d8f832c7abdb988751ad0da0760d078a"
|
|
8196
8196
|
},
|
|
8197
8197
|
{
|
|
8198
8198
|
"path": "public-docs/index.html",
|
|
@@ -9127,7 +9127,7 @@
|
|
|
9127
9127
|
{
|
|
9128
9128
|
"path": "scripts/gds/prove-strict-versioning.js",
|
|
9129
9129
|
"mode": "0000644",
|
|
9130
|
-
"sha256": "
|
|
9130
|
+
"sha256": "65bb758becd002ae830a8338ec6ba0bc21ec5ef91ad564c85a2a94b7ab6ffbbc"
|
|
9131
9131
|
},
|
|
9132
9132
|
{
|
|
9133
9133
|
"path": "scripts/gds/provision-byo-host.js",
|
|
@@ -9147,7 +9147,7 @@
|
|
|
9147
9147
|
{
|
|
9148
9148
|
"path": "scripts/gds/provision-core-upgrade.js",
|
|
9149
9149
|
"mode": "0000644",
|
|
9150
|
-
"sha256": "
|
|
9150
|
+
"sha256": "70ee5d90d63c8b7de938bb22c7a2e49154f5e80ce7e4ac78d180148017a09e74"
|
|
9151
9151
|
},
|
|
9152
9152
|
{
|
|
9153
9153
|
"path": "scripts/gds/provision-disconnect.js",
|
|
@@ -10237,7 +10237,7 @@
|
|
|
10237
10237
|
{
|
|
10238
10238
|
"path": "src/module-api.js",
|
|
10239
10239
|
"mode": "0000644",
|
|
10240
|
-
"sha256": "
|
|
10240
|
+
"sha256": "6003f41c9940fe1e5a265d2d1812fd4ee43a67aed8cf9a4a60c5495b3918c388"
|
|
10241
10241
|
},
|
|
10242
10242
|
{
|
|
10243
10243
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11152,7 +11152,7 @@
|
|
|
11152
11152
|
{
|
|
11153
11153
|
"path": "tests/core_upgrade_runner.mjs",
|
|
11154
11154
|
"mode": "0000644",
|
|
11155
|
-
"sha256": "
|
|
11155
|
+
"sha256": "104ff5419638902eb272d15040a8fe69c4e1d14c38c7a977b1c2fd64c3cadd11"
|
|
11156
11156
|
},
|
|
11157
11157
|
{
|
|
11158
11158
|
"path": "tests/cosmos_token_contrast.mjs",
|
|
@@ -11662,7 +11662,7 @@
|
|
|
11662
11662
|
{
|
|
11663
11663
|
"path": "tests/goal_archive_disposition.mjs",
|
|
11664
11664
|
"mode": "0000644",
|
|
11665
|
-
"sha256": "
|
|
11665
|
+
"sha256": "de5b561ffe78ead5f6a1ad08066aaa2672627f441f8602fd46e1fdac453512e0"
|
|
11666
11666
|
},
|
|
11667
11667
|
{
|
|
11668
11668
|
"path": "tests/goal_archive_hall.mjs",
|
|
@@ -11769,6 +11769,11 @@
|
|
|
11769
11769
|
"mode": "0000644",
|
|
11770
11770
|
"sha256": "c93422d76ff7f0107190c6de1954f110e92a18913fabbcdea2b4f67693d538e6"
|
|
11771
11771
|
},
|
|
11772
|
+
{
|
|
11773
|
+
"path": "tests/goal_owner_manager_powers.mjs",
|
|
11774
|
+
"mode": "0000644",
|
|
11775
|
+
"sha256": "9853fe32906b842e6c78dd024e33bee5c00649ed2de2f6b593d736d0155e7832"
|
|
11776
|
+
},
|
|
11772
11777
|
{
|
|
11773
11778
|
"path": "tests/goal_routes.mjs",
|
|
11774
11779
|
"mode": "0000644",
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: read-session-export
|
|
3
3
|
description: >-
|
|
4
|
-
Read a Claude Code /export zip — conversation, thinking,
|
|
4
|
+
Read a Claude Code /export zip — conversation, thinking, tool calls — to answer questions about a past session. READS an existing export, never creates one. Triggers: a dropped session-export-*.zip, "read/summarize this session", "what happened in this session", "what was I/you thinking", "/read-session-export".
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
The user already has the zip — Claude Code's built-in `/export` made it.
|
|
7
|
+
The user already has the zip — Claude Code's built-in `/export` made it. Read it with the reader rather than by hand: it prints a compact transcript (prompts + thinking + replies + one-line tool calls) to stdout and skips the megabytes of debug logs.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
node scripts/gds/read-session-export.js <export.zip> #
|
|
10
|
+
node scripts/gds/read-session-export.js <export.zip> # compact transcript → stdout
|
|
11
11
|
node scripts/gds/read-session-export.js --latest # newest session-export-*.zip in ~/Downloads
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
Then **
|
|
14
|
+
Then **answer what the user actually asked** — summarize the session, pull out the reasoning behind a decision, find where something went wrong, list what was built. Don't paste the transcript back; that's the raw material, not the answer.
|
|
15
15
|
|
|
16
16
|
## Pick the right depth (it's fast either way)
|
|
17
17
|
|
|
@@ -22,38 +22,14 @@ Then **read that output and respond to the user's real intent** — summarize th
|
|
|
22
22
|
| Normal "read / summarize this session" | _(no flag)_ — compact transcript, tool results clipped |
|
|
23
23
|
| "I need the full tool output / exact text" | `--full` (loosens the result clipping) |
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
**A long export is a file, not a dump.** Always open with `--meta`; if it reports hundreds of tool calls, redirect to your session's scratchpad directory and read it in slices, rather than pouring thousands of lines into context at once:
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- `metadata.json` — model, effort, title, cwd, branch, timestamps.
|
|
32
|
-
- `<cliSessionId>.jsonl` — **the transcript**, one JSON record per line. Assistant records carry `content[]` blocks of type `thinking` (the thoughts), `text` (the reply), and `tool_use`; the matching `tool_result` rides in the next `user` record. The reader stitches these together.
|
|
33
|
-
- `<cliSessionId>/subagents/agent-*.jsonl` — subagent transcripts (rendered after the main one).
|
|
34
|
-
- `<cliSessionId>/tool-results/*.txt` — outputs too big to inline.
|
|
35
|
-
- `logs/*.log` — app debug logs. **Not the conversation** — ignored.
|
|
36
|
-
|
|
37
|
-
You rarely need this — the reader handles it — but it's here so you can read a record by hand if you must.
|
|
38
|
-
|
|
39
|
-
## The thinking may be redacted — and what to show instead
|
|
40
|
-
|
|
41
|
-
Whether "the thoughts" are actually readable depends on the model that ran the session, not on this tool:
|
|
42
|
-
|
|
43
|
-
- **Sonnet sessions** → thinking exports as plaintext; you'll see the real reasoning.
|
|
44
|
-
- **Opus sessions** (e.g. `claude-opus-4-8`) → thinking is **redacted**: the export carries only an encrypted signature, not the words. The reader states this once in the header (`💭 0 thoughts (+N redacted)` + a one-line banner) and otherwise keeps the body clean.
|
|
45
|
-
|
|
46
|
-
**The substitute for redacted thinking is already in the transcript — not in the logs.** When the thoughts are encrypted, lean on what Claude actually *said and did*: the **text replies** routinely state the reasoning in plain language ("the problem is X, so I'll Y"), and the **tool-call sequence** shows the investigation step by step. The reader surfaces both, so you can reconstruct the "why" of a redacted session from its actions. (The zip's `logs/*.log` are NOT a fallback: they're app-wide, multi-day rolling logs with no per-session correlation and no reasoning — ignore them.)
|
|
47
|
-
|
|
48
|
-
So if the user exported an Opus session hoping to re-read the literal reasoning, tell them plainly it's redacted — then answer their question anyway from the text + actions, which usually makes the reasoning clear.
|
|
49
|
-
|
|
50
|
-
## Constraints
|
|
27
|
+
```bash
|
|
28
|
+
node scripts/gds/read-session-export.js <zip> > <scratchpad>/transcript.md # then sed -n '1,400p', '400,850p', …
|
|
29
|
+
```
|
|
51
30
|
|
|
52
|
-
|
|
53
|
-
- **Local + zero-dependency.** Pure Node stdlib (a small `zlib` ZIP reader) — no Bongos session, no network, no install. Works on any builder machine.
|
|
54
|
-
- ZIP64 archives (multi-GB) are refused with a clear message; no real session export hits that.
|
|
31
|
+
## If the thinking is redacted
|
|
55
32
|
|
|
56
|
-
|
|
33
|
+
Opus sessions export encrypted reasoning rather than words, and the reader says so in its header. Tell the user plainly — then answer their question anyway from the **text replies**, which routinely state the reasoning outright ("the problem is X, so I'll Y"), and the **tool-call sequence**, which shows the investigation step by step. The zip's `logs/*.log` are not a fallback: app-wide rolling debug logs, no per-session correlation, no reasoning.
|
|
57
34
|
|
|
58
|
-
|
|
59
|
-
- Reads: the `session-export-*.zip` you point it at. Writes: nothing.
|
|
35
|
+
Reads only — it creates no files and never modifies the zip.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ADR 0327 — A cloud-host runs on the owner's own account, and the key is borrowed, not stored
|
|
2
2
|
|
|
3
|
-
- **Status:** accepted
|
|
3
|
+
- **Status:** accepted — **one Context finding retracted 2026-09-23** ([task 1004186](https://cloudbongos.com/builders#/task/1004186)): `mercury` was *not* already on Render, and the first Consequences bullet that rested on it is retracted with it. Decisions 1–5 are unaffected and stand. See the correction in Context.
|
|
4
4
|
- **Date:** 2026-09-22
|
|
5
5
|
- **Task:** [task 1004175](https://cloudbongos.com/builders#/task/1004175) (goal 1000106 — working area 1, Project creation)
|
|
6
6
|
- **Deciders:** Lars (Archon) — decisions 1, 3 and 4 are the owner's. Decision 2 is Claude's design answer to the owner's question "is there a simpler way", and decision 5 records a measurement.
|
|
@@ -14,9 +14,18 @@
|
|
|
14
14
|
|
|
15
15
|
Standing that up exposed what the unexamined assumption costs. A Render API key is issued against a *user* and carries that user's rights in **every workspace they belong to**. The owner's key reached three workspaces, one of them another builder's, holding their live production services. We would have been one bug away from acting on a third party's infrastructure with a credential they never gave us.
|
|
16
16
|
|
|
17
|
-
The same check found
|
|
17
|
+
The same check found what was recorded here as a second thing: "**`mercury` was already on Render**, run by its own owner from their own repository. Under ADR 0323 we would have migrated a working project *off* a correct setup and onto our bill."
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
**Correction, 2026-09-23 ([task 1004186](https://cloudbongos.com/builders#/task/1004186)). That finding is wrong; the sentence above is kept only to show what was claimed.** The Bongos instance is on our own box, and always was. Measured against the live fleet and the live endpoint:
|
|
20
|
+
|
|
21
|
+
- Fleet row 31: `hosting_shape='standalone'`, `domain=mercury.cloudbongos.com`, `db_name=mercury`, `port=3018`, `onboard_mode=adopt`, `target_ref=willHermesLines/mercury_bongos`, last verified up 2026-09-24.
|
|
22
|
+
- `GET https://mercury.cloudbongos.com/healthz` → `200`, carrying the header **`via: 1.1 Caddy`** — our Caddy, our control-plane box, behind Cloudflare. `/version` reports core 1.19.312.
|
|
23
|
+
|
|
24
|
+
**What was actually seen, and why it misled.** `mercury_bongos` is a *hybrid* repository: its owner's own Python warehouse application (`app.py`, plus a `Procfile` reading `web: gunicorn app:app`) with an **adopted** Bongos instance layered onto it (`package.json` depends on `@bongos/core` as `file:vendor/bongos-core-1.19.305.tgz` — the vendoring path). It carries no `render.yaml`. So the live production services in that workspace are most plausibly the owner's *Python app* — a real Render deployment that is simply not the Bongos instance. A workspace listing and an instance row were read as the same object; they are two systems sharing a name and a repository.
|
|
25
|
+
|
|
26
|
+
That last step is the likeliest reading, not a measurement — confirming it needs the owner, or a look inside their workspace, and neither was available. What *is* measured is the half that matters: **the Bongos instance is on our box and has not moved.**
|
|
27
|
+
|
|
28
|
+
**The decision below does not depend on the retracted half.** The first finding — that a Render API key carries its user's rights into every workspace they belong to — is measured, and on its own it is sufficient. The remaining landlord relationship was never the servers; it was the **account**.
|
|
20
29
|
|
|
21
30
|
## Decision
|
|
22
31
|
|
|
@@ -86,13 +95,13 @@ So the web tier process **can** read the DigitalOcean, Cloudflare and platform t
|
|
|
86
95
|
|
|
87
96
|
## Consequences
|
|
88
97
|
|
|
89
|
-
-
|
|
98
|
+
- ~~**The migration shrinks to an adoption.** `mercury` is already in the target state; its instance record needs pointing at a deployment its owner runs, not a move.~~ **Retracted 2026-09-23 ([task 1004186](https://cloudbongos.com/builders#/task/1004186)) — see the correction in Context.** The migration does not shrink: `mercury`'s Bongos instance runs on our box and still has to move, like the other three. Because the project is someone else's, that move needs its owner's agreement and their own Render account, which makes it the courtesy call this ADR's closing thought was reaching for. <redacted> remains a real distinction — `scripts/gds/provision-repo.js` already draws it for repositories and the platform leg still needs it — but nothing in the fleet is currently an example of it, so it has to be built from the spec rather than from a live case. The pilot is [task 1004142](https://cloudbongos.com/builders#/task/1004142) and the moves are [task 1004184](https://cloudbongos.com/builders#/task/1004184).
|
|
90
99
|
|
|
91
100
|
- **The fleet cost ledger's question changes, and it is no longer "what do we owe".** [ADR 0325](0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md) §6 filed [task 1004148](https://cloudbongos.com/builders#/task/1004148) because `costEstimateUsd` returns 0 for a project "the platform is about to be billed for". The platform is not going to be billed for it. A ledger reporting our spend should read ~$0 per `cloud-host` and be *correct*; what an owner is worth telling is **their** estimated Render cost, which is a different column with a different meaning. Reporting one as the other is the defect now.
|
|
92
101
|
|
|
93
102
|
- **Metering and charging lose their forcing function.** [task 1001949](https://cloudbongos.com/builders#/task/1001949) was sized against recovering ~$14/project/month. There is nothing to recover. Cost *visibility* — showing an owner what their project costs them — survives as a product feature; billing for hosting does not, because we do not host.
|
|
94
103
|
|
|
95
|
-
- **`dedicated` becomes dead
|
|
104
|
+
- **`dedicated` becomes dead code, and — measured — no data at all.** Its stored value must be dropped rather than merely orphaned. Pointer corrected 2026-09-23: that work split out of task 1004142 into [task 1004182](https://cloudbongos.com/builders#/task/1004182). The same check found the fleet carries **zero** `dedicated` rows, so the "runs over live rows" caution belongs to the 22 `standalone` rows being renamed to `cloud-host`, not to `dedicated`, whose migration is a no-op. Re-verify before writing one.
|
|
96
105
|
|
|
97
106
|
- **Our own token exposure is now the largest unmitigated item in this area.** Decision 5 is filed as its own task. It was not urgent while it protected only tokens we could rotate ourselves; it stays that way, but it should not stay unowned.
|
|
98
107
|
|
package/docs/api/openapi.json
CHANGED
|
@@ -6127,7 +6127,7 @@
|
|
|
6127
6127
|
"goals"
|
|
6128
6128
|
],
|
|
6129
6129
|
"summary": "POST /goals/:id/archive",
|
|
6130
|
-
"description": "POST /goals/:id/archive — retire a goal (open|achieved → archived). Archived goals are re-openable (below). This is the lifecycle disposition the /goal-review skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64). rank: metic+archon
|
|
6130
|
+
"description": "POST /goals/:id/archive — retire a goal (open|achieved → archived). Archived goals are re-openable (below). This is the lifecycle disposition the /goal-review skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64). rank: metic+archon + owner|manager|Archon (task 1001739); archive is reversible, so a manager may.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `goal.archive` (all required).",
|
|
6131
6131
|
"x-rank": "metic+archon",
|
|
6132
6132
|
"x-source": "modules/lifecycle/routes/goals.js",
|
|
6133
6133
|
"x-permissions": [
|
|
@@ -7134,7 +7134,7 @@
|
|
|
7134
7134
|
"goals"
|
|
7135
7135
|
],
|
|
7136
7136
|
"summary": "POST /goals/:id/scope",
|
|
7137
|
-
"description": "POST /goals/:id/scope — \"ask to widen\": add module(s) to a goal's scope_modules (ADR 0086 §4 / BV1.R64). Scope is NEVER self-granted, so this is the gated approve-and-apply:
|
|
7137
|
+
"description": "POST /goals/:id/scope — \"ask to widen\": add module(s) to a goal's scope_modules (ADR 0086 §4 / BV1.R64). Scope is NEVER self-granted, so this is the gated approve-and-apply: the goal.scope.widen permission + the caller must be the goal's owner or a manager (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the POST /goals create gate). A plain member therefore cannot widen at all — they ask a Metic+/Archon out-of-band, exactly as joining a protected goal works (R57). The goal must be open. Body: { add: [\"module-key\", ...] }; idempotent (set-union). rank: metic+archon + owner|manager|Archon (task 1001739) — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `goal.scope.widen` (all required).",
|
|
7138
7138
|
"x-rank": "metic+archon",
|
|
7139
7139
|
"x-source": "modules/lifecycle/routes/goals.js",
|
|
7140
7140
|
"x-permissions": [
|
|
@@ -2466,5 +2466,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2466
2466
|
landed since 1.19.994 with no explicit bump. run 35933457545. (task 1002620)
|
|
2467
2467
|
1.19.996 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2468
2468
|
landed since 1.19.995 with no explicit bump. run 35934673169. (task 1002620)
|
|
2469
|
+
1.19.997 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2470
|
+
landed since 1.19.996 with no explicit bump. run 35937246865. (task 1002620)
|
|
2471
|
+
1.19.998 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2472
|
+
landed since 1.19.997 with no explicit bump. run 35940192900. (task 1002620)
|
|
2469
2473
|
---------------------------------------------------------------------------
|
|
2470
2474
|
```
|
|
@@ -14,10 +14,23 @@ const api = require('../../../src/module-api');
|
|
|
14
14
|
const db = require('../db');
|
|
15
15
|
const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
|
|
16
16
|
const goalScopeCheck = api.goalScopeCheck;
|
|
17
|
-
const { isGoalOwner, authorizeGoalManagement } = require('../goal-authz');
|
|
17
|
+
const { isGoalOwner, isGoalOwnerOrManager, authorizeGoalManagement } = require('../goal-authz');
|
|
18
18
|
const { versionAcceptsNewWork } = require('../db-tasks.js');
|
|
19
19
|
const METIC_PLUS = ['metic', 'archon'];
|
|
20
20
|
|
|
21
|
+
// isOwnerManagerOrArchon — a goal's OWN authority (task 1001739): owner (created_by),
|
|
22
|
+
// manager ('lead' membership) or Archon. Gates archive / reopen / scope-widen; a plain
|
|
23
|
+
// member holds none of these, and no route deletes a goal. An OWNERLESS goal
|
|
24
|
+
// (created_by NULL — the migration-160 system goals) has no owner or manager to
|
|
25
|
+
// ask, so any member keeps the power they had before; only Archons would otherwise
|
|
26
|
+
// be left able to disposition it.
|
|
27
|
+
async function isOwnerManagerOrArchon(builder, goal) {
|
|
28
|
+
if (builder.rank === 'archon') return true;
|
|
29
|
+
if (goal.created_by == null) return db.isGoalMember({ goalId: goal.id, builderId: builder.id });
|
|
30
|
+
const actorMembership = await db.getGoalMember({ goalId: goal.id, builderId: builder.id });
|
|
31
|
+
return isGoalOwnerOrManager({ goal, actorId: builder.id, actorRank: builder.rank, actorMembership });
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
async function isMemberOrArchon(builder, goalId) {
|
|
22
35
|
if (builder.rank === 'archon') return true;
|
|
23
36
|
return db.isGoalMember({ goalId, builderId: builder.id });
|
|
@@ -666,6 +679,7 @@ module.exports = {
|
|
|
666
679
|
decorateGoalScope,
|
|
667
680
|
goalNotOpenRefusal,
|
|
668
681
|
isMemberOrArchon,
|
|
682
|
+
isOwnerManagerOrArchon,
|
|
669
683
|
planGoalArchiveDispositions,
|
|
670
684
|
protectedScopeAdmissionBlock,
|
|
671
685
|
};
|
|
@@ -73,7 +73,7 @@ const JOIN_REQUEST_RESPONSE_ACTIONS = ['approve', 'deny']; // an owner/manager/A
|
|
|
73
73
|
// proceed; a false result means "not a member" → the route should 403. This is the
|
|
74
74
|
// membership half of the wall; the protected-module rank check is enforced per-route
|
|
75
75
|
// (scope is never self-granted).
|
|
76
|
-
const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, goalNotOpenRefusal, isMemberOrArchon, planGoalArchiveDispositions, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
76
|
+
const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, goalNotOpenRefusal, isMemberOrArchon, isOwnerManagerOrArchon, planGoalArchiveDispositions, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
|
|
77
77
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
78
78
|
const log = api.logger('lifecycle');
|
|
79
79
|
module.exports = function buildGoalsRouter() {
|
|
@@ -1099,7 +1099,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
1099
1099
|
// POST /goals/:id/archive — retire a goal (open|achieved → archived). Archived
|
|
1100
1100
|
// goals are re-openable (below). This is the lifecycle disposition the /goal-review
|
|
1101
1101
|
// skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64).
|
|
1102
|
-
// rank: metic+archon
|
|
1102
|
+
// rank: metic+archon + owner|manager|Archon (task 1001739); archive is reversible, so a manager may.
|
|
1103
1103
|
router.post('/goals/:id/archive', auth.requireBuilder, auth.requirePermission('goal.archive'), async (req, res) => {
|
|
1104
1104
|
// ADR 0118 / R12: every write route that reads a body declares it, so an
|
|
1105
1105
|
// unknown field is REJECTED rather than silently ignored — a typo'd
|
|
@@ -1117,8 +1117,8 @@ module.exports = function buildGoalsRouter() {
|
|
|
1117
1117
|
try {
|
|
1118
1118
|
const goal = await db.getGoal(id);
|
|
1119
1119
|
if (!goal) return res.fail('goal_not_found', 404);
|
|
1120
|
-
if (!(await
|
|
1121
|
-
return res.fail('
|
|
1120
|
+
if (!(await isOwnerManagerOrArchon(req.builder, goal))) {
|
|
1121
|
+
return res.fail('not_owner_or_manager', { status: 403, message: 'Only the goal owner or a manager (or an Archon) may archive it.' });
|
|
1122
1122
|
}
|
|
1123
1123
|
if (goal.status === 'archived') {
|
|
1124
1124
|
return res.fail('already_archived', 409);
|
|
@@ -1241,8 +1241,8 @@ module.exports = function buildGoalsRouter() {
|
|
|
1241
1241
|
try {
|
|
1242
1242
|
const goal = await db.getGoal(id);
|
|
1243
1243
|
if (!goal) return res.fail('goal_not_found', 404);
|
|
1244
|
-
if (!(await
|
|
1245
|
-
return res.fail('
|
|
1244
|
+
if (!(await isOwnerManagerOrArchon(req.builder, goal))) {
|
|
1245
|
+
return res.fail('not_owner_or_manager', { status: 403, message: 'Only the goal owner or a manager (or an Archon) may reopen it.' });
|
|
1246
1246
|
}
|
|
1247
1247
|
if (goal.status !== 'archived') {
|
|
1248
1248
|
return res.fail('not_archived', 409, { status: goal.status });
|
|
@@ -1257,12 +1257,12 @@ module.exports = function buildGoalsRouter() {
|
|
|
1257
1257
|
|
|
1258
1258
|
// POST /goals/:id/scope — "ask to widen": add module(s) to a goal's scope_modules
|
|
1259
1259
|
// (ADR 0086 §4 / BV1.R64). Scope is NEVER self-granted, so this is the gated
|
|
1260
|
-
// approve-and-apply:
|
|
1261
|
-
// (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the
|
|
1262
|
-
// POST /goals create gate). A
|
|
1260
|
+
// approve-and-apply: the goal.scope.widen permission + the caller must be the goal's
|
|
1261
|
+
// owner or a manager (or Archon), and a PROTECTED module can only be added by an Archon (mirrors the
|
|
1262
|
+
// POST /goals create gate). A plain member therefore cannot widen at all — they
|
|
1263
1263
|
// ask a Metic+/Archon out-of-band, exactly as joining a protected goal works (R57).
|
|
1264
1264
|
// The goal must be open. Body: { add: ["module-key", ...] }; idempotent (set-union).
|
|
1265
|
-
// rank: metic+archon — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.
|
|
1265
|
+
// rank: metic+archon + owner|manager|Archon (task 1001739) — widens the module wall; pinned in route-rank-check.EXPECTED_RANKS.
|
|
1266
1266
|
router.post('/goals/:id/scope', auth.requireBuilder, auth.requirePermission('goal.scope.widen'), async (req, res) => {
|
|
1267
1267
|
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
1268
1268
|
if (id === null) return;
|
|
@@ -1288,8 +1288,8 @@ module.exports = function buildGoalsRouter() {
|
|
|
1288
1288
|
if (goal.status !== 'open') {
|
|
1289
1289
|
return res.fail('goal_not_open', 409, { status: goal.status });
|
|
1290
1290
|
}
|
|
1291
|
-
if (!(await
|
|
1292
|
-
return res.fail('
|
|
1291
|
+
if (!(await isOwnerManagerOrArchon(req.builder, goal))) {
|
|
1292
|
+
return res.fail('not_owner_or_manager', { status: 403, message: 'Only the goal owner or a manager (or an Archon) may widen its scope.' });
|
|
1293
1293
|
}
|
|
1294
1294
|
const updated = await db.addGoalScopeModules({ goalId: id, modules: add });
|
|
1295
1295
|
return res.json({ ok: true, goal: updated });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.998",
|
|
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.998",
|
|
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.998",
|
|
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",
|
|
@@ -216,7 +216,7 @@ async function runProbes(ctx) {
|
|
|
216
216
|
const r = await probePost(`/goals/${target.id}/archive`, {});
|
|
217
217
|
const after = (await get(`/goals/${target.id}`)).goal;
|
|
218
218
|
out.push({ absent: r.status === 404, id: 'P2', rule: `R10 — bare archive of goal ${target.id} (holds ${held.get(String(target.id))})`,
|
|
219
|
-
status: r.status, refused: r.status === 409, error: r.why, mutated: after.status !== 'open' });
|
|
219
|
+
status: r.status, refused: r.status === 409 || r.status === 403, error: r.why, mutated: after.status !== 'open' });
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
// P3 — R12: a version holding open goals refuses a close with no dispositions.
|
|
@@ -520,29 +520,141 @@ function parseChangeRecords(stdout) {
|
|
|
520
520
|
* is interpolated into a command is checked where it is interpolated — the rule
|
|
521
521
|
* upgradeInvocation states about the slug.
|
|
522
522
|
*/
|
|
523
|
+
/**
|
|
524
|
+
* Classify a git command that did not succeed. PURE.
|
|
525
|
+
*
|
|
526
|
+
* "COULD NOT LOOK" AND "LOOKED AND DID NOT FIND" ARE DIFFERENT ANSWERS (task 1004212).
|
|
527
|
+
* The first cut collapsed both into "could not find those releases in the core history on
|
|
528
|
+
* this machine", and that sentence sent the investigation at the history for an hour when
|
|
529
|
+
* the history was never the problem — the directory was. A message that names the wrong
|
|
530
|
+
* cause is worse than a vague one, because it is followed.
|
|
531
|
+
*/
|
|
532
|
+
function gitFault(r, dir) {
|
|
533
|
+
const said = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
|
|
534
|
+
if (/dubious ownership|safe\.directory/i.test(said)) {
|
|
535
|
+
return `the core checkout at ${dir} belongs to another account, so this could not read it`;
|
|
536
|
+
}
|
|
537
|
+
if (/not a git repository/i.test(said)) return `${dir} is not a git checkout`;
|
|
538
|
+
const tail = said.trim().split('\n').filter(Boolean).slice(-1)[0] || '';
|
|
539
|
+
return `could not read ${dir}${tail ? ` — ${tail.slice(0, 160)}` : ''}`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Where the CORE's own git history lives on this machine, if anywhere (task 1004212).
|
|
544
|
+
*
|
|
545
|
+
* WHAT THE FIRST CUT GOT WRONG, and it was not a small thing. It read `selfInstanceRoot()`
|
|
546
|
+
* on the assumption that the control plane's instance root is a checkout of the core. On
|
|
547
|
+
* the live box it is not: `/version` there reports a commit that does not exist in the core
|
|
548
|
+
* repository at all, because the instance root is a checkout of the INSTANCE's own repo
|
|
549
|
+
* with the core vendored in at node_modules (the task 1004123 shape). So every lookup
|
|
550
|
+
* searched the wrong repository's history.
|
|
551
|
+
*
|
|
552
|
+
* THAT IS A CORRECTNESS BUG, NOT ONLY A MISSING FEATURE. On a box whose instance repo
|
|
553
|
+
* happened to carry `Merge <branch>: <summary>` commits — which any repo built this way
|
|
554
|
+
* does — the page would have shown ANOTHER project's changelog as the core's, with no
|
|
555
|
+
* error at all. Which is why a candidate is not trusted for being a git repo: it has to
|
|
556
|
+
* prove it carries core RELEASE history, and `looksLikeCore` is that proof.
|
|
557
|
+
*
|
|
558
|
+
* NO PATH IS HARDCODED. `audit-authorship.sh` forbids naming the founder's directories,
|
|
559
|
+
* and a guessed path is how this got here. The candidates are: an explicit operator
|
|
560
|
+
* setting, the directory the CLI is executing from, and the instance root — each checked,
|
|
561
|
+
* none assumed.
|
|
562
|
+
*/
|
|
563
|
+
function coreCheckoutCandidates(deps) {
|
|
564
|
+
const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
|
|
565
|
+
const env = deps.env || process.env;
|
|
566
|
+
const out = [];
|
|
567
|
+
const add = (dir, why) => { if (dir && !out.some((c) => c.dir === dir)) out.push({ dir, why }); };
|
|
568
|
+
add(String(env.PROVISION_CORE_CHECKOUT || '').trim() || null, 'PROVISION_CORE_CHECKOUT');
|
|
569
|
+
add(REPO_ROOT, 'the directory this core is running from');
|
|
570
|
+
add(selfRoot(), "this project's own root");
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Read the core's release history, or say precisely why it cannot be. Returns
|
|
576
|
+
* `{ dir }` on success, or `{ error }` — never a directory it has not verified.
|
|
577
|
+
*/
|
|
578
|
+
function findCoreCheckout(deps, git) {
|
|
579
|
+
const tried = [];
|
|
580
|
+
for (const { dir, why } of coreCheckoutCandidates(deps)) {
|
|
581
|
+
const probe = git(dir, 'rev-parse --is-shallow-repository');
|
|
582
|
+
if (!probe.ok) { tried.push(`${why}: ${gitFault(probe, dir)}`); continue; }
|
|
583
|
+
if (probe.stdout.trim() === 'true') {
|
|
584
|
+
// A depth-limited clone resolves HEAD perfectly well — which is why /version reports
|
|
585
|
+
// `resolvedVia: git` on a box that cannot answer this — and carries no old releases.
|
|
586
|
+
tried.push(`${why}: ${dir} is a shallow clone, so it carries no release history`);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
// THE PROOF THAT THIS IS THE CORE. Any repository answers `rev-parse`; only the core's
|
|
590
|
+
// carries release commits, and accepting a repo without asking is how the wrong
|
|
591
|
+
// project's changelog would have been shown as this one's.
|
|
592
|
+
const marker = git(dir, "log --format=%H --max-count=1 -E --grep='^release: core [0-9]+\\.[0-9]+\\.[0-9]+ '");
|
|
593
|
+
if (!marker.ok) { tried.push(`${why}: ${gitFault(marker, dir)}`); continue; }
|
|
594
|
+
if (!marker.stdout.trim()) { tried.push(`${why}: ${dir} is a git checkout, but not of the core`); continue; }
|
|
595
|
+
return { dir };
|
|
596
|
+
}
|
|
597
|
+
return { error: `no core checkout is reachable on this machine (${tried.join('; ')}). Set PROVISION_CORE_CHECKOUT to one.` };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* What landed between two core releases, as the summaries their ships wrote.
|
|
602
|
+
*
|
|
603
|
+
* Returns `{ from, to, entries, more }`, or `{ from, to, error }` — NEVER an empty
|
|
604
|
+
* `entries` for a range it could not resolve. An empty list reads as "nothing changed",
|
|
605
|
+
* which on a page whose job is to say what a restart buys is the one wrong impression it
|
|
606
|
+
* must not give, and it is exactly what a missing checkout produces.
|
|
607
|
+
*
|
|
608
|
+
* Every version reaching a shell is re-checked against the strict three-number form
|
|
609
|
+
* FIRST. They arrive from an intent row whose column has the same CHECK, but a value that
|
|
610
|
+
* is interpolated into a command is checked where it is interpolated — the rule
|
|
611
|
+
* upgradeInvocation states about the slug.
|
|
612
|
+
*/
|
|
523
613
|
function changesBetween(fromVersion, toVersion, deps) {
|
|
524
614
|
const bad = [fromVersion, toVersion].find((v) => !CORE_VERSION_RE.test(String(v || '')));
|
|
525
615
|
if (bad !== undefined) return { from: fromVersion || null, to: toVersion || null, error: 'could not work out which releases this covers' };
|
|
526
|
-
// The CONTROL PLANE's executor
|
|
527
|
-
//
|
|
528
|
-
//
|
|
616
|
+
// The CONTROL PLANE's executor, never the previewed instance's: under
|
|
617
|
+
// PROVISION_REMOTE_HOST `exec` is the remote box, while the core history — and the whole
|
|
618
|
+
// question of what changed in the CORE — belongs to the machine that builds it.
|
|
529
619
|
const exec = deps.controlExec || deps.exec;
|
|
530
|
-
|
|
531
|
-
|
|
620
|
+
// AS THE ACCOUNT THAT OWNS THE CHECKOUT, for the same reason the dry run beside this one
|
|
621
|
+
// does (task 1004128). `appUser()` is defined as "the unprivileged account that owns this
|
|
622
|
+
// checkout" and throws rather than return root, so a runner executing as root reads the
|
|
623
|
+
// files through the account that may actually read them instead of tripping git's
|
|
624
|
+
// ownership check. Unprivileged runners are unchanged — there is nothing to escalate to.
|
|
625
|
+
let runAs = null;
|
|
626
|
+
if (deps.privileged) {
|
|
627
|
+
try { runAs = (deps.appUser || appUser)(); } catch { runAs = null; }
|
|
628
|
+
}
|
|
629
|
+
const git = (dir, args) => {
|
|
630
|
+
const r = exec(`${runAs ? `sudo -u ${runAs} ` : ''}git -C ${dir} ${args}`, { allowFail: true });
|
|
631
|
+
return {
|
|
632
|
+
ok: !!(r && r.softFailed !== true && r.ok !== false),
|
|
633
|
+
stdout: String((r && r.stdout) || ''),
|
|
634
|
+
stderr: String((r && r.stderr) || ''),
|
|
635
|
+
};
|
|
636
|
+
};
|
|
637
|
+
const found = findCoreCheckout(deps, git);
|
|
638
|
+
if (found.error) return { from: fromVersion, to: toVersion, error: found.error };
|
|
639
|
+
const dir = found.dir;
|
|
532
640
|
const shaOf = (v) => {
|
|
533
|
-
const r =
|
|
534
|
-
|
|
535
|
-
|
|
641
|
+
const r = git(dir, `log --format=%H --max-count=1 -F --grep='release: core ${v} '`);
|
|
642
|
+
if (!r.ok) return { fault: gitFault(r, dir) };
|
|
643
|
+
const sha = r.stdout.trim().split('\n')[0] || '';
|
|
644
|
+
return /^[0-9a-f]{7,40}$/.test(sha) ? { sha } : {};
|
|
536
645
|
};
|
|
537
646
|
const from = shaOf(fromVersion);
|
|
538
647
|
const to = shaOf(toVersion);
|
|
539
|
-
if (
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
648
|
+
if (from.fault || to.fault) return { from: fromVersion, to: toVersion, error: from.fault || to.fault };
|
|
649
|
+
// LOOKED, AND DID NOT FIND — a different sentence from "could not look", and it names
|
|
650
|
+
// WHICH version is missing so the reader can tell a not-yet-published target from a
|
|
651
|
+
// checkout that has not been fetched.
|
|
652
|
+
const missing = [!from.sha && fromVersion, !to.sha && toVersion].filter(Boolean);
|
|
653
|
+
if (missing.length) {
|
|
654
|
+
return { from: fromVersion, to: toVersion, error: `core ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not in the core history on this machine yet` };
|
|
545
655
|
}
|
|
656
|
+
const r = git(dir, `log --merges --format=%s%x1f%b%x1e ${from.sha}..${to.sha}`);
|
|
657
|
+
if (!r.ok) return { from: fromVersion, to: toVersion, error: gitFault(r, dir) };
|
|
546
658
|
const all = parseChangeRecords(r.stdout);
|
|
547
659
|
return { from: fromVersion, to: toVersion, entries: all.slice(0, CHANGE_CAP), more: Math.max(0, all.length - CHANGE_CAP) };
|
|
548
660
|
}
|
|
@@ -668,4 +780,4 @@ async function snapshotAllCoreVersions(deps) {
|
|
|
668
780
|
return { seen };
|
|
669
781
|
}
|
|
670
782
|
|
|
671
|
-
module.exports = { UPGRADE_SHAPES, CHANGE_CAP, parseChangeRecords, changesBetween, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
783
|
+
module.exports = { UPGRADE_SHAPES, CHANGE_CAP, parseChangeRecords, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.998'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -883,16 +883,36 @@ await t('a paragraph-long summary is bounded — it reaches a DOM', () => {
|
|
|
883
883
|
|
|
884
884
|
// --- the range, and every way it can fail to resolve ------------------------------
|
|
885
885
|
|
|
886
|
-
|
|
886
|
+
// A box that really does hold a core checkout. `repos` maps a directory to what git
|
|
887
|
+
// there answers: `core` (a real core checkout), `notCore` (a git repo of something else),
|
|
888
|
+
// `shallow`, `owned` (git refuses on ownership) or absent (not a git repo).
|
|
889
|
+
const changeDeps = ({
|
|
890
|
+
shas = { '1.19.900': 'a'.repeat(40), '1.19.949': 'b'.repeat(40) },
|
|
891
|
+
log = LOG, logOk = true, repos = { '/srv/core': 'core' }, env = {}, privileged = false,
|
|
892
|
+
} = {}) => {
|
|
887
893
|
const ran = [];
|
|
888
894
|
return {
|
|
889
|
-
ran,
|
|
895
|
+
ran, env, privileged,
|
|
890
896
|
selfInstanceRoot: () => '/srv/core',
|
|
897
|
+
appUser: () => 'appacct',
|
|
891
898
|
exec: (cmd) => {
|
|
892
899
|
ran.push(cmd);
|
|
893
|
-
const
|
|
900
|
+
const dir = (/git -C (\S+) /.exec(cmd) || [])[1];
|
|
901
|
+
const kind = repos[dir];
|
|
902
|
+
if (!kind) return { ok: false, softFailed: true, stdout: '', stderr: `fatal: not a git repository: '${dir}'` };
|
|
903
|
+
if (kind === 'owned') {
|
|
904
|
+
return { ok: false, softFailed: true, stdout: '', stderr: `fatal: detected dubious ownership in repository at '${dir}'` };
|
|
905
|
+
}
|
|
906
|
+
if (/rev-parse --is-shallow-repository/.test(cmd)) {
|
|
907
|
+
return { ok: true, stdout: `${kind === 'shallow' ? 'true' : 'false'}\n` };
|
|
908
|
+
}
|
|
909
|
+
// The marker probe: does this repo carry core RELEASE commits at all?
|
|
910
|
+
if (/-E --grep='\^release: core/.test(cmd)) {
|
|
911
|
+
return { ok: true, stdout: kind === 'core' ? `${'c'.repeat(40)}\n` : '\n' };
|
|
912
|
+
}
|
|
913
|
+
const m = /-F --grep='release: core ([0-9.]+) '/.exec(cmd);
|
|
894
914
|
if (m) return { ok: true, stdout: `${shas[m[1]] || ''}\n` };
|
|
895
|
-
if (!logOk) return { ok: false, softFailed: true, stdout: '' };
|
|
915
|
+
if (!logOk) return { ok: false, softFailed: true, stdout: '', stderr: 'fatal: bad revision' };
|
|
896
916
|
return { ok: true, stdout: log };
|
|
897
917
|
},
|
|
898
918
|
};
|
|
@@ -908,29 +928,85 @@ await t('the range resolves through the release commits and lists what landed be
|
|
|
908
928
|
assert.ok(d.ran.some((c) => /--merges/.test(c) && /a{40}\.\.b{40}/.test(c)), 'the log is asked for the resolved range');
|
|
909
929
|
});
|
|
910
930
|
|
|
911
|
-
await t('IT
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
// PROVISION_REMOTE_HOST `exec` is the remote box, so the executor matters too.
|
|
931
|
+
await t('IT USES THE CONTROL PLANE’S EXECUTOR, whichever instance is previewed', () => {
|
|
932
|
+
// Under PROVISION_REMOTE_HOST `exec` is the remote box, while the core history belongs
|
|
933
|
+
// to the machine that builds the core.
|
|
915
934
|
const d = changeDeps();
|
|
916
935
|
d.controlExec = d.exec;
|
|
917
936
|
d.exec = () => { throw new Error('the instance executor must not be used for core history'); };
|
|
937
|
+
assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 2);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
// ---- WHICH checkout, and the bug that made this task (task 1004212) ---------------
|
|
941
|
+
|
|
942
|
+
await t('A GIT REPO THAT IS NOT THE CORE IS REFUSED, not read as if it were', () => {
|
|
943
|
+
// THE BUG THIS TASK EXISTS FOR. The first cut trusted selfInstanceRoot() to be a core
|
|
944
|
+
// checkout. On the live box it is the INSTANCE's own repo with the core vendored in at
|
|
945
|
+
// node_modules — its /version commit does not exist in the core repository at all. Any
|
|
946
|
+
// repo built that way carries `Merge <branch>: <summary>` commits, so without this the
|
|
947
|
+
// page would have shown ANOTHER project's changelog as the core's, with no error.
|
|
948
|
+
const d = changeDeps({ repos: { '/srv/core': 'notCore' } });
|
|
918
949
|
const r = R.changesBetween('1.19.900', '1.19.949', d);
|
|
919
|
-
assert.equal(r.entries
|
|
920
|
-
|
|
950
|
+
assert.equal(r.entries, undefined, 'nothing is listed from the wrong repository');
|
|
951
|
+
assert.match(r.error, /not of the core/);
|
|
952
|
+
assert.ok(!d.ran.some((c) => /--merges/.test(c)), 'and its log is never even read');
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
await t('the candidates are tried in order, and an operator setting wins', () => {
|
|
956
|
+
const d = changeDeps({
|
|
957
|
+
repos: { '/opt/corerepo': 'core', '/srv/core': 'core' },
|
|
958
|
+
env: { PROVISION_CORE_CHECKOUT: '/opt/corerepo' },
|
|
959
|
+
});
|
|
960
|
+
assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 2);
|
|
961
|
+
assert.ok(d.ran.every((c) => /git -C \/opt\/corerepo /.test(c)), 'only the named checkout is touched');
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
await t('a candidate that is not a core checkout FALLS THROUGH to one that is', () => {
|
|
965
|
+
const d = changeDeps({
|
|
966
|
+
repos: { '/opt/notcore': 'notCore', '/srv/core': 'core' },
|
|
967
|
+
env: { PROVISION_CORE_CHECKOUT: '/opt/notcore' },
|
|
968
|
+
});
|
|
969
|
+
assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 2);
|
|
970
|
+
assert.ok(d.ran.some((c) => /git -C \/srv\/core /.test(c)), 'the next candidate is reached');
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
await t('NO reachable core checkout names every candidate it tried, and the way out', () => {
|
|
974
|
+
const d = changeDeps({ repos: {} });
|
|
975
|
+
const r = R.changesBetween('1.19.900', '1.19.949', d);
|
|
976
|
+
assert.equal(r.entries, undefined);
|
|
977
|
+
assert.match(r.error, /no core checkout is reachable/);
|
|
978
|
+
assert.match(r.error, /PROVISION_CORE_CHECKOUT/, 'and the setting that fixes it');
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// ---- "could not look" vs "looked and did not find" -------------------------------
|
|
982
|
+
|
|
983
|
+
await t('AN OWNERSHIP REFUSAL SAYS SO — it is not a missing release', () => {
|
|
984
|
+
// The misdiagnosis this replaces: every one of these used to print "could not find
|
|
985
|
+
// those releases in the core history", which sends the reader at the history when the
|
|
986
|
+
// history was never the problem.
|
|
987
|
+
const d = changeDeps({ repos: { '/srv/core': 'owned' } });
|
|
988
|
+
const r = R.changesBetween('1.19.900', '1.19.949', d);
|
|
989
|
+
assert.match(r.error, /belongs to another account/);
|
|
990
|
+
assert.doesNotMatch(r.error, /not in the core history/);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
await t('a shallow clone says so — it resolves HEAD fine and carries no releases', () => {
|
|
994
|
+
const d = changeDeps({ repos: { '/srv/core': 'shallow' } });
|
|
995
|
+
assert.match(R.changesBetween('1.19.900', '1.19.949', d).error, /shallow clone/);
|
|
921
996
|
});
|
|
922
997
|
|
|
923
|
-
await t('
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
assert.
|
|
929
|
-
|
|
998
|
+
await t('a release the checkout has not fetched NAMES which one', () => {
|
|
999
|
+
const d = changeDeps();
|
|
1000
|
+
const r = R.changesBetween('1.19.900', '9.9.9', d);
|
|
1001
|
+
assert.equal(r.entries, undefined, 'no list at all');
|
|
1002
|
+
assert.match(r.error, /core 9\.9\.9 is not in the core history on this machine yet/);
|
|
1003
|
+
assert.doesNotMatch(r.error, /could not read/, 'it looked successfully — it just did not find it');
|
|
1004
|
+
});
|
|
930
1005
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
assert.
|
|
1006
|
+
await t('a log that fails after the range resolved is reported as a read failure', () => {
|
|
1007
|
+
const r = R.changesBetween('1.19.900', '1.19.949', changeDeps({ logOk: false }));
|
|
1008
|
+
assert.equal(r.entries, undefined);
|
|
1009
|
+
assert.match(r.error, /could not read \/srv\/core/);
|
|
934
1010
|
});
|
|
935
1011
|
|
|
936
1012
|
await t('a version that is not three numbers never reaches the shell', () => {
|
|
@@ -941,6 +1017,33 @@ await t('a version that is not three numbers never reaches the shell', () => {
|
|
|
941
1017
|
assert.match(R.changesBetween(null, '1.19.949', changeDeps()).error, /could not work out/, 'an unknown starting point too');
|
|
942
1018
|
});
|
|
943
1019
|
|
|
1020
|
+
// ---- as WHICH account ------------------------------------------------------------
|
|
1021
|
+
|
|
1022
|
+
await t('a privileged runner reads git as the account that owns the checkout', () => {
|
|
1023
|
+
// The same move the dry run beside this one makes (task 1004128). appUser() is defined
|
|
1024
|
+
// as "the unprivileged account that owns this checkout", so a runner executing as root
|
|
1025
|
+
// reads through an account that may actually read the files.
|
|
1026
|
+
const d = changeDeps({ privileged: true });
|
|
1027
|
+
R.changesBetween('1.19.900', '1.19.949', d);
|
|
1028
|
+
assert.ok(d.ran.length > 0);
|
|
1029
|
+
for (const c of d.ran) assert.match(c, /^sudo -u appacct git -C /, `every read is escalated: ${c}`);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
await t('an UNPRIVILEGED runner escalates nothing — there is nothing to escalate to', () => {
|
|
1033
|
+
const d = changeDeps();
|
|
1034
|
+
R.changesBetween('1.19.900', '1.19.949', d);
|
|
1035
|
+
for (const c of d.ran) assert.doesNotMatch(c, /sudo/);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
await t('an app user that cannot be resolved does not take the whole read down', () => {
|
|
1039
|
+
// appUser() THROWS when it would return root or an unusable name. That is a reason to
|
|
1040
|
+
// read unescalated and let git say whether it could, not a reason to return no answer.
|
|
1041
|
+
const d = changeDeps({ privileged: true });
|
|
1042
|
+
d.appUser = () => { throw new Error('the app user "root" is root'); };
|
|
1043
|
+
assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 2);
|
|
1044
|
+
for (const c of d.ran) assert.doesNotMatch(c, /sudo/);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
944
1047
|
await t('a long range is capped, and says how many it did not list', () => {
|
|
945
1048
|
const many = Array.from({ length: R.CHANGE_CAP + 5 }, (_, i) => REC(`Merge b-${i}: summary number ${i}`)).join('');
|
|
946
1049
|
const r = R.changesBetween('1.19.900', '1.19.949', changeDeps({ log: many }));
|
|
@@ -109,12 +109,12 @@ test('the refusal RETURNS the tasks, so the caller can decide without a second f
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
test('the check runs AFTER identity/authorization and BEFORE the status write', () => {
|
|
112
|
-
const auth = ARCHIVE.indexOf('
|
|
112
|
+
const auth = ARCHIVE.indexOf('isOwnerManagerOrArchon');
|
|
113
113
|
const already = ARCHIVE.indexOf("'already_archived'");
|
|
114
114
|
const gate = ARCHIVE.indexOf('openTasksInGoal');
|
|
115
115
|
const write = ARCHIVE.indexOf('db.setGoalStatus');
|
|
116
116
|
assert.ok(auth !== -1 && already !== -1 && gate !== -1 && write !== -1, 'all four landmarks present');
|
|
117
|
-
assert.ok(auth < gate, 'a non-
|
|
117
|
+
assert.ok(auth < gate, 'a non-owner must not learn what a goal is holding');
|
|
118
118
|
assert.ok(already < gate, 're-archiving an archived goal keeps its own answer');
|
|
119
119
|
assert.ok(gate < write, 'a refusal after the write is not a refusal');
|
|
120
120
|
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// tests/goal_owner_manager_powers.mjs — archive / reopen / scope-widen belong to a
|
|
2
|
+
// goal's own authority (task 1001739): the owner (created_by), a manager (a 'lead'
|
|
3
|
+
// membership) or an Archon. A plain member — who used to pass — is refused 403
|
|
4
|
+
// not_owner_or_manager. Drives the REAL router with the db layer stubbed.
|
|
5
|
+
//
|
|
6
|
+
// Run: node tests/goal_owner_manager_powers.mjs
|
|
7
|
+
|
|
8
|
+
import { strict as assert } from 'node:assert';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
process.env.NODE_ENV = 'test';
|
|
12
|
+
|
|
13
|
+
const express = require('express');
|
|
14
|
+
const api = require('../src/module-api.js');
|
|
15
|
+
const db = require('../modules/lifecycle/db.js');
|
|
16
|
+
|
|
17
|
+
let WHO = { id: '10', rank: 'metic' };
|
|
18
|
+
api.requireBuilder = (req, _res, next) => { req.builder = WHO; next(); };
|
|
19
|
+
api.requirePermission = () => (req, _res, next) => next();
|
|
20
|
+
|
|
21
|
+
const OWNER = '10', MANAGER = '11', MEMBER = '12';
|
|
22
|
+
const GOALS = {
|
|
23
|
+
1: { id: 1, status: 'open', title: 'g', version_id: 'V', created_by: OWNER, scope_modules: [] },
|
|
24
|
+
3: { id: 3, status: 'open', title: 'system', version_id: 'V', created_by: null, scope_modules: [] },
|
|
25
|
+
2: { id: 2, status: 'archived', title: 'a', version_id: 'V', created_by: OWNER, scope_modules: [] },
|
|
26
|
+
};
|
|
27
|
+
const KINDS = { [MANAGER]: 'lead', [MEMBER]: 'member' };
|
|
28
|
+
db.getGoal = async (id) => GOALS[Number(id)] ?? null;
|
|
29
|
+
db.getGoalMember = async ({ builderId }) => (KINDS[builderId] ? { builder_id: builderId, membership_kind: KINDS[builderId] } : null);
|
|
30
|
+
db.isGoalMember = async ({ builderId }) => !!KINDS[builderId] || builderId === OWNER;
|
|
31
|
+
db.getVersion = async () => null;
|
|
32
|
+
db.openTasksInGoal = async () => ({ total: 0, tasks: [] });
|
|
33
|
+
db.archiveGoalWithDispositions = async ({ goalId }) => ({ goal: { ...GOALS[goalId], status: 'archived' }, applied: [] });
|
|
34
|
+
db.setGoalStatus = async ({ goalId, status }) => ({ ...GOALS[goalId], status });
|
|
35
|
+
db.addGoalScopeModules = async ({ goalId }) => GOALS[goalId];
|
|
36
|
+
|
|
37
|
+
const app = express();
|
|
38
|
+
app.use(express.json());
|
|
39
|
+
app.use((req, res, next) => {
|
|
40
|
+
res.fail = (code, o, d) => { const x = typeof o === 'number' ? { status: o } : (o || {}); return res.status(x.status || 400).json({ error: code, details: x.details ?? d }); };
|
|
41
|
+
next();
|
|
42
|
+
});
|
|
43
|
+
app.use('/api/gds', require('../modules/lifecycle/routes/goals.js')());
|
|
44
|
+
const server = app.listen(0);
|
|
45
|
+
await new Promise(r => server.once('listening', r));
|
|
46
|
+
const base = `http://127.0.0.1:${server.address().port}/api/gds`;
|
|
47
|
+
const post = async (p, body) => {
|
|
48
|
+
const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
|
|
49
|
+
return { status: r.status, body: await r.json().catch(() => null) };
|
|
50
|
+
};
|
|
51
|
+
const calls = [
|
|
52
|
+
['/goals/1/archive', {}],
|
|
53
|
+
['/goals/2/reopen', {}],
|
|
54
|
+
['/goals/1/scope', { add: ['hall-ui'] }],
|
|
55
|
+
];
|
|
56
|
+
let n = 0;
|
|
57
|
+
for (const [who, label, expectOk] of [
|
|
58
|
+
[{ id: OWNER, rank: 'metic' }, 'owner', true],
|
|
59
|
+
[{ id: MANAGER, rank: 'metic' }, 'manager', true],
|
|
60
|
+
[{ id: '99', rank: 'archon' }, 'archon', true],
|
|
61
|
+
[{ id: MEMBER, rank: 'metic' }, 'plain member', false],
|
|
62
|
+
[{ id: '77', rank: 'metic' }, 'non-member', false],
|
|
63
|
+
]) {
|
|
64
|
+
WHO = who;
|
|
65
|
+
for (const [path, body] of calls) {
|
|
66
|
+
const r = await post(path, body);
|
|
67
|
+
if (expectOk) assert.equal(r.status, 200, `${label} ${path} → ${r.status} ${JSON.stringify(r.body)}`);
|
|
68
|
+
else { assert.equal(r.status, 403, `${label} ${path}`); assert.equal(r.body.error, 'not_owner_or_manager'); }
|
|
69
|
+
n++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// An OWNERLESS goal (created_by NULL, the system catch-alls): a member keeps the
|
|
73
|
+
// power, a non-member has none — otherwise only an Archon could ever disposition it.
|
|
74
|
+
WHO = { id: MEMBER, rank: 'metic' };
|
|
75
|
+
assert.equal((await post('/goals/3/archive')).status, 200); n++;
|
|
76
|
+
WHO = { id: '77', rank: 'metic' };
|
|
77
|
+
assert.equal((await post('/goals/3/archive')).status, 403); n++;
|
|
78
|
+
|
|
79
|
+
// POST /goals creation gate: a protected-module scope is Archon-only.
|
|
80
|
+
WHO = { id: OWNER, rank: 'metic' };
|
|
81
|
+
let c = await post('/goals', { version_id: 'V', title: 't', scope_modules: ['kernel'] });
|
|
82
|
+
assert.equal(c.status, 403); assert.equal(c.body.error, 'archon_required_for_protected_scope'); n++;
|
|
83
|
+
c = await post('/goals', { version_id: 'V', title: 't', scope_modules: ['hall-ui'], admit_reason: 'because reasons' });
|
|
84
|
+
assert.equal(c.body.error, 'archon_required_for_admission'); n++;
|
|
85
|
+
WHO = { id: '99', rank: 'archon' };
|
|
86
|
+
c = await post('/goals', { version_id: 'V', title: 't', scope_modules: ['kernel'] });
|
|
87
|
+
assert.notEqual(c.body && c.body.error, 'archon_required_for_protected_scope'); n++;
|
|
88
|
+
|
|
89
|
+
console.log(`${n} owner/manager/member checks passed against the real router.`);
|
|
90
|
+
server.close();
|