@bongos/core 1.19.911 → 1.19.913
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 +28 -23
- package/docs/adr/0250-strict-versioning-the-version-boundary-is-the-scope-gate.md +27 -0
- package/docs/adr/0314-module-protection-is-per-path-not-per-module.md +7 -2
- package/docs/adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md +96 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/self-host.md +1 -1
- package/modules/government/catalog.js +13 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-net.js +41 -0
- package/scripts/gds/provision.js +11 -3
- package/src/bongos/module-scope-map.js +43 -5
- package/src/module-api.js +1 -1
- package/tests/government_catalog.mjs +22 -0
- package/tests/module-scope-map.mjs +87 -3
- package/tests/provision_caddy_wiring.mjs +76 -2
- package/tests/quarantine_canonical.mjs +32 -4
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.913",
|
|
6
|
+
"core_contract": "1.19.913",
|
|
7
|
+
"source_commit": "e5423d044bfab389c03a2bf60d244f83c015154c",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-20T16:36:18.057Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 504,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
14
|
"functional_verbatim": 2287,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2817,
|
|
20
|
+
"tree_sha256": "f335bd0e9dfe731d6a3b535d10eab167f47b4517a03e5ed410bf48e8d607cc4d",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1742,7 +1742,7 @@
|
|
|
1742
1742
|
{
|
|
1743
1743
|
"path": "docs/adr/0250-strict-versioning-the-version-boundary-is-the-scope-gate.md",
|
|
1744
1744
|
"mode": "0000644",
|
|
1745
|
-
"sha256": "
|
|
1745
|
+
"sha256": "dc2f3cbb649c974f8c13ae057a8fe662c44548a1d75ff4585b094673d5cbe22d"
|
|
1746
1746
|
},
|
|
1747
1747
|
{
|
|
1748
1748
|
"path": "docs/adr/0251-a-suggestion-of-who-to-invite-is-a-recruiting-surface-and-owes-the-opt-out.md",
|
|
@@ -2062,12 +2062,17 @@
|
|
|
2062
2062
|
{
|
|
2063
2063
|
"path": "docs/adr/0314-module-protection-is-per-path-not-per-module.md",
|
|
2064
2064
|
"mode": "0000644",
|
|
2065
|
-
"sha256": "
|
|
2065
|
+
"sha256": "accaba461051e427c251e9c4ec4ff74304447fd6cd68f940311f4b978ff234c2"
|
|
2066
|
+
},
|
|
2067
|
+
{
|
|
2068
|
+
"path": "docs/adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md",
|
|
2069
|
+
"mode": "0000644",
|
|
2070
|
+
"sha256": "bc9dde45a33790e4fbce47682a5734a1adaf4fdd7f9347bb0128c6cca6fcf4e5"
|
|
2066
2071
|
},
|
|
2067
2072
|
{
|
|
2068
2073
|
"path": "docs/adr/README.md",
|
|
2069
2074
|
"mode": "0000644",
|
|
2070
|
-
"sha256": "
|
|
2075
|
+
"sha256": "a6bf1805ee02ca941a1910d722cfc61e4f0e519aa79c9d102aadfb8bf7d95a5a"
|
|
2071
2076
|
},
|
|
2072
2077
|
{
|
|
2073
2078
|
"path": "docs/api-reference.md",
|
|
@@ -2637,7 +2642,7 @@
|
|
|
2637
2642
|
{
|
|
2638
2643
|
"path": "docs/module-api-changelog.md",
|
|
2639
2644
|
"mode": "0000644",
|
|
2640
|
-
"sha256": "
|
|
2645
|
+
"sha256": "16f8d72724c91138e8fb17da0723f2901c66dee89a53e44dbc41dd65eb71b161"
|
|
2641
2646
|
},
|
|
2642
2647
|
{
|
|
2643
2648
|
"path": "docs/modules-contract.md",
|
|
@@ -2857,7 +2862,7 @@
|
|
|
2857
2862
|
{
|
|
2858
2863
|
"path": "docs/recipes/self-host.md",
|
|
2859
2864
|
"mode": "0000644",
|
|
2860
|
-
"sha256": "
|
|
2865
|
+
"sha256": "2a8a4075086fbcc2815c7b809f38dd887726e53561755e65a7990a687772cf7c"
|
|
2861
2866
|
},
|
|
2862
2867
|
{
|
|
2863
2868
|
"path": "docs/recipes/server-bring-up.md",
|
|
@@ -4272,7 +4277,7 @@
|
|
|
4272
4277
|
{
|
|
4273
4278
|
"path": "modules/government/catalog.js",
|
|
4274
4279
|
"mode": "0000644",
|
|
4275
|
-
"sha256": "
|
|
4280
|
+
"sha256": "65ff329fd00c94ed4571d852079515e173339ee070d5489ce10d5cabe219b20b"
|
|
4276
4281
|
},
|
|
4277
4282
|
{
|
|
4278
4283
|
"path": "modules/government/config.js",
|
|
@@ -7792,12 +7797,12 @@
|
|
|
7792
7797
|
{
|
|
7793
7798
|
"path": "package-lock.json",
|
|
7794
7799
|
"mode": "0000644",
|
|
7795
|
-
"sha256": "
|
|
7800
|
+
"sha256": "599eeee82f59bc74d6e6e19ebc61bba89421c050ca7444478e13860d6e0276cf"
|
|
7796
7801
|
},
|
|
7797
7802
|
{
|
|
7798
7803
|
"path": "package.json",
|
|
7799
7804
|
"mode": "0000644",
|
|
7800
|
-
"sha256": "
|
|
7805
|
+
"sha256": "899fa307dabbae44dd14efe9d121af734c3cdbb9d0027668ff21ba5e2b8e49dd"
|
|
7801
7806
|
},
|
|
7802
7807
|
{
|
|
7803
7808
|
"path": "public-docs/index.html",
|
|
@@ -8752,7 +8757,7 @@
|
|
|
8752
8757
|
{
|
|
8753
8758
|
"path": "scripts/gds/provision-net.js",
|
|
8754
8759
|
"mode": "0000644",
|
|
8755
|
-
"sha256": "
|
|
8760
|
+
"sha256": "734ae6982953f2474d424bf16789adfb709b5657ce62014df5c29a791506a988"
|
|
8756
8761
|
},
|
|
8757
8762
|
{
|
|
8758
8763
|
"path": "scripts/gds/provision-process.js",
|
|
@@ -8782,7 +8787,7 @@
|
|
|
8782
8787
|
{
|
|
8783
8788
|
"path": "scripts/gds/provision.js",
|
|
8784
8789
|
"mode": "0000644",
|
|
8785
|
-
"sha256": "
|
|
8790
|
+
"sha256": "8722cf581fac65b5839f2b44987c9b09b721452d8f20d2079f2029b386841396"
|
|
8786
8791
|
},
|
|
8787
8792
|
{
|
|
8788
8793
|
"path": "scripts/gds/publish-credential-check.js",
|
|
@@ -9607,7 +9612,7 @@
|
|
|
9607
9612
|
{
|
|
9608
9613
|
"path": "src/bongos/module-scope-map.js",
|
|
9609
9614
|
"mode": "0000644",
|
|
9610
|
-
"sha256": "
|
|
9615
|
+
"sha256": "b82d3257c27689e53b3d491251cc46d474c8f4da944e2e8f73242525df878484"
|
|
9611
9616
|
},
|
|
9612
9617
|
{
|
|
9613
9618
|
"path": "src/bongos/module-submissions.js",
|
|
@@ -9797,7 +9802,7 @@
|
|
|
9797
9802
|
{
|
|
9798
9803
|
"path": "src/module-api.js",
|
|
9799
9804
|
"mode": "0000644",
|
|
9800
|
-
"sha256": "
|
|
9805
|
+
"sha256": "5f5e11f8e4ade6fc0642e3051c89a1f3495628cb567c0fa30037b2fd55640170"
|
|
9801
9806
|
},
|
|
9802
9807
|
{
|
|
9803
9808
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11397,7 +11402,7 @@
|
|
|
11397
11402
|
{
|
|
11398
11403
|
"path": "tests/government_catalog.mjs",
|
|
11399
11404
|
"mode": "0000644",
|
|
11400
|
-
"sha256": "
|
|
11405
|
+
"sha256": "ba1c95ca55d8d8e256ba03f2543e44cee4272b9652742343617e35cebefe3ab9"
|
|
11401
11406
|
},
|
|
11402
11407
|
{
|
|
11403
11408
|
"path": "tests/government_charter_library.mjs",
|
|
@@ -12297,7 +12302,7 @@
|
|
|
12297
12302
|
{
|
|
12298
12303
|
"path": "tests/module-scope-map.mjs",
|
|
12299
12304
|
"mode": "0000644",
|
|
12300
|
-
"sha256": "
|
|
12305
|
+
"sha256": "fae747eaeea8e9d657de097a72e5f8d244bd2ece6c7e890ff4c9756787721bc5"
|
|
12301
12306
|
},
|
|
12302
12307
|
{
|
|
12303
12308
|
"path": "tests/module_api.mjs",
|
|
@@ -12782,7 +12787,7 @@
|
|
|
12782
12787
|
{
|
|
12783
12788
|
"path": "tests/provision_caddy_wiring.mjs",
|
|
12784
12789
|
"mode": "0000644",
|
|
12785
|
-
"sha256": "
|
|
12790
|
+
"sha256": "2eb850e431c5355d2645eec48554c686ad5b57222ee863489a29b78d945b7a26"
|
|
12786
12791
|
},
|
|
12787
12792
|
{
|
|
12788
12793
|
"path": "tests/provision_disconnect.mjs",
|
|
@@ -12967,7 +12972,7 @@
|
|
|
12967
12972
|
{
|
|
12968
12973
|
"path": "tests/quarantine_canonical.mjs",
|
|
12969
12974
|
"mode": "0000644",
|
|
12970
|
-
"sha256": "
|
|
12975
|
+
"sha256": "672096eb5917d45b1c77e1d07c4b7a6613a0714eb4b38327057fcea11a41b7ad"
|
|
12971
12976
|
},
|
|
12972
12977
|
{
|
|
12973
12978
|
"path": "tests/rank_floor_wiring.mjs",
|
|
@@ -125,6 +125,33 @@ now a disposition over scope, not a piece of authoring.
|
|
|
125
125
|
|
|
126
126
|
The rest of ADR 0157 stands; this touches exactly one key.
|
|
127
127
|
|
|
128
|
+
### 6a. The `system` flag on that key: asked, and answered NOT NOW (2026-09-17)
|
|
129
|
+
|
|
130
|
+
Raising the floor left a gap that §6 did not close and did not hide. `version.create`
|
|
131
|
+
is `system: false`, and the `system` flag is what permanently bars granting a key to a
|
|
132
|
+
**custom** rank. So the government may still grant `version.create` to a custom
|
|
133
|
+
sub-Archon rank, and for a builder holding that rank D1 is decorative again: refused a
|
|
134
|
+
goal on a building version, they cut a fresh version and put it there — exactly the
|
|
135
|
+
escape hatch D1 exists to close.
|
|
136
|
+
|
|
137
|
+
The question "should `version.create` become `system: true`, joining `version.close`?"
|
|
138
|
+
was raised as task 1003853 and **decided by the owner on 2026-09-17: not now.**
|
|
139
|
+
|
|
140
|
+
The case *for* was real and was heard: create is now a disposition over scope in this
|
|
141
|
+
ADR's own words, its pair `version.close` is already `system: true`, and a gate a custom
|
|
142
|
+
rank can be granted is not a gate. It lost to the cost of the remedy. `system: true` is
|
|
143
|
+
**permanent and instance-wide** — it would remove for good the ability to delegate
|
|
144
|
+
version authoring to any custom rank, which is a real capability an instance might want,
|
|
145
|
+
and no instance has asked for the loophole to be closed. The gap is theoretical until
|
|
146
|
+
someone defines a custom sub-Archon rank *and* grants it this key; at that moment it
|
|
147
|
+
stops being theoretical and this is worth reopening.
|
|
148
|
+
|
|
149
|
+
This subsection records that the question is **answered, not pending**. A future reader
|
|
150
|
+
should not implement the one-word catalog change on the strength of the For argument
|
|
151
|
+
alone — it was already weighed. `tests/government_catalog.mjs` pins `version.create` at
|
|
152
|
+
`system: false` with the archon floor so the flag cannot move without someone meeting
|
|
153
|
+
this decision head-on.
|
|
154
|
+
|
|
128
155
|
## 7. Consequences
|
|
129
156
|
|
|
130
157
|
- The live instance is **not** in the state these rules describe, and the rules cannot be enforced on data that violates them. Bringing it over is explicit work under criterion `sv-cutover-proven-live`: fold `CB-V1` into `BONGOS-V1`, disposition the closed goals holding open tasks, rehome the catch-all tasks, and scope `BONGOS-V2` in `planning`. Each invariant lands with a query proving it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ADR 0314 — A module owns a protected path without the whole module becoming protected
|
|
2
2
|
|
|
3
|
-
- **Status:** accepted
|
|
3
|
+
- **Status:** accepted — **implemented** by [task 1004109](https://cloudbongos.com/builders#/task/1004109) (2026-09-20)
|
|
4
4
|
- **Date:** 2026-09-20
|
|
5
5
|
- **Task:** [task 1004070](https://cloudbongos.com/builders#/task/1004070), goal 1000095 — BONGOS-V2
|
|
6
6
|
- **From:** idea 1000689; owner decision 2026-09-19 — *option 1, narrow what counts as protected*
|
|
@@ -105,7 +105,12 @@ Three reasons this is the honest shape rather than the convenient one:
|
|
|
105
105
|
Per the task: an ADR first, implementation as a follow-up. This decision builds no
|
|
106
106
|
new derivation mechanism. Filed separately:
|
|
107
107
|
|
|
108
|
-
- The implementation of the per-path goal-scope wall
|
|
108
|
+
- ~~The implementation of the per-path goal-scope wall.~~ **Landed** in
|
|
109
|
+
[task 1004109](https://cloudbongos.com/builders#/task/1004109): `isProtected` now
|
|
110
|
+
filters a module's protecting globs to those that are not CLI entrypoints (a
|
|
111
|
+
structural path test — directories under `scripts/` are territory and still
|
|
112
|
+
protect), narrowing the derived set from twelve to the nine predicted below.
|
|
113
|
+
Nothing was unprotected to achieve it and the path wall did not move.
|
|
109
114
|
- **`builder-settings`, deliberately out of scope.** Its overlap is
|
|
110
115
|
`src/bongos/routes/me.js` caught by the blanket `src/bongos/routes/` prefix, so
|
|
111
116
|
narrowing it means either finishing the strangler move of `me.js` into
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# ADR 0315 — A plane with no proxy is refused, not given a no-op ingress
|
|
2
|
+
|
|
3
|
+
**Status:** accepted · **Date:** 2026-09-20 · **Task:** [task 1004017](https://cloudbongos.com/builders#/task/1004017) (goal 1000106 — BONGOS-V2)
|
|
4
|
+
|
|
5
|
+
**Related:** [ADR 0303](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md) (detect-and-refuse on the base Caddyfile — the check this one sits in front of) · [ADR 0306](0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md) (the supervision seam that explicitly deferred Caddy reload to this task) · [ADR 0285](0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md) (the capacity preflight this one is ordered behind) · [ADR 0111](0111-instance-hosting-provisioning-module.md) (the standup runbook both checks live inside)
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Provisioning step 6 writes a per-instance `<slug>.caddy` snippet and runs
|
|
10
|
+
`systemctl reload caddy`. The self-host image installs `postgresql-client bash tini curl
|
|
11
|
+
ca-certificates git`, runs `USER node`, and ships no proxy of any kind — so on a compose
|
|
12
|
+
self-host that step cannot work, and a child instance provisioned there has no front door.
|
|
13
|
+
|
|
14
|
+
ADR 0306 named this explicitly as the piece its seam does not cover: *"`systemctl reload
|
|
15
|
+
caddy` is shared-proxy config, governed by ADR 0303, and the container has no Caddy at
|
|
16
|
+
all — that is task 1004017, not this seam."*
|
|
17
|
+
|
|
18
|
+
**Two failure shapes, and only one of them was visible.** ADR 0303's preflight is gated on
|
|
19
|
+
`inst.domain`, so:
|
|
20
|
+
|
|
21
|
+
- A **domained** child already half-refused — `caddy adapt` fails with no binary, which
|
|
22
|
+
ADR 0303's own `catch` turns into a gap string. But the message told the operator to add
|
|
23
|
+
a directive to `/etc/caddy/Caddyfile`: a file the container does not have and that
|
|
24
|
+
provisioning will never create (ADR 0303 §1 forbids writing it).
|
|
25
|
+
- A **domainless** child skipped the preflight entirely, walked to step 6, and died on
|
|
26
|
+
`boxWriteFile('/etc/caddy/sites/<slug>.caddy', …, {sudo: true})`. That throw is uncaught,
|
|
27
|
+
so it bypasses the `{ok:false}` pending-retry path and hard-errors the instance on attempt
|
|
28
|
+
one. The runner recorded a crash where the honest answer was a refusal.
|
|
29
|
+
|
|
30
|
+
## Decision
|
|
31
|
+
|
|
32
|
+
**A plane with no reverse proxy is refused before anything is attempted, and the refusal
|
|
33
|
+
names what the plane lacks.**
|
|
34
|
+
|
|
35
|
+
`ingressPlaneGap(exec)` in `scripts/gds/provision-net.js` probes the plane with
|
|
36
|
+
`caddy version` — one cheap, unprivileged, read-only command, the same posture as
|
|
37
|
+
`checkCaddyWiring`'s `caddy adapt`. It returns `null` when a proxy answers, or a string
|
|
38
|
+
naming the missing proxy. Both standup legs (`provisionInstance` step 6's owner, and
|
|
39
|
+
`domainAttachInstance`) call it and return `{ok:false, error: 'Ingress preflight failed: …'}`.
|
|
40
|
+
|
|
41
|
+
**It is deliberately NOT domain-gated.** That is the whole point: the domainless child is
|
|
42
|
+
the one ADR 0303 could not see.
|
|
43
|
+
|
|
44
|
+
**A missing binary is the only failure that proves there is no proxy.** Under remote exec
|
|
45
|
+
`boxExec` is `ssh <host> bash -s`, so a briefly-unreachable co-hosting box throws here too —
|
|
46
|
+
and answering that with "this plane has no reverse proxy, provision from a host that runs
|
|
47
|
+
Caddy" would be a confident wrong diagnosis, burning the intent's retry budget on a network
|
|
48
|
+
blip. Exit 127 (and the `command not found` / `is not recognized` wordings) is read as proof;
|
|
49
|
+
anything else returns *"could not confirm a reverse proxy on this plane"* with the real cause
|
|
50
|
+
leading. ssh reports its own failures as 255 and otherwise propagates the remote command's
|
|
51
|
+
status, so 127 still means "no caddy" across the hop.
|
|
52
|
+
|
|
53
|
+
**The three refusals are ordered capacity → ingress → wiring**, cheapest and most
|
|
54
|
+
fundamental first, and all three return before anything on the box changes:
|
|
55
|
+
|
|
56
|
+
1. `capacityPreflight` measures locally (ADR 0285 promises a full box is measured first and
|
|
57
|
+
refuses before anything else runs);
|
|
58
|
+
2. ingress asks whether this plane has a proxy **at all**;
|
|
59
|
+
3. ADR 0303 then asks whether that proxy is wired for **this domain**.
|
|
60
|
+
|
|
61
|
+
Asking 0303 first is what produced the "edit a Caddyfile you do not have" answer, so this
|
|
62
|
+
ADR moves 0303's preflight behind the capacity measurement to make room. Both still refuse
|
|
63
|
+
before any mutation, so neither guarantee is weakened — only the precedence between two
|
|
64
|
+
refusals changes.
|
|
65
|
+
|
|
66
|
+
**No ingress seam, and no no-op backend.** The obvious symmetry with ADR 0306 would be a
|
|
67
|
+
`resolveIngress()` registry with a `none` implementation whose `writeSite`/`reload` are
|
|
68
|
+
no-ops. That is rejected: a no-op backend lets the standup **succeed** while leaving the
|
|
69
|
+
child unreachable, which is neither "reachable at a documented address" nor "refuses with a
|
|
70
|
+
clear error" — it is the silently-broken instance this task exists to prevent. ADR 0306's
|
|
71
|
+
seam exists because two real planes both need supervising; here the second plane cannot
|
|
72
|
+
serve a child at all, and the honest encoding of that is a refusal, not an implementation.
|
|
73
|
+
|
|
74
|
+
## Consequences
|
|
75
|
+
|
|
76
|
+
- **A compose self-host still cannot provision a child instance — it now says so.** This
|
|
77
|
+
closes the failure mode, not the capability. Giving the container a real front door
|
|
78
|
+
(a published port, a bundled proxy, an explicit localhost-only shape) remains unbuilt, and
|
|
79
|
+
a future ADR that adds one replaces this refusal rather than extending it.
|
|
80
|
+
|
|
81
|
+
- **One extra `caddy version` per applied standup.** Read-only, unprivileged, and it runs
|
|
82
|
+
after the capacity measurement, so a full box still refuses without it. A dry run never
|
|
83
|
+
probes, and a dedicated droplet is skipped — Caddy comes from cloud-init there.
|
|
84
|
+
|
|
85
|
+
- **ADR 0303's territory is untouched.** "Caddy is here but mis-wired" is still its question
|
|
86
|
+
and still its message; this ADR only answers the prior one. Every command string ADR 0303
|
|
87
|
+
pins in `tests/provision_caddy_wiring.mjs`, `tests/provision.mjs` and
|
|
88
|
+
`tests/provision_teardown_address.mjs` is unchanged.
|
|
89
|
+
|
|
90
|
+
- **Teardown is not covered.** `provision-teardown.js` removes the snippet and reloads with
|
|
91
|
+
`allowFail: true`, so it already degrades rather than crashing. Left alone deliberately: a
|
|
92
|
+
refusal on the teardown path would strand a row instead of cleaning it.
|
|
93
|
+
|
|
94
|
+
- **The probe is a capability question, not a configuration knob.** There is no
|
|
95
|
+
`PROVISION_INGRESS` env var. A plane either answers `caddy version` or it does not, and an
|
|
96
|
+
operator who wants provisioning to proceed needs a proxy, not a flag that says to pretend.
|
package/docs/adr/README.md
CHANGED
|
@@ -406,3 +406,4 @@ This keeps the decision history honest and traceable.
|
|
|
406
406
|
| 0312 | [**Provenance is a SECOND ideas-owned edge table, written by the seams that already know** ([task 1004080](https://cloudbongos.com/builders#/task/1004080), goal 1000095 — BONGOS-V2). The spike [ADR 0311](0311-an-idea-is-a-permanent-origin-tag.md) deferred: how the descent edges are physically recorded. A descent (`idea → task → rewrite → split → removal`) is four levels deep and crosses a version boundary, and the task hierarchy refuses both — `attachParent` enforces `SELF_PARENT`, `VERSION_MISMATCH`, `PARENT_HAS_PARENT` and `TARGET_HAS_CHILDREN`, all load-bearing for ship rollup and claim safety. The spike was told to CONFIRM `ideas_task_lineage` rather than assume it, and confirmed one half while refuting the other: the table is right as the **idea→task anchor** (bipartite, no depth cap, no version predicate) but CANNOT carry the task→task edge, because a derivation is a property of the task PAIR and [ADR 0236](0236-many-ideas-one-task-the-lineage-key-is-the-pair.md) made co-source ideas both legal and common — a `derived_from_task_id` column would write the same derivation once per co-source idea and let two rows disagree about where a task came from, the same under-keying that silently lost attribution on prod once already. A merge also gives a task SEVERAL parents, so the relation is a **DAG, not a tree**. Decision: a new `ideas_task_descent` (`task_id`, `parent_task_id`, CHECK'd `relation`, PK on the pair, indexed both ways), with NO depth cap and NO version predicate, and **cycles refused at write time** because the payout walks this graph and a cycle is a non-terminating walk rather than a cosmetic error. On who writes it — the question the task actually set, since an edge depending on memory will have holes — **three seams already know and none is telling anyone**: `cascade.js` encodes the originating task id inside `source_ref` as a STRING nothing can join on, `tasks.merged_into_task_id` already records the fold, and `parent_task_id` already records the split. Only `rewrite` and cross-version `merge` stay manual, which is honest — they are editorial acts with no automatic signal, and their absence degrades the picture rather than corrupting it. What it fixes: `sourceIdeaForTask` and `awardIdeaTaskShipCredit` are both one-hop `LIMIT 1` point reads, so a DERIVED task resolves to `no_source_idea` and the origin is not paid at all. Does not decide: the decay rate beyond its default, what counts as removed ([ADR 0313](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md)), or how any of it is drawn.](0312-provenance-is-a-second-ideas-owned-edge-table.md) | ideas / lifecycle |
|
|
407
407
|
| 0313 | [**A removal is derived from the SERVER's own merge diff and confirmed by a person** ([task 1004081](https://cloudbongos.com/builders#/task/1004081), goal 1000095 — BONGOS-V2). The other half [ADR 0311](0311-an-idea-is-a-permanent-origin-tag.md) deferred: what counts as removed, so that brightness can fall at all. Nothing records it today — `shipped` is terminal (`rot.js` says so outright) and `merged_into_task_id` is CHECK'd to `abandoned` rows, describing only work cut BEFORE it landed. Owner decision 13 set the shape (system proposes, person confirms); this settles the detail, and corrects two premises the task was written on. **`cascade.js` is no longer empty** — it carries a production rule and [ADR 0242](0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md) fixed the contract (append an entry, never touch the runner), so there is a precedent to copy including its anti-recursion guard. **`netNegativeBonusAmount` cannot be the trigger** despite being the named nearest signal: it is an aggregate with NO TARGET (it says deletion happened, never what or whose), it is CLIENT-SUPPLIED via `signals.diff_stats`, and `grader-score.js` already documents the forgery it defends against — and brightness sits beside a payout, so a forgeable target-less counter cannot carry it. **Nothing durable records what any ship touched**: `committed_files` and `diff_stats` are client-supplied at grade time and never persisted, `touches[]` is advisory ([ADR 0049](0049-split-parallel-safety-contract.md)), so a removal cannot be reconstructed from history — only derived as a ship lands. Decision: derive it from the SERVER's own merge diff ([ADR 0055](0055-server-mediated-branch-publish.md) — the server holds the repo and does the merge, so it computes the diff rather than being told one), and attribute the deleted hunks through **`claims.published_head_sha`** (`migrations/171`), NOT by parsing `(task NNNN)` out of commit text — only 7 of `main`'s last 15 commits carry a task id, and a convention that holds most of the time cannot underwrite a payout. An UNATTRIBUTABLE deletion files nothing: a wrong proposal costs attention and dims a real star. A rename, a move and a rewrite-in-place are NOT removals, and a cold body is not a remnant — removal applies only below a task that actually shipped. The proposal is an appended `CASCADES` entry filing a QUESTION; confirmation is Metic+/`task.promote` on the existing awaiting-nod surface; an unanswered one rots on the normal clock. Only a CONFIRMED proposal dims a star. The derivation must be NON-BLOCKING — landing work matters more than dimming a star on time. Brightness can only fall forward from here, so historical sizes need a marker or the number lies.](0313-a-removal-is-proposed-by-the-ship-and-confirmed-by-a-person.md) | ideas / lifecycle |
|
|
408
408
|
| 0314 | [**A module owns a protected path without the whole module becoming protected** ([task 1004070](https://cloudbongos.com/builders#/task/1004070), goal 1000095 — BONGOS-V2). Three modules derive `protected` — and so are Archon-only to scope a goal to — because of ONE file that is not their domain: `onboarding` on `scripts/gds/setup.js`, `autonomy` on `scripts/gds/conductor-dispatch.js`, `copy-desk` on `scripts/gds/copy-inventory.js`. The owner chose *narrow what counts as protected*; the task set the fork and required the choice be justified before any glob moved. **Deleting the glob is out**: `MODULE_GLOBS` is also the territory deciding who may AUTHOR a task on a path, and the shared floor is only `tests/`, so an unowned path is authorable by no goal at all. That left reassigning each CLI to an already-protected module — **measured, not reasoned about**, by applying the edit to the source and re-requiring in a fresh process (`MODULE_SCOPE_MAP` is frozen and built from a COPY of `MODULE_GLOBS` at load, so an in-process mutation proves nothing and passes green). The measurement kills it: `copy-desk` and `autonomy` lose the ability to author on their own generator and their own conductor, and — the row that decides it — `devsecops` as the new owner **cannot author the generator together with the two artifacts it writes**, because `docs/copy-registry.json` and `docs/copy-inventory.md` stay `copy-desk`'s. So *change a UI string, regenerate the registry* becomes authorable by no single goal, and that is the ship-check freshness gate every UI task hits. Decision: **protection becomes per-path at the goal-scope wall, as it already is at the other three** — `matchProtected` decides task-authoring, the grader and gate decide ship, the post-push audit decides the dishonest case; only goal-creation asked about the MODULE, and that inconsistency is the whole defect. The derivation stays computed from `PROTECTED_GLOBS` and no flag is hand-set ([ADR 0086](0086-goal-scoped-work-hierarchy.md) §7 / SEC #863), and the Metic floor on those paths does not move. **Corrects the task's own numbers**: 12 of 26 modules derive protected, not 11, and `agents` (protected by `modules/agents/`, itself a protected glob) is absent from its list of seven — which is why the one thing this ADR SHIPS is a test pinning the full protected key set **by name**, a count assertion passing on the wrong twelve being exactly how that drifted. Implementation is deferred to task 1004109, per the task's own instruction to stop after the ADR. Also filed: `builder-settings` (task 1004110), whose overlap rides the blanket `src/bongos/routes/` prefix and so needs either the `me.js` strangler move or its own security ADR; and **nine protected paths owned by no module at all** (task 1004111) — the freshness generators, the merge-driver pair and `cli-lib.js` among them — found while checking whether deletion was viable, a pre-existing hole rather than one this opens. Does not decide: the floor a protected path carries, whether `scripts/gds/` deserves a shared-floor entry the way `tests/` has one, or how the nine unowned paths get a home.](0314-module-protection-is-per-path-not-per-module.md) | modules / permissions |
|
|
409
|
+
| 0315 | [**A plane with no proxy is refused, not given a no-op ingress** ([task 1004017](https://cloudbongos.com/builders#/task/1004017), goal 1000106 — BONGOS-V2). The piece [ADR 0306](0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md) explicitly deferred: the self-host image ships no proxy, so provisioning step 6's `<slug>.caddy` snippet write and `systemctl reload caddy` cannot work there and a child instance gets no front door. **Two failure shapes, only one visible**: a DOMAINED child already half-refused, because `caddy adapt` fails with no binary and [ADR 0303](0303-provisioning-verifies-the-base-caddyfile-and-never-edits-it.md)'s own catch turns that into a gap — but the message told the operator to add a directive to `/etc/caddy/Caddyfile`, a file the container does not have and that ADR 0303 §1 forbids provisioning from creating; a DOMAINLESS child skipped that preflight entirely (it is gated on `inst.domain`), reached step 6 and died on the `sudo` write, an uncaught throw that bypasses the `{ok:false}` pending-retry path and hard-errors the instance on attempt one — a crash recorded where a refusal was the honest answer. Decision: `ingressPlaneGap(exec)` probes the plane with `caddy version` — one cheap unprivileged read, the same posture as `checkCaddyWiring` — and both standup legs refuse with `Ingress preflight failed: …`, deliberately NOT domain-gated, because the domainless child is the one 0303 could not see. **Only a missing binary proves there is no proxy**: under remote exec `boxExec` is `ssh <host> bash -s`, so an unreachable co-hosting box throws here too, and diagnosing a network blip as 'this plane has no proxy, provision elsewhere' would be a confident wrong answer that burns the intent's retry budget — exit 127 is read as proof, anything else answers 'could not confirm a reverse proxy on this plane' with the real cause leading. The three refusals are ordered **capacity → ingress → wiring**, cheapest and most fundamental first and all before any mutation, which moves 0303's preflight behind the ADR 0285 capacity measurement; both still refuse before anything changes, so only the precedence between two refusals moves. **No ingress seam and no no-op backend**, despite the obvious symmetry with 0306: a `none` implementation whose `writeSite`/`reload` are no-ops lets the standup SUCCEED while leaving the child unreachable — neither half of the task's done-when, and exactly the silently-broken instance it exists to prevent. Does not decide: what a child's front door IS on a container plane (a published port, a bundled proxy, an explicit localhost-only shape) — a future ADR that adds one replaces this refusal rather than extending it. Teardown is left alone on purpose: it already reloads `allowFail`, and refusing there would strand a row instead of cleaning it.](0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md) | provisioning / ingress |
|
|
@@ -2296,5 +2296,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2296
2296
|
landed since 1.19.909 with no explicit bump. run 35519966415. (task 1002620)
|
|
2297
2297
|
1.19.911 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2298
2298
|
landed since 1.19.910 with no explicit bump. run 35520751450. (task 1002620)
|
|
2299
|
+
1.19.912 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2300
|
+
landed since 1.19.911 with no explicit bump. run 35522352719. (task 1002620)
|
|
2301
|
+
1.19.913 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2302
|
+
landed since 1.19.912 with no explicit bump. run 35523207325. (task 1002620)
|
|
2299
2303
|
---------------------------------------------------------------------------
|
|
2300
2304
|
```
|
|
@@ -39,7 +39,7 @@ Author `config/branding.json` from the neutral starter — see [the branding con
|
|
|
39
39
|
|
|
40
40
|
**Or scaffold it with `bongos init`** ([task #1204](https://example.com/builders#/task/1204)): a short interview (or `bongos init --from spec.json` for a hands-free / CI run) that writes both config files, **seeds your first version + its done-when goal**, and **files the human-only kickoff steps** (register a GitHub OAuth app, first owner sign-in, repo binding, admission) as real tracked tasks — so a fresh instance has a claimable queue on day one instead of an empty hall. It never invents your branding (it points you at the fill prompt for that). Preview the whole plan with `bongos init --dry-run`.
|
|
41
41
|
|
|
42
|
-
**Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image now carries the tools the scaffold needs — git, and a core it can package out of its own `.git`-less checkout (task 1003995) — and supervision is no longer systemd-only: the runner picks a process manager, falling back to supervisord where systemd is not running (task 1003997, [ADR 0306](../adr/0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md)). Three standup legs still assume a systemd box, so on a compose self-host a new project
|
|
42
|
+
**Production hosting, optional (the `provisioning` module — [ADR 0111](../adr/0111-instance-hosting-provisioning-module.md) §3).** If you enable the `provisioning` module and answer the interview's hosting questions (shape — co-tenant `$0` or a dedicated droplet — bring-your-own domain, credential ref, tier), init turns those answers into a real **provisioning request** (a queued `provision` intent the control-plane runner drains) instead of only filing a manual "production topology" task. **A queued request does nothing until a runner drains it:** on the control plane that is a systemd timer running `node scripts/gds/provision.js run-intents --apply`. This Docker image now carries the tools the scaffold needs — git, and a core it can package out of its own `.git`-less checkout (task 1003995) — and supervision is no longer systemd-only: the runner picks a process manager, falling back to supervisord where systemd is not running (task 1003997, [ADR 0306](../adr/0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md)). Three standup legs still assume a systemd box, so on a compose self-host a new project cannot stand up: the container has no reverse proxy for a child instance's front door, cannot create the per-instance unix account that isolates one instance from another (task 1004018), and installs its backup dir as a hardcoded founder user (task 1002719). **The ingress leg now refuses instead of failing opaquely** (task 1004017, [ADR 0315](../adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md)): provisioning probes the plane with `caddy version` and returns `Ingress preflight failed: …` naming the missing proxy, before it writes a Caddy snippet or reloads anything. Previously a domained child was told to edit an `/etc/caddy/Caddyfile` the container does not have, and a domainless one crashed on the snippet write. Giving the container a real front door is still unbuilt — the refusal closes the failure mode, not the capability. Note supervisord has no timer concept either, so a container-hosted instance gets no automatic DB backup — the standup log prints the `crontab` line to add instead. The two irreducibly-human legs — the GitHub OAuth app and the first owner sign-in — stay guided/manual. If the module isn't enabled on the target instance, init falls back to the manual production-topology task, so nothing is silently skipped.
|
|
43
43
|
|
|
44
44
|
## Configuration surface
|
|
45
45
|
|
|
@@ -139,6 +139,19 @@ const PERMISSIONS = [
|
|
|
139
139
|
// is a second-order change to what custom ranks may hold — the same reasoning that
|
|
140
140
|
// keeps `dependency.cross_tier.manage` where it is — and neither ADR 0250 nor this
|
|
141
141
|
// task decided it. Filed for the owner rather than taken here.
|
|
142
|
+
//
|
|
143
|
+
// ANSWERED 2026-09-17 — NOT NOW. The owner decided in an Archon session that
|
|
144
|
+
// `version.create` STAYS system:false (task 1003853, ADR 0250 §6a). The gap above is
|
|
145
|
+
// real and stands as written; it is simply not worth what closing it costs today.
|
|
146
|
+
// `system:true` is PERMANENT — it would remove, for every instance and for good, the
|
|
147
|
+
// ability to delegate version authoring to a custom rank, and no instance has asked
|
|
148
|
+
// for the loophole to be closed. The gap stays theoretical until someone actually
|
|
149
|
+
// defines a custom sub-Archon rank AND grants it this key.
|
|
150
|
+
//
|
|
151
|
+
// So: do NOT flip this to `true` on the strength of the "a gate a custom rank can be
|
|
152
|
+
// granted is not a gate" argument. That argument was heard and was not the deciding
|
|
153
|
+
// one. The revisit trigger is a concrete one — the first time such a rank exists —
|
|
154
|
+
// and tests/government_catalog.mjs pins the flag so the change cannot land silently.
|
|
142
155
|
{ key: 'version.create', system: false, floor: 'archon', guards: 'POST /versions — create a version + its done-when criteria (ADR 0250 §6 returns it to archon; ADR 0157 had lowered it to metic)' },
|
|
143
156
|
{ key: 'task.confirm.grade_bypass', system: true, floor: 'archon', guards: 'POST /tasks/:id/confirm — ratify past a failed grade (env-relaxable, ADR 0041)' },
|
|
144
157
|
{ key: 'dependency.cross_tier.manage', system: true, floor: 'archon', guards: 'POST|DELETE /dependencies — cross-tier structural edges (ADR 0086)' },
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.913",
|
|
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.913",
|
|
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.913",
|
|
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",
|
|
@@ -270,6 +270,45 @@ function checkCaddyWiring(exec, opts = {}) {
|
|
|
270
270
|
}
|
|
271
271
|
return caddyWiringGap(res && res.stdout, { ...opts, caddyfile });
|
|
272
272
|
}
|
|
273
|
+
// Can this plane give a child instance a front door AT ALL? (task 1004017)
|
|
274
|
+
//
|
|
275
|
+
// ADR 0303 asks whether a box's Caddy is wired right. This is the question BEFORE it: the
|
|
276
|
+
// self-host image installs no caddy binary and runs unprivileged, so step 6's snippet write
|
|
277
|
+
// and `systemctl reload caddy` cannot work there. The 0303 preflight is gated on inst.domain,
|
|
278
|
+
// so a DOMAINLESS child skipped it entirely and died on the write — a hard error on attempt
|
|
279
|
+
// one, not the pending-retry a refusal earns. Probed, not configured: a plane either answers
|
|
280
|
+
// `caddy version` or it does not. Same cheap-read posture as checkCaddyWiring — no privilege,
|
|
281
|
+
// no config, no running server.
|
|
282
|
+
// A MISSING BINARY is the only failure that proves there is no proxy. Under remote exec
|
|
283
|
+
// boxExec is `ssh <host> bash -s`, which throws on a transport blip too — and diagnosing a
|
|
284
|
+
// briefly-unreachable co-hosting box as "this platform has no proxy, provision elsewhere"
|
|
285
|
+
// would be a confident wrong answer, the thing this check exists to stop. ssh reports its
|
|
286
|
+
// OWN failures as 255 and otherwise propagates the remote command's status, so 127 still
|
|
287
|
+
// means "no caddy" across the hop. cmd.exe says 9009 and words it differently.
|
|
288
|
+
const NO_CADDY_BINARY = /command not found|not recognized as an internal|No such file or directory/i;
|
|
289
|
+
function ingressPlaneGap(exec, { caddyfile = CONFIG.caddyfile } = {}) {
|
|
290
|
+
try { exec('caddy version'); return null; } catch (e) {
|
|
291
|
+
const why = String((e && (e.stderr || e.message)) || e).trim().slice(0, 200);
|
|
292
|
+
const status = e && e.status;
|
|
293
|
+
if (status !== 127 && status !== 9009 && !NO_CADDY_BINARY.test(why)) {
|
|
294
|
+
return `could not confirm a reverse proxy on this plane: 'caddy version' failed (${why}). Provisioning writes a Caddy site snippet for every child instance, so it refuses rather than assume — re-run once the box answers.`;
|
|
295
|
+
}
|
|
296
|
+
return `this plane has no reverse proxy, so a child instance would have no front door: 'caddy version' failed (${why}). Provision from a host that runs Caddy — a plane with no proxy cannot serve a child, and ${caddyfile} is not something provisioning will create.`;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// The preflight around ingressPlaneGap: the narration its sibling preflights already carry,
|
|
300
|
+
// including the dry-run line without which the plan prints step 6's Caddy write as though it
|
|
301
|
+
// will work. Returns the refusal, or null to proceed. `dedicated` skips it (Caddy comes from
|
|
302
|
+
// cloud-init). Lives here rather than inline at the two call sites to keep provision.js under
|
|
303
|
+
// its 1,500-line ratchet.
|
|
304
|
+
function ingressPreflight({ exec, apply, log, shape, probe = ingressPlaneGap, before = 'writing any Caddy config' }) {
|
|
305
|
+
if (shape === 'dedicated') return null;
|
|
306
|
+
if (!apply) { log(` [preflight] would probe the plane for a reverse proxy before ${before}`); return null; }
|
|
307
|
+
const missing = probe(exec);
|
|
308
|
+
if (missing) return `Ingress preflight failed: ${missing}`;
|
|
309
|
+
log(' [preflight] the plane serves a reverse proxy ✓');
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
273
312
|
function healthzCmd(inst) {
|
|
274
313
|
return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/healthz`;
|
|
275
314
|
}
|
|
@@ -322,6 +361,8 @@ module.exports = {
|
|
|
322
361
|
healthzCmd,
|
|
323
362
|
identityCmd,
|
|
324
363
|
identityVerdict,
|
|
364
|
+
ingressPlaneGap,
|
|
365
|
+
ingressPreflight,
|
|
325
366
|
shouldFederate,
|
|
326
367
|
zoneForDomain,
|
|
327
368
|
};
|
package/scripts/gds/provision.js
CHANGED
|
@@ -52,7 +52,7 @@ const crypto = require('node:crypto');
|
|
|
52
52
|
const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
|
|
53
53
|
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
54
54
|
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
|
-
const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
55
|
+
const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
const { resolveProcessManager } = require('./provision-process.js');
|
|
57
57
|
const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.js');
|
|
58
58
|
const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
|
|
@@ -268,6 +268,14 @@ async function provisionInstance(inst, deps) {
|
|
|
268
268
|
log(` [preflight] would verify the Cloudflare token can reach zone ${zoneForDomain(inst.domain)} before the address step`);
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
|
+
const room = await capacityPreflight(inst, deps); // box capacity (ADR 0285, task 1003927): an honest refusal BEFORE the status flip — never a silent oversubscribe
|
|
272
|
+
if (room.ok === false) return room;
|
|
273
|
+
// Ingress preflight (task 1004017, ADR 0315). Ordered capacity → ingress → wiring, all three
|
|
274
|
+
// before any mutation: ingress asks whether this plane has a proxy AT ALL, 0303 below whether
|
|
275
|
+
// that proxy is wired for THIS domain. Deliberately NOT domain-gated — a domainless child
|
|
276
|
+
// skipped 0303 entirely, reached step 6 and died on the snippet write.
|
|
277
|
+
const ingressGap = ingressPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, probe: deps.ingressPlaneGap });
|
|
278
|
+
if (ingressGap) return { ok: false, error: ingressGap };
|
|
271
279
|
// Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
|
|
272
280
|
// can never certify this domain, so refuse before touching it. Injectable like the DNS check.
|
|
273
281
|
const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
|
|
@@ -275,8 +283,6 @@ async function provisionInstance(inst, deps) {
|
|
|
275
283
|
const gap = caddyCheck(boxExec, { domain: inst.domain });
|
|
276
284
|
if (gap) return { ok: false, error: `Caddy preflight failed: ${gap}` };
|
|
277
285
|
}
|
|
278
|
-
const room = await capacityPreflight(inst, deps); // box capacity (ADR 0285, task 1003927): an honest refusal BEFORE the status flip — never a silent oversubscribe
|
|
279
|
-
if (room.ok === false) return room;
|
|
280
286
|
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
|
|
281
287
|
const patch = {};
|
|
282
288
|
|
|
@@ -750,6 +756,8 @@ async function domainAttachInstance(inst, deps) {
|
|
|
750
756
|
}
|
|
751
757
|
log(` [preflight] DNS token can reach zone ${scope.zone} ✓`);
|
|
752
758
|
}
|
|
759
|
+
const ingressGap = ingressPreflight({ exec: boxExec, apply, log, shape: inst.hosting_shape, probe: deps.ingressPlaneGap, before: 're-writing the Caddy snippet' });
|
|
760
|
+
if (ingressGap) return { ok: false, error: ingressGap }; // Ingress preflight (task 1004017, ADR 0315)
|
|
753
761
|
const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring; // Caddy preflight (task 1002860, ADR 0303)
|
|
754
762
|
if (apply) {
|
|
755
763
|
const gap = caddyCheck(boxExec, { domain: inst.domain });
|
|
@@ -456,12 +456,50 @@ const SHARED_GLOBS = Object.freeze(
|
|
|
456
456
|
SHARED_GLOBS_RAW.filter((g) => !touchesOverlap([g], PROTECTED_GLOBS)),
|
|
457
457
|
);
|
|
458
458
|
|
|
459
|
-
// A
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
459
|
+
// A CLI ENTRYPOINT — a single executable script under scripts/, e.g.
|
|
460
|
+
// 'scripts/gds/setup.js'. Structural, not a list: it is a path test, so no roster
|
|
461
|
+
// is hand-maintained here and nothing has to be kept in step with PROTECTED_GLOBS.
|
|
462
|
+
// Directories ('scripts/deploy/', 'scripts/gds/hooks/') are territory, not
|
|
463
|
+
// commands, and are deliberately NOT matched.
|
|
464
|
+
function isCommandPath(glob) {
|
|
465
|
+
return /^scripts\/.*\.js$/.test(glob);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Which of a module's globs make it protected? A glob qualifies iff it overlaps
|
|
469
|
+
// the imported PROTECTED_GLOBS — the one source of truth (permission-path-check.js),
|
|
470
|
+
// prefix-aware both directions (path-match.touchesOverlap), so e.g. a module owning
|
|
471
|
+
// 'src/bongos/routes/inbox.js' qualifies because PROTECTED_GLOBS carries the
|
|
472
|
+
// 'src/bongos/routes/' subtree.
|
|
473
|
+
function protectingGlobs(globs) {
|
|
474
|
+
return (globs || []).filter((g) => touchesOverlap([g], PROTECTED_GLOBS));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// A module is protected iff it owns protected TERRITORY — ADR 0314.
|
|
478
|
+
//
|
|
479
|
+
// WHY THIS IS NOT JUST touchesOverlap(globs, PROTECTED_GLOBS) ANY MORE.
|
|
480
|
+
// That asked "does this module touch the trust core", and three modules answered
|
|
481
|
+
// yes because of exactly one CLI they own that happens to sit on a protected
|
|
482
|
+
// path: onboarding (scripts/gds/setup.js), autonomy (conductor-dispatch.js),
|
|
483
|
+
// copy-desk (copy-inventory.js). Owning the command is not owning the authority
|
|
484
|
+
// — modules/government/ IS government, but setup.js merely lives near
|
|
485
|
+
// onboarding — so the whole module became Archon-only to scope a goal to on the
|
|
486
|
+
// strength of one file that is not its domain. ADR 0314 narrows the QUESTION to
|
|
487
|
+
// the one the other three walls already ask: is the territory being scoped
|
|
488
|
+
// itself protected?
|
|
489
|
+
//
|
|
490
|
+
// So a protected CLI entrypoint no longer protects its owning module; protected
|
|
491
|
+
// territory (a directory, or a non-command file like server.js or CLAUDE.md)
|
|
492
|
+
// still does. The derivation stays COMPUTED from PROTECTED_GLOBS — no hand-set
|
|
493
|
+
// flag, no override list (ADR 0086 §7 / SEC #863). It can only ever SHRINK the
|
|
494
|
+
// protected set relative to the old rule, never grow it, because it filters a
|
|
495
|
+
// subset: no module can become protected that was not protected before.
|
|
496
|
+
//
|
|
497
|
+
// THE WALL THAT MATTERS DOES NOT MOVE. This decides who may CREATE a goal whose
|
|
498
|
+
// territory includes a protected path. It does not decide who may WRITE one: a
|
|
499
|
+
// sub-Metic builder touching scripts/gds/setup.js is still refused by
|
|
500
|
+
// checkPermissionPaths (goal-scope-check.js wall 2), exactly as before.
|
|
463
501
|
function isProtected(globs) {
|
|
464
|
-
return
|
|
502
|
+
return protectingGlobs(globs).some((g) => !isCommandPath(g));
|
|
465
503
|
}
|
|
466
504
|
|
|
467
505
|
// The frozen exported roster: key → { globs:[...], protected:bool } (derived).
|
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.913'; // 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');
|
|
@@ -38,6 +38,28 @@ test('system and operational partition the catalog exactly', () => {
|
|
|
38
38
|
assert.deepEqual([...sys, ...op].sort(), permissionKeys().slice().sort());
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
// version.create is the one key in this catalog whose `system` flag is a RECORDED
|
|
42
|
+
// DECISION rather than a derivation, so it gets a tripwire of its own (task 1003853).
|
|
43
|
+
//
|
|
44
|
+
// WHY. ADR 0250 §6 raised its floor to archon; §6a asked whether the flag should follow
|
|
45
|
+
// to `system: true` and the owner answered NOT NOW on 2026-09-17 — `system: true` is
|
|
46
|
+
// permanent and would remove for good the ability to delegate version authoring to a
|
|
47
|
+
// custom rank. The For argument is a good one and is written down in the catalog beside
|
|
48
|
+
// the key, which is exactly why a future reader may act on it without noticing it was
|
|
49
|
+
// already weighed. A comment cannot stop that; a red test can.
|
|
50
|
+
test('version.create stays system:false at the archon floor — an ANSWERED question, not an open one', () => {
|
|
51
|
+
const p = byKey('version.create');
|
|
52
|
+
assert.equal(p.floor, 'archon', 'ADR 0250 §6 returned version.create to the archon floor');
|
|
53
|
+
assert.equal(p.system, false,
|
|
54
|
+
'version.create must stay system:false. This is not an oversight: ADR 0250 §6a records '
|
|
55
|
+
+ 'the owner deciding NOT NOW on 2026-09-17 (task 1003853). system:true is permanent and '
|
|
56
|
+
+ 'instance-wide — it removes the ability to delegate version authoring to any custom rank. '
|
|
57
|
+
+ 'Read §6a before changing this; the revisit trigger is a custom sub-Archon rank actually '
|
|
58
|
+
+ 'existing AND being granted this key.');
|
|
59
|
+
// The pair it is often compared to HAS made that trip, which is the whole temptation.
|
|
60
|
+
assert.equal(isSystem('version.close'), true, 'version.close is system:true — create is deliberately not');
|
|
61
|
+
});
|
|
62
|
+
|
|
41
63
|
test('isGrantable: operational→true, system→false, unknown→false', () => {
|
|
42
64
|
assert.equal(isGrantable('idea.triage'), true);
|
|
43
65
|
assert.equal(isGrantable('builder.rank.set'), false); // system
|
|
@@ -12,6 +12,9 @@ const { PROTECTED_GLOBS } = require(path.join(ROOT, 'src', 'bongos', 'permission
|
|
|
12
12
|
// the REAL consumer of the roster — asserted against directly so a wall assertion
|
|
13
13
|
// here can never diverge from what the live goal-task-create gate computes.
|
|
14
14
|
const gsc = require(path.join(ROOT, 'src', 'bongos', 'goal-scope-check.js'));
|
|
15
|
+
// the OLD protection rule, recomputed in-test so the shrink-only property below
|
|
16
|
+
// is proved against real globs rather than a copied literal (ADR 0314).
|
|
17
|
+
const { touchesOverlap } = require(path.join(ROOT, 'src', 'bongos', 'path-match.js'));
|
|
15
18
|
|
|
16
19
|
test('roster covers the decided ~16 modules + kernel, each with non-empty globs', () => {
|
|
17
20
|
const keys = scope.moduleKeys();
|
|
@@ -66,11 +69,21 @@ test('`protected` is DERIVED from the imported PROTECTED_GLOBS, never hand-set',
|
|
|
66
69
|
//
|
|
67
70
|
// So the set is spelled out. A module ARRIVING here is a deliberate line in a
|
|
68
71
|
// diff; a module LEAVING is the one that must never happen quietly.
|
|
72
|
+
// NARROWED TWELVE -> NINE BY ADR 0314 (task 1004109). `onboarding`, `autonomy`
|
|
73
|
+
// and `copy-desk` left the set. Each derived protected on the strength of exactly
|
|
74
|
+
// one CLI it owns that sits on a protected path — setup.js, conductor-dispatch.js,
|
|
75
|
+
// copy-inventory.js — and owning the command is not owning the authority. Nothing
|
|
76
|
+
// was unprotected to achieve it: the globs still belong to those modules, the
|
|
77
|
+
// paths are still in PROTECTED_GLOBS, and a sub-Metic builder touching any of the
|
|
78
|
+
// three is still refused by the path wall (asserted below). What changed is who
|
|
79
|
+
// may CREATE a goal whose territory happens to include them.
|
|
80
|
+
//
|
|
81
|
+
// `builder-settings` STAYS, deliberately: its overlap is src/bongos/routes/me.js
|
|
82
|
+
// caught by the blanket src/bongos/routes/ prefix, not a command, so narrowing it
|
|
83
|
+
// needs the me.js strangler move or a blanket-glob change — out of scope here.
|
|
69
84
|
const PROTECTED_MODULES = [
|
|
70
85
|
'agents',
|
|
71
|
-
'autonomy',
|
|
72
86
|
'builder-settings',
|
|
73
|
-
'copy-desk',
|
|
74
87
|
'dev-box',
|
|
75
88
|
'devsecops',
|
|
76
89
|
'government',
|
|
@@ -78,7 +91,6 @@ const PROTECTED_MODULES = [
|
|
|
78
91
|
'kernel',
|
|
79
92
|
'lifecycle',
|
|
80
93
|
'methodology',
|
|
81
|
-
'onboarding',
|
|
82
94
|
];
|
|
83
95
|
|
|
84
96
|
test('the protected module set is exactly this, by name', () => {
|
|
@@ -133,6 +145,78 @@ test('isProtected actually consults PROTECTED_GLOBS (overlap, prefix-aware both
|
|
|
133
145
|
assert.equal(scope.isProtected([]), false, 'empty scope is not protected');
|
|
134
146
|
});
|
|
135
147
|
|
|
148
|
+
// ADR 0314 — A PROTECTED CLI ENTRYPOINT NO LONGER PROTECTS ITS OWNING MODULE.
|
|
149
|
+
//
|
|
150
|
+
// These three are the whole reason the ADR exists: each derived protected on the
|
|
151
|
+
// strength of exactly one command it owns that happens to sit on a protected
|
|
152
|
+
// path. Owning the command is not owning the authority. They are asserted by
|
|
153
|
+
// name rather than by count because a count passes on the wrong three.
|
|
154
|
+
const COMMAND_ONLY_MODULES = [
|
|
155
|
+
['onboarding', 'scripts/gds/setup.js'],
|
|
156
|
+
['autonomy', 'scripts/gds/conductor-dispatch.js'],
|
|
157
|
+
['copy-desk', 'scripts/gds/copy-inventory.js'],
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
test('a module protected ONLY by a CLI entrypoint it owns now derives non-protected', () => {
|
|
161
|
+
for (const [mod, cli] of COMMAND_ONLY_MODULES) {
|
|
162
|
+
const e = scope.scopeForModule(mod);
|
|
163
|
+
assert.ok(e, `fixture assumption: ${mod} is a roster key`);
|
|
164
|
+
assert.ok(e.globs.includes(cli), `fixture assumption: ${mod} still OWNS ${cli}`);
|
|
165
|
+
assert.ok(PROTECTED_GLOBS.includes(cli), `fixture assumption: ${cli} is still a protected surface`);
|
|
166
|
+
// The glob was not deleted and the path was not unprotected — only the
|
|
167
|
+
// question changed. That distinction is the ADR, so assert all three.
|
|
168
|
+
assert.equal(e.protected, false, `${mod} must no longer derive protected (ADR 0314)`);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('...and creating a goal scoped to one is therefore no longer Archon-only', () => {
|
|
173
|
+
// The consequence the ADR was filed for: onboarding was Archon-mediated-join
|
|
174
|
+
// for a Metic because of setup.js. This is the assertion that would catch a
|
|
175
|
+
// revert of the behaviour while the name-pin above still passed.
|
|
176
|
+
for (const [mod] of COMMAND_ONLY_MODULES) {
|
|
177
|
+
assert.equal(scope.scopeIncludesProtected([mod]), false, `a goal scoped to ${mod} must not require Archon`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('protected TERRITORY still protects — a directory, and a non-command file', () => {
|
|
182
|
+
// The other side of the rule. If this ever goes false the ADR has been
|
|
183
|
+
// over-applied and the trust core is scopeable by anyone.
|
|
184
|
+
assert.equal(scope.isProtected(['migrations/']), true, 'a protected directory is territory');
|
|
185
|
+
assert.equal(scope.isProtected(['modules/government/']), true, 'a module namespace is territory');
|
|
186
|
+
assert.equal(scope.isProtected(['server.js']), true, 'a protected non-command file is territory');
|
|
187
|
+
assert.equal(scope.isProtected(['CLAUDE.md']), true, 'a protected doc is territory');
|
|
188
|
+
// A DIRECTORY under scripts/ is territory, not a command — the boundary the
|
|
189
|
+
// path test has to get right, since both start with 'scripts/'.
|
|
190
|
+
assert.ok(PROTECTED_GLOBS.includes('scripts/deploy/'), 'fixture assumption: scripts/deploy/ is protected');
|
|
191
|
+
assert.equal(scope.isProtected(['scripts/deploy/']), true, 'a protected directory under scripts/ is territory');
|
|
192
|
+
assert.ok(PROTECTED_GLOBS.includes('scripts/gds/hooks/'), 'fixture assumption: scripts/gds/hooks/ is protected');
|
|
193
|
+
assert.equal(scope.isProtected(['scripts/gds/hooks/']), true, 'scripts/gds/hooks/ is a directory, not a command');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('a lone protected CLI computes non-protected, but a CLI PLUS territory does not', () => {
|
|
197
|
+
assert.equal(scope.isProtected(['scripts/gds/setup.js']), false, 'a lone protected CLI does not protect');
|
|
198
|
+
assert.equal(
|
|
199
|
+
scope.isProtected(['scripts/gds/setup.js', 'migrations/']), true,
|
|
200
|
+
'one command must not mask owned territory — the mixed case is still protected',
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('ADR 0314 can only SHRINK the protected set, never grow it', () => {
|
|
205
|
+
// The safety property that makes this a narrowing and not a re-derivation: the
|
|
206
|
+
// new rule filters a SUBSET of the globs the old rule accepted, so no module
|
|
207
|
+
// can arrive in the protected set that was not already in it. Computed here
|
|
208
|
+
// from the OLD rule rather than from a literal, so it keeps holding as the
|
|
209
|
+
// roster changes.
|
|
210
|
+
const oldRule = (globs) => touchesOverlap(globs || [], PROTECTED_GLOBS);
|
|
211
|
+
for (const k of scope.moduleKeys()) {
|
|
212
|
+
const globs = scope.scopeForModule(k).globs;
|
|
213
|
+
if (scope.isProtected(globs)) {
|
|
214
|
+
assert.equal(oldRule(globs), true, `${k} is newly protected — the rule grew the set, which it must never do`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
|
|
136
220
|
test('globsForModules flattens a scope set; unknown keys contribute nothing', () => {
|
|
137
221
|
const g = scope.globsForModules(['ideas']);
|
|
138
222
|
assert.ok(g.includes('modules/ideas/'), 'ideas owns its module directory (carved BV1.R78)');
|
|
@@ -9,7 +9,7 @@ import { createRequire } from 'node:module';
|
|
|
9
9
|
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
11
11
|
const P = require('../scripts/gds/provision.js');
|
|
12
|
-
const { caddyWiringGap, checkCaddyWiring } = require('../scripts/gds/provision-net.js');
|
|
12
|
+
const { caddyWiringGap, checkCaddyWiring, ingressPlaneGap } = require('../scripts/gds/provision-net.js');
|
|
13
13
|
|
|
14
14
|
const DOMAIN = 'mercury.example.com';
|
|
15
15
|
// What `caddy adapt` prints: 2.7 writes the ask as a string, 2.8+ as a permission module.
|
|
@@ -63,11 +63,13 @@ const WEB_ENV = 'PORT=3010\nBONGOS_PUBLIC_ORIGIN=https://old.example.com\n';
|
|
|
63
63
|
|
|
64
64
|
// `served` = the hosts Caddy's resolved config carries once a snippet is written — the
|
|
65
65
|
// fake flips to them after the first .caddy write, like the real import would.
|
|
66
|
-
function harness({ onDemand = ASK_27, importsSnippets = true, reloadFails = false } = {}) {
|
|
66
|
+
function harness({ onDemand = ASK_27, importsSnippets = true, reloadFails = false, noProxy = false } = {}) {
|
|
67
67
|
const cmds = [], writes = [], statuses = [];
|
|
68
68
|
let snippetWritten = null;
|
|
69
69
|
const exec = (cmd, opts = {}) => {
|
|
70
70
|
cmds.push({ cmd, opts });
|
|
71
|
+
// A plane with no proxy at all: the self-host image installs no caddy binary.
|
|
72
|
+
if (noProxy && /^caddy /.test(cmd)) { const e = new Error('Command failed'); e.stderr = Buffer.from('bash: caddy: command not found'); throw e; }
|
|
71
73
|
if (cmd.startsWith('caddy adapt')) return { ok: true, stdout: adapted({ onDemand, hosts: importsSnippets && snippetWritten ? [snippetWritten] : [] }) };
|
|
72
74
|
if (/cat\b.*web\.env/.test(cmd)) return { ok: true, stdout: WEB_ENV };
|
|
73
75
|
if (/reload caddy/.test(cmd) && reloadFails) throw new Error('Job for caddy.service failed');
|
|
@@ -146,3 +148,75 @@ test('domain-attach: refused before the env patch without the ask; fails loudly
|
|
|
146
148
|
assert.equal((await P.domainAttachInstance(activeStandalone, wired.deps)).ok, true);
|
|
147
149
|
assert.notEqual(wired.cmds.find((c) => /reload caddy/.test(c.cmd)).opts.allowFail, true);
|
|
148
150
|
});
|
|
151
|
+
|
|
152
|
+
// -- the plane has no reverse proxy AT ALL (task 1004017) -------------------
|
|
153
|
+
// ADR 0303 asks whether a box's Caddy is wired right. This is the prior question:
|
|
154
|
+
// the self-host image ships no proxy, so a child instance provisioned inside it gets
|
|
155
|
+
// a snippet write and a `systemctl reload caddy` that cannot work.
|
|
156
|
+
|
|
157
|
+
test('ingressPlaneGap: a plane that answers `caddy version` is wired; one that cannot is named as lacking a proxy', () => {
|
|
158
|
+
const cmds = [];
|
|
159
|
+
assert.equal(ingressPlaneGap((c) => { cmds.push(c); return { ok: true, stdout: 'v2.8.4' }; }), null);
|
|
160
|
+
assert.deepEqual(cmds, ['caddy version'], 'one cheap, unprivileged probe — no config read, no running server');
|
|
161
|
+
const boom = () => { const e = new Error('Command failed'); e.stderr = Buffer.from('bash: caddy: command not found'); throw e; };
|
|
162
|
+
const gap = ingressPlaneGap(boom);
|
|
163
|
+
assert.match(gap, /no reverse proxy/, 'names what the plane lacks');
|
|
164
|
+
assert.match(gap, /command not found/, 'carries the real reason');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('ingressPlaneGap: only a MISSING BINARY proves there is no proxy — a transport failure says it could not confirm', () => {
|
|
168
|
+
// boxExec is `ssh <host> bash -s` for a co-tenant, so an unreachable box throws here too.
|
|
169
|
+
// Diagnosing that as "this platform has no proxy, provision elsewhere" would be a confident
|
|
170
|
+
// wrong answer, and the intent would burn its retry budget on it.
|
|
171
|
+
const unreachable = () => { const e = new Error('Command failed'); e.status = 255; e.stderr = Buffer.from('ssh: connect to host box.example.com port 22: Connection timed out'); throw e; };
|
|
172
|
+
const unsure = ingressPlaneGap(unreachable);
|
|
173
|
+
assert.match(unsure, /could not confirm a reverse proxy/);
|
|
174
|
+
assert.match(unsure, /Connection timed out/, 'the real cause leads, not a parenthetical');
|
|
175
|
+
assert.doesNotMatch(unsure, /has no reverse proxy/, 'never asserts a diagnosis it cannot support');
|
|
176
|
+
// Exit 127 still means "no caddy" across the ssh hop — ssh reports its OWN failures as 255
|
|
177
|
+
// and otherwise propagates the remote command's status.
|
|
178
|
+
const remoteMissing = () => { const e = new Error('Command failed'); e.status = 127; e.stderr = Buffer.from(''); throw e; };
|
|
179
|
+
assert.match(ingressPlaneGap(remoteMissing), /has no reverse proxy/);
|
|
180
|
+
const cmdExe = () => { const e = new Error("'caddy' is not recognized as an internal or external command"); throw e; };
|
|
181
|
+
assert.match(ingressPlaneGap(cmdExe), /has no reverse proxy/, 'cmd.exe words it differently');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('provision: a DOMAINLESS child on a plane with no reverse proxy is refused before a single write', async () => {
|
|
185
|
+
// The hole ADR 0303 leaves open: its preflight is gated on inst.domain, so a domainless
|
|
186
|
+
// child walked all the way to step 6 and died on the snippet write instead of being refused.
|
|
187
|
+
const h = harness({ noProxy: true });
|
|
188
|
+
const r = await P.provisionInstance({ ...coTenant, domain: null }, h.deps);
|
|
189
|
+
assert.equal(r.ok, false);
|
|
190
|
+
assert.match(r.error, /no reverse proxy/);
|
|
191
|
+
assert.equal(h.writes.length, 0, 'nothing written — not the snippet, not web.env');
|
|
192
|
+
assert.ok(!h.ran(/reload caddy/), 'the reload that cannot work is never attempted');
|
|
193
|
+
assert.deepEqual(h.statuses, [], 'the instance never flips to provisioning');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('provision: a domained child on a proxyless plane names the missing proxy, not a Caddyfile to edit', async () => {
|
|
197
|
+
const h = harness({ noProxy: true });
|
|
198
|
+
const r = await P.provisionInstance(coTenant, h.deps);
|
|
199
|
+
assert.equal(r.ok, false);
|
|
200
|
+
assert.match(r.error, /no reverse proxy/);
|
|
201
|
+
assert.doesNotMatch(r.error, /add 'import|add 'on_demand_tls/, 'a plane that will never have Caddy is not told to edit a Caddyfile it does not have');
|
|
202
|
+
assert.equal(h.writes.length, 0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('domain-attach: a plane with no reverse proxy is refused before the env patch', async () => {
|
|
206
|
+
const h = harness({ noProxy: true });
|
|
207
|
+
const r = await P.domainAttachInstance(activeStandalone, h.deps);
|
|
208
|
+
assert.equal(r.ok, false);
|
|
209
|
+
assert.match(r.error, /no reverse proxy/);
|
|
210
|
+
assert.equal(h.writes.length, 0, 'web.env untouched');
|
|
211
|
+
assert.ok(!h.ran(/reload caddy/));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('a dry run never probes the plane, and a wired box still reloads Caddy loudly', async () => {
|
|
215
|
+
const dry = harness({ noProxy: true });
|
|
216
|
+
dry.deps.apply = false;
|
|
217
|
+
await P.provisionInstance(coTenant, dry.deps);
|
|
218
|
+
assert.ok(!dry.ran(/caddy version/), 'a dry run changes nothing and asks nothing of the box');
|
|
219
|
+
const wired = harness();
|
|
220
|
+
assert.equal((await P.provisionInstance(coTenant, wired.deps)).ok, true, 'ADR 0303 behaviour on a real box is unchanged');
|
|
221
|
+
assert.ok(wired.ran(/reload caddy/));
|
|
222
|
+
});
|
|
@@ -22,11 +22,39 @@ const q = require('../modules/security/artifact-scan/quarantine.js');
|
|
|
22
22
|
const SHORT = os.tmpdir();
|
|
23
23
|
const LONG = fs.realpathSync.native(SHORT);
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// eightDotThreeReason — why the 8.3 case cannot be evaluated on THIS box, or null (task 1004051).
|
|
26
|
+
//
|
|
27
|
+
// The assertion below it is a statement about one specific divergence: a Windows 8.3 short name,
|
|
28
|
+
// which plain fs.realpathSync does NOT expand. Asking "do the two spellings differ?" is a
|
|
29
|
+
// different question, and macOS is where the two come apart — there os.tmpdir() is /var/folders/…
|
|
30
|
+
// against a real /private/var/folders/…, a SYMLINK divergence that plain realpathSync resolves
|
|
31
|
+
// perfectly well, so the notEqual fails and the suite goes red over a fact that was never claimed
|
|
32
|
+
// about that platform. So ask for the 8.3 signature itself (a `~N` component) rather than
|
|
33
|
+
// inferring it from inequality.
|
|
34
|
+
//
|
|
35
|
+
// It must not read as a PASS either — the task 1003993 contract. This returns a REASON for
|
|
36
|
+
// node:test's own skip, never a bare early `return`, which prints as ✔ over a case that never
|
|
37
|
+
// ran. Same shape as shallowCloneReason() in tests/helpers.mjs (task 1004049); kept local
|
|
38
|
+
// because the 8.3 question belongs to this file alone.
|
|
39
|
+
function eightDotThreeReason() {
|
|
40
|
+
if (SHORT === LONG) {
|
|
41
|
+
return `${process.platform}: os.tmpdir() is already its canonical spelling, so there is no `
|
|
42
|
+
+ 'second spelling to expand here';
|
|
43
|
+
}
|
|
44
|
+
if (!/~\d/.test(SHORT)) {
|
|
45
|
+
return `${process.platform}: os.tmpdir() (${SHORT}) diverges from its real path (${LONG}) for `
|
|
46
|
+
+ 'a reason that is not an 8.3 short name — plain realpathSync resolves that kind, so the '
|
|
47
|
+
+ '8.3 fact cannot be evaluated here';
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const NO_8_3 = eightDotThreeReason();
|
|
52
|
+
|
|
53
|
+
test('canonical reports where os.tmpdir() really points, whatever its spelling', () => {
|
|
26
54
|
assert.equal(q.canonical(SHORT), LONG);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('plain realpathSync does NOT expand an 8.3 short name', { skip: NO_8_3 ?? false }, () => {
|
|
30
58
|
assert.notEqual(fs.realpathSync(SHORT), LONG, 'this is the fact the obvious fix gets wrong');
|
|
31
59
|
});
|
|
32
60
|
|