@bongos/core 1.19.698 → 1.19.700

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.698",
6
- "core_contract": "1.19.698",
7
- "source_commit": "8fb86a2225d9ed2a99fe253ea3886c7b2e7bcfc9",
5
+ "core_version": "1.19.700",
6
+ "core_contract": "1.19.700",
7
+ "source_commit": "7af21bffa8227c67a1c257f64a8fd73f2c1760dc",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T02:48:21.644Z",
9
+ "built_at": "2026-09-13T03:16:24.200Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 477,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2126,
14
+ "functional_verbatim": 2128,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2627,
20
- "tree_sha256": "53de7b22ab854dd4a788f5ce3827fca83328bb1f43e0415eae9528923e68596b",
19
+ "file_count": 2629,
20
+ "tree_sha256": "3e7d4889874e8dafb6bc2f7d30722f62c5495396f2760d7a3d06985a79b6adb8",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -367,12 +367,12 @@
367
367
  {
368
368
  "path": "clients/bongos-client/README.md",
369
369
  "mode": "0000644",
370
- "sha256": "b15290057c38e4b80db4136d7fdd32549718e31f5975484e77098c66050c4366"
370
+ "sha256": "cfa105831443d1543f03b217a1673b12ba54e40198d768b57bac1c10459ee35c"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/bongos-client.global.js",
374
374
  "mode": "0000644",
375
- "sha256": "bdf715d3196df99d675570748f7ac054522046cbe89d78b84c27eef89a52d18f"
375
+ "sha256": "a418c0eace4c6335d4bd131ce33032b716bdd33f1b7b080c03b76db4259aa069"
376
376
  },
377
377
  {
378
378
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -382,17 +382,17 @@
382
382
  {
383
383
  "path": "clients/bongos-client/index.cjs",
384
384
  "mode": "0000644",
385
- "sha256": "2fbbab84a89021945a707fd21d72acbad7f03b77c5fb6eb39abce44a64d4cc1b"
385
+ "sha256": "c36be893037b5c271ca116d9a26d66defcde55f1b06a1ea5b6a736b1997be0d4"
386
386
  },
387
387
  {
388
388
  "path": "clients/bongos-client/index.d.ts",
389
389
  "mode": "0000644",
390
- "sha256": "101095af6616b30d3338ec12394343f360d8a583ef3da84f0dce49edc80630cb"
390
+ "sha256": "4fc17fc7a5a9ecdaa4539d99f6f30dde4a600043b24affd4677018c95fb94b17"
391
391
  },
392
392
  {
393
393
  "path": "clients/bongos-client/index.mjs",
394
394
  "mode": "0000644",
395
- "sha256": "eeb72bda7a89900279647d6d999d2a920a986bf2f4ed3a63782cd4e36b1b3dfc"
395
+ "sha256": "83c4a4fc8f4adccb95f15c1943cc40eb6c29d8027c58269271313bb37dbad46c"
396
396
  },
397
397
  {
398
398
  "path": "clients/bongos-client/package.json",
@@ -1922,12 +1922,12 @@
1922
1922
  {
1923
1923
  "path": "docs/api-reference.md",
1924
1924
  "mode": "0000644",
1925
- "sha256": "f4f639df647605d86971b62b81921c77c465ded0782eb29d7fc5254ba2f16d0d"
1925
+ "sha256": "2e05ec4efeab7341561a2230e880f6f34d55ada2d542e53b3dcf87f1efa2062c"
1926
1926
  },
1927
1927
  {
1928
1928
  "path": "docs/api/openapi.json",
1929
1929
  "mode": "0000644",
1930
- "sha256": "b09110e4eec5225c0594e4f7a3bd3e18878f751ee4697af87209568d69483c3a"
1930
+ "sha256": "7263a9c74173ef0ca46a444310156f5d901ad4bb191fbd181f690387171d755c"
1931
1931
  },
1932
1932
  {
1933
1933
  "path": "docs/architecture.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "5c48de96a5d2d3ed794c4f503a665f0c4defc7d06f384ba4012eeb02cf3036aa"
2805
+ "sha256": "ba12cd298d7b45de5f7764920115a0419101b4ba4f172ef3ff95ef8798c57508"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -3862,7 +3862,7 @@
3862
3862
  {
3863
3863
  "path": "modules/agents/lib/validate.js",
3864
3864
  "mode": "0000644",
3865
- "sha256": "505b62e453d62b820720c00abf661f1ca1ffbfd93d3eba1bdcdf20c7cca95ba7"
3865
+ "sha256": "5cff70fc4e280c49d33dfb8f60ddb01181decd1645625b0bb5e7c2eb97335b81"
3866
3866
  },
3867
3867
  {
3868
3868
  "path": "modules/agents/migrations/agents_001_definitions.sql",
@@ -3872,7 +3872,12 @@
3872
3872
  {
3873
3873
  "path": "modules/agents/module.json",
3874
3874
  "mode": "0000644",
3875
- "sha256": "055d1a806c40d8153b45bac2518b183874d9a8df7924f00355c86ed50682dd24"
3875
+ "sha256": "af487dc7d134b45f2c1616b1a06cb017106529510e5ebc9d15d7dbb9f72d5fe5"
3876
+ },
3877
+ {
3878
+ "path": "modules/agents/routes/agents.js",
3879
+ "mode": "0000644",
3880
+ "sha256": "31fbc1118ac1f315babb9ba3d0ab4ae634bdce3a6e83cb79defdad73288b2e00"
3876
3881
  },
3877
3882
  {
3878
3883
  "path": "modules/agents/spawn.js",
@@ -4257,7 +4262,7 @@
4257
4262
  {
4258
4263
  "path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
4259
4264
  "mode": "0000644",
4260
- "sha256": "2fbbab84a89021945a707fd21d72acbad7f03b77c5fb6eb39abce44a64d4cc1b"
4265
+ "sha256": "c36be893037b5c271ca116d9a26d66defcde55f1b06a1ea5b6a736b1997be0d4"
4261
4266
  },
4262
4267
  {
4263
4268
  "path": "modules/dev-box/app/test/unit.mjs",
@@ -7772,12 +7777,12 @@
7772
7777
  {
7773
7778
  "path": "package-lock.json",
7774
7779
  "mode": "0000644",
7775
- "sha256": "0faabe7f40491c6ab374488d6ae4a244694e1fbf40c280ad98ef0a249c207b80"
7780
+ "sha256": "4cab339364e2c40b9adb593184fe0c78b8775c11203d6551d019c12de6675e5d"
7776
7781
  },
7777
7782
  {
7778
7783
  "path": "package.json",
7779
7784
  "mode": "0000644",
7780
- "sha256": "f623a28e95671e15f28bc19bc1b29f26f00312c5f0dc5e8761b872dd42e85923"
7785
+ "sha256": "ed864f4879e51f4bc1adf7222e46b6fb9446fa93d59ea3b17bc26dbde19f18e2"
7781
7786
  },
7782
7787
  {
7783
7788
  "path": "public-docs/index.html",
@@ -9512,7 +9517,7 @@
9512
9517
  {
9513
9518
  "path": "src/bongos/serve-internal.js",
9514
9519
  "mode": "0000644",
9515
- "sha256": "5c99fec0061221143be5e22bb51ca690aec883d9eaf094bd25007d3e21b5f96c"
9520
+ "sha256": "61b13299ffdd7f036d5bc3d364135bd8ca4d3470223001b8ff6cc45d858d6107"
9516
9521
  },
9517
9522
  {
9518
9523
  "path": "src/bongos/uptime-poller.js",
@@ -9542,7 +9547,7 @@
9542
9547
  {
9543
9548
  "path": "src/module-api.js",
9544
9549
  "mode": "0000644",
9545
- "sha256": "f03b116eece1a5517cda1bf812627f7148aaf2827806d53d70f3b67d343026ab"
9550
+ "sha256": "46a02032df3b4b36ac48c77f8f373ee44735d00dca00ec483100ffd0f7b9062e"
9546
9551
  },
9547
9552
  {
9548
9553
  "path": "src/module-loader/catalog.js",
@@ -9644,6 +9649,11 @@
9644
9649
  "mode": "0000644",
9645
9650
  "sha256": "bdc9d3678e12dee3f3252785ca82e06071421762c7146cbc1591d3fc2ec72180"
9646
9651
  },
9652
+ {
9653
+ "path": "tests/agents_routes.mjs",
9654
+ "mode": "0000644",
9655
+ "sha256": "4e07055707e77bffe2b60750273d16e92a6b213dbbc999e8ed7e0cc742915f7c"
9656
+ },
9647
9657
  {
9648
9658
  "path": "tests/agents_spawn.mjs",
9649
9659
  "mode": "0000644",
@@ -10327,7 +10337,7 @@
10327
10337
  {
10328
10338
  "path": "tests/currency_label.mjs",
10329
10339
  "mode": "0000644",
10330
- "sha256": "b88f7ab55948f0bfbf0e6982cc96465ff2c34d76b143f40b86f933c323b8ff1d"
10340
+ "sha256": "20994467d64d35647433c320265e360a520391bff974b1a39a918ef66ad7f172"
10331
10341
  },
10332
10342
  {
10333
10343
  "path": "tests/cutover_codemod.mjs",
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
5
5
  by hand; it regenerates when the spec changes, so it can never drift from the routes.
6
6
 
7
7
  - API version: **v1** (served at `/api/bongos/v1`)
8
- - 363 operations across 55 resource groups
8
+ - 365 operations across 56 resource groups
9
9
 
10
10
  ## Use it from your project
11
11
 
@@ -100,6 +100,12 @@ function createClient(opts = {}) {
100
100
  // GET /achievements — rank: any-builder — GET /achievements
101
101
  getAchievements: (args) => request("GET", "/achievements", { hasBody: false }, args),
102
102
  },
103
+ "agents": {
104
+ // GET /agents — rank: any-builder — GET /agents
105
+ getAgents: (args) => request("GET", "/agents", { hasBody: false }, args),
106
+ // GET /agents/{name} — rank: any-builder — GET /agents/:name
107
+ getAgentsName: (args) => request("GET", "/agents/{name}", { hasBody: false }, args),
108
+ },
103
109
  "analytics": {
104
110
  // GET /analytics/builder/{id} — rank: any-builder — GET /analytics/builder/:id
105
111
  getAnalyticsBuilderId: (args) => request("GET", "/analytics/builder/{id}", { hasBody: false }, args),
@@ -99,6 +99,12 @@ function createClient(opts = {}) {
99
99
  // GET /achievements — rank: any-builder — GET /achievements
100
100
  getAchievements: (args) => request("GET", "/achievements", { hasBody: false }, args),
101
101
  },
102
+ "agents": {
103
+ // GET /agents — rank: any-builder — GET /agents
104
+ getAgents: (args) => request("GET", "/agents", { hasBody: false }, args),
105
+ // GET /agents/{name} — rank: any-builder — GET /agents/:name
106
+ getAgentsName: (args) => request("GET", "/agents/{name}", { hasBody: false }, args),
107
+ },
102
108
  "analytics": {
103
109
  // GET /analytics/builder/{id} — rank: any-builder — GET /analytics/builder/:id
104
110
  getAnalyticsBuilderId: (args) => request("GET", "/analytics/builder/{id}", { hasBody: false }, args),
@@ -19,6 +19,8 @@ export interface ErrorEnvelope { error: ApiErrorObject }
19
19
  export interface GetAccessRequestsResponse { requests: unknown }
20
20
  export interface GetAccessRequestsStatusResponse { github_login: unknown; status: unknown; admitted: unknown }
21
21
  export interface GetAchievementsResponse { achievements: unknown }
22
+ export interface GetAgentsNameResponse { ok: boolean; agent: unknown }
23
+ export interface GetAgentsResponse { ok: boolean; agents: unknown; counts: unknown; vocabulary: unknown }
22
24
  export interface GetAuditLogResponse { rows: unknown; page: unknown }
23
25
  export interface GetAuthAppPairInfoResponse { pair_code: unknown; device_name: unknown; platform: unknown; status: unknown }
24
26
  export interface GetAuthWebAdmissionStatusResponse { admitted: boolean }
@@ -434,6 +436,12 @@ export interface BongosClient {
434
436
  /** GET /achievements — rank: any-builder */
435
437
  getAchievements(args?: RequestArgs): Promise<GetAchievementsResponse>;
436
438
  };
439
+ "agents": {
440
+ /** GET /agents — rank: any-builder */
441
+ getAgents(args?: RequestArgs): Promise<GetAgentsResponse>;
442
+ /** GET /agents/{name} — rank: any-builder */
443
+ getAgentsName(args?: RequestArgs): Promise<GetAgentsNameResponse>;
444
+ };
437
445
  "analytics": {
438
446
  /** GET /analytics/builder/{id} — rank: any-builder */
439
447
  getAnalyticsBuilderId(args?: RequestArgs): Promise<ApiResponse>;
@@ -96,6 +96,12 @@ export function createClient(opts = {}) {
96
96
  // GET /achievements — rank: any-builder — GET /achievements
97
97
  getAchievements: (args) => request("GET", "/achievements", { hasBody: false }, args),
98
98
  },
99
+ "agents": {
100
+ // GET /agents — rank: any-builder — GET /agents
101
+ getAgents: (args) => request("GET", "/agents", { hasBody: false }, args),
102
+ // GET /agents/{name} — rank: any-builder — GET /agents/:name
103
+ getAgentsName: (args) => request("GET", "/agents/{name}", { hasBody: false }, args),
104
+ },
99
105
  "analytics": {
100
106
  // GET /analytics/builder/{id} — rank: any-builder — GET /analytics/builder/:id
101
107
  getAnalyticsBuilderId: (args) => request("GET", "/analytics/builder/{id}", { hasBody: false }, args),
@@ -29,6 +29,10 @@
29
29
  "name": "achievements",
30
30
  "description": "Endpoints under `/api/bongos/achievements`."
31
31
  },
32
+ {
33
+ "name": "agents",
34
+ "description": "Endpoints under `/api/bongos/agents`."
35
+ },
32
36
  {
33
37
  "name": "analytics",
34
38
  "description": "Endpoints under `/api/bongos/analytics`."
@@ -505,6 +509,96 @@
505
509
  ]
506
510
  }
507
511
  },
512
+ "/agents": {
513
+ "get": {
514
+ "operationId": "get_agents",
515
+ "tags": [
516
+ "agents"
517
+ ],
518
+ "summary": "GET /agents",
519
+ "description": "GET /agents — the whole registry. Unpaginated on purpose: agents are instance CONFIGURATION reconciled from committed files, so the row count is bounded by what a human wrote, not by traffic. If that ever stops being true, this is the line that needs a cursor — not a silent LIMIT, which would make the registry quietly incomplete.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
520
+ "x-rank": "any-builder",
521
+ "x-source": "modules/agents/routes/agents.js",
522
+ "responses": {
523
+ "200": {
524
+ "description": "Success.",
525
+ "content": {
526
+ "application/json": {
527
+ "schema": {
528
+ "$ref": "#/components/schemas/GetAgentsResponse"
529
+ }
530
+ }
531
+ }
532
+ },
533
+ "400": {
534
+ "$ref": "#/components/responses/BadRequest"
535
+ },
536
+ "401": {
537
+ "$ref": "#/components/responses/Unauthorized"
538
+ },
539
+ "403": {
540
+ "$ref": "#/components/responses/Forbidden"
541
+ }
542
+ },
543
+ "security": [
544
+ {
545
+ "builderSession": []
546
+ }
547
+ ]
548
+ }
549
+ },
550
+ "/agents/{name}": {
551
+ "get": {
552
+ "operationId": "get_agents_name",
553
+ "tags": [
554
+ "agents"
555
+ ],
556
+ "summary": "GET /agents/:name",
557
+ "description": "GET /agents/:name — one definition, persona included. The name is validated against the SAME regex the schema CHECKs, before it reaches the query. The query is parameterised regardless, so this is not the injection wall — it is the difference between a 404 and a 400 for a caller who sent something that could never have been a name.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
558
+ "x-rank": "any-builder",
559
+ "x-source": "modules/agents/routes/agents.js",
560
+ "parameters": [
561
+ {
562
+ "name": "name",
563
+ "in": "path",
564
+ "required": true,
565
+ "schema": {
566
+ "type": "string"
567
+ },
568
+ "description": "Path parameter `name`."
569
+ }
570
+ ],
571
+ "responses": {
572
+ "200": {
573
+ "description": "Success.",
574
+ "content": {
575
+ "application/json": {
576
+ "schema": {
577
+ "$ref": "#/components/schemas/GetAgentsNameResponse"
578
+ }
579
+ }
580
+ }
581
+ },
582
+ "400": {
583
+ "$ref": "#/components/responses/BadRequest"
584
+ },
585
+ "401": {
586
+ "$ref": "#/components/responses/Unauthorized"
587
+ },
588
+ "403": {
589
+ "$ref": "#/components/responses/Forbidden"
590
+ },
591
+ "404": {
592
+ "$ref": "#/components/responses/NotFound"
593
+ }
594
+ },
595
+ "security": [
596
+ {
597
+ "builderSession": []
598
+ }
599
+ ]
600
+ }
601
+ },
508
602
  "/analytics/builder/{id}": {
509
603
  "get": {
510
604
  "operationId": "get_analytics_builder_id",
@@ -16776,6 +16870,36 @@
16776
16870
  "achievements"
16777
16871
  ]
16778
16872
  },
16873
+ "GetAgentsNameResponse": {
16874
+ "type": "object",
16875
+ "properties": {
16876
+ "ok": {
16877
+ "type": "boolean"
16878
+ },
16879
+ "agent": {}
16880
+ },
16881
+ "required": [
16882
+ "ok",
16883
+ "agent"
16884
+ ]
16885
+ },
16886
+ "GetAgentsResponse": {
16887
+ "type": "object",
16888
+ "properties": {
16889
+ "ok": {
16890
+ "type": "boolean"
16891
+ },
16892
+ "agents": {},
16893
+ "counts": {},
16894
+ "vocabulary": {}
16895
+ },
16896
+ "required": [
16897
+ "ok",
16898
+ "agents",
16899
+ "counts",
16900
+ "vocabulary"
16901
+ ]
16902
+ },
16779
16903
  "GetAuditLogResponse": {
16780
16904
  "type": "object",
16781
16905
  "properties": {
@@ -22992,9 +23116,9 @@
22992
23116
  "description": "A required dependency/feature is not configured or is temporarily down."
22993
23117
  }
22994
23118
  },
22995
- "x-endpoint-count": 363,
22996
- "x-schema-count": 401,
23119
+ "x-endpoint-count": 365,
23120
+ "x-schema-count": 403,
22997
23121
  "x-undocumented-bodies": 9,
22998
- "x-response-schemas": 270,
23122
+ "x-response-schemas": 272,
22999
23123
  "x-generated-by": "scripts/gds/gen-api-docs.js"
23000
23124
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 363 endpoints across 58 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 365 endpoints across 59 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -23,6 +23,13 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
23
23
  |---|---|---|---|---|
24
24
  | GET | `/api/bongos/achievements` | `any-builder` | — | All seeded achievements — lets /builders render locked + unlocked side by side. |
25
25
 
26
+ ## `agents` (2)
27
+
28
+ | Method | Path | Rank | Body | Description |
29
+ |---|---|---|---|---|
30
+ | GET | `/api/bongos/agents` | `any-builder` | — | GET /agents — the whole registry. |
31
+ | GET | `/api/bongos/agents/:name` | `any-builder` | — | GET /agents/:name — one definition, persona included. |
32
+
26
33
  ## `analytics` (1)
27
34
 
28
35
  | Method | Path | Rank | Body | Description |
@@ -1855,5 +1855,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1855
1855
  landed since 1.19.696 with no explicit bump. run 34733301156. (task 1002620)
1856
1856
  1.19.698 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1857
1857
  landed since 1.19.697 with no explicit bump. run 34733917938. (task 1002620)
1858
+ 1.19.699 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1859
+ landed since 1.19.698 with no explicit bump. run 34734848584. (task 1002620)
1860
+ 1.19.700 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1861
+ landed since 1.19.699 with no explicit bump. run 34735048203. (task 1002620)
1858
1862
  ---------------------------------------------------------------------------
1859
1863
  ```
@@ -303,4 +303,10 @@ module.exports = {
303
303
  MODEL_TIERS,
304
304
  RANK_ORDER,
305
305
  SCOPE_RANK_FLOOR,
306
+ // Exported for the read routes (task 1002488), which reject an impossible
307
+ // :name with a 400 before it reaches the query. Shared rather than re-spelled:
308
+ // this pair already mirrors the agents_definitions_name_fmt_chk CHECK, and a
309
+ // second copy in the route layer is one more place for the three to drift.
310
+ NAME_RE,
311
+ NAME_MAX,
306
312
  };
@@ -5,7 +5,7 @@
5
5
  "coreVersion": "^1.19.0",
6
6
  "default": false,
7
7
  "maintenance": { "status": "core-maintained" },
8
- "contributes": { "migrations": true },
8
+ "contributes": { "routes": ["agents"], "migrations": true },
9
9
  "provides": [],
10
10
  "consumes": ["grade"]
11
11
  }
@@ -0,0 +1,177 @@
1
+ // modules/agents/routes/agents.js — the agent registry's READ surface (task
2
+ // 1002488, BONGOS-V1 goal 1000038 Phase 1).
3
+ //
4
+ // WHY. agents-sync reconciles .claude/agents/*.md into agents_definitions at
5
+ // deploy, and the spawn path fires from that table — so the table is the only
6
+ // honest answer to "what agents does this instance actually have, and which of
7
+ // them are armed?". Until now nothing could ask. A builder wondering why an
8
+ // agent did or did not fire had to read the deploy log or the DB directly, and
9
+ // an instance author had no way to see that the definition they committed was
10
+ // flagged for a scope violation and therefore silently disarmed.
11
+ //
12
+ // GET /agents — the registry, newest-synced first.
13
+ // GET /agents/:name — one definition, by its stable name.
14
+ //
15
+ // SELF-GATED, and that is the whole gate: `requireBuilder` and nothing else.
16
+ // These are reads of the instance's own configuration by the people who build
17
+ // it, the same audience and posture as GET /tasks. There is deliberately NO rank
18
+ // floor — a xenos who can see WHICH agents are armed is better placed to
19
+ // understand the review they just received, and none of the authority-bearing
20
+ // fields is writable here (authoring is a separate task, with its own wall).
21
+ //
22
+ // WHAT IS NOT SERVED, ON PURPOSE:
23
+ //
24
+ // `persona` is omitted from the LIST and served only on the detail route. It
25
+ // is capped at 32KB per row by the schema, so a 40-agent registry would be a
26
+ // 1.2MB response to render a table that shows none of it. This is a size
27
+ // decision, not a secrecy one — the detail route hands it over to anyone who
28
+ // asks by name.
29
+ //
30
+ // `author_builder_id` is never served. The FACT that matters downstream is
31
+ // `author_rank` — the rank the definition was stamped with at sync, which is
32
+ // what the spawn gate consults — and serving the id would turn a registry read
33
+ // into a people-directory read, which has its own gate (GET /scouting).
34
+ //
35
+ // Same idioms as modules/copy-desk/routes/copy-desk.js: doorway-only requires,
36
+ // factory export mounted from module.json `contributes.routes`, res.fail codes.
37
+
38
+ 'use strict';
39
+
40
+ const express = require('express');
41
+ const api = require('../../../src/module-api');
42
+ const validate = require('../lib/validate');
43
+
44
+ const log = api.logger('agents');
45
+
46
+ // The columns the serializer needs. Spelled out rather than `SELECT *` so that a
47
+ // column added later (an author's email, a raw credential) cannot reach a
48
+ // response by default — the read surface widens only when someone edits this
49
+ // list and reads the comment above about what is deliberately not served.
50
+ const COLUMNS = `
51
+ id, name, title, trigger_type, trigger_spec, model_tier,
52
+ scope_modules, scope_paths, scope_violation,
53
+ source, provenance, author_rank,
54
+ source_path, last_synced_at, enabled, created_at, updated_at
55
+ `;
56
+
57
+ // PURE. Row → wire shape, so the projection is unit-testable without a database.
58
+ //
59
+ // `model_tier` is stored as the TIER ('default' | 'routine'), never a model id:
60
+ // the id a tier resolves to is a deployment fact that changes under the row, and
61
+ // a registry that answered with a stale model name would be worse than one that
62
+ // answers with the tier and lets the caller resolve it. The task asks for the
63
+ // "resolved model tier", and the tier IS the resolved value at this layer.
64
+ //
65
+ // `armed` is derived rather than aliased from `enabled`, because two schema
66
+ // CHECKs make "enabled" narrower than it reads: a row carrying a
67
+ // `scope_violation` is forced to enabled=false, and an enabled row must carry an
68
+ // author_rank. Surfacing the derived boolean beside the reason means a caller
69
+ // asking "why did this not fire?" gets the answer in the same object.
70
+ function serializeDefinition(row, { includePersona = false } = {}) {
71
+ if (!row) return null;
72
+ const out = {
73
+ name: row.name,
74
+ title: row.title || null,
75
+ trigger: {
76
+ type: row.trigger_type,
77
+ // trigger_spec is jsonb and NOT NULL DEFAULT '{}', but a hand-written row
78
+ // could still be any object; the event name is lifted out because it is
79
+ // the only part of the spec every event agent must carry (schema CHECK).
80
+ spec: row.trigger_spec && typeof row.trigger_spec === 'object' ? row.trigger_spec : {},
81
+ event: row.trigger_type === 'event' && row.trigger_spec ? (row.trigger_spec.event || null) : null,
82
+ },
83
+ model_tier: row.model_tier,
84
+ provenance: row.provenance, // 'built-in' | 'instance'
85
+ source: row.source, // 'file' | 'db'
86
+ // A file-sourced row's path is its identity in the repo; a db-authored row
87
+ // has none, and null says so rather than an empty string that sorts oddly.
88
+ source_path: row.source_path || null,
89
+ author_rank: row.author_rank || null,
90
+ scope: {
91
+ modules: Array.isArray(row.scope_modules) ? row.scope_modules : [],
92
+ paths: Array.isArray(row.scope_paths) ? row.scope_paths : [],
93
+ violation: row.scope_violation || null,
94
+ },
95
+ armed: row.enabled === true,
96
+ // Why an agent is not armed, when the answer is knowable. A scope violation
97
+ // is the one disarm the registry can explain; anything else is simply off.
98
+ disarmed_reason: row.enabled === true ? null : (row.scope_violation ? 'scope_violation' : null),
99
+ last_synced_at: row.last_synced_at || null,
100
+ created_at: row.created_at,
101
+ updated_at: row.updated_at,
102
+ };
103
+ if (includePersona) out.persona = row.persona;
104
+ return out;
105
+ }
106
+
107
+ module.exports = function agentsRoutes() {
108
+ const router = express.Router();
109
+
110
+ // GET /agents — the whole registry. Unpaginated on purpose: agents are
111
+ // instance CONFIGURATION reconciled from committed files, so the row count is
112
+ // bounded by what a human wrote, not by traffic. If that ever stops being
113
+ // true, this is the line that needs a cursor — not a silent LIMIT, which
114
+ // would make the registry quietly incomplete.
115
+ router.get('/agents', api.requireBuilder, async (req, res) => {
116
+ try {
117
+ const { rows } = await api.pool.query(
118
+ `SELECT ${COLUMNS} FROM agents_definitions
119
+ ORDER BY enabled DESC, name ASC`
120
+ );
121
+ res.json({
122
+ ok: true,
123
+ agents: rows.map((r) => serializeDefinition(r)),
124
+ // The counts a reader actually wants first, computed here so three
125
+ // callers do not each reduce the array their own way.
126
+ counts: {
127
+ total: rows.length,
128
+ armed: rows.filter((r) => r.enabled === true).length,
129
+ flagged: rows.filter((r) => r.scope_violation).length,
130
+ },
131
+ // The vocabulary, from the validator that enforces it — so a UI building
132
+ // a filter cannot spell a trigger type the registry will never contain.
133
+ // Same rule as the task-kind filter (task 1003453): serve the enum, do
134
+ // not let the client keep a copy.
135
+ vocabulary: {
136
+ trigger_types: [...validate.TRIGGER_TYPES],
137
+ model_tiers: [...validate.MODEL_TIERS],
138
+ events: [...validate.EVENT_CATALOG],
139
+ },
140
+ });
141
+ } catch (err) {
142
+ log.error({ err }, 'GET /agents failed');
143
+ if (!res.headersSent) res.fail('agents_list_failed', 500);
144
+ }
145
+ });
146
+
147
+ // GET /agents/:name — one definition, persona included.
148
+ //
149
+ // The name is validated against the SAME regex the schema CHECKs, before it
150
+ // reaches the query. The query is parameterised regardless, so this is not the
151
+ // injection wall — it is the difference between a 404 and a 400 for a caller
152
+ // who sent something that could never have been a name.
153
+ router.get('/agents/:name', api.requireBuilder, async (req, res) => {
154
+ const name = String(req.params.name || '');
155
+ if (!validate.NAME_RE.test(name) || name.length > validate.NAME_MAX) {
156
+ return res.fail('invalid_agent_name', 400);
157
+ }
158
+ try {
159
+ const { rows } = await api.pool.query(
160
+ `SELECT ${COLUMNS}, persona FROM agents_definitions WHERE name = $1`,
161
+ [name]
162
+ );
163
+ if (!rows.length) return res.fail('agent_not_found', 404);
164
+ return res.json({ ok: true, agent: serializeDefinition(rows[0], { includePersona: true }) });
165
+ } catch (err) {
166
+ log.error({ err, name }, 'GET /agents/:name failed');
167
+ if (!res.headersSent) return res.fail('agent_read_failed', 500);
168
+ return undefined;
169
+ }
170
+ });
171
+
172
+ return router;
173
+ };
174
+
175
+ // Exported for the unit tests, which drive the projection with fabricated rows
176
+ // rather than standing up Postgres.
177
+ module.exports.serializeDefinition = serializeDefinition;
@@ -99,6 +99,12 @@ function createClient(opts = {}) {
99
99
  // GET /achievements — rank: any-builder — GET /achievements
100
100
  getAchievements: (args) => request("GET", "/achievements", { hasBody: false }, args),
101
101
  },
102
+ "agents": {
103
+ // GET /agents — rank: any-builder — GET /agents
104
+ getAgents: (args) => request("GET", "/agents", { hasBody: false }, args),
105
+ // GET /agents/{name} — rank: any-builder — GET /agents/:name
106
+ getAgentsName: (args) => request("GET", "/agents/{name}", { hasBody: false }, args),
107
+ },
102
108
  "analytics": {
103
109
  // GET /analytics/builder/{id} — rank: any-builder — GET /analytics/builder/:id
104
110
  getAnalyticsBuilderId: (args) => request("GET", "/analytics/builder/{id}", { hasBody: false }, args),
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.698",
3
+ "version": "1.19.700",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.698",
9
+ "version": "1.19.700",
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.698",
3
+ "version": "1.19.700",
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",
@@ -107,25 +107,66 @@ function themeMarkup(theme) {
107
107
  if (!theme) return '';
108
108
  const decls = [];
109
109
  for (const [key, cssVar] of Object.entries(COLOR_VARS)) {
110
- const v = theme.colors?.[key];
110
+ const v = cssSafe(theme.colors?.[key]);
111
111
  if (v) decls.push(`${cssVar}:${v};`);
112
112
  }
113
- if (theme.fonts?.display) decls.push(`--font-display:${theme.fonts.display};`);
114
- if (theme.fonts?.body) decls.push(`--font-body:${theme.fonts.body};`);
113
+ const display = cssSafe(theme.fonts?.display);
114
+ if (display) decls.push(`--font-display:${display};`);
115
+ const body = cssSafe(theme.fonts?.body);
116
+ if (body) decls.push(`--font-body:${body};`);
115
117
  let out = '';
116
118
  for (const href of theme.fonts?.links || []) {
117
- out += `<link rel="stylesheet" href="${href}">`;
119
+ out += `<link rel="stylesheet" href="${esc(href)}">`;
118
120
  }
119
121
  // `backdrop` paints the page background (e.g. the Cloud Bongos sunrise gradient,
120
122
  // ADR 0064 / R74) over the flat var(--bg); omitted packs (OTB) keep --bg.
121
123
  const css = [];
122
124
  if (decls.length) css.push(`:root{${decls.join('')}}`);
123
- if (theme.backdrop) css.push(`body{background:${theme.backdrop};}`);
125
+ const backdrop = cssSafe(theme.backdrop);
126
+ if (backdrop) css.push(`body{background:${backdrop};}`);
124
127
  if (css.length) out += `<style>${css.join('')}</style>`;
125
- if (theme.favicon) out += `<link rel="icon" href="${theme.favicon}">`;
128
+ // esc() is lossless in an attribute: the HTML parser decodes the entities back
129
+ // before anything reads the URL, so the neutral pack's inline `data:image/svg+xml,
130
+ // <svg …>` favicon resolves to exactly the same bytes it does today.
131
+ if (theme.favicon) out += `<link rel="icon" href="${esc(theme.favicon)}">`;
126
132
  return out;
127
133
  }
128
134
 
135
+ // A pack-supplied value on its way into <style> TEXT. esc() is the wrong tool
136
+ // here and would corrupt the output: CSS is not entity-decoded, so a font stack
137
+ // like `"Cinzel", serif` would reach the browser as `&quot;Cinzel&quot;, serif`
138
+ // and simply not parse. There is no encoding that both survives CSS and
139
+ // neutralizes markup, so this fails closed instead. The only way out of a <style>
140
+ // element is a literal `</style`, every route to it begins with `<`, and no
141
+ // colour, font stack, or backdrop has a legitimate use for one — so a value
142
+ // carrying `<` is dropped whole rather than emitted half-escaped. Dropping one
143
+ // declaration loses that pack's colour and falls back to style.css's default;
144
+ // emitting it loses the page.
145
+ function cssSafe(v) {
146
+ const s = String(v == null ? '' : v);
147
+ return s.includes('<') ? '' : s;
148
+ }
149
+
150
+ // Serialize a pack value for injection INSIDE a <script> element. JSON.stringify
151
+ // alone is not enough: its output is valid JSON, but the browser reads a script
152
+ // body as raw text FIRST, so a `</script>` anywhere in the pack ends the element
153
+ // early and everything after it lands in the document as markup (the latent
154
+ // self-XSS from an operator-authored pack — idea 348, Hacker dispatch on task
155
+ // 1374). Escaping `<` alone closes it: `</script`, `<script`, and `<!--` all need
156
+ // one. `>` and `&` need no escaping here precisely because a classic script body
157
+ // is NOT entity-decoded — there is nothing for them to start. U+2028/U+2029 are
158
+ // escaped as well: legal in string literals since ES2019, so not a break-out on
159
+ // any browser we serve, but free to escape and still fatal to an older parser.
160
+ // `\u003c` is the same character to the JS parser and never one to the HTML
161
+ // parser, so the value the page actually reads is unchanged. Same fix, same
162
+ // reasoning as modules/provisioning/routes/callback-page.js.
163
+ function jsonForScript(value) {
164
+ return JSON.stringify(value)
165
+ .replace(/</g, '\\u003c')
166
+ .replace(/\u2028/g, '\\u2028')
167
+ .replace(/\u2029/g, '\\u2029');
168
+ }
169
+
129
170
  // Inject the client-safe branding pack into a served HTML document (R56 task 1195
130
171
  // + R57 task 1196, ADR 0062 §3):
131
172
  // * `window.__BRANDING__` — a synchronous global so every hall/status script
@@ -145,7 +186,7 @@ function injectBrandingGlobal(html, brand = clientBranding(), mods = clientModul
145
186
  // UI sections for off modules synchronously (no fetch, no race). R60 wires the
146
187
  // actual section-hiding; this exposes the data.
147
188
  const stamped = applyBrandTokens(html, brand);
148
- const tag = `${clientBundleScriptTag()}<script>window.__BRANDING__=${JSON.stringify(brand)};window.__MODULES__=${JSON.stringify(mods)};</script>${themeMarkup(brand.theme)}`;
189
+ const tag = `${clientBundleScriptTag()}<script>window.__BRANDING__=${jsonForScript(brand)};window.__MODULES__=${jsonForScript(mods)};</script>${themeMarkup(brand.theme)}`;
149
190
  return stamped.includes('</head>') ? stamped.replace('</head>', `${tag}</head>`) : tag + stamped;
150
191
  }
151
192
 
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.698'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.700'; // 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');
@@ -0,0 +1,194 @@
1
+ // tests/agents_routes.mjs — the agent registry's read surface (task 1002488,
2
+ // goal 1000038 Phase 1).
3
+ //
4
+ // WHY THIS LIVES IN tests/ AND NOT modules/agents/tests/. The `agents` module is
5
+ // `default: false`, and run-unit-tests.js discovers a module's own tests only
6
+ // when that module is ENABLED ("it isn't mounted, so its tests don't run
7
+ // either"). On this instance agents is disabled, so a module-local file here
8
+ // would be silently skipped forever — coverage in name only. tests/ always runs.
9
+ // Same call task 1002487 made for tests/agents_validate.mjs; this follows it.
10
+ //
11
+ // No database, no fixtures, no HTTP. The projection is a pure function and the
12
+ // gate is a property of the mounted router, so both are checked directly.
13
+ //
14
+ // Run: node tests/agents_routes.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const routes = require('../modules/agents/routes/agents.js');
21
+ const validate = require('../modules/agents/lib/validate.js');
22
+ const api = require('../src/module-api.js');
23
+
24
+ const { serializeDefinition } = routes;
25
+
26
+ let passed = 0;
27
+ let failed = 0;
28
+ function t(name, fn) {
29
+ try { fn(); console.log(` PASS ${name}`); passed += 1; }
30
+ catch (err) { console.log(` FAIL ${name}\n ${err.message}`); failed += 1; }
31
+ }
32
+
33
+ // A full row as agents-sync would have written it — every column the serializer
34
+ // reads, including the two it must never emit.
35
+ function row(over = {}) {
36
+ return {
37
+ id: 7,
38
+ name: 'historian',
39
+ title: 'The Historian',
40
+ persona: 'PERSONA BODY',
41
+ trigger_type: 'event',
42
+ trigger_spec: { event: 'task.shipped' },
43
+ model_tier: 'routine',
44
+ scope_modules: ['lifecycle'],
45
+ scope_paths: [],
46
+ scope_violation: null,
47
+ source: 'file',
48
+ provenance: 'built-in',
49
+ author_rank: 'archon',
50
+ author_builder_id: 4242,
51
+ source_path: '.claude/agents/historian.md',
52
+ last_synced_at: '2026-09-01T00:00:00.000Z',
53
+ enabled: true,
54
+ created_at: '2026-08-01T00:00:00.000Z',
55
+ updated_at: '2026-09-01T00:00:00.000Z',
56
+ ...over,
57
+ };
58
+ }
59
+
60
+ console.log('\nthe projection — what a registry read may and may not carry:');
61
+
62
+ t('the LIST omits persona; the DETAIL route serves it', () => {
63
+ assert.ok(!('persona' in serializeDefinition(row())), 'a 32KB body per row must not ride the list');
64
+ assert.equal(serializeDefinition(row(), { includePersona: true }).persona, 'PERSONA BODY');
65
+ });
66
+
67
+ t('author_builder_id is NEVER served, on either route', () => {
68
+ // The rank is the fact the spawn gate consults; the id would turn a registry
69
+ // read into a people-directory read, which has its own gate (GET /scouting).
70
+ for (const opts of [{}, { includePersona: true }]) {
71
+ const json = JSON.stringify(serializeDefinition(row(), opts));
72
+ assert.doesNotMatch(json, /4242/, 'the author id leaked into the response');
73
+ assert.doesNotMatch(json, /author_builder_id/, 'the author id key leaked into the response');
74
+ }
75
+ assert.equal(serializeDefinition(row()).author_rank, 'archon', 'the rank IS served — it is the gate-relevant fact');
76
+ });
77
+
78
+ t('model_tier is served as the TIER, never a resolved model id', () => {
79
+ // The id a tier resolves to is a deployment fact that changes under the row.
80
+ // A registry answering with a stale model name is worse than one answering
81
+ // with the tier, so this asserts the absence of any model-shaped string.
82
+ const o = serializeDefinition(row());
83
+ assert.equal(o.model_tier, 'routine');
84
+ assert.ok(validate.MODEL_TIERS.includes(o.model_tier));
85
+ assert.doesNotMatch(JSON.stringify(o), /claude-|sonnet|opus|haiku/i, 'a concrete model id must not appear');
86
+ });
87
+
88
+ console.log('\n`armed` is DERIVED, because two CHECKs make `enabled` narrower than it reads:');
89
+
90
+ t('a scope-violating row reads as disarmed AND says why', () => {
91
+ // agents_definitions_flagged_not_armed_chk forces enabled=false when a
92
+ // violation is recorded. A caller asking "why did this not fire?" gets the
93
+ // answer in the same object rather than having to know that CHECK exists.
94
+ const o = serializeDefinition(row({ enabled: false, scope_violation: 'scope_paths exceeds module' }));
95
+ assert.equal(o.armed, false);
96
+ assert.equal(o.disarmed_reason, 'scope_violation');
97
+ assert.equal(o.scope.violation, 'scope_paths exceeds module');
98
+ });
99
+
100
+ t('an ordinary disabled row is disarmed with no invented reason', () => {
101
+ const o = serializeDefinition(row({ enabled: false }));
102
+ assert.equal(o.armed, false);
103
+ assert.equal(o.disarmed_reason, null, 'absence of a reason must not be reported as a violation');
104
+ });
105
+
106
+ t('armed is a strict boolean, not a truthy passthrough', () => {
107
+ assert.equal(serializeDefinition(row({ enabled: true })).armed, true);
108
+ // A driver returning 't'/1 for a boolean column must not read as armed by
109
+ // accident — the fail-safe direction for "is this thing allowed to fire" is
110
+ // false, so the check is `=== true`.
111
+ assert.equal(serializeDefinition(row({ enabled: 1 })).armed, false);
112
+ });
113
+
114
+ console.log('\nshape tolerance — a hand-written db row is still a row:');
115
+
116
+ t('a null/odd trigger_spec degrades to {} instead of throwing', () => {
117
+ assert.deepEqual(serializeDefinition(row({ trigger_spec: null })).trigger.spec, {});
118
+ assert.deepEqual(serializeDefinition(row({ trigger_spec: 'nope' })).trigger.spec, {});
119
+ assert.equal(serializeDefinition(row({ trigger_type: 'on-demand' })).trigger.event, null,
120
+ 'an on-demand agent has no event, even when a spec carries one');
121
+ });
122
+
123
+ t('null arrays degrade to [], and a missing row serializes to null', () => {
124
+ const o = serializeDefinition(row({ scope_modules: null, scope_paths: null }));
125
+ assert.deepEqual(o.scope.modules, []);
126
+ assert.deepEqual(o.scope.paths, []);
127
+ assert.equal(serializeDefinition(null), null);
128
+ assert.equal(serializeDefinition(undefined), null);
129
+ });
130
+
131
+ console.log('\nthe gate — self-gated means requireBuilder, on EVERY route:');
132
+
133
+ t('both routes mount requireBuilder, and neither is left ungated', () => {
134
+ const router = routes();
135
+ const mounted = router.stack.filter((l) => l.route);
136
+ assert.equal(mounted.length, 2, 'exactly two routes — a third needs its own gate and its own row here');
137
+ const paths = mounted.map((l) => l.route.path).sort();
138
+ assert.deepEqual(paths, ['/agents', '/agents/:name']);
139
+ for (const layer of mounted) {
140
+ const handlers = layer.route.stack.map((s) => s.handle);
141
+ assert.ok(handlers.includes(api.requireBuilder),
142
+ `${layer.route.path} does not mount requireBuilder — a registry read must not be anonymous`);
143
+ assert.ok(handlers.length >= 2, `${layer.route.path} has no handler behind its gate`);
144
+ }
145
+ });
146
+
147
+ t('every mounted route is a GET — this surface writes nothing', () => {
148
+ // The read half is a separate task from authoring on purpose. A write verb
149
+ // appearing here would carry no author-rank clamp and no scope wall.
150
+ for (const layer of routes().stack.filter((l) => l.route)) {
151
+ assert.deepEqual(Object.keys(layer.route.methods), ['get'],
152
+ `${layer.route.path} exposes a non-GET verb on the read surface`);
153
+ }
154
+ });
155
+
156
+ console.log('\nthe vocabulary is SERVED, not copied into the client:');
157
+
158
+ t('the route file spells no enum of its own', () => {
159
+ // Task 1003453's lesson, applied before the bug rather than after: a filter
160
+ // built from a client-side copy goes stale against the validator and the DB
161
+ // CHECK. The list response carries the enums, and they come from validate.js.
162
+ const src = require('node:fs').readFileSync(new URL('../modules/agents/routes/agents.js', import.meta.url), 'utf8');
163
+ assert.doesNotMatch(src, /\[\s*'event'\s*,\s*'on-demand'\s*\]/, 'trigger types re-spelled in the route');
164
+ assert.doesNotMatch(src, /\[\s*'default'\s*,\s*'routine'\s*\]/, 'model tiers re-spelled in the route');
165
+ assert.match(src, /validate\.TRIGGER_TYPES/);
166
+ assert.match(src, /validate\.MODEL_TIERS/);
167
+ assert.match(src, /validate\.EVENT_CATALOG/);
168
+ });
169
+
170
+ t('the name guard reuses the validator regex, not a third copy of it', () => {
171
+ // The pattern already exists twice — in validate.js and in the schema's
172
+ // agents_definitions_name_fmt_chk. The route makes a third place to drift.
173
+ assert.ok(validate.NAME_RE instanceof RegExp, 'NAME_RE is exported for the route to share');
174
+ assert.equal(typeof validate.NAME_MAX, 'number');
175
+ assert.ok(validate.NAME_RE.test('historian'));
176
+ assert.equal(validate.NAME_RE.test('Historian'), false, 'the CHECK is lowercase-anchored');
177
+ assert.equal(validate.NAME_RE.test('9lives'), false, 'must start with a letter');
178
+ const src = require('node:fs').readFileSync(new URL('../modules/agents/routes/agents.js', import.meta.url), 'utf8');
179
+ assert.doesNotMatch(src, /\/\^\[a-z\]\[a-z0-9-\]\*\$\//, 'the name regex was re-spelled in the route');
180
+ });
181
+
182
+ console.log('\nthe module declares the routes it now ships:');
183
+
184
+ t('module.json contributes the agents route file', () => {
185
+ const manifest = require('../modules/agents/module.json');
186
+ assert.ok(Array.isArray(manifest.contributes.routes), 'contributes.routes is declared');
187
+ assert.ok(manifest.contributes.routes.includes('agents'),
188
+ 'the loader mounts modules/agents/routes/<name>.js from this list — without it the file is dead');
189
+ assert.equal(manifest.contributes.migrations, true, 'the existing migrations contribution survives');
190
+ assert.equal(manifest.default, false, 'shipped is not enabled — an instance opts in (ADR 0083)');
191
+ });
192
+
193
+ console.log(`\nagents_routes: ${passed} passed, ${failed} failed`);
194
+ process.exit(failed ? 1 : 0);
@@ -121,6 +121,89 @@ test('injectBrandingGlobal degrades safely when there is no </head>', () => {
121
121
  assert.ok(out.includes('<div>no head</div>'));
122
122
  });
123
123
 
124
+ // --- task 1001434 (idea 348): everything the pack contributes is escaped for the
125
+ // context it lands in. The pack is operator-authored, so this is not a defence
126
+ // against a hostile visitor — it is a defence against one stray character in a
127
+ // config file silently turning every served page's <head> into markup.
128
+
129
+ // A pack string carrying `</script>` is the whole bug: the browser finds the end
130
+ // of the script element by scanning raw text, before any JS parsing happens.
131
+ const HOSTILE = {
132
+ identity: { productName: '</script><img src=x onerror=alert(1)>', worldName: 'w' },
133
+ copy: { tagline: 'a < b && c > d' },
134
+ };
135
+
136
+ function brandingJson(out) {
137
+ const m = /window\.__BRANDING__=(.*?);window\.__MODULES__=/s.exec(out);
138
+ assert.ok(m, 'the injected branding assignment must be findable');
139
+ return m[1];
140
+ }
141
+
142
+ test('1001434: a `</script>` in the pack cannot close the injected script element', () => {
143
+ const out = injectBrandingGlobal('<head></head>', clientBranding(HOSTILE), {});
144
+ assert.ok(/<script>window\.__BRANDING__=(.*?)<\/script>/s.exec(out), 'the injected block is well-formed');
145
+ // Two closers are correct and expected: the client-bundle <script src=…></script>
146
+ // that leads the tag, and our own terminator. A third would BE the bug — the
147
+ // pack's `</script>` ending the element early. Unfixed, this counts 3.
148
+ assert.equal(out.split('</script>').length - 1, 2, 'no early close');
149
+ assert.ok(!out.includes('<img src=x'), 'the payload never reaches the document as a tag');
150
+ });
151
+
152
+ test('1001434: every `<` in the injected JSON is \\u003c, and the VALUE is unchanged', () => {
153
+ const out = injectBrandingGlobal('<head></head>', clientBranding(HOSTILE), {});
154
+ const json = brandingJson(out);
155
+ assert.ok(!json.includes('<'), 'no raw < survives into the script body');
156
+ assert.ok(json.includes('\\u003c'), 'it is escaped, not stripped');
157
+ // Escaping must be lossless: < is the same character to the JSON parser,
158
+ // so the page reads exactly what the operator configured.
159
+ const parsed = JSON.parse(json);
160
+ assert.equal(parsed.identity.productName, '</script><img src=x onerror=alert(1)>');
161
+ assert.equal(parsed.copy.tagline, 'a < b && c > d', '> and & are left alone — a script body is not entity-decoded');
162
+ });
163
+
164
+ test('1001434: __MODULES__ is escaped too — it rides the same script tag', () => {
165
+ const out = injectBrandingGlobal('<head></head>', clientBranding({}), { enabled: { '</script>': true } });
166
+ assert.equal(out.split('</script>').length - 1, 2, 'the bundle tag and our terminator, and nothing else');
167
+ const m = /window\.__MODULES__=(.*?);<\/script>/s.exec(out);
168
+ assert.ok(m && !m[1].includes('<'));
169
+ assert.deepEqual(JSON.parse(m[1]), { enabled: { '</script>': true } });
170
+ });
171
+
172
+ test('1001434: a theme value carrying `<` is DROPPED rather than breaking out of <style>', () => {
173
+ const m = themeMarkup({
174
+ colors: { bg: '#fff', ink: '</style><script>alert(1)</script>' },
175
+ backdrop: '</style>x',
176
+ fonts: { display: '"Cinzel", serif' },
177
+ });
178
+ assert.ok(!m.includes('<script>'), 'no escape from the style element');
179
+ assert.equal(m.split('</style>').length - 1, 1, 'exactly one style terminator — our own');
180
+ assert.ok(m.includes('--bg:#fff;'), 'the clean declarations still ship');
181
+ assert.ok(!m.includes('--ink:'), 'the poisoned one is dropped whole, not half-escaped');
182
+ assert.ok(!m.includes('body{background:'), 'a poisoned backdrop is dropped too');
183
+ // CSS is not entity-decoded, so esc() here would be a bug of its own: the font
184
+ // stack has to keep its real quotes.
185
+ assert.ok(m.includes('--font-display:"Cinzel", serif;'), 'quotes in a font stack survive intact');
186
+ });
187
+
188
+ test('1001434: a pack cannot break out of the favicon or font-link href attribute', () => {
189
+ const m = themeMarkup({ favicon: 'x"><script>alert(1)</script>', fonts: { links: ['y"><script>alert(2)</script>'] } });
190
+ assert.ok(!m.includes('<script>'), 'neither href can escape its attribute');
191
+ assert.ok(m.includes('&quot;'), 'the quote is entity-escaped, which the parser undoes on read');
192
+ });
193
+
194
+ test('1001434: escaping the favicon is lossless — the neutral inline-SVG data URI still resolves', () => {
195
+ const favicon = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><circle r='8'/></svg>";
196
+ const m = themeMarkup({ favicon });
197
+ const href = /<link rel="icon" href="([^"]*)">/.exec(m);
198
+ assert.ok(href, 'the attribute is still well-formed');
199
+ // Decode the way an HTML parser does; the operator's bytes must come back.
200
+ const decoded = href[1]
201
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
202
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'")
203
+ .replace(/&amp;/g, '&');
204
+ assert.equal(decoded, favicon, 'the browser reads exactly the URI the pack configured');
205
+ });
206
+
124
207
  // --- BV1.R75: module hall-widget script injection (ADR 0062 §4) ---
125
208
 
126
209
  const HALL = '<body><script src="/builders/builders.js?v=2026-06-27-r75" defer></script></body>';