Compare commits

...

331 Commits

Author SHA1 Message Date
Jarvis
1c41adad1c test(fleet): make unreadable persona fixture deterministic
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:14:05 -05:00
Jarvis
552f99fa0a chore(docs): normalize fleet task table
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:14:05 -05:00
Jarvis
9233019cff feat(fleet): add shared role semantics
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:14:05 -05:00
Jarvis
088d91eb8d chore(orchestrator): reassign FCM-M1-002 to fresh worker
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:14:05 -05:00
Jarvis
b8f508038e chore(orchestrator): start FCM-M1-002 shared role resolution
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:14:05 -05:00
2e2280070a docs(#753): clear KBN-010 threat and schema gate (#765)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 21:46:44 +00:00
aa5b43bba2 feat(fleet): add roster v2 structural compiler (#764)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 20:37:52 +00:00
ba13c08890 Fixes #756 (#763)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-14 20:26:15 +00:00
c32d85a337 docs(fleet): define declarative configuration M0 (#760)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 19:53:12 +00:00
48b2bc42c9 docs(mos): format Option 2 qualification report (#762)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 19:33:09 +00:00
5e832049bb docs(mos): preserve Option 2 qualification evidence (#759)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-14 19:16:11 +00:00
49e8a54105 docs(#751): Publish native Kanban/SOT canon (#752)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 17:08:09 +00:00
d077183554 docs(tess): remediate M5 qualification findings (#750)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 19:59:38 +00:00
405984af5a De-hardcode orchestrator and interaction agent names (#748)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 18:59:27 +00:00
8dd4e9d541 docs(tess): ledger sync m4 — M5-003 done, #745/#746 merged (#749)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 18:14:24 +00:00
bc8016c831 docs(#744): complete Tess documentation gate (#746)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 17:44:17 +00:00
e72388b2cb docs(tess): ledger sync m3 — M5-001 + M5-002 done (#745)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 16:14:23 +00:00
6345dbfcf2 feat(agent): add Matrix native runtime provider (#744)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 15:29:37 +00:00
c6e3cfbd95 docs(tess): ledger sync — W-001 3-of-3 merged, M5-002 approved, M5-001 in TDD (#743)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-13 15:29:33 +00:00
5789711ee0 docs(tess): add migration evidence set (#742)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 15:14:29 +00:00
f40e6ba388 docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending) (#741)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 15:00:15 +00:00
b7b0f508e6 feat(gateway): register Hermes runtime provider (#740)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 14:45:08 +00:00
3378b857eb feat(memory): bind operator plugin to agent sessions (#739)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 13:44:20 +00:00
e2376190e5 feat(gateway): expose Mos coordination boundary (#737)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-13 13:14:44 +00:00
cca6aaf947 feat(agent): add Hermes transitional capability matrix (#738)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-13 13:14:43 +00:00
2363f155b4 feat(memory): add operator retrieval plugin (#736)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 12:44:31 +00:00
76325ca3f2 feat(tess): add Mos coordination boundary (#735)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 11:59:16 +00:00
9e5b9188ce feat(agent): add transitional Hermes runtime adapter (#734)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 11:29:27 +00:00
f1c6b37b46 fix(tess): route bare Discord approvals (#733)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 10:29:10 +00:00
0b621660c8 feat(tess): wire durable interaction surfaces (#732)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 10:05:29 +00:00
84d884b932 feat(#709): add configured Discord interaction binding (#730)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 09:02:45 +00:00
8246ee0137 feat(tess): add generic interaction CLI (#731)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 05:52:41 +00:00
99a2d0fc9d feat(tess): persist durable session state (#729)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 05:14:11 +00:00
e3b5113be2 feat(tess): add configurable Pi interaction service (#728)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 02:59:27 +00:00
24b07d0f83 docs(tess): sync M1 ledger to merged state; M1-V Mos-owned (#727)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 02:14:13 +00:00
86a50138a9 feat(tess): add safe runtime observability (#726)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 01:29:18 +00:00
7b9f40d3b7 fix(tess): redact chat persistence and egress (#725)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 01:14:16 +00:00
9a8a572fcf feat(tess): add roster-bound tmux fleet provider (#724)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 00:59:24 +00:00
753a360517 fix(#707): scope session GC retention (#720)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 00:44:19 +00:00
e92186d768 feat: add Tess runtime provider registry (#722)
All checks were successful
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 23:53:54 +00:00
119f64e69d feat(#707): secure Discord service ingress (#716)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 23:18:29 +00:00
46ca3ce742 fix(tess): enforce command authorization approvals (#718)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-12 23:18:01 +00:00
227b73fcdf fix(security): enforce Tess MCP server identity (#717)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 22:49:00 +00:00
a959b1d6b4 ci: restrict push-event CI to protected branches (halve feature-branch load) (#721)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-12 22:48:58 +00:00
62f8177806 feat(tess): define runtime provider contract (#719)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-12 22:29:21 +00:00
353e43c947 fix: enforce Tess session ownership scope (#715)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-12 22:14:17 +00:00
ca9c2b5c23 restore Tess markdown formatting gate (v2, non-author) (#714)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-07-12 21:32:16 +00:00
b580d37d51 Fixes #703 (#705)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-12 20:49:43 +00:00
59e49cfd15 docs(tess): define Pi-native interaction agent mission (#712)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-12 18:09:54 +00:00
a99aded26d fix(tools/git): -h/--help now exits 0 across 7 wrappers (#702)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-11 09:23:46 +00:00
4df38f7e81 fix(tools/_lib): /etc/mosaic host-level fallback for credential resolution (#700)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-10 01:57:12 +00:00
4e9e053800 fix(tools/tmux): unique per-invocation paste buffer; track auto-submit-drafts.sh (#697)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-09 17:37:40 +00:00
851c67c27b docs(bootstrap): add python-is-python3 to agent-host prerequisites (#694)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-03 09:38:40 +00:00
86e106fcc9 feat(#462): add federation list verb (#682)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-25 02:15:17 +00:00
67135d3822 fix(fleet): guard mosaic fleet restart against tight-loop re-entry race (#680)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-25 01:44:48 +00:00
adb153428b feat(installer): --dev flag builds CLI + gateway from source (#681)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 23:54:52 +00:00
c739256a2c feat(#462): add federation scope enforcement service (#672)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 23:22:46 +00:00
fc2970916f style(federation): re-run prettier on TASKS.md (unblock format:check) (#679)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 22:51:22 +00:00
79eae2ffce fix(fleet): raise fleet-personas.spec timeout to 30s (mirror #665) (#677)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-24 22:16:49 +00:00
7035cd23bf docs(federation): record M3-07/09 merges + fix M3-11 dependency DAG (#678)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 21:44:57 +00:00
6b94d014a8 feat(#462): add federation capabilities verb (#674)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-24 21:39:56 +00:00
838c44086c feat(#462): add federation query source routing (#673)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-24 21:39:45 +00:00
3eeed04e17 docs(federation): sync M3 backlog to origin/main reality (#671)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 21:15:14 +00:00
e0e7be70f5 chore(release): @mosaicstack/mosaic 0.0.48 (#670)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 20:01:17 +00:00
d7eaa19380 feat(fleet): provision roster from system-type profile (H3) (#665)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 19:48:54 +00:00
248193cd3b fix(fleet): export MOSAIC_AGENT_CLASS into the agent pane so personas inject (#669)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 19:38:15 +00:00
a9857c5043 fix(release): republish @mosaicstack/db 0.0.4 with BacklogService; mosaic 0.0.47 (#668)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 18:54:33 +00:00
e4ede69144 chore(release): mosaic 0.0.46 (persona contracts live) (#666)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 18:29:01 +00:00
a8008138c8 docs(fleet): record per-agent model switch in north star (#667)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 18:14:37 +00:00
0d17a29ebe feat(fleet): export MOSAIC_AGENT_CLASS into agent env (A3a) (#663)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 17:19:59 +00:00
28cfecda94 feat(fleet): inject persona contract at launch (A3b) (#664)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-06-24 17:06:51 +00:00
6c84ccd0b1 feat(fleet): dedicated orchestrator persona, split from planner (#662)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 16:42:23 +00:00
84d2757817 feat(fleet): update-surviving persona customization (H4) (#661)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 16:21:01 +00:00
a738ac1410 feat(fleet): system-type profiles (H2) (#660)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 16:02:25 +00:00
538f0556d5 feat(fleet): cross-domain baseline persona library (H1) (#659)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 15:31:56 +00:00
a094c86eea feat(fleet): North Star scope — general-purpose system, personas & system profiles (workstream H) (#658)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-24 15:25:57 +00:00
f852250419 feat(fleet): native Mosaic backlog on @mosaicstack/db (atomic claim + TTL) (#657)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-24 14:55:10 +00:00
61b1bdac2a feat(fleet): add machine-readable NORTH_STAR.yaml + Markdown projection (#656)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 14:40:09 +00:00
cabb179d5a feat(fleet): seed role registry markdown library (#655)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-24 14:39:54 +00:00
eb795bab18 chore(release): mosaic CLI 0.0.45 (#654)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 14:11:33 +00:00
937077f6be fix(fleet): report idle agents as available, reserve stuck for genuine blocks (#653)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 13:58:22 +00:00
1020cfaf9b chore(release): mosaic CLI 0.0.44 (#652)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 06:49:04 +00:00
70661e3fab fix(fleet): derive pane idle from window activity fallback (#651)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 06:37:45 +00:00
ec8dd7ca86 chore(release): mosaic CLI 0.0.43 (#650)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 06:08:20 +00:00
d887555852 feat(fleet): classify agent readiness in fleet ps (#649)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 05:55:47 +00:00
e3adc6a1bc chore(release): mosaic CLI 0.0.42 (#648)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 05:28:28 +00:00
aa27c42129 fix(fleet): pre-trust claude agent workdir to clear the folder-trust gate (#644) (#645)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 05:16:46 +00:00
16ae809442 fix(update): re-seed framework on version drift, not just in-command updates (#642) (#646)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 05:04:34 +00:00
6980e40e51 fix(db): stop pglite migration tests flaking CI (timeout + WASM OOM) (#647)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
2026-06-24 05:04:28 +00:00
e6b53ea103 fix(tools): default AGENT_WORK_ROOT to $HOME/mosaic/agent-work (#641)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
2026-06-23 13:40:13 +00:00
4da87640e8 feat(tmux): agent-send.sh --class triage tag for the comms daemon (#552)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-23 03:25:16 +00:00
a38a491403 chore(release): mosaic CLI 0.0.41 (#640)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-23 02:21:04 +00:00
78d67c6261 chore(ci): bump ci-base image node 22 → 24-alpine (#639)
All checks were successful
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-23 00:59:39 +00:00
94e5cd7a81 ci: eliminate cold pnpm install via pre-baked CI base image (Phase 1) (#635)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-22 22:50:21 +00:00
4e84f8e850 feat(fleet): comms-block emitter + FLEET-LAUNCH runbook (#633) (#638)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 22:23:50 +00:00
cf8ceb3095 CI: add pre-baked ci-base image (producer) [Phase 1a] (#637)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-22 22:20:48 +00:00
bf2a6745c8 fix(install): preserve user fleet data on re-seed + refresh active units (CRITICAL) (#632)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 21:38:09 +00:00
d539d61e0e refactor(fleet): rename tmux socket mosaic-factory → mosaic-fleet (#630)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 21:08:43 +00:00
3f69d45334 docs(fleet): consolidate north-star doctrine (budget + control plane + identity) (#629)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 21:08:41 +00:00
e2336bb0ca chore(release): mosaic CLI 0.0.40 (#624)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-22 19:49:45 +00:00
7342415a32 fix(fleet): consume model_hint + fix socket-default trap (stand-up fixes) (#627)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 19:18:01 +00:00
095e19443b feat(fleet): onboarding-injection — comms cheat-sheet + peer roster per agent (#621)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 17:54:54 +00:00
fabc413407 feat(fleet): F4 Phase 2a — Matrix CS-API connector client + factory (#618)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 16:48:17 +00:00
858d90329d feat(fleet): F4 Phase 1 — chat connector abstraction + Matrix design (#617)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 16:14:32 +00:00
2bf66136e4 feat(fleet): enhancer role + two-agent floor (orchestrator + enhancer) (#615)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 13:15:59 +00:00
4434c3c481 docs(fleet): orchestrator+enhancer two-agent floor + role library + Discord plugin north-star (#613)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-22 13:15:05 +00:00
dd0a0d38c6 ci(publish): gate kaniko image builds + publish on changed paths (CI throughput) (#619)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 13:14:31 +00:00
d46ac40890 fix(fleet): boot-survival symmetry — disable-on-remove + add-enable + init-R5 (#612)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 08:12:58 +00:00
8ddd48c843 feat(mosaic): mosaic update re-seeds framework + relaunches agents (R13) (#610)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 03:34:05 +00:00
528700ceea feat(framework): P6 — docs + compliance matrix + resident-budget CI (#607)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/tag/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 02:20:35 +00:00
32f4215461 chore(release): bump @mosaicstack/mosaic 0.0.38 -> 0.0.39 (#608)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-22 02:16:12 +00:00
23343bb7f0 feat(mosaic): P5 — overlay composer (compose-contract + *.local overlays) (#605)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 02:16:05 +00:00
c8b2dab0ca chore(release): bump @mosaicstack/mosaic 0.0.37 -> 0.0.38 (#603)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-22 01:48:27 +00:00
6dbe452a9f fix(fleet): watch viewer-session leak + workdir test settle-race (#601)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 01:43:21 +00:00
59c755067e feat(fleet): F3-m2 — native Pi heartbeat + model surface + mosaic_mission_status tool (#602)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 01:43:18 +00:00
6ffb27787e fix(fleet): complete HB reader/writer consistency + sidecar hardening (#599)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 01:22:35 +00:00
130837365f chore(release): bump @mosaicstack/mosaic 0.0.36 -> 0.0.37 (#597)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 23:27:14 +00:00
67df06f1c4 feat(fleet): orchestrator-mutable fleet — fleet add/remove (F5/R9) (#596)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:26:21 +00:00
60a309d5a4 fix(fleet): heartbeat consistency — MOSAIC_HOME path + configurable interval (#595)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 23:25:53 +00:00
2dc0f24828 docs(fleet): Fleet Suite PRD (init/configure/operate + Mos-on-Discord) (#588)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:17:10 +00:00
31e7a4d25e docs(framework): P4.1 — fix stale install.sh comments + cmp-equal early-exit (#593)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 23:12:31 +00:00
ca19d57bba feat(fleet): config-type presets + AI-free init wizard (F1) (#591)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:07:41 +00:00
bb7d549080 feat(framework): P4 — upgrade-safe Constitution migration (both installers) (#590)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 23:03:48 +00:00
5bef2c35eb feat(fleet): fleet ps surfaces unmanaged socket sessions (#586)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
2026-06-21 22:37:34 +00:00
2849a8f9db chore(release): bump @mosaicstack/mosaic 0.0.35 -> 0.0.36 (#585)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 21:46:15 +00:00
7ced5588c9 feat(fleet): launcher heartbeat sidecar — HB for all runtimes (pi/claude/codex) (#584)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-21 21:14:20 +00:00
afcbbb302f feat(fleet): auto-enable units on install + drift recognizes wrapped runtimes (#583)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 20:02:19 +00:00
c2c0b5fe8d chore(release): bump @mosaicstack/mosaic 0.0.34 -> 0.0.35 (#582)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 18:59:39 +00:00
c9cfe36204 docs(framework): P3.1 fast-follow — governance wording + gate scope + bare-launch note (#577)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 18:56:50 +00:00
fc90c89913 fix(fleet): durable runtime PATH for detached agent launch (#581)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 17:30:40 +00:00
af2eede7a9 feat(fleet): Phase-2 observability — fleet ps + watch + send verify (#579)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 04:23:51 +00:00
5118be74cb feat(framework): P3 — extract Constitution (L0) + gut AGENTS dispatcher (#575)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 03:20:32 +00:00
bf24066a49 feat(framework): P1+P2 — public sanitization + blocking CI gate (#572)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 02:40:11 +00:00
92316ab41e feat(framework): P0 — MIT license + executable-leak sanitization (#570)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 01:43:49 +00:00
b354bc8fae docs(framework): add agency & persistence patterns to config + guides (#543)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 01:43:36 +00:00
e834bbb83c fix(fleet): install executable tmux helpers (#568)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 22:27:46 +00:00
7498fcb20d fix(fleet): preserve agent env overrides on install (#567)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 21:50:46 +00:00
42d081613f chore(release): bump mosaic cli to 0.0.32 (#566)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 21:15:25 +00:00
b5c1381e45 fix(fleet): harden operator sends for release (#565)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 20:41:11 +00:00
6dfd78f643 feat(fleet): add local canary CLI (#563)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 17:49:01 +00:00
45e2c2aad8 docs: plan durable tmux fleet install (#557)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 16:19:19 +00:00
57919c38d8 fix(framework/tools): wrapper hardening — TLS validation, cred-path fallback, no-CI fast-exit (#551)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-20 10:16:38 +00:00
87f561c1f8 fix(launch): include Pi native skill roots in 'all' mode; dedup 'discover' force-loads (#556)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-19 19:58:09 +00:00
8c45857859 feat(launch): force-load fleet-critical Pi skills + reconcile skill docs (#555)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-19 18:31:02 +00:00
605221d42f docs(framework/tools): lead TOOLS.md with high-salience fleet-tools cheatsheet (#554)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-19 18:03:03 +00:00
ee584ab48c fix(framework/tools): prettier-format woodpecker README — restore main format gate (#553)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-18 22:39:35 +00:00
ab4e138003 feat(framework/tools): orchestration helpers — lane-brief.sh + ci-wait.sh (#547)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was canceled
2026-06-18 22:08:40 +00:00
719c6ac3db fix(framework/tools): eval injection, broken JSON, tmpfile leak (#549)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-18 21:35:32 +00:00
b8807e60df feat(agent-reflection): durable kernel — reflection.v1 capture + risk-floor + Phase-0 (#545)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-16 21:35:40 +00:00
c461380a4a feat(mosaic-as): agent registration + scoped/revocable tokens (US-007) (#541)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-16 01:10:44 +00:00
98a771c8f8 Fix Gitea wrapper login resolution (#538)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-12 02:34:18 +00:00
bd9527c033 docs(framework): canonize merge-authority policy (hard gate 13 + E2E gate note) (#537)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-11 23:56:20 +00:00
aa221bf92e release(mosaic): bump @mosaicstack/mosaic 0.0.30 -> 0.0.31 (#534)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-06-11 19:55:43 +00:00
799df40f4e feat(appservice): room provisioning (M4c) (#535)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-11 19:50:55 +00:00
b79e9f32c6 chore(framework): canonize Vault-as-SSOT + ESO-default secrets policy (#519)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-11 19:07:00 +00:00
89d69eb23b docs: add mission control and coordination resilience docs (#511)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
2026-06-11 19:06:35 +00:00
59b611ba8a refactor(framework): thin-core prompt diet — cut injected contract ~53% (#529)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-06-11 18:10:42 +00:00
dfa0be42f6 feat(framework/tools): inter-agent tmux comms — agent-send.sh + addressing standard (#533)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
2026-06-11 18:01:44 +00:00
bb96a3f23e ci: publish mosaic-as appservice image (#532)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-10 23:00:38 +00:00
48b2f28e45 feat(appservice): mosaic-as daemon host + container (M4a) (#531)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-10 22:16:28 +00:00
8f09c910a9 feat(appservice): Matrix Application Service core library (M4a) (#530)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-10 21:23:25 +00:00
dde95a59b3 fix(pi): reduce startup skill-token overhead (#527)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-05 18:36:42 +00:00
821e19dcbb fix(mosaic-tools): roll up Gitea and Woodpecker wrapper fixes (#524)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-05-26 20:56:09 +00:00
755df9079e Merge pull request 'fix(db): bootstrap migrations on local-tier gateway startup' (#510) from fix/db-bootstrap-migrations into main 2026-05-04 22:13:14 +00:00
ac5650d9f9 fix(db): bootstrap migrations on local-tier gateway startup
Fresh `mosaic gateway install` (npm) left the gateway DB schema empty —
sign-in 500'd with `relation "users" does not exist`, and every entry
point (auth, bootstrap setup) failed because they all query the users
table first. Five stacked bugs on the local (PGlite) tier:

1. `packages/db/package.json` `files: ["dist"]` excluded the `drizzle/`
   SQL migrations from the published tarball.
2. `runMigrations()` only supports postgres-js — unusable for embedded
   PGlite.
3. `apps/gateway/src/database/database.module.ts` never invoked
   migrations at startup.
4. `createPgliteDb` didn't load pgvector, so migration 0001's
   `CREATE EXTENSION vector` failed.
5. Drizzle's PG migrator wraps every migration in one outer
   transaction, which trips Postgres' `check_safe_enum_use` on
   migration 0009 (`ALTER TYPE ADD VALUE 'pending'` → `SET DEFAULT
   'pending'` in the same tx).

Changes:
- Ship `drizzle/` in the published tarball.
- `createPgliteDb` loads `@electric-sql/pglite/vector`.
- New `runPgliteMigrations(handle)` walks the Drizzle journal and
  runs each statement-breakpoint chunk through PGlite's `client.exec()`
  (autocommit per statement). Records into `drizzle.__drizzle_migrations`
  for interop with the postgres-js path. Per-statement try/catch
  surfaces which statement of which migration failed.
- `DatabaseModule` runs migrations in `OnModuleInit` before
  `app.listen()`. Local tier: explicit `runPgliteMigrations` then
  `storageAdapter.migrate()`. Postgres tier: just `storageAdapter.migrate()`,
  which already calls `runMigrations(url)` internally — no double-call.
- Removed `packages/storage/src/test-utils/pglite-with-vector.ts`. The
  "intentionally not exported" rationale is moot now that migration
  0001 forces pgvector load anyway. The integration test uses
  `createPgliteDb` + `runPgliteMigrations` from `@mosaicstack/db`.

Tests: BetterAuth tables exist after migrate; idempotent (re-runs 0009);
partial-failure surfaces statement-level context and leaves no ledger row.

QA on a fresh PGlite install:
- `Applying PGlite schema migrations...` then `Initializing storage
  adapter (pglite)...` in startup log.
- `GET /api/bootstrap/status` → `{"needsSetup":true}` HTTP 200 (was 500).
- `POST /api/bootstrap/setup` reaches Zod validator (was 500).

Scope: this PR fixes the local (PGlite) tier. Postgres-tier first
install still has the outer-transaction problem and a journal ordering
bug (0009's `when` < 0008's). Documented inline as TODO and in the
scratchpad — needs a separate change with real-Postgres validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:06:50 -05:00
bd83f86740 Merge pull request 'feat(federation): mTLS AuthGuard with OID-based grant resolution (FED-M3-03)' (#509) from feat/federation-m3-auth-guard into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-25 13:27:20 +00:00
Jarvis
0af3e218a1 fix(federation/auth-guard): remediate CRIT-1/CRIT-2 + HIGH-1..4 review findings
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
- CRIT-1: Validate cert subjectUserId against grant.subjectUserId from DB;
  use authoritative DB value in FederationContext
- CRIT-2: Add @Inject(GrantsService) decorator (tsx/esbuild requirement)
- HIGH-1: Validate UTF8String TLV tag, length, and bounds in OID parser
- HIGH-2: Collapse all 403 wire messages to a generic string to prevent
  grant enumeration; keep internal logger detail
- HIGH-3: Assert federation wire envelope shape in all guard tests
- HIGH-4: Regression test for subjectUserId cert/DB mismatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 06:33:37 -05:00
Jarvis
b01c9b3bb0 feat(federation): mTLS AuthGuard with OID-based grant resolution (FED-M3-03)
Adds FederationAuthGuard that validates inbound mTLS client certs on
federation API routes. Extracts custom OIDs (grantId, subjectUserId),
loads the grant+peer from DB in one query, asserts active status, and
validates cert serial as defense-in-depth. Attaches FederationContext
to requests on success and uses federation wire-format error envelopes
(not raw NestJS exceptions) for 401/403 responses.

New files:
- apps/gateway/src/federation/oid.util.ts — shared OID extraction (no dupe ASN.1 logic)
- apps/gateway/src/federation/server/federation-auth.guard.ts — guard impl
- apps/gateway/src/federation/server/federation-context.ts — FederationContext type + module augment
- apps/gateway/src/federation/server/index.ts — barrel export
- apps/gateway/src/federation/server/__tests__/federation-auth.guard.spec.ts — 11 unit tests

Modified:
- apps/gateway/src/federation/grants.service.ts — adds getGrantWithPeer() with join
- apps/gateway/src/federation/federation.module.ts — registers FederationAuthGuard as provider

Closes #462

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 06:33:37 -05:00
b67f2c9f08 Merge pull request 'feat(federation): outbound mTLS FederationClient (FED-M3-08)' (#508) from feat/federation-m3-client into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-24 04:30:29 +00:00
Jarvis
37675ae3f2 fix(federation/client): serialize cache fills, destroy evicted Agent, cover env-var guard
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
- HIGH-A: resolveEntry now uses promise-cache pattern so concurrent
  callers serialize on a single in-flight build, eliminating duplicate
  key material in heap and duplicate DB round-trips
- HIGH-B: flushPeer destroys the evicted undici Agent so stale TLS
  connections close on cert rotation
- MED-C: add regression test for PEER_MISCONFIGURED when
  STEP_CA_ROOT_CERT_PATH is unset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:56:57 -05:00
Jarvis
a4a6769a6d fix(federation/client): pin Step-CA root, fix lockfile, harden cache test
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
CRIT-1: regenerate pnpm-lock.yaml so apps/gateway resolves undici@7.24.6
(prior PR pushed package.json without lockfile update; CI failed with
ERR_PNPM_OUTDATED_LOCKFILE). Incidentally cleans 57 lines of stale
peer-dep entries.

CRIT-2: cache-hit test no longer swallows resolveEntry errors. Calls the
private method directly twice and asserts identity equality plus a
single DB select, removing the silent-failure path the prior assertion
allowed.

HIGH-1: mTLS Agent now pins Step-CA root via STEP_CA_ROOT_CERT_PATH.
Without the env var resolveEntry throws PEER_MISCONFIGURED, refusing to
dial peers against the public trust store. PEM is read once and cached
on the service instance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 22:30:09 -05:00
Jarvis
21650fb194 feat(federation): outbound mTLS FederationClient (FED-M3-08)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
Implements FederationClientService — a NestJS injectable that dials peer
gateways over mTLS (undici Agent with cert+sealed-key from federation_peers),
invokes list/get/capabilities verbs, validates responses via Zod, and surfaces
all failure modes as typed FederationClientError with a coherent error code
taxonomy (PEER_NOT_FOUND, PEER_INACTIVE, PEER_MISCONFIGURED, NETWORK,
FORBIDDEN, HTTP_{status}, INVALID_RESPONSE).

Per-peer Agent instances are cached in a Map for the service lifetime;
flushPeer(peerId) invalidates the cache for M5/M6 cert rotation and
revocation events.

Wired into FederationModule providers + exports so QuerySourceService
(M3-09) can inject it.

13 unit tests covering all required scenarios via undici MockAgent +
real sealClientKey/unsealClientKey round-trip.

Closes #462

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:16:52 -05:00
89c733e0b9 feat(federation): two-gateway test harness scaffold (FED-M3-02) (#505)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-24 03:01:25 +00:00
ee3f2defd9 feat(types): federation v1 DTOs (FED-M3-01) (#506)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-24 02:54:40 +00:00
7342c1290d fix(federation): use real PEM certs in enrollment + ca service tests (#507)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-24 02:43:42 +00:00
e64ddd2c1c docs(federation): M3 mission planning — 14-task decomposition (#504)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-04-24 01:13:40 +00:00
4ece6dc643 chore(federation): M2 milestone close (FED-M2-13) (#503)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/publish Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 06:09:54 +00:00
194c3b603e docs(federation): M2 Step-CA setup guide + admin CLI reference (FED-M2-12) (#502)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-22 06:06:45 +00:00
fc1600b738 fix(federation): security hardening — OID verification, atomic activation, audit on failure (#501)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-22 06:02:52 +00:00
0ee5b14c68 test(federation): M2 E2E peer-add enrollment flow (FED-M2-10) (#500)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 05:37:06 +00:00
3eee176cc3 test(federation): M2 integration tests (FED-M2-09) (#499)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 05:08:24 +00:00
74fe60d8d6 feat(federation): admin controller + CLI federation commands (FED-M2-08) (#498)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 04:39:46 +00:00
0bfaa56e9e feat(federation): enrollment controller + single-use token flow (FED-M2-07) (#497)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 04:23:19 +00:00
01dd6b9fa1 feat(federation): grants service CRUD + status transitions (FED-M2-06) (#496)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 03:57:12 +00:00
1038ae76e1 feat(federation): Step-CA client service for grant certs (FED-M2-04) (#494)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 03:34:37 +00:00
bf082d95a0 feat(federation): seal federation peer client keys at rest (FED-M2-05) (#495)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 03:10:20 +00:00
bb24292cf7 fix(federation): healthcheck + restart policy for federated-test stacks (#492)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 02:56:40 +00:00
f2cda52e1a fix(deploy): bump gateway image digest to sha-9f1a081 [DEPLOY-IMG-FIX] (#491)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-04-22 02:35:19 +00:00
7d7cf012f0 feat(federation): scope schema validator [FED-M2-03] (#489)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-22 02:31:13 +00:00
c56dda74aa feat(federation): Step-CA sidecar in federated compose [FED-M2-02] (#490)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-22 02:21:49 +00:00
9f1a08185e docs(federation): S21 tracking — DEPLOY-01/02 done, IMG-FIX in flight, M2-01 in remediation (#487)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-22 02:02:36 +00:00
d2e408656b fix(docker): pnpm deploy for self-contained gateway runtime image (#488)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-22 02:02:29 +00:00
54c278b871 feat(db): federation schema — grants/peers/audit_log [FED-M2-01] (#486)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-22 02:02:21 +00:00
4dbd429203 feat(deploy): portainer stack template for federation test instances [DEPLOY-02] (#485)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-04-22 01:34:44 +00:00
b985d7bfe2 docs(federation): M2 mission planning — TASKS decomposition + manifest update (#483)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-22 01:24:00 +00:00
45e8f02c91 feat(mosaic-portainer): PORTAINER_INSECURE flag for self-signed TLS (#484)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-22 01:21:54 +00:00
54c422ab06 Merge pull request 'docs(federation): close FED-M1 milestone' (#481) from feat/federation-m1-close into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-04-20 02:20:43 +00:00
Jarvis
b9fb8aab57 docs(federation): close FED-M1 milestone
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
- TASKS.md: mark FED-M1-12 done with PR/issue/tag references
- MISSION-MANIFEST.md: phase=M1 complete, progress 1/7, M1 row done with PR range #470-#481, session log appended
- scratchpad: Session 19 entry covering M1-09 → M1-12 with PR ledger and M1 retrospective learnings

Refs #460
2026-04-19 21:12:52 -05:00
78841f228a docs(federation): operator setup + migration guides (FED-M1-11) (#480)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 02:07:15 +00:00
dc4afee848 fix(storage): redact credentials in driver errors + advisory lock (FED-M1-10) (#479)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-20 02:02:57 +00:00
1e2b8ac8de test(federation): standalone regression canary — no breakage from M1 (FED-M1-09) (#478)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 01:46:35 +00:00
15d849c166 test(storage): integration test for migrate-tier (FED-M1-08) + camelCase column fix (#477)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-20 01:40:02 +00:00
78251d4af8 test(federation): integration tests for federated tier gateway boot (FED-M1-07) (#476)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 01:13:10 +00:00
1a4b1ebbf1 feat(gateway,storage): mosaic gateway doctor with tier health JSON (FED-M1-06) (#475)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 01:00:39 +00:00
ccad30dd27 feat(storage): mosaic storage migrate-tier with dry-run + idempotency (FED-M1-05) (#474)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 00:35:08 +00:00
4c2b177eab feat(gateway): tier-detector with fail-fast PG/Valkey/pgvector probes (FED-M1-04) (#473)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-20 00:07:07 +00:00
58169f9979 feat(storage): pgvector adapter support gated on tier=federated (FED-M1-03) (#472)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-19 23:42:18 +00:00
51402bdb6d feat(infra): docker-compose.federated.yml overlay (FED-M1-02) (#471)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-19 23:21:31 +00:00
9c89c32684 feat(config): add federated tier + rename team→standalone (FED-M1-01) (#470)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-19 23:11:11 +00:00
8aabb8c5b2 docs(mission): author MVP rollup manifest, archive install-ux-v2 (#469)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-19 22:51:11 +00:00
66512550df docs(federation): PRD, milestones, mission manifest, and M1 task breakdown (#468)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-19 22:27:09 +00:00
46dd799548 docs(federation): PRD, milestones, mission manifest, and M1 task breakdown (#467)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-19 22:09:20 +00:00
5f03c05523 chore(release): @mosaicstack/mosaic 0.0.30 (#459)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-12 02:18:17 +00:00
c3f810bbd1 fix(mosaic): seed TOOLS.md from defaults on install (#458)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-12 02:02:21 +00:00
b2cbf898d7 docs(scratchpad): finalize yolo runtime hotfix evidence (#456)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Follow-up to mosaicstack/stack#455.

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-11 17:14:00 +00:00
b2cec8c6ba fix(mosaic): stop yolo runtime from leaking runtime name as first user message (#455)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Fixes mosaicstack/stack#454

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-11 16:57:43 +00:00
81c1775a03 chore(release): @mosaicstack/mosaic 0.0.29 (#453)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-08 00:42:54 +00:00
f64ec12f39 fix(installer): preserve credentials dir and seed STANDARDS.md (#452)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-08 00:40:49 +00:00
026382325c feat(framework): superpowers enforcement, typecheck hook, file-ownership rules (#451)
All checks were successful
ci/woodpecker/manual/ci Pipeline was successful
ci/woodpecker/manual/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-07 00:44:22 +00:00
1bfd8570d6 chore(release): @mosaicstack/mosaic 0.0.28 (#450) 2026-04-06 00:46:31 +00:00
312acd8bad chore: sweep mosaicstack/mosaic-stack → mosaicstack/stack + add short install URL (#448) 2026-04-06 00:39:56 +00:00
d08b969918 fix(mosaic): mask password input in TUI login prompt (#449) 2026-04-06 00:33:54 +00:00
051de0d8a9 docs: update README for mosaicstack/stack repo rename (#447) 2026-04-06 00:22:20 +00:00
bd76df1a50 feat(mosaic): drill-down main menu + provider-first flow + quick start (#446) 2026-04-06 00:15:23 +00:00
62b2ce2da1 docs: orchestrator close-out IUV-M02 (#445)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 23:50:55 +00:00
172bacb30f feat(mosaic): IUV-M02 — CORS/FQDN UX polish + skill installer rework (#444)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 23:44:07 +00:00
43667d7349 docs: orchestrator close-out IUV-M01 — mark tasks done, append session 2 (#443)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 22:40:08 +00:00
783884376c docs: mark IUV-M01 complete — mosaic-v0.0.26 released (#436) (#442)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 22:31:37 +00:00
c08aa6fa46 fix: add vitest.config.ts to eslint allowDefaultProject (#440 build fix) (#441)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-04-05 22:01:57 +00:00
0ae932ab34 fix: bootstrap hotfix — DTO erasure, wizard failure, port prefill, Pi SDK copy (mosaic-v0.0.26) (#440)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
2026-04-05 21:43:30 +00:00
a8cd52e88c docs: scaffold install-ux-v2 mission (#439)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 21:27:19 +00:00
a4c94d9a90 chore(release): @mosaicstack/mosaic 0.0.25 (#435)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-04-05 20:53:19 +00:00
cee838d22e docs: close out install-ux-hardening mission (#434)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 19:19:54 +00:00
732f8a49cf feat: unified first-run flow — merge wizard + gateway install (IUH-M03) (#433)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 19:13:02 +00:00
be917e2496 docs: mark IUH-M02 complete, start IUH-M03 (#432)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 18:02:21 +00:00
cd8b1f666d feat: wizard remediation — password mask, hooks preview, headless (IUH-M02) (#431)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 17:47:53 +00:00
8fa5995bde docs: scaffold install-ux-hardening mission + archive cli-unification (#430)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 17:15:39 +00:00
25cada7735 feat: mosaic uninstall (IUH-M01) (#429)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 17:06:21 +00:00
be6553101c docs: finalize CLI unification mission at mosaic-v0.0.24 (#424)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 14:54:48 +00:00
417805f330 fix: bump memory/queue/storage to 0.0.4 to force republish (#423)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/tag/publish Pipeline was successful
2026-04-05 14:39:15 +00:00
2472ce52e8 fix: bump stale sub-package versions (brain/forge/log) (#422)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 14:26:30 +00:00
597eb232d7 fix: revert mosaic to 0.0.22 alpha + republish macp (#421)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 14:15:46 +00:00
afe997db82 docs: mission cli-unification-20260404 complete (#420)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 07:54:50 +00:00
b9d464de61 docs: CLI unification release v0.1.0 (M8) (#419)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/tag/publish Pipeline was successful
2026-04-05 07:46:00 +00:00
872c124581 feat(mosaic): unified first-run UX wizard -> gateway install -> verify (#418)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 07:29:17 +00:00
a531029c5b feat(mosaic): mosaic telemetry command (M6 CU-06-01..05) (#417)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 07:06:42 +00:00
35ab619bd0 docs: session 2 orchestrator bookkeeping (M3/M4/M5 complete) (#416)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-05 07:06:40 +00:00
831193cdd8 fix(macp): align exports + add CLI smoke test (#415)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 06:57:42 +00:00
df460d5a49 feat(macp): mosaic macp CLI surface (#410)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 06:33:52 +00:00
119ff0eb1b fix(mosaic): gateway token recovery review remediations (#414)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 06:13:29 +00:00
3abd63ea5c Merge pull request 'feat(mosaic): mosaic auth CLI surface' (#413) from feat/mosaic-auth-cli into main
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 06:11:33 +00:00
641e4604d5 feat(forge): mosaic forge CLI surface (#412)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 06:08:50 +00:00
Jarvis
9b5ecc0171 feat(mosaic): add auth command and stage parallel agent changes
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Picks up auth command and spec written by parallel agent, and updated
mosaic cli.ts wiring from parallel development during cli-unification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:58:03 -05:00
Jarvis
a00325da0e feat(forge): add registerForgeCommand for mosaic forge CLI surface
Adds mosaic forge run|status|resume|personas list subcommands to
@mosaicstack/forge, wires registerForgeCommand into the root mosaic CLI,
and ships a smoke test asserting command structure. Ref CU-05-01
cli-unification-20260404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:58:03 -05:00
4ebce3422d feat(log): mosaic log CLI surface (#407)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:57:22 +00:00
751e0ee330 feat(storage): mosaic storage CLI surface (#405)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:48:13 +00:00
54b2920ef3 feat(memory): mosaic memory CLI surface (#406)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:44:06 +00:00
5917016509 feat(mosaic): gateway token recovery via BetterAuth cookie (#411)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-04-05 05:43:49 +00:00
7b4f1d249d feat(mosaic): top-level mosaic config command (#408)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 05:37:05 +00:00
5425f9268e feat(queue): mosaic queue CLI surface (#404)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 05:27:59 +00:00
febd866098 feat(brain): mosaic brain CLI surface (#403)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:20:44 +00:00
2446593fff feat(mosaic): alphabetize and group mosaic --help output (#402)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 05:12:32 +00:00
651426cf2e docs(plan): gateway admin token recovery flow (#401)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 05:11:33 +00:00
cf46f6e0ae docs: capture planning decisions + session 1 handoff (#400)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 04:57:00 +00:00
6f15a84ccf docs: archive stale mission, scaffold CLI unification mission (#399)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 04:47:54 +00:00
c39433c361 chore: remove legacy @mosaicstack/cli package (#398)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 04:39:46 +00:00
257796ce87 Merge pull request 'chore: bump @mosaicstack/mosaic to 0.0.21 for republish' (#397) from chore/bump-mosaic-0.0.21 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 04:12:05 +00:00
Jarvis
2357602f50 chore: bump @mosaicstack/mosaic to 0.0.21 for publish
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-04-04 23:09:52 -05:00
1230f6b984 ci: fail publish pipeline loudly on registry/auth/network errors (#396)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 03:58:35 +00:00
14b775f1b9 Merge pull request 'fix: populate KNOWN_PACKAGES for mosaic update command' (#395) from fix/populate-known-packages-list into main
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
Reviewed-on: mosaicstack/mosaic-stack#395
2026-04-05 03:52:57 +00:00
Jarvis
c7691d9807 fix: populate KNOWN_PACKAGES with all workspace packages for 'mosaic update'
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
- Remove @mosaicstack/cli (absorbed into @mosaicstack/mosaic)
- Add all 21 remaining workspace packages so the multi-package
  update checker actually covers every published package
2026-04-04 22:49:45 -05:00
9a53d55678 Merge pull request 'fix: update Gitea org references from mosaic/ to mosaicstack/' (#394) from fix/gitea-org-rename into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: mosaicstack/mosaic-stack#394
2026-04-05 03:35:11 +00:00
Jarvis
31008ef7ff fix: update Gitea org references from mosaic/ to mosaicstack/
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
- Update all package.json repo URLs (mosaic/mosaic-stack → mosaicstack/mosaic-stack)
- Update npm registry URLs (/api/packages/mosaic/npm → /api/packages/mosaicstack/npm)
- Update woodpecker publish destinations
- Update tools/install.sh registry and repo base URLs
2026-04-04 22:31:20 -05:00
621ab260c0 fix(mosaic): resumable gateway install + prominent admin token (#393)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/manual/publish Pipeline failed
ci/woodpecker/manual/ci Pipeline failed
2026-04-05 03:19:07 +00:00
2b1840214e Merge pull request 'fix: rename @mosaic/* packages to @mosaicstack/*' (#392) from fix/rename-mosaic-scope-391 into main
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 03:11:55 +00:00
Jarvis
5cfccc2ead fix(mosaic): remove unused hasUpdate variable in formatAllPackagesTable
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Fixes lint error:
@typescript-eslint/no-unused-vars on hasUpdate
2026-04-04 22:01:01 -05:00
Jarvis
774b76447d fix: rename all packages from @mosaic/* to @mosaicstack/*
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
- Updated all package.json name fields and dependency references
- Updated all TypeScript/JavaScript imports
- Updated .woodpecker/publish.yml filters and registry paths
- Updated tools/install.sh scope default
- Updated .npmrc registry paths (worktree + host)
- Enhanced update-checker.ts with checkForAllUpdates() multi-package support
- Updated CLI update command to show table of all packages
- Added KNOWN_PACKAGES, formatAllPackagesTable, getInstallAllCommand
- Marked checkForUpdate() with @deprecated JSDoc

Closes #391
2026-04-04 21:43:23 -05:00
80994bdc8e fix(packages): bump db/memory/queue for PGlite + adapter factories (#389)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 02:20:23 +00:00
2e31626f87 fix: simplify updater to @mosaic/mosaic only, add explicit tea repo/login flags (#388)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 02:09:23 +00:00
255ba46a4d fix(packages): republish @mosaic/config and bump dependents (#386)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 01:56:57 +00:00
10285933a0 fix: retarget updater to @mosaic/mosaic (#384)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-05 01:52:30 +00:00
543388e18b fix(mosaic): resolve framework scripts via import.meta.url (#385)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Fixes #383 — resolveTool now uses fileURLToPath(import.meta.url). Adds package.json/framework subpath exports. Bumps @mosaic/mosaic to 0.0.18.
2026-04-05 01:41:46 +00:00
07a1f5d594 Merge pull request 'feat(mosaic): merge @mosaic/cli into @mosaic/mosaic' (#381) from fix/merge-cli-into-mosaic into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 01:11:33 +00:00
Jarvis
c6fc090c98 feat(mosaic): merge @mosaic/cli into @mosaic/mosaic
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
@mosaic/mosaic is now the single package providing both:
- 'mosaic' binary (CLI: yolo, coord, prdy, tui, gateway, etc.)
- 'mosaic-wizard' binary (installation wizard)

Changes:
- Move packages/cli/src/* into packages/mosaic/src/
- Convert dynamic @mosaic/mosaic imports to static relative imports
- Add CLI deps (ink, react, socket.io-client, @mosaic/config) to mosaic
- Add jsx: react-jsx to mosaic's tsconfig
- Exclude packages/cli from workspace (pnpm-workspace.yaml)
- Update install.sh to install @mosaic/mosaic instead of @mosaic/cli
- Bump version to 0.0.17

This eliminates the circular dependency between @mosaic/cli and
@mosaic/mosaic that was blocking the build graph.
2026-04-04 20:07:27 -05:00
9723b6b948 chore: bump @mosaic/cli and @mosaic/mosaic to 0.0.16 (#379)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-05 00:52:09 +00:00
c0d0fd44b7 refactor(storage): replace better-sqlite3 with PGlite adapter (#378)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 21:58:14 +00:00
30c0fb1308 fix: remediate npm deprecation warnings in @mosaic/gateway 0.0.3 (#377)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 21:03:54 +00:00
26fac4722f fix: gateway install preserves npm prefix via registry flag (#376)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 20:36:15 +00:00
e3f64c79d9 chore: move gateway default port from 4000 to 14242 (#375)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 20:17:40 +00:00
cbd5e8c626 fix: scope Gitea registry to @mosaic packages only (#374)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 19:09:14 +00:00
7560c7dee7 fix: gateway install uses Gitea registry instead of npmjs (#373)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-04 18:59:40 +00:00
982a0e8f83 chore: bump @mosaic/mosaic and @mosaic/cli to 0.0.11 (#372)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 18:47:03 +00:00
fc7fa11923 feat: local tier gateway with PGlite + Gitea-only publishing (#371)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-04 18:39:20 +00:00
86d6c214fe feat: gateway publishability + npmjs publish script (#370)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-04 18:07:05 +00:00
39ccba95d0 feat: mosaic gateway CLI daemon management + admin token auth (#369)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-04 18:03:12 +00:00
202e375f41 Merge pull request 'fix: add build tools to CI install step for better-sqlite3 native bindings' (#368) from feat/task-1775219952-fix-add-build-tools-to-ci-install-step-for-better-sqlite3 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 15:41:23 +00:00
Jarvis
d0378c5723 fix: add Alpine build tools before pnpm install in CI
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
2026-04-03 09:13:25 -05:00
d6f04a0757 Merge pull request 'fix: add build tools to CI install step for better-sqlite3 native bindings' (#366) from fix/storage-sqlite-ci into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 13:41:04 +00:00
afedb8697e Merge pull request 'fix: allow better-sqlite3 build script in pnpm 10' (#367) from fix/pnpm-build-scripts into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 13:11:07 +00:00
Jarvis
1274df7ffc fix: allow better-sqlite3 build script in pnpm 10
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-04-03 08:06:01 -05:00
Jarvis
1b4767bd8b fix: add build tools to CI install step for better-sqlite3 native bindings
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
2026-04-03 07:41:39 -05:00
0b0fe10b37 Merge pull request 'feat: storage abstraction retrofit — adapters for queue, storage, memory (phases 1-4)' (#365) from feat/storage-abstraction into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 04:40:57 +00:00
acfb31f8f6 fix: quality-rails Commander version mismatch + installer defaults (#364)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 02:40:02 +00:00
Jarvis
fd83bd4f2d chore(orchestrator): Phase 4 complete — config schema + CLI lifecycle commands
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
381 tests passing (347 gateway + 34 CLI), 40/40 tasks clean
2026-04-02 21:38:40 -05:00
Jarvis
ce3ca1dbd1 feat(cli): add gateway start/stop/status lifecycle commands
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 21:37:20 -05:00
Jarvis
95e7b071d4 feat(cli): add mosaic gateway init command with tier selection wizard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 21:35:32 -05:00
d4c5797a65 fix: installer copies default framework files (AGENTS.md) to mosaicHome (#363)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:34:43 +00:00
70a51ba711 fix: all CLI script resolution uses bundled-first resolveTool() (#362)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:28:07 +00:00
db8023bdbb fix: fwScript prefers npm-bundled scripts over stale deployed copies (#361)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:21:58 +00:00
9e597ecf87 chore: bump @mosaic/mosaic and @mosaic/cli to 0.0.6 (#360)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:13:37 +00:00
a23c117ea4 fix: auto-migrate customized skills to skills-local/ on sync (#359)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-03 02:11:03 +00:00
0cf80dab8c fix: stale update banner + skill sync dirty worktree crash (#358)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-03 02:04:05 +00:00
Jarvis
04a80fb9ba feat(config): add MosaicConfig schema + loader with tier auto-detection
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 21:03:00 -05:00
Jarvis
626adac363 chore(orchestrator): Phase 3 complete — local tier implemented (SQLite + keyword search + JSON queue)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
42 new tests: 4 queue, 18 storage, 20 memory
347 total tests passing
2026-04-02 20:56:39 -05:00
Jarvis
35fbd88a1d feat(memory): implement keyword search adapter — no vector dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:55:00 -05:00
381b0eed7b Merge pull request 'chore: bump @mosaic/mosaic and @mosaic/cli to 0.0.4' (#357) from chore/bump-0.0.4 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: mosaic/mosaic-stack#357
2026-04-03 01:51:55 +00:00
Jarvis
25383ea645 feat(storage): implement SQLite adapter with better-sqlite3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:51:13 -05:00
Jarvis
e7db9ddf98 chore: bump @mosaic/mosaic and @mosaic/cli to 0.0.4
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-02 20:50:44 -05:00
Jarvis
7bb878718d feat(queue): implement local adapter with JSON persistence
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:46:11 -05:00
Jarvis
46a31d4e71 chore(orchestrator): Phase 2 complete — existing backends wrapped as adapters
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-04-02 20:44:11 -05:00
Jarvis
e128a7a322 feat(gateway): wire adapter factories + DI tokens alongside existing providers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:44:11 -05:00
Jarvis
27b1898ec6 refactor(memory): wrap pgvector logic as MemoryAdapter implementation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:44:11 -05:00
Jarvis
d19ef45bb0 feat(storage): implement Postgres adapter wrapping Drizzle + @mosaic/db
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:44:10 -05:00
Jarvis
5e852df6c3 refactor(queue): wrap ioredis as bullmq adapter behind QueueAdapter interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:44:10 -05:00
Jarvis
e0eca771c6 chore(orchestrator): Phase 1 complete — all interfaces defined 2026-04-02 20:44:10 -05:00
Jarvis
9d22ef4cc9 feat: add adapter factory + registry pattern for queue, storage, memory
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:44:10 -05:00
Jarvis
41961a6980 feat(memory): define MemoryAdapter interface types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:44:10 -05:00
Jarvis
e797676a02 feat(storage): define StorageAdapter interface types + scaffold package
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:44:10 -05:00
Jarvis
05d61e62be feat(queue): define QueueAdapter interface types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 20:44:10 -05:00
Jarvis
73043773d8 chore(orchestrator): Bootstrap storage abstraction retrofit
Mission: Decouple gateway from hardcoded Postgres/Valkey backends.
20 tasks across 5 phases. Estimated total: ~214K tokens.

Phase 1: Interface extraction (4 tasks)
Phase 2: Wrap existing backends as adapters (5 tasks)
Phase 3: Local tier implementation (4 tasks)
Phase 4: Config + CLI commands (4 tasks)
Phase 5: Migration + docs (3 tasks)
2026-04-02 20:44:10 -05:00
0be9729e40 Merge pull request 'fix: syncDirectory same-path guard, nested .git exclusion, and sync stash handling' (#356) from fix/idempotent-init into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: mosaic/mosaic-stack#356
2026-04-03 01:42:18 +00:00
910 changed files with 127528 additions and 3749 deletions

View File

@@ -23,8 +23,8 @@ VALKEY_URL=redis://localhost:6380
# ─── Gateway ─────────────────────────────────────────────────────────────────
# TCP port the NestJS/Fastify gateway listens on (default: 4000)
GATEWAY_PORT=4000
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
GATEWAY_PORT=14242
# Comma-separated list of allowed CORS origins.
# Must include the web app origin in production.
@@ -37,12 +37,12 @@ GATEWAY_CORS_ORIGIN=http://localhost:3000
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
# Public base URL of the gateway (used by BetterAuth for callback URLs)
BETTER_AUTH_URL=http://localhost:4000
BETTER_AUTH_URL=http://localhost:14242
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
# Public gateway URL — accessible from the browser, not just the server.
NEXT_PUBLIC_GATEWAY_URL=http://localhost:4000
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
@@ -121,12 +121,12 @@ OTEL_SERVICE_NAME=mosaic-gateway
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
# DISCORD_BOT_TOKEN=
# DISCORD_GUILD_ID=
# DISCORD_GATEWAY_URL=http://localhost:4000
# DISCORD_GATEWAY_URL=http://localhost:14242
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_GATEWAY_URL=http://localhost:4000
# TELEGRAM_GATEWAY_URL=http://localhost:14242
# ─── SSO Providers (add credentials to enable) ───────────────────────────────

13
.gitignore vendored
View File

@@ -9,3 +9,16 @@ coverage
*.tsbuildinfo
.pnpm-store
docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example
infra/step-ca/dev-password
# Scratch dirs created by the framework git-wrapper shell test harnesses
.mosaic-test-work/
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
# loading it, then unlink. They are untracked but were not ignored, so turbo's
# package traversal hashed them and intermittently failed CI with "Package
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
# file vanished mid-scan. Ignoring them removes the race.
*.timestamp-*.mjs

6
.npmrc
View File

@@ -1 +1,5 @@
@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
# instead of repopulating a fresh one.
store-dir=/root/.local/share/pnpm/store

View File

@@ -5,3 +5,5 @@ pnpm-lock.yaml
**/drizzle
**/.next
.claude/
docs/tess/TASKS.md
docs/scratchpads/

40
.woodpecker/ci-image.yml Normal file
View File

@@ -0,0 +1,40 @@
# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea
# registry CI already publishes to. Reuses the exact kaniko + auth pattern
# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker
# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest`
# for their install step.
#
# Rebuild ONLY when the dependency set or the image recipe changes — a normal
# code push must not trigger a 25-min image build. `path` applies to push/PR
# events; `event: tag` (releases) rebuilds unconditionally so a tagged release
# always ships a fresh base.
when:
- event: tag
- event: [push, manual]
branch: main
path:
include:
- 'pnpm-lock.yaml'
- 'Dockerfile.ci'
steps:
build-ci-base:
image: gcr.io/kaniko-project/executor:debug
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
# Lockfile-hash tag: an immutable identity for the exact dep set baked
# into this image. `:latest` is the mutable pointer pipelines consume.
LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12)
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH"
/kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS

View File

@@ -1,9 +1,20 @@
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
# store. The install step resolves from the baked store (--prefer-offline)
# instead of paying a ~731s cold fetch + native compile every run.
variables:
- &node_image 'node:22-alpine'
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
when:
- event: [push, pull_request, manual]
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
# push CI is restricted to protected branches (main) so a feature-branch push no
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
# CI load on the storage-constrained runner with zero loss of gating (branch
# protection requires no push/ci status context; main still gets full push CI).
- event: [pull_request, manual]
- event: push
branch: main
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
@@ -15,7 +26,21 @@ steps:
image: *node_image
commands:
- corepack enable
- pnpm install --frozen-lockfile
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
# the baked pnpm store.
- pnpm install --frozen-lockfile --prefer-offline
# Blocking gate: public framework package must contain no operator-specific
# personal data or private $HOME defaults. Runs early (no node_modules needed).
sanitization:
image: *node_image
commands:
- apk add --no-cache bash
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
# Resident line-count ceiling over framework-owned resident files
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
typecheck:
image: *node_image
@@ -24,6 +49,7 @@ steps:
- pnpm typecheck
depends_on:
- install
- sanitization
# lint, format, and test are independent — run in parallel after typecheck
lint:
@@ -45,27 +71,36 @@ steps:
test:
image: *node_image
environment:
DATABASE_URL: postgresql://mosaic:mosaic@postgres:5432/mosaic
# Avoid the namespace-level Woodpecker DB service named "postgres".
# The Kubernetes backend exposes service containers by step name.
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
commands:
- *enable_pnpm
# Install postgresql-client for pg_isready
- apk add --no-cache postgresql-client
# Wait up to 30s for postgres to be ready
# postgresql-client (pg_isready) is baked into ci-base.
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- |
for i in $(seq 1 30); do
pg_isready -h postgres -p 5432 -U mosaic && break
echo "Waiting for postgres ($i/30)..."
ready=0
for i in $(seq 1 60); do
if pg_isready -h ci-postgres -p 5432 -U mosaic; then
ready=1
break
fi
echo "Waiting for ci-postgres ($i/60)..."
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "ci-postgres did not become ready" >&2
exit 1
fi
# Run migrations (DATABASE_URL is set in environment above)
- pnpm --filter @mosaic/db run db:migrate
- pnpm --filter @mosaicstack/db run db:migrate
# Run all tests
- pnpm test
depends_on:
- typecheck
services:
postgres:
ci-postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_USER: mosaic

View File

@@ -2,8 +2,27 @@
# Runs only on main branch push/tag
variables:
- &node_image 'node:22-alpine'
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
# toolchain + warm pnpm store. Kills the second cold install publish pays.
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
# (tags) always build everything. Exclude-list keeps the default SAFE: any
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
- &image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main]
@@ -14,7 +33,8 @@ steps:
image: *node_image
commands:
- corepack enable
- pnpm install --frozen-lockfile
# Resolve from the baked pnpm store instead of a cold network fetch.
- pnpm install --frozen-lockfile --prefer-offline
build:
image: *node_image
@@ -26,6 +46,15 @@ steps:
publish-npm:
image: *node_image
# Publish only when a publishable package changed (or on a release tag); a
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
when:
- event: tag
- event: [push, manual]
branch: main
path:
include:
- 'packages/**'
environment:
NPM_TOKEN:
from_secret: gitea_token
@@ -33,21 +62,65 @@ steps:
- *enable_pnpm
# Configure auth for Gitea npm registry
- |
echo "//git.mosaicstack.dev/api/packages/mosaic/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaic:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/" >> ~/.npmrc
# Publish all non-private packages (--no-git-checks skips dirty/branch checks in CI)
# --filter excludes private apps (gateway, web) and the root
- >
pnpm --filter "@mosaic/*"
--filter "!@mosaic/gateway"
--filter "!@mosaic/web"
publish --no-git-checks --access public
|| echo "[publish] Some packages may already exist at this version — continuing"
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
# Publish non-private packages to Gitea.
#
# The only publish failure we tolerate is "version already exists" —
# that legitimately happens when only some packages were bumped in
# the merge. Any other failure (registry 404, auth error, network
# error) MUST fail the pipeline loudly: the previous
# `|| echo "... continuing"` fallback silently hid a 404 from the
# Gitea org rename and caused every @mosaicstack/* publish to fall
# on the floor while CI still reported green.
- |
# Portable sh (Alpine ash) — avoid bashisms like PIPESTATUS.
set +e
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public >/tmp/publish.log 2>&1
EXIT=$?
set -e
cat /tmp/publish.log
if [ "$EXIT" -eq 0 ]; then
echo "[publish] all packages published successfully"
exit 0
fi
# Hard registry / auth / network errors → fatal. Match npm's own
# error lines specifically to avoid false positives on arbitrary
# log text that happens to contain "E404" etc.
if grep -qE "npm (error|ERR!) code (E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND)" /tmp/publish.log; then
echo "[publish] FATAL: registry/auth/network error detected — failing pipeline" >&2
exit 1
fi
# Only tolerate the explicit "version already published" case.
# npm returns this as E403 with body "You cannot publish over..."
# or EPUBLISHCONFLICT depending on version.
if grep -qE "EPUBLISHCONFLICT|You cannot publish over|previously published" /tmp/publish.log; then
echo "[publish] some packages already at this version — continuing (non-fatal)"
exit 0
fi
echo "[publish] FATAL: publish failed with unrecognized error — failing pipeline" >&2
exit 1
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
# image: *node_image
# environment:
# NPM_TOKEN:
# from_secret: npmjs_token
# commands:
# - *enable_pnpm
# - apk add --no-cache jq bash
# - bash scripts/publish-npmjs.sh
# depends_on:
# - build
# when:
# - event: [tag]
build-gateway:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
@@ -60,19 +133,20 @@ steps:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/mosaic-stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/gateway:latest"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/gateway:$CI_COMMIT_TAG"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
depends_on:
- build
build-web:
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
@@ -85,12 +159,38 @@ steps:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/mosaic-stack/web:sha-${CI_COMMIT_SHA:0:7}"
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/web:latest"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaic/mosaic-stack/web:$CI_COMMIT_TAG"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
depends_on:
- build
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
depends_on:

View File

@@ -21,11 +21,11 @@ Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo
| `apps/web` | Next.js dashboard | React 19, Tailwind |
| `packages/types` | Shared TypeScript contracts | class-validator |
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
| `packages/auth` | BetterAuth configuration | better-auth, @mosaic/db |
| `packages/brain` | Data layer (PG-backed) | @mosaic/db |
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
| `packages/queue` | Valkey task queue + MCP | ioredis |
| `packages/coord` | Mission coordination | @mosaic/queue |
| `packages/cli` | Unified CLI + Pi TUI | Ink, Pi SDK |
| `packages/coord` | Mission coordination | @mosaicstack/queue |
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
| `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf |
@@ -33,9 +33,9 @@ Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo
1. Gateway is the single API surface — all clients connect through it
2. Pi SDK is ESM-only — gateway and CLI must use ESM
3. Socket.IO typed events defined in `@mosaic/types` enforce compile-time contracts
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
4. OTEL auto-instrumentation loads before NestJS bootstrap
5. BetterAuth manages auth tables; schema defined in `@mosaic/db`
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
@@ -58,14 +58,14 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
| Value | When to use | Budget |
| -------- | ----------------------------------------------------------- | -------------------------- |
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
| Value | When to use | Budget |
| --------- | ----------------------------------------------------------- | -------------------------- |
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.

View File

@@ -10,7 +10,7 @@ Self-hosted, multi-user AI agent platform. TypeScript monorepo.
- **Web**: Next.js 16 + React 19 (`apps/web`)
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
- **Auth**: BetterAuth (`packages/auth`)
- **Agent**: Pi SDK (`packages/agent`, `packages/cli`)
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
- **Queue**: Valkey 8 (`packages/queue`)
- **Build**: pnpm workspaces + Turborepo
- **CI**: Woodpecker CI
@@ -26,13 +26,13 @@ pnpm test # Vitest (all packages)
pnpm build # Build all packages
# Database
pnpm --filter @mosaic/db db:push # Push schema to PG (dev)
pnpm --filter @mosaic/db db:generate # Generate migrations
pnpm --filter @mosaic/db db:migrate # Run migrations
pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev)
pnpm --filter @mosaicstack/db db:generate # Generate migrations
pnpm --filter @mosaicstack/db db:migrate # Run migrations
# Dev
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
pnpm --filter @mosaic/gateway exec tsx src/main.ts # Start gateway
pnpm --filter @mosaicstack/gateway exec tsx src/main.ts # Start gateway
```
## Conventions

45
Dockerfile.ci Normal file
View File

@@ -0,0 +1,45 @@
# Pre-baked CI base image for Woodpecker pipelines.
#
# Purpose: eliminate the cold `pnpm install` that dominates every pipeline
# (~731s median). This image ships the native toolchain (no per-run `apk add`)
# AND a warm, content-addressable pnpm store with the dependency-tree tarballs
# already fetched at build time. `pnpm fetch` only populates the store from the
# lockfile — it does NOT run the native node-gyp builds (better-sqlite3,
# node-pty, sqlite3, canvas, sharp); those still compile at `pnpm install`,
# which is exactly why the musl toolchain stays baked into this image. A
# pipeline `pnpm install --frozen-lockfile --prefer-offline` then resolves
# tarballs from local hard-links (no network) and compiles natives against the
# already-present toolchain, in tens of seconds instead of ~731s.
#
# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change
# (see .woodpecker/ci-image.yml).
#
# Node version is pinned to 24 (Active LTS). This is the follow-up bump from
# node:22 — sequenced AFTER the CI cache work landed so the runtime change
# carries zero cache variables. node:26 stays held until it reaches LTS
# (Oct 2026); the Current line risks native-module (node-gyp) breakage on a
# runner that compiles better-sqlite3 / canvas / sharp / node-pty from source.
FROM node:24-alpine
# Native toolchain required to compile node-gyp deps on musl, plus the
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
# `apk add bash`.
RUN apk add --no-cache python3 make g++ postgresql-client bash
# Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
WORKDIR /app
# Pin the store location so the pipeline can point `store-dir` at the same path.
ENV PNPM_HOME=/root/.local/share/pnpm
RUN pnpm config set store-dir /root/.local/share/pnpm/store
# Warm the store. `pnpm fetch` populates the content-addressable store with the
# dependency tarballs directly from the lockfile (no package.json / workspace
# needed), so a baked store stays valid until the lockfile changes. Note:
# `fetch` does NOT compile native modules — that happens later at `pnpm install`
# in the pipeline, against the toolchain baked above.
COPY pnpm-lock.yaml ./
RUN pnpm fetch --frozen-lockfile

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mosaic Stack
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

164
README.md
View File

@@ -7,26 +7,39 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
## Quick Install
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
```bash
bash <(curl -fsSL …) --yes # Accept all defaults
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
```
This installs both components:
| Component | What | Where |
| --------------- | ----------------------------------------------------- | -------------------- |
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaic/cli** | TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
| Component | What | Where |
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
After install, set up your agent identity:
After install, the wizard runs automatically or you can invoke it manually:
```bash
mosaic init # Interactive wizard
mosaic wizard # Full guided setup (gateway install → verify)
```
### Requirements
- Node.js ≥ 20
- npm (for global @mosaic/cli install)
- npm (for global @mosaicstack/mosaic install)
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
## Usage
@@ -45,14 +58,40 @@ mosaic yolo pi # Pi in yolo mode
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
### TUI & Gateway
```bash
mosaic tui # Interactive TUI connected to the gateway
mosaic login # Authenticate with a gateway instance
mosaic gateway login # Authenticate with a gateway instance
mosaic sessions list # List active agent sessions
```
### Gateway Management
```bash
mosaic gateway install # Install and configure the gateway service
mosaic gateway verify # Post-install health check
mosaic gateway login # Authenticate and store a session token
mosaic gateway config rotate-token # Rotate your API token
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
```
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
### Configuration
Mosaic supports three storage tiers: `local` (PGlite, single-host), `standalone` (PostgreSQL, single-host), and `federated` (PostgreSQL + pgvector + Valkey, multi-host). See [Federated Tier Setup](docs/federation/SETUP.md) for multi-user and production deployments, or [Migrating to Federated](docs/guides/migrate-tier.md) to upgrade from existing tiers.
```bash
mosaic config show # Print full config as JSON
mosaic config get <key> # Read a specific key
mosaic config set <key> <val># Write a key
mosaic config edit # Open config in $EDITOR
mosaic config path # Print config file path
```
### Management
```bash
@@ -65,6 +104,80 @@ mosaic coord init # Initialize a new orchestration mission
mosaic prdy init # Create a PRD via guided session
```
### Sub-package Commands
Each Mosaic sub-package exposes its API surface through the unified CLI:
```bash
# User management
mosaic auth users list
mosaic auth users create
mosaic auth sso
# Agent brain (projects, missions, tasks)
mosaic brain projects
mosaic brain missions
mosaic brain tasks
mosaic brain conversations
# Agent forge pipeline
mosaic forge run
mosaic forge status
mosaic forge resume
mosaic forge personas
# Structured logging
mosaic log tail
mosaic log search
mosaic log export
mosaic log level
# MACP protocol
mosaic macp tasks
mosaic macp submit
mosaic macp gate
mosaic macp events
# Agent memory
mosaic memory search
mosaic memory stats
mosaic memory insights
mosaic memory preferences
# Task queue (Valkey)
mosaic queue list
mosaic queue stats
mosaic queue pause
mosaic queue resume
mosaic queue jobs
mosaic queue drain
# Object storage
mosaic storage status
mosaic storage tier
mosaic storage export
mosaic storage import
mosaic storage migrate
```
### Telemetry
```bash
# Local observability (OTEL / Jaeger)
mosaic telemetry local status
mosaic telemetry local tail
mosaic telemetry local jaeger
# Remote telemetry (dry-run by default)
mosaic telemetry status
mosaic telemetry opt-in
mosaic telemetry opt-out
mosaic telemetry test
mosaic telemetry upload # Dry-run unless opted in
```
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
## Development
### Prerequisites
@@ -76,8 +189,8 @@ mosaic prdy init # Create a PRD via guided session
### Setup
```bash
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git
cd mosaic-stack
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
cd stack
# Start infrastructure (Postgres, Valkey, Jaeger)
docker compose up -d
@@ -86,7 +199,7 @@ docker compose up -d
pnpm install
# Run migrations
pnpm --filter @mosaic/db run db:migrate
pnpm --filter @mosaicstack/db run db:migrate
# Start all services in dev mode
pnpm dev
@@ -126,13 +239,12 @@ npm packages are published to the Gitea package registry on main merges.
## Architecture
```
mosaic-stack/
stack/
├── apps/
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
│ └── web/ Next.js dashboard (React 19, Tailwind)
├── packages/
│ ├── cli/ Mosaic CLI — TUI, gateway client, wizard
│ ├── mosaic/ Framework — wizard, runtime detection, update checker
│ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
│ ├── auth/ BetterAuth configuration
@@ -153,7 +265,7 @@ mosaic-stack/
│ ├── macp/ OpenClaw MACP runtime plugin
│ └── mosaic-framework/ OpenClaw framework injection plugin
├── tools/
│ └── install.sh Unified installer (framework + npm CLI)
│ └── install.sh Unified installer (framework + npm CLI, --yes / --no-auto-launch)
├── scripts/agent/ Agent session lifecycle scripts
├── docker-compose.yml Dev infrastructure
└── .woodpecker/ CI pipeline configs
@@ -163,7 +275,7 @@ mosaic-stack/
- **Gateway is the single API surface** — all clients (TUI, web, Discord, Telegram) connect through it
- **ESM everywhere** — `"type": "module"`, `.js` extensions in imports, NodeNext resolution
- **Socket.IO typed events** — defined in `@mosaic/types`, enforced at compile time
- **Socket.IO typed events** — defined in `@mosaicstack/types`, enforced at compile time
- **OTEL auto-instrumentation** — loads before NestJS bootstrap
- **Explicit `@Inject()` decorators** — required since tsx/esbuild doesn't emit decorator metadata
@@ -200,7 +312,13 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
Run the installer again — it handles upgrades automatically:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
Or use the CLI:
@@ -215,10 +333,12 @@ The CLI also performs a background update check on every invocation (cached for
### Installer Flags
```bash
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
```
## Contributing

View File

@@ -0,0 +1,35 @@
{
"name": "@mosaicstack/mosaic-as",
"version": "0.0.1",
"type": "module",
"private": true,
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "apps/appservice"
},
"main": "dist/main.js",
"bin": {
"mosaic-as": "dist/main.js",
"mosaic-as-registration": "dist/registration-main.js"
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"dev": "tsx watch src/main.ts"
},
"dependencies": {
"@mosaicstack/appservice": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"files": [
"dist"
]
}

View File

@@ -0,0 +1,388 @@
import { describe, expect, it, vi } from 'vitest';
import { AppserviceDaemon } from '../server.js';
import type { DaemonConfig, DaemonRequest } from '../server.js';
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
const cfg: DaemonConfig = {
homeserverUrl: 'https://hs.example',
domain: 'hs.example',
asToken: 'as-secret',
hsToken: 'hs-secret',
bridgeTokens: ['bridge-secret'],
};
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const request = (overrides: Partial<DaemonRequest>): DaemonRequest => ({
method: 'GET',
path: '/',
searchParams: new URLSearchParams(),
body: undefined,
...overrides,
});
const makeDaemon = () => {
const fetchMock = vi.fn(async (_input: URL | string) => jsonResponse(200, { event_id: '$sent' }));
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
return { daemon, fetchMock };
};
describe('AppserviceDaemon routing', () => {
it('serves health unauthenticated', async () => {
const { daemon } = makeDaemon();
expect((await daemon.handle(request({ path: '/health' }))).status).toBe(200);
});
it('404s unknown paths', async () => {
const { daemon } = makeDaemon();
expect((await daemon.handle(request({ path: '/nope' }))).status).toBe(404);
});
it('transactions require the hs_token', async () => {
const { daemon } = makeDaemon();
const bad = await daemon.handle(
request({
method: 'PUT',
path: '/_matrix/app/v1/transactions/t1',
authorizationHeader: 'Bearer wrong',
body: { events: [] },
}),
);
expect(bad.status).toBe(403);
const ok = await daemon.handle(
request({
method: 'PUT',
path: '/_matrix/app/v1/transactions/t1',
authorizationHeader: 'Bearer hs-secret',
body: { events: [{ type: 'm.room.message', event_id: '$e' }] },
}),
);
expect(ok.status).toBe(200);
});
it('bridge requires a bridge token (hs/as tokens do not work)', async () => {
const { daemon } = makeDaemon();
for (const token of [undefined, 'Bearer hs-secret', 'Bearer as-secret', 'Bearer nope']) {
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: token,
body: {},
}),
);
expect(res.status).toBe(403);
}
});
it('bridge message sends as the agent and returns the event id', async () => {
const { daemon, fetchMock } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: 'Bearer bridge-secret',
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi', thread_root: '$req' },
}),
);
expect(res.status).toBe(200);
expect(res.body.event_id).toBe('$sent');
const sendCall = fetchMock.mock.calls
.map((c) => new URL(String(c[0])))
.find((u) => u.pathname.includes('/send/m.room.message/'));
expect(sendCall).toBeDefined();
expect(sendCall!.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example');
});
it('bridge rejects invalid payloads with 400', async () => {
const { daemon } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: 'Bearer bridge-secret',
body: { room_id: 'bad', agent: 'pi0', body: 'x' },
}),
);
expect(res.status).toBe(400);
});
it('bridge typing endpoint works', async () => {
const { daemon, fetchMock } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/typing',
authorizationHeader: 'Bearer bridge-secret',
body: { room_id: '!r:hs.example', agent: 'pi0-web1', typing: true },
}),
);
expect(res.status).toBe(200);
const typingCall = fetchMock.mock.calls
.map((c) => new URL(String(c[0])))
.find((u) => u.pathname.includes('/typing/'));
expect(typingCall).toBeDefined();
});
it('authenticated unknown bridge sub-paths return 405, never fall through', async () => {
const { daemon } = makeDaemon();
const res = await daemon.handle(
request({
method: 'GET',
path: '/bridge/v1/unknown',
authorizationHeader: 'Bearer bridge-secret',
}),
);
expect(res.status).toBe(405);
});
it('provisions a room as the AS sender with space linking', async () => {
const calls: Array<{ url: URL; body: unknown }> = [];
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
const url = new URL(String(input));
calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
if (url.pathname.endsWith('/createRoom'))
return jsonResponse(200, { room_id: '!new:hs.example' });
return jsonResponse(200, {});
});
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: {
name: 'proj-x',
alias: 'mosaic-proj-x',
invite: ['@jason.woltje:hs.example'],
space_id: '!space:hs.example',
},
}),
);
expect(res.status).toBe(200);
expect(res.body.room_id).toBe('!new:hs.example');
expect(res.body.space_linked).toBe(true);
const create = calls.find((c) => c.url.pathname.endsWith('/createRoom'));
expect(create!.url.searchParams.get('user_id')).toBe('@mosaic-as:hs.example');
const body = create!.body as Record<string, unknown>;
expect(body.room_alias_name).toBe('mosaic-proj-x');
expect((body.power_level_content_override as Record<string, unknown>).users).toEqual({
'@mosaic-as:hs.example': 100,
});
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.child/'))).toBe(true);
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.parent/'))).toBe(true);
});
it('space-link failure still returns the room id (no orphan)', async () => {
const fetchMock = vi.fn(async (input: URL | string) => {
const url = new URL(String(input));
if (url.pathname.endsWith('/createRoom'))
return jsonResponse(200, { room_id: '!new:hs.example' });
if (url.pathname.includes('/state/m.space.child/'))
return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'no PL in space' });
return jsonResponse(200, {});
});
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: { name: 'proj-x', space_id: '!space:hs.example' },
}),
);
expect(res.status).toBe(200);
expect(res.body.room_id).toBe('!new:hs.example');
expect(res.body.space_linked).toBe(false);
expect(String(res.body.space_error)).toContain('403');
});
it('invite list cap enforced', async () => {
const { daemon } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: { name: 'x', invite: Array.from({ length: 51 }, (_, i) => `@u${i}:hs`) },
}),
);
expect(res.status).toBe(400);
});
it('provision rejects bad payloads and requires auth', async () => {
const { daemon } = makeDaemon();
const noAuth = await daemon.handle(
request({ method: 'POST', path: '/bridge/v1/provision/rooms', body: { name: 'x' } }),
);
expect(noAuth.status).toBe(403);
const bad = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: { name: '', alias: 'BAD ALIAS' },
}),
);
expect(bad.status).toBe(400);
});
// A daemon whose fetch mock backs account_data with a mutable in-test object,
// so register/verify/revoke round-trip through the (faked) homeserver.
const makeAgentDaemon = () => {
const accountData: { value: Record<string, unknown> | null } = { value: null };
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
const url = new URL(String(input));
const path = url.pathname;
if (path.includes(`/account_data/${AGENTS_TYPE}`)) {
if (init?.method === 'PUT') {
accountData.value = JSON.parse(String(init.body)) as Record<string, unknown>;
return jsonResponse(200, {});
}
if (accountData.value === null) {
return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' });
}
return jsonResponse(200, accountData.value);
}
if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' });
if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' });
return jsonResponse(200, {});
});
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
return { daemon, fetchMock };
};
const registerAgent = async (
daemon: AppserviceDaemon,
body: Record<string, unknown> = { alias: 'pi0', host: 'web1' },
) =>
daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents',
authorizationHeader: 'Bearer bridge-secret',
body,
}),
);
it('host token registers an agent and returns agent_user_id + bridge_token', async () => {
const { daemon, fetchMock } = makeAgentDaemon();
const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
expect(res.status).toBe(200);
expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example');
expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true);
const registerCall = fetchMock.mock.calls
.map((c) => new URL(String(c[0])))
.find((u) => u.pathname.endsWith('/register'));
expect(registerCall).toBeDefined();
});
it('register requires a HOST token (agent token and no token are 403)', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon);
const agentToken = String(minted.body.bridge_token);
const asAgent = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents',
authorizationHeader: `Bearer ${agentToken}`,
body: { alias: 'pi1', host: 'web2' },
}),
);
expect(asAgent.status).toBe(403);
const noAuth = await daemon.handle(
request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }),
);
expect(noAuth.status).toBe(403);
});
it('agent-scoped token may send as itself but not as another agent', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
const agentToken = String(minted.body.bridge_token);
const self = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
}),
);
expect(self.status).toBe(200);
const other = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' },
}),
);
expect(other.status).toBe(403);
expect(other.body.error).toBe('token not scoped to this agent');
});
it('revoked agent token is rejected on messages', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
const agentToken = String(minted.body.bridge_token);
const revoke = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents/revoke',
authorizationHeader: 'Bearer bridge-secret',
body: { agent_user_id: '@agent-pi0-web1:hs.example' },
}),
);
expect(revoke.status).toBe(200);
expect(revoke.body.revoked).toBe(1);
const afterRevoke = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
}),
);
expect(afterRevoke.status).toBe(403);
});
it('GET /bridge/v1/agents lists registered agents (host only)', async () => {
const { daemon } = makeAgentDaemon();
await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' });
const res = await daemon.handle(
request({
method: 'GET',
path: '/bridge/v1/agents',
authorizationHeader: 'Bearer bridge-secret',
}),
);
expect(res.status).toBe(200);
const agents = res.body.agents as Array<Record<string, unknown>>;
expect(agents).toHaveLength(1);
expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example');
expect(agents[0]?.display_name).toBe('Pi Zero');
});
it('empty bridge token list denies everything', async () => {
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/typing',
authorizationHeader: 'Bearer bridge-secret',
body: {},
}),
);
expect(res.status).toBe(403);
});
});

View File

@@ -0,0 +1,23 @@
import type { DaemonConfig } from './server.js';
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`missing required env var ${name}`);
return value;
};
export function configFromEnv(): DaemonConfig & { port: number } {
return {
homeserverUrl: required('MOSAIC_AS_HOMESERVER_URL'),
domain: required('MOSAIC_AS_DOMAIN'),
asToken: required('MOSAIC_AS_TOKEN'),
hsToken: required('MOSAIC_HS_TOKEN'),
userPrefix: process.env.MOSAIC_AS_USER_PREFIX ?? 'agent-',
senderLocalpart: process.env.MOSAIC_AS_SENDER_LOCALPART ?? 'mosaic-as',
bridgeTokens: (process.env.MOSAIC_AS_BRIDGE_TOKENS ?? '')
.split(',')
.map((t) => t.trim())
.filter(Boolean),
port: Number(process.env.MOSAIC_AS_PORT ?? 8008),
};
}

View File

@@ -0,0 +1,67 @@
import http from 'node:http';
import { configFromEnv } from './config.js';
import { AppserviceDaemon } from './server.js';
const cfg = configFromEnv();
const daemon = new AppserviceDaemon(cfg);
const MAX_BODY_BYTES = 1024 * 1024;
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
let received = 0;
let rejected = false;
req.on('data', (chunk: Buffer) => {
received += chunk.length;
if (received > MAX_BODY_BYTES) {
rejected = true;
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'request body too large' }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (rejected) return;
void (async () => {
const url = new URL(req.url ?? '/', 'http://localhost');
let body: unknown;
try {
const raw = Buffer.concat(chunks).toString();
body = raw ? JSON.parse(raw) : undefined;
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ errcode: 'M_NOT_JSON', error: 'invalid json' }));
return;
}
const result = await daemon.handle({
method: req.method ?? 'GET',
path: url.pathname,
searchParams: url.searchParams,
authorizationHeader: req.headers.authorization,
body,
});
res.writeHead(result.status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result.body));
})().catch((error: unknown) => {
console.error('request failed:', error);
if (res.headersSent) {
res.destroy();
return;
}
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'internal error' }));
});
});
});
server.listen(cfg.port, () => {
console.log(
`mosaic-as listening on :${cfg.port} (homeserver ${cfg.homeserverUrl}, domain ${cfg.domain})`,
);
if (cfg.bridgeTokens.length === 0) {
console.warn('WARNING: MOSAIC_AS_BRIDGE_TOKENS is empty — bridge API will deny all requests');
}
});

View File

@@ -0,0 +1,10 @@
import { buildRegistration, registrationToYaml } from '@mosaicstack/appservice';
import { configFromEnv } from './config.js';
// Prints the Synapse registration YAML (mosaic-as.yaml) for the current env.
// Usage: MOSAIC_AS_URL=http://mosaic-as:8008 mosaic-as-registration > mosaic-as.yaml
const cfg = configFromEnv();
const url = process.env.MOSAIC_AS_URL;
if (!url) throw new Error('missing required env var MOSAIC_AS_URL');
process.stdout.write(registrationToYaml(buildRegistration(cfg, { url })));

View File

@@ -0,0 +1,225 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import {
AgentTokenStore,
AppserviceIntent,
TransactionHandler,
validateBridgeMessage,
validateBridgeTyping,
validateProvisionRoom,
validateRegisterAgent,
validateRevokeAgent,
} from '@mosaicstack/appservice';
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
export interface DaemonConfig extends AppserviceConfig {
/** Bearer tokens accepted on /bridge/v1/* (one per agent-comms host daemon). */
bridgeTokens: string[];
}
export interface DaemonRequest {
method: string;
/** URL path without query string. */
path: string;
searchParams: URLSearchParams;
authorizationHeader?: string;
body: unknown;
}
export interface DaemonResponse {
status: number;
body: Record<string, unknown>;
}
// Compare equal-length HMAC digests so neither content nor LENGTH of the
// stored secret is observable through timing.
const HMAC_KEY = randomBytes(32);
const digest = (value: string): Buffer => createHmac('sha256', HMAC_KEY).update(value).digest();
const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
/**
* Resolved identity for an authenticated /bridge/v1/* caller. Host principals
* (the agent-comms host daemons) are unrestricted; agent principals are scoped
* to a single virtual user and may only act as themselves.
*/
export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null;
/**
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
* Application Service transactions endpoint (Synapse-facing) plus the
* internal bridge API v1 (agent-comms daemon-facing). main.ts binds this to
* node:http; tests drive it directly.
*/
export class AppserviceDaemon {
readonly intent: AppserviceIntent;
private readonly transactions: TransactionHandler;
private readonly agents: AgentTokenStore;
constructor(
private readonly cfg: DaemonConfig,
fetchImpl?: typeof fetch,
private readonly log: (line: string) => void = (line) => console.log(line),
) {
this.intent = new AppserviceIntent(cfg, fetchImpl);
this.agents = new AgentTokenStore(this.intent);
this.transactions = new TransactionHandler({
hsToken: cfg.hsToken,
onEvent: (event) => this.onEvent(event),
onError: (error, txnId) => this.log(`txn ${txnId} handler error: ${String(error)}`),
});
}
/** v1: the daemon only observes; room logic lives in the agent-comms daemons. */
private onEvent(event: MatrixEvent): void {
if (event.type === 'm.room.message') {
this.log(
`event ${event.event_id ?? '?'} in ${event.room_id ?? '?'} from ${event.sender ?? '?'}`,
);
}
}
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
* host tokens win (timing-safe compare); otherwise a magt_* bearer is looked
* up in the agent token store; anything else is rejected. */
private async bridgeAuthorized(
authorizationHeader: string | undefined,
): Promise<BridgePrincipal> {
if (!authorizationHeader?.startsWith('Bearer ')) return null;
const presented = authorizationHeader.slice('Bearer '.length);
if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) {
return { kind: 'host' };
}
const agentUserId = await this.agents.verifyToken(presented);
if (agentUserId) return { kind: 'agent', agentUserId };
return null;
}
async handle(req: DaemonRequest): Promise<DaemonResponse> {
if (req.method === 'GET' && req.path === '/health') {
return { status: 200, body: { ok: true } };
}
const txnMatch = req.method === 'PUT' ? TXN_PATH.exec(req.path) : null;
if (txnMatch?.[1] !== undefined) {
return this.transactions.handle(txnMatch[1], req.body, {
authorizationHeader: req.authorizationHeader,
accessTokenParam: req.searchParams.get('access_token') ?? undefined,
});
}
if (req.path.startsWith('/bridge/v1/')) {
const principal = await this.bridgeAuthorized(req.authorizationHeader);
if (!principal) {
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
}
try {
if (req.method === 'POST' && req.path === '/bridge/v1/agents') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' },
};
}
validateRegisterAgent(req.body);
const { agentUserId, token } = await this.agents.register({
alias: req.body.alias,
host: req.body.host,
displayName: req.body.display_name,
});
this.log(`registered agent ${agentUserId}`);
return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' },
};
}
validateRevokeAgent(req.body);
const revoked = await this.agents.revoke(req.body.agent_user_id);
this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`);
return { status: 200, body: { revoked } };
}
if (req.method === 'GET' && req.path === '/bridge/v1/agents') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' },
};
}
const agents = await this.agents.list();
return { status: 200, body: { agents } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
validateBridgeMessage(req.body);
if (
principal.kind === 'agent' &&
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
) {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
};
}
const eventId = await this.intent.sendAsAgent({
roomId: req.body.room_id,
agent: req.body.agent,
body: req.body.body,
threadRoot: req.body.thread_root,
msgtype: req.body.msgtype,
extraContent: req.body.extra_content,
});
return { status: 200, body: { event_id: eventId ?? null } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
validateBridgeTyping(req.body);
if (
principal.kind === 'agent' &&
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
) {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
};
}
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
return { status: 200, body: {} };
}
if (req.method === 'POST' && req.path === '/bridge/v1/provision/rooms') {
validateProvisionRoom(req.body);
const result = await this.intent.createRoom({
name: req.body.name,
alias: req.body.alias,
topic: req.body.topic,
invite: req.body.invite,
spaceId: req.body.space_id,
});
this.log(
`provisioned room ${result.roomId} (${req.body.name}) space_linked=${result.spaceLinked}`,
);
return {
status: 200,
body: {
room_id: result.roomId,
space_linked: result.spaceLinked,
...(result.spaceError ? { space_error: result.spaceError } : {}),
},
};
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log(`bridge error ${req.method} ${req.path}: ${message}`);
return { status: 400, body: { error: message } };
}
// Explicit: never fall out of the authenticated bridge block, so future
// sub-paths cannot accidentally route around the auth guard above.
return { status: 405, body: { error: 'unsupported bridge method/path' } };
}
return { status: 404, body: { error: 'not found' } };
}
}

View File

@@ -2,8 +2,7 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx"
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]

View File

@@ -1,9 +1,23 @@
{
"name": "@mosaic/gateway",
"version": "0.0.2",
"private": true,
"name": "@mosaicstack/gateway",
"version": "0.0.6",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "apps/gateway"
},
"type": "module",
"main": "dist/main.js",
"bin": {
"mosaic-gateway": "dist/main.js"
},
"files": [
"dist"
],
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"scripts": {
"build": "tsc",
"dev": "tsx watch src/main.ts",
@@ -14,32 +28,36 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@fastify/helmet": "^13.0.2",
"@mariozechner/pi-ai": "~0.57.1",
"@mariozechner/pi-coding-agent": "~0.57.1",
"@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@mosaic/auth": "workspace:^",
"@mosaic/brain": "workspace:^",
"@mosaic/coord": "workspace:^",
"@mosaic/db": "workspace:^",
"@mosaic/discord-plugin": "workspace:^",
"@mosaic/log": "workspace:^",
"@mosaic/memory": "workspace:^",
"@mosaic/queue": "workspace:^",
"@mosaic/telegram-plugin": "workspace:^",
"@mosaic/types": "workspace:^",
"@mosaicstack/agent": "workspace:^",
"@mosaicstack/auth": "workspace:^",
"@mosaicstack/brain": "workspace:^",
"@mosaicstack/config": "workspace:^",
"@mosaicstack/coord": "workspace:^",
"@mosaicstack/db": "workspace:^",
"@mosaicstack/discord-plugin": "workspace:^",
"@mosaicstack/log": "workspace:^",
"@mosaicstack/memory": "workspace:^",
"@mosaicstack/queue": "workspace:^",
"@mosaicstack/storage": "workspace:^",
"@mosaicstack/telegram-plugin": "workspace:^",
"@mosaicstack/types": "workspace:^",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-fastify": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.0.0",
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
"@opentelemetry/auto-instrumentations-node": "^0.72.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
"@opentelemetry/resources": "^2.6.0",
"@opentelemetry/sdk-metrics": "^2.6.0",
"@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@peculiar/x509": "^2.0.0",
"@sinclair/typebox": "^0.34.48",
"better-auth": "^1.5.5",
"bullmq": "^5.71.0",
@@ -47,20 +65,30 @@
"class-validator": "^0.15.1",
"dotenv": "^17.3.1",
"fastify": "^5.0.0",
"ioredis": "^5.10.0",
"jose": "^6.2.2",
"node-cron": "^4.2.1",
"openai": "^6.32.0",
"postgres": "^3.4.8",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"socket.io": "^4.8.0",
"uuid": "^11.0.0",
"undici": "^7.24.6",
"zod": "^4.3.6"
},
"devDependencies": {
"@nestjs/testing": "^11.1.18",
"@swc/core": "^1.15.24",
"@swc/helpers": "^0.5.21",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0",
"supertest": "^7.2.2",
"tsx": "^4.0.0",
"typescript": "^5.8.0",
"unplugin-swc": "^1.5.9",
"vitest": "^2.0.0"
}
}

View File

@@ -12,7 +12,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { ConversationHistoryMessage } from '../agent/agent.service.js';
import { ConversationsController } from '../conversations/conversations.controller.js';
import type { Message } from '@mosaic/brain';
import type { Message } from '@mosaicstack/brain';
// ---------------------------------------------------------------------------
// Shared test data

View File

@@ -18,13 +18,13 @@
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createDb } from '@mosaic/db';
import { createConversationsRepo } from '@mosaic/brain';
import { createAgentsRepo } from '@mosaic/brain';
import { createPreferencesRepo, createInsightsRepo } from '@mosaic/memory';
import { users, conversations, messages, agents, preferences, insights } from '@mosaic/db';
import { eq } from '@mosaic/db';
import type { DbHandle } from '@mosaic/db';
import { createDb } from '@mosaicstack/db';
import { createConversationsRepo } from '@mosaicstack/brain';
import { createAgentsRepo } from '@mosaicstack/brain';
import { createPreferencesRepo, createInsightsRepo } from '@mosaicstack/memory';
import { users, conversations, messages, agents, preferences, insights } from '@mosaicstack/db';
import { eq } from '@mosaicstack/db';
import type { DbHandle } from '@mosaicstack/db';
// ─── Fixed IDs so the afterAll cleanup is deterministic ──────────────────────

View File

@@ -0,0 +1,64 @@
/**
* Test B — Gateway boot refuses (fail-fast) when PG is unreachable.
*
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
* (Valkey must be running; only PG is intentionally misconfigured.)
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.pg-unreachable.integration.test.ts
*
* Skipped when FEDERATED_INTEGRATION !== '1'.
*/
import net from 'node:net';
import { beforeAll, describe, expect, it } from 'vitest';
import { TierDetectionError, detectAndAssertTier } from '@mosaicstack/storage';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const VALKEY_URL = 'redis://localhost:6380';
/**
* Reserves a guaranteed-closed port at runtime by binding to an ephemeral OS
* port (port 0) and immediately releasing it. The OS will not reassign the
* port during the TIME_WAIT window, so it remains closed for the duration of
* this test.
*/
async function reserveClosedPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (typeof addr !== 'object' || !addr) return reject(new Error('no addr'));
const port = addr.port;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}
describe.skipIf(!run)('federated boot — PG unreachable', () => {
let badPgUrl: string;
beforeAll(async () => {
const closedPort = await reserveClosedPort();
badPgUrl = `postgresql://mosaic:mosaic@localhost:${closedPort}/mosaic`;
});
it('detectAndAssertTier throws TierDetectionError with service: postgres when PG is down', async () => {
const brokenConfig = {
tier: 'federated' as const,
storage: {
type: 'postgres' as const,
url: badPgUrl,
enableVector: true,
},
queue: {
type: 'bullmq',
url: VALKEY_URL,
},
};
await expect(detectAndAssertTier(brokenConfig)).rejects.toSatisfy(
(err: unknown) => err instanceof TierDetectionError && err.service === 'postgres',
);
}, 10_000);
});

View File

@@ -0,0 +1,50 @@
/**
* Test A — Gateway boot succeeds when federated services are up.
*
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.success.integration.test.ts
*
* Skipped when FEDERATED_INTEGRATION !== '1'.
*/
import postgres from 'postgres';
import { afterAll, describe, expect, it } from 'vitest';
import { detectAndAssertTier } from '@mosaicstack/storage';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const VALKEY_URL = 'redis://localhost:6380';
const federatedConfig = {
tier: 'federated' as const,
storage: {
type: 'postgres' as const,
url: PG_URL,
enableVector: true,
},
queue: {
type: 'bullmq',
url: VALKEY_URL,
},
};
describe.skipIf(!run)('federated boot — success path', () => {
let sql: ReturnType<typeof postgres> | undefined;
afterAll(async () => {
if (sql) {
await sql.end({ timeout: 2 }).catch(() => {});
}
});
it('detectAndAssertTier resolves without throwing when federated services are up', async () => {
await expect(detectAndAssertTier(federatedConfig)).resolves.toBeUndefined();
}, 10_000);
it('pgvector extension is registered (pg_extension row exists)', async () => {
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
const rows = await sql`SELECT * FROM pg_extension WHERE extname = 'vector'`;
expect(rows).toHaveLength(1);
}, 10_000);
});

View File

@@ -0,0 +1,43 @@
/**
* Test C — pgvector extension is functional end-to-end.
*
* Creates a temp table with a vector(3) column, inserts a row, and queries it
* back — confirming the extension is not just registered but operational.
*
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-pgvector.integration.test.ts
*
* Skipped when FEDERATED_INTEGRATION !== '1'.
*/
import postgres from 'postgres';
import { afterAll, describe, expect, it } from 'vitest';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
let sql: ReturnType<typeof postgres> | undefined;
afterAll(async () => {
if (sql) {
await sql.end({ timeout: 2 }).catch(() => {});
}
});
describe.skipIf(!run)('federated pgvector — functional end-to-end', () => {
it('vector ops round-trip: INSERT [1,2,3] and SELECT returns [1,2,3]', async () => {
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
await sql`CREATE TEMP TABLE t (id int, embedding vector(3))`;
await sql`INSERT INTO t VALUES (1, '[1,2,3]')`;
const rows = await sql`SELECT embedding FROM t`;
expect(rows).toHaveLength(1);
// The postgres driver returns vector columns as strings like '[1,2,3]'.
// Normalise by parsing the string representation.
const raw = rows[0]?.['embedding'] as string;
const parsed = JSON.parse(raw) as number[];
expect(parsed).toEqual([1, 2, 3]);
}, 10_000);
});

View File

@@ -0,0 +1,243 @@
/**
* Federation M2 E2E test — peer-add enrollment flow (FED-M2-10).
*
* Covers MILESTONES.md acceptance test #6:
* "`peer add <url>` on Server A yields an `active` peer record with a valid cert + key"
*
* This test simulates two gateways using a single bootstrapped NestJS app:
* - "Server A": the admin API that generates a keypair and stores the cert
* - "Server B": the enrollment endpoint that signs the CSR
* Both share the same DB + Step-CA in the test environment.
*
* Prerequisites:
* docker compose -f docker-compose.federated.yml --profile federated up -d
*
* Run:
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
* STEP_CA_URL=https://localhost:9000 \
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
* pnpm --filter @mosaicstack/gateway test \
* src/__tests__/integration/federation-m2-e2e.integration.test.ts
*
* Obtaining Step-CA credentials:
* # Extract provisioner key from running container:
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
* # Copy root cert from container:
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
*
* Skipped unless both FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1 are set.
*/
import * as crypto from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test } from '@nestjs/testing';
import { ValidationPipe } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import supertest from 'supertest';
import {
createDb,
type Db,
type DbHandle,
federationPeers,
federationGrants,
federationEnrollmentTokens,
inArray,
eq,
} from '@mosaicstack/db';
import * as schema from '@mosaicstack/db';
import { DB } from '../../database/database.module.js';
import { AdminGuard } from '../../admin/admin.guard.js';
import { FederationModule } from '../../federation/federation.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { EnrollmentService } from '../../federation/enrollment.service.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const stepCaRun =
run &&
process.env['STEP_CA_AVAILABLE'] === '1' &&
!!process.env['STEP_CA_URL'] &&
!!process.env['STEP_CA_PROVISIONER_KEY_JSON'] &&
!!process.env['STEP_CA_ROOT_CERT_PATH'];
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = crypto.randomUUID();
describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () => {
let handle: DbHandle;
let db: Db;
let app: NestFastifyApplication;
let agent: ReturnType<typeof supertest>;
let grantsService: GrantsService;
let enrollmentService: EnrollmentService;
const createdTokenGrantIds: string[] = [];
const createdGrantIds: string[] = [];
const createdPeerIds: string[] = [];
const createdUserIds: string[] = [];
beforeAll(async () => {
process.env['BETTER_AUTH_SECRET'] ??= 'test-e2e-sealing-key';
handle = createDb(PG_URL);
db = handle.db;
const moduleRef = await Test.createTestingModule({
imports: [FederationModule],
providers: [{ provide: DB, useValue: db }],
})
.overrideGuard(AdminGuard)
.useValue({ canActivate: () => true })
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
await app.getHttpAdapter().getInstance().ready();
agent = supertest(app.getHttpServer());
grantsService = moduleRef.get(GrantsService);
enrollmentService = moduleRef.get(EnrollmentService);
}, 30_000);
afterAll(async () => {
if (db && createdTokenGrantIds.length > 0) {
await db
.delete(federationEnrollmentTokens)
.where(inArray(federationEnrollmentTokens.grantId, createdTokenGrantIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (db && createdGrantIds.length > 0) {
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (db && createdPeerIds.length > 0) {
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, createdPeerIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (db && createdUserIds.length > 0) {
await db
.delete(schema.users)
.where(inArray(schema.users.id, createdUserIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (app)
await app.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
if (handle)
await handle.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
});
// -------------------------------------------------------------------------
// #6 — peer add: keypair → enrollment → cert storage → active peer record
// -------------------------------------------------------------------------
it('#6 — peer add flow: keypair → enrollment → cert storage → active peer record', async () => {
// Create a subject user to satisfy FK on federation_grants.subject_user_id
const userId = crypto.randomUUID();
await db
.insert(schema.users)
.values({
id: userId,
name: `e2e-user-${RUN_ID}`,
email: `e2e-${RUN_ID}@federation-test.invalid`,
emailVerified: false,
})
.onConflictDoNothing();
createdUserIds.push(userId);
// ── Step A: "Server B" setup ─────────────────────────────────────────
// Server B admin creates a grant and generates an enrollment token to
// share out-of-band with Server A's operator.
// Insert a placeholder peer on "Server B" to satisfy the grant FK
const serverBPeerId = crypto.randomUUID();
await db
.insert(federationPeers)
.values({
id: serverBPeerId,
commonName: `server-b-peer-${RUN_ID}`,
displayName: 'Server B Placeholder',
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `serial-b-${serverBPeerId}`,
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
state: 'pending',
})
.onConflictDoNothing();
createdPeerIds.push(serverBPeerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
peerId: serverBPeerId,
});
createdGrantIds.push(grant.id);
createdTokenGrantIds.push(grant.id);
const { token } = await enrollmentService.createToken({
grantId: grant.id,
peerId: serverBPeerId,
ttlSeconds: 900,
});
// ── Step B: "Server A" generates keypair ─────────────────────────────
const keypairRes = await agent
.post('/api/admin/federation/peers/keypair')
.send({
commonName: `e2e-peer-${RUN_ID.slice(0, 8)}`,
displayName: 'E2E Test Peer',
endpointUrl: 'https://test.invalid',
})
.set('Content-Type', 'application/json');
expect(keypairRes.status).toBe(201);
const { peerId, csrPem } = keypairRes.body as { peerId: string; csrPem: string };
expect(typeof peerId).toBe('string');
expect(csrPem).toContain('-----BEGIN CERTIFICATE REQUEST-----');
createdPeerIds.push(peerId);
// ── Step C: Enrollment (simulates Server A sending CSR to Server B) ──
const enrollRes = await agent
.post(`/api/federation/enrollment/${token}`)
.send({ csrPem })
.set('Content-Type', 'application/json');
expect(enrollRes.status).toBe(200);
const { certPem, certChainPem } = enrollRes.body as {
certPem: string;
certChainPem: string;
};
expect(certPem).toContain('-----BEGIN CERTIFICATE-----');
expect(certChainPem).toContain('-----BEGIN CERTIFICATE-----');
// ── Step D: "Server A" stores the cert ───────────────────────────────
const storeRes = await agent
.patch(`/api/admin/federation/peers/${peerId}/cert`)
.send({ certPem })
.set('Content-Type', 'application/json');
expect(storeRes.status).toBe(200);
// ── Step E: Verify peer record in DB ─────────────────────────────────
const [peer] = await db
.select()
.from(federationPeers)
.where(eq(federationPeers.id, peerId))
.limit(1);
expect(peer).toBeDefined();
expect(peer?.state).toBe('active');
expect(peer?.certPem).toContain('-----BEGIN CERTIFICATE-----');
expect(typeof peer?.certSerial).toBe('string');
expect((peer?.certSerial ?? '').length).toBeGreaterThan(0);
// clientKeyPem is a sealed ciphertext — must not be a raw PEM
expect(peer?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
// certNotAfter must be in the future
expect(peer?.certNotAfter?.getTime()).toBeGreaterThan(Date.now());
}, 60_000);
});

View File

@@ -0,0 +1,483 @@
/**
* Federation M2 integration tests (FED-M2-09).
*
* Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
*
* Prerequisites:
* docker compose -f docker-compose.federated.yml --profile federated up -d
*
* Run DB-only tests (no Step-CA):
* FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \
* src/__tests__/integration/federation-m2.integration.test.ts
*
* Run all tests including Step-CA-dependent ones:
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
* STEP_CA_URL=https://localhost:9000 \
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
* pnpm --filter @mosaicstack/gateway test \
* src/__tests__/integration/federation-m2.integration.test.ts
*
* Obtaining Step-CA credentials:
* # Extract provisioner key from running container:
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
* # Copy root cert from container:
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
*/
import * as crypto from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test } from '@nestjs/testing';
import { GoneException } from '@nestjs/common';
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
import {
createDb,
type Db,
type DbHandle,
federationPeers,
federationGrants,
federationEnrollmentTokens,
inArray,
eq,
} from '@mosaicstack/db';
import * as schema from '@mosaicstack/db';
import { seal } from '@mosaicstack/auth';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { EnrollmentService } from '../../federation/enrollment.service.js';
import { CaService } from '../../federation/ca.service.js';
import { FederationScopeError } from '../../federation/scope-schema.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
// ---------------------------------------------------------------------------
// Helpers for test data isolation
// ---------------------------------------------------------------------------
/** Unique run prefix to identify rows created by this test run. */
const RUN_ID = crypto.randomUUID();
/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
async function insertTestUser(db: Db, id: string): Promise<void> {
await db
.insert(schema.users)
.values({
id,
name: `test-user-${id}`,
email: `test-${id}@federation-test.invalid`,
emailVerified: false,
})
.onConflictDoNothing();
}
/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
await db
.insert(federationPeers)
.values({
id,
commonName: `test-peer-${RUN_ID}-${suffix}`,
displayName: `Test Peer ${suffix}`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `test-serial-${id}`,
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
state: 'pending',
})
.onConflictDoNothing();
}
// ---------------------------------------------------------------------------
// DB-only test module (CaService mocked so env vars not required)
// ---------------------------------------------------------------------------
function buildDbModule(db: Db) {
return Test.createTestingModule({
providers: [
{ provide: DB, useValue: db },
GrantsService,
{
provide: CaService,
useValue: {
issueCert: async () => {
throw new Error('CaService.issueCert should not be called in DB-only tests');
},
},
},
EnrollmentService,
],
}).compile();
}
// ---------------------------------------------------------------------------
// Test suite — DB-only (no Step-CA)
// ---------------------------------------------------------------------------
describe.skipIf(!run)('federation M2 — DB-only tests', () => {
let handle: DbHandle;
let db: Db;
let grantsService: GrantsService;
/** IDs created during this run — cleaned up in afterAll. */
const createdGrantIds: string[] = [];
const createdPeerIds: string[] = [];
const createdUserIds: string[] = [];
beforeAll(async () => {
process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod';
handle = createDb(PG_URL);
db = handle.db;
const moduleRef = await buildDbModule(db);
grantsService = moduleRef.get(GrantsService);
});
afterAll(async () => {
// Clean up in FK-safe order: tokens → grants → peers → users
if (db && createdGrantIds.length > 0) {
await db
.delete(federationEnrollmentTokens)
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdPeerIds.length > 0) {
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, createdPeerIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdUserIds.length > 0) {
await db
.delete(schema.users)
.where(inArray(schema.users.id, createdUserIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (handle)
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
});
// -------------------------------------------------------------------------
// #1 — grant create writes a pending row
// -------------------------------------------------------------------------
it('#1 — createGrant writes a pending row to DB', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 100,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'test1');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
createdGrantIds.push(grant.id);
// Verify the row exists in DB with correct shape
const [row] = await db
.select()
.from(federationGrants)
.where(eq(federationGrants.id, grant.id))
.limit(1);
expect(row).toBeDefined();
expect(row?.status).toBe('pending');
expect(row?.peerId).toBe(peerId);
expect(row?.subjectUserId).toBe(userId);
const storedScope = row?.scope as Record<string, unknown>;
expect(storedScope['resources']).toEqual(['tasks']);
expect(storedScope['max_rows_per_query']).toBe(100);
}, 15_000);
// -------------------------------------------------------------------------
// #7 — scope with unknown resource type rejected
// -------------------------------------------------------------------------
it('#7 — createGrant rejects scope with unknown resource type', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const invalidScope = {
resources: ['totally_unknown_resource'],
excluded_resources: [],
max_rows_per_query: 100,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'test7');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
await expect(
grantsService.createGrant({
subjectUserId: userId,
scope: invalidScope,
peerId,
}),
).rejects.toThrow(FederationScopeError);
}, 15_000);
// -------------------------------------------------------------------------
// #8 — listGrants returns accurate status for grants in various states
// -------------------------------------------------------------------------
it('#8 — listGrants returns accurate status for grants in various states', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['notes'],
excluded_resources: [],
max_rows_per_query: 50,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'test8');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
// Create two pending grants via GrantsService
const grantA = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
const grantB = await grantsService.createGrant({
subjectUserId: userId,
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
peerId,
});
createdGrantIds.push(grantA.id, grantB.id);
// Insert a third grant directly in 'revoked' state to test status variety
const [grantC] = await db
.insert(federationGrants)
.values({
id: crypto.randomUUID(),
subjectUserId: userId,
peerId,
scope: validScope,
status: 'revoked',
revokedAt: new Date(),
})
.returning();
createdGrantIds.push(grantC!.id);
// List all grants for this peer
const allForPeer = await grantsService.listGrants({ peerId });
const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
expect(ourGrants).toHaveLength(3);
const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
expect(pendingGrants).toHaveLength(2);
expect(revokedGrants).toHaveLength(1);
// Status-filtered query
const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
expect(ourPending.every((g) => g.status === 'pending')).toBe(true);
// Verify peer list from DB also shows the peer rows with correct state
const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
expect(peers).toHaveLength(1);
expect(peers[0]?.state).toBe('pending');
}, 15_000);
// -------------------------------------------------------------------------
// #5 — client_key_pem encrypted at rest
// -------------------------------------------------------------------------
it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
const peerId = crypto.randomUUID();
const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
const sealed = seal(rawPem);
await db.insert(federationPeers).values({
id: peerId,
commonName: `test-peer-${RUN_ID}-sealed`,
displayName: 'Sealed Key Test Peer',
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `test-serial-sealed-${peerId}`,
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
state: 'pending',
clientKeyPem: sealed,
});
createdPeerIds.push(peerId);
const [row] = await db
.select()
.from(federationPeers)
.where(eq(federationPeers.id, peerId))
.limit(1);
expect(row).toBeDefined();
// The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
expect(row?.clientKeyPem).toBeDefined();
expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
// The sealed value should be non-trivial (at least 20 chars)
expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
}, 15_000);
});
// ---------------------------------------------------------------------------
// Test suite — Step-CA gated
// ---------------------------------------------------------------------------
describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
let handle: DbHandle;
let db: Db;
let grantsService: GrantsService;
let enrollmentService: EnrollmentService;
const createdGrantIds: string[] = [];
const createdPeerIds: string[] = [];
const createdUserIds: string[] = [];
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
// Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
// STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
const moduleRef = await Test.createTestingModule({
providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
}).compile();
grantsService = moduleRef.get(GrantsService);
enrollmentService = moduleRef.get(EnrollmentService);
});
afterAll(async () => {
if (db && createdGrantIds.length > 0) {
await db
.delete(federationEnrollmentTokens)
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdPeerIds.length > 0) {
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, createdPeerIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdUserIds.length > 0) {
await db
.delete(schema.users)
.where(inArray(schema.users.id, createdUserIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (handle)
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
});
/** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
async function generateCsrPem(cn: string): Promise<string> {
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
const csr = await Pkcs10CertificateRequestGenerator.create({
name: `CN=${cn}`,
keys: keyPair,
signingAlgorithm: alg,
});
return csr.toString('pem');
}
// -------------------------------------------------------------------------
// #2 — enrollment signs CSR and returns cert
// -------------------------------------------------------------------------
it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 100,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'ca-test2');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
createdGrantIds.push(grant.id);
const { token } = await enrollmentService.createToken({
grantId: grant.id,
peerId,
ttlSeconds: 900,
});
const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
const result = await enrollmentService.redeem(token, csrPem);
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');
// Verify the issued cert parses cleanly
const cert = new PeculiarX509(result.certPem);
expect(cert.serialNumber).toBeTruthy();
}, 30_000);
// -------------------------------------------------------------------------
// #3 — token single-use; second attempt returns GoneException
// -------------------------------------------------------------------------
it('#3 — second redeem of the same token throws GoneException', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['notes'],
excluded_resources: [],
max_rows_per_query: 50,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'ca-test3');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
createdGrantIds.push(grant.id);
const { token } = await enrollmentService.createToken({
grantId: grant.id,
peerId,
ttlSeconds: 900,
});
const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);
// First redeem must succeed
const result = await enrollmentService.redeem(token, csrPem);
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
// Second redeem with the same token must be rejected
await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
}, 30_000);
});

View File

@@ -0,0 +1,194 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
import {
createDiscordIngressEnvelope,
DiscordPlugin,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import { InteractionController } from '../../agent/interaction.controller.js';
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
import { DurableSessionService } from '../../agent/durable-session.service.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
const SERVICE_TOKEN = 'test-discord-service-token';
const envKeys = [
'DISCORD_SERVICE_TOKEN',
'DISCORD_SERVICE_TENANT_ID',
'DISCORD_INTERACTION_BINDINGS',
'DISCORD_ALLOWED_GUILD_IDS',
'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
] as const;
const priorEnv = new Map<string, string | undefined>();
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
return {
content,
messageId,
correlationId,
guildId: 'guild-1',
channelId: 'channel-1',
userId: 'discord-admin-1',
conversationId: 'Nova:discord:channel-1',
};
}
function authorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
return new CommandAuthorizationService(
{
select: () => ({
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
}),
} as never,
{
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => entries.set(key, value),
del: async (key: string) => Number(entries.delete(key)),
},
);
}
describe('interaction Discord/CLI durable-session integration', () => {
afterEach(() => {
for (const key of envKeys) {
const value = priorEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
priorEnv.clear();
});
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
for (const key of envKeys) priorEnv.set(key, process.env[key]);
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
},
},
]);
const durable = new DurableSessionService(
new InMemoryDurableSessionStore() as never,
{} as never,
);
const enrollmentRuntime = {
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
};
const controller = new InteractionController(enrollmentRuntime as never, durable);
await controller.enroll(
'Nova',
'Nova:discord:channel-1',
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
'cli-enrollment-correlation',
);
const authz = authorization();
const terminated = vi.fn().mockResolvedValue(undefined);
const runtime = new RuntimeProviderService(
{
require: () => ({
capabilities: async () => ({ supported: ['session.terminate'] }),
terminate: terminated,
}),
} as never,
{ record: async () => undefined } as never,
{
consume: (approvalId, action) =>
authz.consumeRuntimeTerminationApproval(approvalId, action),
},
);
const gateway = new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
authz,
runtime,
durable,
);
const client = { data: { discordService: true }, emit: vi.fn() };
const plugin = new DiscordPlugin({
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-1'],
allowedChannelIds: ['channel-1'],
allowedUserIds: ['discord-admin-1'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
},
},
],
});
const pluginInternals = plugin as unknown as {
client: { user: { id: string } };
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
handleDiscordMessage(message: unknown): void;
};
const pluginSocket = { connected: true, emit: vi.fn() };
pluginInternals.client = { user: { id: 'bot-1' } };
pluginInternals.socket = pluginSocket;
pluginInternals.handleDiscordMessage({
id: 'approve-1',
guildId: 'guild-1',
channelId: 'channel-1',
author: { id: 'discord-admin-1', bot: false },
mentions: { has: () => true },
content: '<@bot-1> /approve',
channel: { parentId: null },
attachments: new Map(),
});
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
success: boolean;
};
expect(approval.success).toBe(true);
await gateway.handleDiscordStop(
client as never,
createDiscordIngressEnvelope(
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
SERVICE_TOKEN,
),
);
expect(terminated).toHaveBeenCalledWith(
'runtime-1',
approval.approvalId,
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
);
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
correlationId: 'discord-stop-correlation',
success: true,
});
});
});

View File

@@ -1,6 +1,6 @@
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaic/db';
import { createQueue } from '@mosaic/queue';
import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue';
import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
@@ -20,7 +20,7 @@ export class AdminHealthController {
async check(): Promise<HealthStatusDto> {
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
const sessions = this.agentService.listSessions();
const sessions = this.agentService.listAllSessionsForSystem();
const providers = this.providerService.listProviders();
const allOk = database.status === 'ok' && cache.status === 'ok';

View File

@@ -0,0 +1,90 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Inject,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto';
import { eq, type Db, adminTokens } from '@mosaicstack/db';
import { v4 as uuid } from 'uuid';
import { DB } from '../database/database.module.js';
import { AdminGuard } from './admin.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import type {
CreateTokenDto,
TokenCreatedDto,
TokenDto,
TokenListDto,
} from './admin-tokens.dto.js';
function hashToken(plaintext: string): string {
return createHash('sha256').update(plaintext).digest('hex');
}
function toTokenDto(row: typeof adminTokens.$inferSelect): TokenDto {
return {
id: row.id,
label: row.label,
scope: row.scope,
expiresAt: row.expiresAt?.toISOString() ?? null,
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
@Controller('api/admin/tokens')
@UseGuards(AdminGuard)
export class AdminTokensController {
constructor(@Inject(DB) private readonly db: Db) {}
@Post()
async create(
@Body() dto: CreateTokenDto,
@CurrentUser() user: { id: string },
): Promise<TokenCreatedDto> {
const plaintext = randomBytes(32).toString('hex');
const tokenHash = hashToken(plaintext);
const id = uuid();
const expiresAt = dto.expiresInDays
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
: null;
const [row] = await this.db
.insert(adminTokens)
.values({
id,
userId: user.id,
tokenHash,
label: dto.label ?? 'CLI token',
scope: dto.scope ?? 'admin',
expiresAt,
})
.returning();
return { ...toTokenDto(row!), plaintext };
}
@Get()
async list(@CurrentUser() user: { id: string }): Promise<TokenListDto> {
const rows = await this.db
.select()
.from(adminTokens)
.where(eq(adminTokens.userId, user.id))
.orderBy(adminTokens.createdAt);
return { tokens: rows.map(toTokenDto), total: rows.length };
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async revoke(@Param('id') id: string, @CurrentUser() _user: { id: string }): Promise<void> {
await this.db.delete(adminTokens).where(eq(adminTokens.id, id));
}
}

View File

@@ -0,0 +1,33 @@
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
export class CreateTokenDto {
@IsString()
label!: string;
@IsOptional()
@IsString()
scope?: string;
@IsOptional()
@IsInt()
@Min(1)
expiresInDays?: number;
}
export interface TokenDto {
id: string;
label: string;
scope: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
}
export interface TokenCreatedDto extends TokenDto {
plaintext: string;
}
export interface TokenListDto {
tokens: TokenDto[];
total: number;
}

View File

@@ -13,8 +13,8 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { eq, type Db, users as usersTable } from '@mosaic/db';
import type { Auth } from '@mosaic/auth';
import { eq, type Db, users as usersTable } from '@mosaicstack/db';
import type { Auth } from '@mosaicstack/auth';
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
import { AdminGuard } from './admin.guard.js';

View File

@@ -6,10 +6,11 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaic/auth';
import type { Db } from '@mosaic/db';
import { eq, users as usersTable } from '@mosaic/db';
import type { Auth } from '@mosaicstack/auth';
import type { Db } from '@mosaicstack/db';
import { eq, adminTokens, users as usersTable } from '@mosaicstack/db';
import type { FastifyRequest } from 'fastify';
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
@@ -19,6 +20,8 @@ interface UserWithRole {
role?: string;
}
type AuthenticatedRequest = FastifyRequest & { user: unknown; session: unknown };
@Injectable()
export class AdminGuard implements CanActivate {
constructor(
@@ -28,8 +31,64 @@ export class AdminGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const headers = fromNodeHeaders(request.raw.headers);
// Try bearer token auth first
const authHeader = request.raw.headers['authorization'];
if (authHeader?.startsWith('Bearer ')) {
return this.validateBearerToken(request, authHeader.slice(7));
}
// Fall back to BetterAuth session
return this.validateSession(request);
}
private async validateBearerToken(request: FastifyRequest, plaintext: string): Promise<boolean> {
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const [row] = await this.db
.select({
tokenId: adminTokens.id,
userId: adminTokens.userId,
scope: adminTokens.scope,
expiresAt: adminTokens.expiresAt,
userName: usersTable.name,
userEmail: usersTable.email,
userRole: usersTable.role,
})
.from(adminTokens)
.innerJoin(usersTable, eq(adminTokens.userId, usersTable.id))
.where(eq(adminTokens.tokenHash, tokenHash))
.limit(1);
if (!row) {
throw new UnauthorizedException('Invalid API token');
}
if (row.expiresAt && row.expiresAt < new Date()) {
throw new UnauthorizedException('API token expired');
}
if (row.userRole !== 'admin') {
throw new ForbiddenException('Admin access required');
}
// Update last-used timestamp (fire-and-forget)
this.db
.update(adminTokens)
.set({ lastUsedAt: new Date() })
.where(eq(adminTokens.id, row.tokenId))
.then(() => {})
.catch(() => {});
const req = request as AuthenticatedRequest;
req.user = { id: row.userId, name: row.userName, email: row.userEmail, role: row.userRole };
req.session = { id: `token:${row.tokenId}`, userId: row.userId };
return true;
}
private async validateSession(request: FastifyRequest): Promise<boolean> {
const headers = fromNodeHeaders(request.raw.headers);
const result = await this.auth.api.getSession({ headers });
if (!result) {
@@ -38,8 +97,6 @@ export class AdminGuard implements CanActivate {
const user = result.user as UserWithRole;
// Ensure the role field is populated. better-auth should include additionalFields
// in the session, but as a fallback, fetch the role from the database if needed.
let userRole = user.role;
if (!userRole) {
const [dbUser] = await this.db
@@ -48,7 +105,6 @@ export class AdminGuard implements CanActivate {
.where(eq(usersTable.id, user.id))
.limit(1);
userRole = dbUser?.role ?? 'member';
// Update the session user object with the fetched role
(user as UserWithRole).role = userRole;
}
@@ -56,8 +112,9 @@ export class AdminGuard implements CanActivate {
throw new ForbiddenException('Admin access required');
}
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
const req = request as AuthenticatedRequest;
req.user = result.user;
req.session = result.session;
return true;
}

View File

@@ -2,10 +2,18 @@ import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller.js';
import { AdminHealthController } from './admin-health.controller.js';
import { AdminJobsController } from './admin-jobs.controller.js';
import { AdminTokensController } from './admin-tokens.controller.js';
import { BootstrapController } from './bootstrap.controller.js';
import { AdminGuard } from './admin.guard.js';
@Module({
controllers: [AdminController, AdminHealthController, AdminJobsController],
controllers: [
AdminController,
AdminHealthController,
AdminJobsController,
AdminTokensController,
BootstrapController,
],
providers: [AdminGuard],
})
export class AdminModule {}

View File

@@ -0,0 +1,102 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Inject,
InternalServerErrorException,
Post,
} from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto';
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
import type { Auth } from '@mosaicstack/auth';
import { v4 as uuid } from 'uuid';
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
import { BootstrapSetupDto } from './bootstrap.dto.js';
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
@Controller('api/bootstrap')
export class BootstrapController {
constructor(
@Inject(AUTH) private readonly auth: Auth,
@Inject(DB) private readonly db: Db,
) {}
@Get('status')
async status(): Promise<BootstrapStatusDto> {
const [result] = await this.db.select({ total: count() }).from(usersTable);
return { needsSetup: (result?.total ?? 0) === 0 };
}
@Post('setup')
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
// Only allow setup when zero users exist
const [result] = await this.db.select({ total: count() }).from(usersTable);
if ((result?.total ?? 0) > 0) {
throw new ForbiddenException('Setup already completed — users exist');
}
// Create admin user via BetterAuth API
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string };
}>;
};
const created = await authApi.createUser({
body: {
name: dto.name,
email: dto.email,
password: dto.password,
role: 'admin',
},
});
// Verify user was created
const [user] = await this.db
.select()
.from(usersTable)
.where(eq(usersTable.id, created.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found');
// Ensure role is admin (createUser may not set it via BetterAuth)
if (user.role !== 'admin') {
await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
}
// Generate admin API token
const plaintext = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const tokenId = uuid();
const [token] = await this.db
.insert(adminTokens)
.values({
id: tokenId,
userId: user.id,
tokenHash,
label: 'Initial setup token',
scope: 'admin',
})
.returning();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: 'admin',
},
token: {
id: token!.id,
plaintext,
label: token!.label,
},
};
}
}

View File

@@ -0,0 +1,31 @@
import { IsString, IsEmail, MinLength } from 'class-validator';
export class BootstrapSetupDto {
@IsString()
name!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}
export interface BootstrapStatusDto {
needsSetup: boolean;
}
export interface BootstrapResultDto {
user: {
id: string;
name: string;
email: string;
role: string;
};
token: {
id: string;
plaintext: string;
label: string;
};
}

View File

@@ -0,0 +1,190 @@
/**
* E2E integration test — POST /api/bootstrap/setup
*
* Regression guard for the `import type { BootstrapSetupDto }` class-erasure
* bug (IUV-M01, issue #436).
*
* When `BootstrapSetupDto` is imported with `import type`, TypeScript erases
* the class at compile time. NestJS then sees `Object` as the `@Body()`
* metatype, and ValidationPipe with `whitelist:true + forbidNonWhitelisted:true`
* treats every property as non-whitelisted, returning:
*
* 400 { message: ["property email should not exist", "property password should not exist"] }
*
* The fix is a plain value import (`import { BootstrapSetupDto }`), which
* preserves the class reference so Nest can read the class-validator decorators.
*
* This test MUST fail if `import type` is re-introduced on `BootstrapSetupDto`.
* A controller unit test that constructs ValidationPipe manually won't catch
* this — only the real DI binding path exercises the metatype lookup.
*/
import 'reflect-metadata';
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import { Test } from '@nestjs/testing';
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { BootstrapController } from './bootstrap.controller.js';
import type { BootstrapResultDto } from './bootstrap.dto.js';
// ─── Minimal mock dependencies ───────────────────────────────────────────────
/**
* We use explicit `@Inject(AUTH)` / `@Inject(DB)` in the controller so we
* can provide mock values by token without spinning up the real DB or Auth.
*/
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
const MOCK_USER_ID = 'mock-user-id-001';
const mockAuth = {
api: {
createUser: () =>
Promise.resolve({
user: {
id: MOCK_USER_ID,
name: 'Admin',
email: 'admin@example.com',
},
}),
},
};
// Override db.select() so the second query (verify user exists) returns a user.
// The bootstrap controller calls select().from() twice:
// 1. count() to check zero users → returns [{total: 0}]
// 2. select().where().limit() → returns [the created user]
let selectCallCount = 0;
const mockDbWithUser = {
select: () => {
selectCallCount++;
return {
from: () => {
if (selectCallCount === 1) {
// First call: count — zero users
return Promise.resolve([{ total: 0 }]);
}
// Subsequent calls: return a mock user row
return {
where: () => ({
limit: () =>
Promise.resolve([
{
id: MOCK_USER_ID,
name: 'Admin',
email: 'admin@example.com',
role: 'admin',
},
]),
}),
};
},
};
},
update: () => ({
set: () => ({
where: () => Promise.resolve([]),
}),
}),
insert: () => ({
values: () => ({
returning: () =>
Promise.resolve([
{
id: 'token-id-001',
label: 'Initial setup token',
},
]),
}),
}),
};
// ─── Test suite ───────────────────────────────────────────────────────────────
describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
let app: INestApplication;
beforeAll(async () => {
selectCallCount = 0;
const moduleRef = await Test.createTestingModule({
controllers: [BootstrapController],
providers: [
{ provide: AUTH, useValue: mockAuth },
{ provide: DB, useValue: mockDbWithUser },
],
}).compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
// Mirror main.ts configuration exactly — this is what reproduced the 400.
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
// Fastify requires waiting for the adapter to be ready
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app.close();
});
it('returns 201 (not 400) when a valid {name, email, password} body is sent', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: 'admin@example.com', password: 'password123' })
.set('Content-Type', 'application/json');
// Before the fix (import type), Nest ValidationPipe returned 400 with
// "property email should not exist" / "property password should not exist"
// because the DTO class was erased and every field looked non-whitelisted.
expect(res.status).not.toBe(400);
expect(res.status).toBe(201);
const body = res.body as BootstrapResultDto;
expect(body.user).toBeDefined();
expect(body.user.email).toBe('admin@example.com');
expect(body.token).toBeDefined();
expect(body.token.plaintext).toBeDefined();
});
it('returns 400 when extra forbidden properties are sent', async () => {
// This proves ValidationPipe IS active and working (forbidNonWhitelisted).
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({
name: 'Admin',
email: 'admin@example.com',
password: 'password123',
extraField: 'should-be-rejected',
})
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
});
it('returns 400 when email is invalid', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: 'not-an-email', password: 'password123' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
});
it('returns 400 when password is too short', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: 'admin@example.com', password: 'short' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
});
});

View File

@@ -0,0 +1,191 @@
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { AgentService, type AgentSession } from '../agent.service.js';
import type { ActorTenantScope } from '../../auth/session-scope.js';
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
type AgentServiceInternals = {
sessions: Map<string, AgentSession>;
creating: Map<string, Promise<AgentSession>>;
};
function makeService(operatorMemory: unknown = null): AgentService {
return new AgentService(
{
getDefaultModel: vi.fn(() => null),
getRegistry: vi.fn(() => ({})),
findModel: vi.fn(),
listAvailableModels: vi.fn(() => []),
} as never,
{} as never,
{} as never,
{ available: false } as never,
{} as never,
{ getToolDefinitions: vi.fn(() => []) } as never,
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
);
}
function internals(service: AgentService): AgentServiceInternals {
return service as unknown as AgentServiceInternals;
}
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp/tess-session-ownership-test',
allowedTools: null,
userId: scope.userId,
tenantId: scope.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
describe('AgentService owner/tenant scope enforcement', () => {
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
const service = makeService();
const session = makeSession();
internals(service).sessions.set(CONVERSATION_ID, session);
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
expect(session.channels.has('websocket:owner')).toBe(true);
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() =>
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
expect(session.modelId).toBe('owner-model');
expect(() =>
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
expect(session.agentConfigId).toBe('agent-owner');
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
cleanup();
await expect(
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
await service.prompt(CONVERSATION_ID, '', OWNER_SCOPE, [
{
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
mimeType: 'image/png',
},
]);
expect(session.piSession.prompt).toHaveBeenLastCalledWith(
'\n\n[Untrusted channel attachments]\n' +
'{"id":"attachment-001","name":"diagram.png","mimeType":"image/png","url":"https://cdn.example.test/diagram.png"}',
);
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
expect(session.piSession.dispose).toHaveBeenCalled();
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
});
it('derives the operator-memory scope on the createSession production path', async () => {
const plugin = { capture: vi.fn(), search: vi.fn() };
const service = makeService(plugin);
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]);
// Session construction reaches the real scope derivation before the intentionally incomplete
// Pi test double rejects later in createAgentSession.
await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined);
expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, {
tenantId: OWNER_SCOPE.tenantId,
ownerId: OWNER_SCOPE.userId,
sessionId: CONVERSATION_ID,
});
});
it('denies a foreign actor before it can obtain another session operator-memory scope', async () => {
const plugin = { capture: vi.fn(), search: vi.fn() };
const service = makeService(plugin);
internals(service).sessions.set(CONVERSATION_ID, makeSession());
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox');
await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(buildTools).not.toHaveBeenCalled();
expect(plugin.capture).not.toHaveBeenCalled();
expect(plugin.search).not.toHaveBeenCalled();
});
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
const service = makeService();
const session = makeSession();
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
await expect(
service.createSession(CONVERSATION_ID, {
userId: FOREIGN_SCOPE.userId,
tenantId: FOREIGN_SCOPE.tenantId,
}),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
service.createSession(CONVERSATION_ID, {
userId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
}),
).resolves.toBe(session);
});
});

View File

@@ -62,7 +62,7 @@ function restoreEnv(saved: Map<EnvKey, string | undefined>): void {
}
function makeRegistry(): ModelRegistry {
return new ModelRegistry(AuthStorage.inMemory());
return ModelRegistry.inMemory(AuthStorage.inMemory());
}
// ---------------------------------------------------------------------------

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RoutingService } from '../routing.service.js';
import type { ModelInfo } from '@mosaic/types';
import type { ModelInfo } from '@mosaicstack/types';
const mockModels: ModelInfo[] = [
{

View File

@@ -0,0 +1,370 @@
import { describe, expect, it } from 'vitest';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import type { ActorTenantScope } from '../../auth/session-scope.js';
import {
RuntimeProviderAuditService,
RuntimeProviderService,
type RuntimeAuditEvent,
type RuntimeAuditSink,
type RuntimeApprovalVerifier,
} from '../runtime-provider-registry.service.js';
process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
const CONTEXT = {
actorScope: OWNER_SCOPE,
channelId: 'cli',
correlationId: 'correlation-1',
};
class RecordingRuntimeProvider implements AgentRuntimeProvider {
readonly id = 'fleet';
readonly receivedScopes: RuntimeScope[] = [];
readonly sentMessages: RuntimeMessage[] = [];
terminateCalls = 0;
throwAfterSend = false;
throwAuthorization = false;
constructor(private readonly supported: RuntimeCapability[]) {}
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
this.receivedScopes.push(scope);
return { supported: this.supported };
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
this.receivedScopes.push(scope);
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
this.receivedScopes.push(scope);
return [];
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
this.receivedScopes.push(scope);
return [];
}
async *streamSession(
_sessionId: string,
_cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
this.receivedScopes.push(scope);
return;
}
async sendMessage(
_sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
this.receivedScopes.push(scope);
this.sentMessages.push(message);
if (this.throwAuthorization) {
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
}
if (this.throwAfterSend) {
throw new Error('provider acknowledgement failed');
}
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
this.receivedScopes.push(scope);
return {
attachmentId: 'attachment-1',
sessionId,
mode,
expiresAt: '2026-07-12T00:00:00.000Z',
};
}
async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
}
async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
this.terminateCalls += 1;
}
}
class RecordingAuditSink implements RuntimeAuditSink {
readonly events: RuntimeAuditEvent[] = [];
async record(event: RuntimeAuditEvent): Promise<void> {
this.events.push(event);
}
}
class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
async consume(): Promise<boolean> {
return false;
}
}
class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;
async consume(
_approvalRef: string,
action: Parameters<RuntimeApprovalVerifier['consume']>[1],
): Promise<boolean> {
this.consumedAction = action;
return true;
}
}
function makeService(
provider: RecordingRuntimeProvider,
audit: RuntimeAuditSink = new RecordingAuditSink(),
approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
): RuntimeProviderService {
const registry = new AgentRuntimeProviderRegistry();
registry.register(provider);
return new RuntimeProviderService(registry, audit, approval);
}
describe('RuntimeProviderService security boundary', (): void => {
it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
);
const providerScope = provider.receivedScopes[0];
expect(providerScope).toEqual({
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
});
expect(Object.isFrozen(providerScope)).toBe(true);
expect(audit.events).toContainEqual(
expect.objectContaining({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
resourceId: 'session-1',
durationMs: expect.any(Number),
}),
);
expect(JSON.stringify(audit.events)).not.toContain('hello');
expect(JSON.stringify(audit.events)).not.toContain('key-1');
});
it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let persisted: unknown;
const durableAudit = new RuntimeProviderAuditService({
logs: {
ingest: async (entry: unknown): Promise<unknown> => {
persisted = entry;
return entry;
},
},
} as never);
const service = makeService(provider, durableAudit);
await service.sendMessage(
'fleet',
'session/credential-canary=secret-value',
{ content: 'safe message', idempotencyKey: 'key-1' },
CONTEXT,
);
expect(provider.sentMessages).toHaveLength(1);
expect(JSON.stringify(persisted)).not.toContain('secret-value');
});
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider([]);
const service = makeService(provider);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/capability denied/);
expect(provider.sentMessages).toEqual([]);
});
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new DenyingApprovalVerifier();
const audit = new RecordingAuditSink();
const service = makeService(provider, audit, approval);
await expect(
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
).rejects.toThrow(/approval denied/);
expect(provider.terminateCalls).toBe(0);
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
});
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new AcceptingApprovalVerifier();
const service = makeService(provider, new RecordingAuditSink(), approval);
await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);
expect(approval.consumedAction).toEqual({
providerId: 'fleet',
sessionId: 'session-1',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
agentName: process.env['MOSAIC_AGENT_NAME'],
});
expect(provider.terminateCalls).toBe(1);
});
it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const unavailableAudit: RuntimeAuditSink = {
async record(): Promise<void> {
throw new Error('audit unavailable');
},
};
const service = makeService(provider, unavailableAudit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/audit unavailable/);
expect(provider.sentMessages).toEqual([]);
});
it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAfterSend = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider acknowledgement failed/);
expect(provider.sentMessages).toHaveLength(1);
expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
'requested',
'failed',
]);
expect(audit.events.at(-1)).toMatchObject({
errorCode: 'provider_error',
durationMs: expect.any(Number),
});
});
it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAuthorization = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider authorization denied/);
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
});
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
let persisted: unknown;
const ingest = async (entry: unknown): Promise<unknown> => {
persisted = entry;
return entry;
};
const service = new RuntimeProviderAuditService({ logs: { ingest } } as never);
await service.record({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: 'owner-1',
tenantId: 'tenant-1',
channelId: 'cli',
correlationId: 'correlation-1',
resourceId: 'session-1',
durationMs: 12,
});
expect(persisted).toMatchObject({
content: 'runtime.provider.audit',
metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }),
});
expect(JSON.stringify(persisted)).not.toContain('approval');
});
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let auditCalls = 0;
const audit: RuntimeAuditSink = {
async record(): Promise<void> {
auditCalls += 1;
if (auditCalls === 2) {
throw new Error('completion audit unavailable');
}
},
};
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).resolves.toBeUndefined();
expect(provider.sentMessages).toHaveLength(1);
expect(auditCalls).toBe(2);
});
});

View File

@@ -0,0 +1,262 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
vi.mock('../../commands/command-executor.service.js', () => ({
CommandExecutorService: class CommandExecutorService {},
}));
vi.mock('../routing/routing-engine.service.js', () => ({
RoutingEngineService: class RoutingEngineService {},
}));
import { SessionsController } from '../sessions.controller.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
promptCount: 0,
channels: [],
durationMs: 0,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
...overrides,
};
}
function makeAgentSession(owner = USER_A): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp',
allowedTools: null,
userId: owner.id,
tenantId: owner.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
function makeScopedAgentService() {
const foreign = makeAgentSession(USER_A);
return {
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
),
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
),
destroySession: vi.fn(),
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
recordMessage: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
}
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
it('requires explicit owner+tenant scope on protected session operations', () => {
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain('listSessions(scope: ActorTenantScope)');
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain(
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
);
expect(source).toContain('scope: ActorTenantScope,');
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
expect(source).not.toContain('scope?: ActorTenantScope');
});
});
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
expect(agentService.listSessions).toHaveBeenCalledWith({
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not reveal another owner/tenant session by guessed id', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not terminate another owner/tenant session by guessed id', async () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
NotFoundException,
);
expect(agentService.destroySession).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
const agentService = makeScopedAgentService();
const controller = new ChatController(agentService as never);
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toMatchObject({ status: 404 });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.prompt).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
function makeGateway(agentService = makeScopedAgentService()) {
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
const commandExecutor = { execute: vi.fn() };
const routingEngine = {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
commandRegistry as never,
commandExecutor as never,
routingEngine as never,
);
return { gateway, agentService };
}
function makeSocket() {
return {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
}
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
});
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.onEvent).not.toHaveBeenCalled();
expect(agentService.addChannel).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
});

View File

@@ -7,7 +7,7 @@ import type {
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaic/types';
} from '@mosaicstack/types';
/**
* Anthropic provider adapter.

View File

@@ -6,7 +6,7 @@ import type {
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaic/types';
} from '@mosaicstack/types';
/** Embedding models that Ollama ships with out of the box */
const OLLAMA_EMBEDDING_MODELS: ReadonlyArray<{

View File

@@ -7,7 +7,7 @@ import type {
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaic/types';
} from '@mosaicstack/types';
/**
* OpenAI provider adapter.

View File

@@ -6,7 +6,7 @@ import type {
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaic/types';
} from '@mosaicstack/types';
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';

View File

@@ -6,7 +6,7 @@ import type {
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaic/types';
} from '@mosaicstack/types';
import { getModelCapability } from '../model-capabilities.js';
/**

View File

@@ -13,7 +13,7 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import type { Brain } from '@mosaic/brain';
import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';

View File

@@ -1,4 +1,5 @@
import { Global, Module } from '@nestjs/common';
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
@@ -8,24 +9,66 @@ import { SkillLoaderService } from './skill-loader.service.js';
import { ProvidersController } from './providers.controller.js';
import { SessionsController } from './sessions.controller.js';
import { AgentConfigsController } from './agent-configs.controller.js';
import { InteractionController } from './interaction.controller.js';
import { RoutingController } from './routing/routing.controller.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
const registry = new AgentRuntimeProviderRegistry();
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
return registry;
}
@Global()
@Module({
imports: [CoordModule, McpClientModule, SkillsModule, GCModule],
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
providers: [
ProviderService,
ProviderCredentialsService,
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionRepository,
DurableSessionService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry,
},
RuntimeProviderAuditService,
{
provide: RUNTIME_PROVIDER_AUDIT_SINK,
useExisting: RuntimeProviderAuditService,
},
{
provide: RUNTIME_APPROVAL_VERIFIER,
useExisting: CommandRuntimeApprovalVerifier,
},
RuntimeProviderService,
AgentService,
],
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
controllers: [
ProvidersController,
SessionsController,
AgentConfigsController,
InteractionController,
RoutingController,
],
exports: [
AgentService,
ProviderService,
@@ -33,6 +76,9 @@ import { GCModule } from '../gc/gc.module.js';
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionService,
RuntimeProviderService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
],
})
export class AgentModule {}

View File

@@ -1,4 +1,11 @@
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
import {
ForbiddenException,
Inject,
Injectable,
Logger,
Optional,
type OnModuleDestroy,
} from '@nestjs/common';
import {
createAgentSession,
DefaultResourceLoader,
@@ -7,10 +14,12 @@ import {
type AgentSessionEvent,
type ToolDefinition,
} from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaic/brain';
import type { Memory } from '@mosaic/memory';
import type { Brain } from '@mosaicstack/brain';
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js';
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
import { EmbeddingService } from '../memory/embedding.service.js';
import { CoordService } from '../coord/coord.service.js';
import { ProviderService } from './provider.service.js';
@@ -28,12 +37,15 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
import { PreferencesService } from '../preferences/preferences.service.js';
import { SessionGCService } from '../gc/session-gc.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
/** A single message from DB conversation history, used for context injection. */
export interface ConversationHistoryMessage {
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: Date;
/** Validated, URI-referenced channel attachments preserved on session resume. */
attachments?: readonly ChannelAttachmentDto[];
}
export interface AgentSessionOptions {
@@ -68,6 +80,8 @@ export interface AgentSessionOptions {
agentConfigId?: string;
/** ID of the user who owns this session. Used for preferences and system override lookups. */
userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/**
* Prior conversation messages to inject as context when resuming a session.
* These messages are formatted and prepended to the system prompt so the
@@ -94,6 +108,8 @@ export interface AgentSession {
allowedTools: string[] | null;
/** User ID that owns this session, used for preference lookups. */
userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/** Agent config ID applied to this session, if any (M5-001). */
agentConfigId?: string;
/** Human-readable agent name applied to this session, if any (M5-001). */
@@ -123,6 +139,9 @@ export class AgentService implements OnModuleDestroy {
@Inject(PreferencesService)
private readonly preferencesService: PreferencesService | null,
@Inject(SessionGCService) private readonly gc: SessionGCService,
@Optional()
@Inject(OPERATOR_MEMORY_PLUGIN)
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
) {}
/**
@@ -134,6 +153,7 @@ export class AgentService implements OnModuleDestroy {
private buildToolsForSandbox(
sandboxDir: string,
sessionUserId: string | undefined,
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
): ToolDefinition[] {
return [
...createBrainTools(this.brain),
@@ -142,6 +162,9 @@ export class AgentService implements OnModuleDestroy {
this.memory,
this.embeddingService.available ? this.embeddingService : null,
sessionUserId,
this.operatorMemory && sessionScope
? { plugin: this.operatorMemory, scope: sessionScope }
: undefined,
),
...createFileTools(sandboxDir),
...createGitTools(sandboxDir),
@@ -174,12 +197,20 @@ export class AgentService implements OnModuleDestroy {
.filter((t) => t.length > 0);
}
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
const scope = this.scopeFromOptions(options);
const existing = this.sessions.get(sessionId);
if (existing) return existing;
if (existing) {
this.assertSessionScope(existing, scope);
return existing;
}
const inflight = this.creating.get(sessionId);
if (inflight) return inflight;
if (inflight) {
const session = await inflight;
this.assertSessionScope(session, scope);
return session;
}
const promise = this.doCreateSession(sessionId, options).finally(() => {
this.creating.delete(sessionId);
@@ -208,6 +239,7 @@ export class AgentService implements OnModuleDestroy {
isAdmin: options.isAdmin,
agentConfigId: options.agentConfigId,
userId: options.userId,
tenantId: options.tenantId,
conversationHistory: options.conversationHistory,
};
this.logger.log(
@@ -247,7 +279,15 @@ export class AgentService implements OnModuleDestroy {
}
// Build per-session tools scoped to the sandbox directory and authenticated user
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
const sessionUserId = mergedOptions?.userId;
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
const sandboxTools = this.buildToolsForSandbox(
sandboxDir,
sessionUserId,
sessionUserId && sessionTenantId
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
: undefined,
);
// Combine static tools with dynamically discovered MCP client tools and skill tools
const mcpTools = this.mcpClientService.getToolDefinitions();
@@ -342,6 +382,7 @@ export class AgentService implements OnModuleDestroy {
sandboxDir,
allowedTools,
userId: mergedOptions?.userId,
tenantId: sessionTenantId,
agentConfigId: mergedOptions?.agentConfigId,
agentName: resolvedAgentName,
metrics: {
@@ -390,7 +431,7 @@ export class AgentService implements OnModuleDestroy {
const formatMessage = (msg: ConversationHistoryMessage): string => {
const roleLabel =
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
return `**${roleLabel}:** ${msg.content}`;
return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`;
};
const formatted = history.map((msg) => formatMessage(msg));
@@ -449,6 +490,21 @@ export class AgentService implements OnModuleDestroy {
return result;
}
private attachmentContext(attachments: readonly ChannelAttachmentDto[]): string {
if (attachments.length === 0) return '';
return `\n\n[Untrusted channel attachments]\n${attachments
.map((attachment: ChannelAttachmentDto): string =>
JSON.stringify({
id: attachment.id,
name: attachment.name,
mimeType: attachment.mimeType,
url: attachment.url,
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
}),
)
.join('\n')}`;
}
private resolveModel(options?: AgentSessionOptions) {
if (!options?.provider && !options?.modelId) {
return this.providerService.getDefaultModel() ?? null;
@@ -473,38 +529,70 @@ export class AgentService implements OnModuleDestroy {
return this.providerService.getDefaultModel() ?? null;
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
const session = this.sessions.get(sessionId);
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
return session;
}
listSessions(): SessionInfoDto[] {
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => ({
id: s.id,
provider: s.provider,
modelId: s.modelId,
...(s.agentName ? { agentName: s.agentName } : {}),
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: now - s.createdAt,
metrics: { ...s.metrics },
}));
return Array.from(this.sessions.values())
.filter((s) => this.sessionMatchesScope(s, scope))
.map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
listAllSessionsForSystem(): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined {
const s = this.sessions.get(sessionId);
if (!s) return undefined;
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
return this.toSessionInfo(s);
}
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
if (!options.userId) {
throw new ForbiddenException('Session owner scope is required');
}
return {
id: s.id,
provider: s.provider,
modelId: s.modelId,
...(s.agentName ? { agentName: s.agentName } : {}),
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: Date.now() - s.createdAt,
metrics: { ...s.metrics },
userId: options.userId,
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
};
}
private tenantIdFor(
userId: string | undefined,
tenantId: string | undefined,
): string | undefined {
return tenantId ?? userId;
}
private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean {
return (
session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId
);
}
private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void {
if (!this.sessionMatchesScope(session, scope)) {
throw new ForbiddenException('Session does not belong to the current owner/tenant scope');
}
}
private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto {
return {
id: session.id,
provider: session.provider,
modelId: session.modelId,
...(session.agentName ? { agentName: session.agentName } : {}),
createdAt: new Date(session.createdAt).toISOString(),
promptCount: session.promptCount,
channels: Array.from(session.channels),
durationMs: now - session.createdAt,
metrics: { ...session.metrics },
};
}
@@ -553,9 +641,10 @@ export class AgentService implements OnModuleDestroy {
* not reconstructed — the model is used on the next createSession call for
* the same conversationId when the session is torn down or a new one is created.
*/
updateSessionModel(sessionId: string, modelId: string): void {
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
const prev = session.modelId;
session.modelId = modelId;
this.recordModelSwitch(sessionId);
@@ -572,48 +661,67 @@ export class AgentService implements OnModuleDestroy {
sessionId: string,
agentConfigId: string,
agentName: string,
scope: ActorTenantScope,
modelId?: string,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
session.agentConfigId = agentConfigId;
session.agentName = agentName;
if (modelId) {
this.updateSessionModel(sessionId, modelId);
this.updateSessionModel(sessionId, modelId, scope);
}
this.logger.log(
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
);
}
addChannel(sessionId: string, channel: string): void {
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.add(channel);
}
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.add(channel);
}
removeChannel(sessionId: string, channel: string): void {
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.delete(channel);
}
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.delete(channel);
}
async prompt(sessionId: string, message: string): Promise<void> {
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] | undefined,
): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] = [],
): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.promptCount += 1;
// Channel attachments are untrusted URI references. Preserve exact,
// authenticated metadata for the agent without treating it as authority.
const attachmentContext = this.attachmentContext(attachments);
// Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = message;
let effectiveMessage = `${message}${attachmentContext}`;
if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId);
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
await this.systemOverride.renew(sessionId);
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}
}
@@ -629,16 +737,28 @@ export class AgentService implements OnModuleDestroy {
}
}
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
onEvent(
sessionId: string,
listener: (event: AgentSessionEvent) => void,
scope: ActorTenantScope,
): () => void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.listeners.add(listener);
return () => session.listeners.delete(listener);
}
async destroySession(sessionId: string): Promise<void> {
async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
await this.destroySessionForSystem(sessionId);
}
private async destroySessionForSystem(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.logger.log(`Destroying agent session ${sessionId}`);
@@ -667,7 +787,7 @@ export class AgentService implements OnModuleDestroy {
async onModuleDestroy(): Promise<void> {
this.logger.log('Shutting down all agent sessions');
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
const results = await Promise.allSettled(stops);
for (const result of results) {
if (result.status === 'rejected') {

View File

@@ -0,0 +1,10 @@
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
/** Server-side request for a replay-safe provider message. */
export interface ProviderOutboxDto {
sessionId: string;
idempotencyKey: string;
correlationId: string;
content: string;
context: RuntimeProviderRequestContext;
}

View File

@@ -0,0 +1,416 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova',
sessionId: 'tess-pglite-session',
tenantId: 'tenant-pglite',
ownerId: 'tess-owner',
providerId: 'fleet',
runtimeSessionId: 'nova',
};
describe('DurableSessionRepository', () => {
let dataDir: string | undefined;
let handle: DbHandle;
let previousAuthSecret: string | undefined;
beforeAll(async (): Promise<void> => {
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key';
dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
await seedOwner(handle);
}, 30_000);
beforeEach(async (): Promise<void> => {
await handle.db.execute(sql`DELETE FROM interaction_handoffs`);
await handle.db.execute(sql`DELETE FROM interaction_checkpoints`);
await handle.db.execute(sql`DELETE FROM interaction_inbox`);
await handle.db.execute(sql`DELETE FROM interaction_outbox`);
await handle.db.execute(sql`DELETE FROM interaction_sessions`);
});
afterAll(async (): Promise<void> => {
await handle.close();
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
});
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await beforeRestart.create(IDENTITY);
await beforeRestart.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-before-kill',
correlationId: 'correlation-before-kill',
content: 'resume after a kill',
});
await beforeRestart.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-before-kill',
correlationId: 'correlation-before-kill',
channelId: 'cli',
kind: 'provider.send',
content: 'one response only',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-before-kill',
cursor: 'cursor-before-kill',
summary: 'restart-safe state',
compactionEpoch: 1,
});
await beforeRestart.handoff({
sessionId: IDENTITY.sessionId,
handoffId: 'handoff-before-kill',
destination: 'mos',
correlationId: 'correlation-before-kill',
checkpointId: 'checkpoint-before-kill',
status: 'pending',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-after-handoff',
cursor: 'cursor-after-handoff',
summary: 'newer state cannot strand the portable handoff',
compactionEpoch: 2,
});
await handle.close();
handle = createPgliteDb(dataDir!);
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const recovered = await afterRestart.recover(IDENTITY.sessionId);
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
const handled: string[] = [];
const effects: string[] = [];
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
handled.push(entry.idempotencyKey);
});
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
effects.push(entry.idempotencyKey);
});
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
handled.push(entry.idempotencyKey);
});
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
effects.push(entry.idempotencyKey);
});
expect(recovered.identity).toEqual(IDENTITY);
expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' });
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]);
expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' });
expect(handled).toEqual(['inbox-before-kill']);
expect(effects).toEqual(['outbox-before-kill']);
}, 30_000);
it('redacts sensitive durable payloads before persistence', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'redacted-inbox',
correlationId: 'correlation-redaction',
content: 'api_key=super-secret-canary',
});
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'redacted-outbox',
correlationId: 'correlation-redaction',
channelId: 'cli',
kind: 'provider.send',
content: 'email operator@example.test api_key=super-secret-canary',
});
await coordinator.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'redacted-checkpoint',
cursor: 'bearer super-secret-canary',
summary: 'email operator@example.test',
compactionEpoch: 0,
});
const snapshot = await coordinator.snapshot(IDENTITY.sessionId);
const [persisted] = await handle.db
.select({ content: interactionInbox.content })
.from(interactionInbox)
.where(eq(interactionInbox.idempotencyKey, 'redacted-inbox'));
expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary');
expect(JSON.stringify(snapshot)).not.toContain('operator@example.test');
expect(persisted?.content).not.toContain('super-secret-canary');
expect(persisted?.content).not.toContain('[REDACTED]');
}, 30_000);
it('fails closed when the configured idempotency secret is unavailable', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const secret = process.env['BETTER_AUTH_SECRET'];
delete process.env['BETTER_AUTH_SECRET'];
try {
await expect(
coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'requires-idempotency-secret',
correlationId: 'correlation-secret',
content: 'sensitive payload',
}),
).rejects.toThrow(/required for durable idempotency digests/);
} finally {
if (secret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = secret;
}
}, 30_000);
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const input = {
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-secret-conflict',
cursor: 'api_key=secret-one',
summary: 'bearer secret-one',
compactionEpoch: 1,
};
await coordinator.checkpoint(input);
await expect(
coordinator.checkpoint({
...input,
cursor: 'api_key=secret-two',
summary: 'bearer secret-two',
}),
).rejects.toThrow(/checkpoint identity conflict/);
const [persisted] = await handle.db
.select({
digest: interactionCheckpoints.contentDigest,
cursor: interactionCheckpoints.cursor,
})
.from(interactionCheckpoints)
.where(eq(interactionCheckpoints.checkpointId, input.checkpointId));
expect(persisted?.cursor).not.toContain('secret-one');
expect(persisted?.digest).not.toBe(
createHash('sha256')
.update(JSON.stringify([input.cursor, input.summary]))
.digest('hex'),
);
await coordinator.checkpoint({
...input,
checkpointId: 'checkpoint-delimiter-conflict',
cursor: 'a\u0000b',
summary: 'c',
});
await expect(
coordinator.checkpoint({
...input,
checkpointId: 'checkpoint-delimiter-conflict',
cursor: 'a',
summary: 'b\u0000c',
}),
).rejects.toThrow(/checkpoint identity conflict/);
}, 30_000);
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const inbox = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-secret-conflict',
correlationId: 'correlation-inbox-secret',
content: 'api_key=secret-one',
};
const outbox = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-secret-conflict',
correlationId: 'correlation-outbox-secret',
channelId: 'cli',
kind: 'provider.send',
content: 'api_key=secret-one',
};
await coordinator.receive(inbox);
await coordinator.enqueueOutbox(outbox);
await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow(
/idempotency conflict/,
);
await expect(
coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }),
).rejects.toThrow(/idempotency conflict/);
}, 30_000);
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-conflict',
correlationId: 'correlation-inbox',
content: 'original inbox',
});
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'cli',
kind: 'provider.send',
content: 'original outbox',
});
await expect(
coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-conflict',
correlationId: 'forged-correlation',
content: 'original inbox',
}),
).rejects.toThrow(/idempotency conflict/);
await expect(
coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'forged-channel',
kind: 'provider.send',
content: 'original outbox',
}),
).rejects.toThrow(/idempotency conflict/);
}, 30_000);
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'live-effect',
correlationId: 'correlation-live',
content: 'must not duplicate',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-live',
},
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(input);
expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({
status: 'processing',
});
await service.dispatchProviderOutbox(IDENTITY.sessionId, input);
await expect(
service.recoverProviderSession(IDENTITY.sessionId, {
...input,
context: {
...input.context,
actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' },
},
}),
).rejects.toThrow(/scope or correlation mismatch/);
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }],
});
}, 30_000);
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'mismatch-effect',
correlationId: 'correlation-expected',
content: 'must remain pending',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-expected',
},
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(input);
await expect(
service.dispatchProviderOutbox(IDENTITY.sessionId, {
...input,
correlationId: 'correlation-forged',
context: { ...input.context, correlationId: 'correlation-forged' },
}),
).rejects.toThrow(/scope or correlation mismatch/);
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }],
});
}, 30_000);
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const first = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'scoped-effect-one',
correlationId: 'correlation-one',
content: 'first result',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-one',
},
};
const second = {
...first,
idempotencyKey: 'scoped-effect-two',
correlationId: 'correlation-two',
content: 'second result',
context: { ...first.context, correlationId: 'correlation-two' },
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(first);
await service.queueProviderSend(second);
await service.dispatchProviderOutbox(IDENTITY.sessionId, first);
expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1);
expect(runtimeProviders.sendMessage).toHaveBeenCalledWith(
IDENTITY.providerId,
IDENTITY.runtimeSessionId,
{ content: 'first result', idempotencyKey: 'scoped-effect-one' },
first.context,
);
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [
{ idempotencyKey: 'scoped-effect-one', status: 'delivered' },
{ idempotencyKey: 'scoped-effect-two', status: 'pending' },
],
});
}, 30_000);
});
async function seedOwner(handle: DbHandle): Promise<void> {
await handle.db.execute(sql`
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
VALUES ('tess-owner', 'Tess Owner', 'tess-owner@example.test', false, now(), now())
`);
}

View File

@@ -0,0 +1,529 @@
import { createHash, createHmac } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
desc,
eq,
interactionCheckpoints,
interactionHandoffs,
interactionInbox,
interactionOutbox,
interactionSessions,
type Db,
} from '@mosaicstack/db';
import { seal, unseal } from '@mosaicstack/auth';
import { redactSensitiveContent } from '@mosaicstack/log';
import type {
DurableCheckpoint,
DurableCheckpointInput,
DurableEnqueueResult,
DurableHandoff,
DurableHandoffInput,
DurableInboxEntry,
DurableInboxInput,
DurableInboxStatus,
DurableOutboxEntry,
DurableOutboxInput,
DurableOutboxStatus,
DurableSessionIdentity,
DurableSessionSnapshot,
DurableSessionStore,
} from '@mosaicstack/agent';
import { DB } from '../database/database.module.js';
@Injectable()
export class DurableSessionRepository implements DurableSessionStore {
constructor(@Inject(DB) private readonly db: Db) {}
async create(identity: DurableSessionIdentity): Promise<void> {
await this.db
.insert(interactionSessions)
.values({
id: identity.sessionId,
agentName: identity.agentName,
tenantId: identity.tenantId,
ownerId: identity.ownerId,
providerId: identity.providerId,
runtimeSessionId: identity.runtimeSessionId,
})
.onConflictDoNothing();
const existing = await this.session(identity.sessionId);
if (!existing || !sameEnrollmentScope(existing, identity)) {
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
}
// A recovered/re-enrolled runtime can receive a new provider session ID;
// the conversation handle and owner scope remain immutable.
if (
existing.providerId !== identity.providerId ||
existing.runtimeSessionId !== identity.runtimeSessionId
) {
await this.db
.update(interactionSessions)
.set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId })
.where(eq(interactionSessions.id, identity.sessionId));
}
}
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
const identity = await this.session(sessionId);
if (!identity) return null;
const [inbox, outbox, checkpoints, handoffs] = await Promise.all([
this.db
.select()
.from(interactionInbox)
.where(eq(interactionInbox.sessionId, sessionId))
.orderBy(asc(interactionInbox.createdAt)),
this.db
.select()
.from(interactionOutbox)
.where(eq(interactionOutbox.sessionId, sessionId))
.orderBy(asc(interactionOutbox.createdAt)),
this.db
.select()
.from(interactionCheckpoints)
.where(eq(interactionCheckpoints.sessionId, sessionId))
.orderBy(
desc(interactionCheckpoints.compactionEpoch),
desc(interactionCheckpoints.createdAt),
)
.limit(1),
this.db
.select()
.from(interactionHandoffs)
.where(eq(interactionHandoffs.sessionId, sessionId))
.orderBy(asc(interactionHandoffs.createdAt)),
]);
const checkpoint = checkpoints[0];
return {
identity,
inbox: inbox.map(toInbox),
outbox: outbox.map(toOutbox),
...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}),
handoffs: handoffs.map(toHandoff),
};
}
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
const digest = contentDigest(input.content);
const record: DurableInboxInput = {
...input,
content: redactSensitiveContent(input.content).content,
};
const inserted = await this.db
.insert(interactionInbox)
.values({
...record,
content: seal(record.content),
contentDigest: digest,
status: 'pending',
})
.onConflictDoNothing()
.returning({ status: interactionInbox.status });
if (inserted[0]) return { accepted: true, status: inserted[0].status };
const existing = await this.db
.select()
.from(interactionInbox)
.where(
and(
eq(interactionInbox.sessionId, input.sessionId),
eq(interactionInbox.idempotencyKey, input.idempotencyKey),
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
const entry = toInbox(existing[0]);
if (
!sameInbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const candidate = await this.db
.select()
.from(interactionInbox)
.where(
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')),
)
.orderBy(asc(interactionInbox.createdAt))
.limit(1);
const entry = candidate[0];
if (!entry) return null;
const claimed = await this.db
.update(interactionInbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending')))
.returning();
if (claimed[0]) return toInbox(claimed[0]);
}
return null;
}
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionInbox)
.set({ status: 'processed', updatedAt: new Date() })
.where(
and(
eq(interactionInbox.sessionId, sessionId),
eq(interactionInbox.idempotencyKey, idempotencyKey),
eq(interactionInbox.status, 'processing'),
),
);
}
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionInbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(
eq(interactionInbox.sessionId, sessionId),
eq(interactionInbox.idempotencyKey, idempotencyKey),
eq(interactionInbox.status, 'processing'),
),
);
}
async enqueueOutbox(
input: DurableOutboxInput,
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
const digest = contentDigest(input.content);
const record: DurableOutboxInput = {
...input,
content: redactSensitiveContent(input.content).content,
};
const inserted = await this.db
.insert(interactionOutbox)
.values({
...record,
content: seal(record.content),
contentDigest: digest,
status: 'pending',
})
.onConflictDoNothing()
.returning({ status: interactionOutbox.status });
if (inserted[0]) return { accepted: true, status: inserted[0].status };
const existing = await this.db
.select()
.from(interactionOutbox)
.where(
and(
eq(interactionOutbox.sessionId, input.sessionId),
eq(interactionOutbox.idempotencyKey, input.idempotencyKey),
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
const entry = toOutbox(existing[0]);
if (
!sameOutbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const candidate = await this.db
.select()
.from(interactionOutbox)
.where(
and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')),
)
.orderBy(asc(interactionOutbox.createdAt))
.limit(1);
const entry = candidate[0];
if (!entry) return null;
const claimed = await this.db
.update(interactionOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending')))
.returning();
if (claimed[0]) return toOutbox(claimed[0]);
}
return null;
}
async claimOutboxByKey(
sessionId: string,
idempotencyKey: string,
): Promise<DurableOutboxEntry | null> {
const claimed = await this.db
.update(interactionOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'pending'),
),
)
.returning();
return claimed[0] ? toOutbox(claimed[0]) : null;
}
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionOutbox)
.set({ status: 'delivered', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'processing'),
),
);
}
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionOutbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'processing'),
),
);
}
async checkpoint(input: DurableCheckpointInput): Promise<void> {
// Compute identity before redaction. The persisted digest is keyed so a database
// reader cannot use it as an offline oracle for sensitive cursor/summary values.
const digest = contentDigest(JSON.stringify([input.cursor, input.summary]));
const checkpoint: DurableCheckpointInput = {
...input,
cursor: redactSensitiveContent(input.cursor).content,
summary: redactSensitiveContent(input.summary).content,
};
const inserted = await this.db
.insert(interactionCheckpoints)
.values({
...checkpoint,
contentDigest: digest,
cursor: seal(checkpoint.cursor),
summary: seal(checkpoint.summary),
})
.onConflictDoNothing()
.returning({ checkpointId: interactionCheckpoints.checkpointId });
if (inserted[0]) return;
const existing = await this.db
.select()
.from(interactionCheckpoints)
.where(
and(
eq(interactionCheckpoints.sessionId, input.sessionId),
eq(interactionCheckpoints.checkpointId, input.checkpointId),
),
)
.limit(1);
if (
!existing[0] ||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
) {
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
}
}
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
const checkpoints = await this.db
.select()
.from(interactionCheckpoints)
.where(
and(
eq(interactionCheckpoints.sessionId, sessionId),
eq(interactionCheckpoints.checkpointId, checkpointId),
),
)
.limit(1);
const checkpoint = checkpoints[0];
return checkpoint ? toCheckpoint(checkpoint) : null;
}
async handoff(input: DurableHandoffInput): Promise<void> {
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
if (!checkpoint) {
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
}
const inserted = await this.db
.insert(interactionHandoffs)
.values({ ...input })
.onConflictDoNothing()
.returning({ handoffId: interactionHandoffs.handoffId });
if (inserted[0]) return;
const existing = await this.findHandoff(input.handoffId);
if (!existing || !sameHandoff(existing, input)) {
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
}
}
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
const handoffs = await this.db
.select()
.from(interactionHandoffs)
.where(eq(interactionHandoffs.handoffId, handoffId))
.limit(1);
const handoff = handoffs[0];
return handoff ? toHandoff(handoff) : null;
}
async requeueInFlight(sessionId: string): Promise<void> {
// Inbox handlers are process-local work. A provider outbox claim may have
// reached an external target before a crash, so it is deliberately not
// replayed by generic recovery.
await this.db
.update(interactionInbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')),
);
}
private async session(sessionId: string): Promise<DurableSessionIdentity | null> {
const sessions = await this.db
.select()
.from(interactionSessions)
.where(eq(interactionSessions.id, sessionId))
.limit(1);
const session = sessions[0];
return session
? {
agentName: session.agentName,
sessionId: session.id,
tenantId: session.tenantId,
ownerId: session.ownerId,
providerId: session.providerId,
runtimeSessionId: session.runtimeSessionId,
}
: null;
}
}
function contentDigest(content: string): string {
const secret = process.env['BETTER_AUTH_SECRET'];
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests');
}
return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`;
}
function matchesContentDigest(stored: string, content: string): boolean {
return (
stored === contentDigest(content) ||
stored === createHash('sha256').update(content).digest('hex')
);
}
function matchesCheckpointDigest(stored: string, digest: string): boolean {
// Legacy rows predate any pre-redaction identity and cannot safely prove equality.
// Reject rather than let redaction collapse distinct sensitive checkpoint payloads.
return stored === digest;
}
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
return (
left.agentName === right.agentName &&
left.sessionId === right.sessionId &&
left.tenantId === right.tenantId &&
left.ownerId === right.ownerId
);
}
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.content === right.content
);
}
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.channelId === right.channelId &&
left.kind === right.kind &&
left.content === right.content
);
}
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
return (
left.sessionId === right.sessionId &&
left.checkpointId === right.checkpointId &&
left.compactionEpoch === right.compactionEpoch
);
}
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
return (
left.sessionId === right.sessionId &&
left.handoffId === right.handoffId &&
left.destination === right.destination &&
left.correlationId === right.correlationId &&
left.checkpointId === right.checkpointId &&
left.status === right.status
);
}
function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry {
return {
sessionId: row.sessionId,
idempotencyKey: row.idempotencyKey,
correlationId: row.correlationId,
content: unseal(row.content),
status: row.status,
};
}
function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry {
return {
sessionId: row.sessionId,
idempotencyKey: row.idempotencyKey,
correlationId: row.correlationId,
channelId: row.channelId,
kind: row.kind,
content: unseal(row.content),
status: row.status,
};
}
function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint {
return {
sessionId: row.sessionId,
checkpointId: row.checkpointId,
cursor: unseal(row.cursor),
summary: unseal(row.summary),
compactionEpoch: row.compactionEpoch,
};
}
function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff {
return {
sessionId: row.sessionId,
handoffId: row.handoffId,
destination: row.destination,
correlationId: row.correlationId,
checkpointId: row.checkpointId,
status: row.status,
};
}

View File

@@ -0,0 +1,123 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import type { ProviderOutboxDto } from './durable-session.dto.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
* uses composition: raw state methods cannot be injected into channel, CLI, or
* MCP adapters without a server-derived actor/tenant/correlation context.
*/
@Injectable()
export class DurableSessionService {
private readonly coordinator: DurableSessionCoordinator;
constructor(
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
) {
this.coordinator = new DurableSessionCoordinator(repository);
}
/** Enroll a verified runtime session under the stable cross-surface conversation handle. */
async enroll(
identity: DurableSessionIdentity,
context: RuntimeProviderRequestContext,
): Promise<void> {
if (
identity.ownerId !== context.actorScope.userId ||
identity.tenantId !== context.actorScope.tenantId
) {
throw new ForbiddenException('Durable session enrollment scope mismatch');
}
await this.coordinator.create(identity);
}
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(input.sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.enqueueOutbox({
sessionId: input.sessionId,
idempotencyKey: input.idempotencyKey,
correlationId: input.correlationId,
channelId: input.context.channelId,
kind: 'provider.send',
content: input.content,
});
}
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
if (sessionId !== input.sessionId) {
throw new ForbiddenException('Durable outbox session mismatch');
}
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
const pendingEntry = snapshot.outbox.find(
(entry): boolean => entry.idempotencyKey === input.idempotencyKey,
);
if (!pendingEntry) return;
// Validate immutable routing before claiming. A caller with a mismatched
// correlation/channel must not strand a pending external side effect.
this.assertOutboxScope(pendingEntry, input);
await this.coordinator.dispatchOutboxEntry(
sessionId,
input.idempotencyKey,
async (entry): Promise<void> => {
this.assertOutboxScope(entry, input);
await this.runtimeProviders.sendMessage(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
{ content: entry.content, idempotencyKey: entry.idempotencyKey },
input.context,
);
},
);
}
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
sessionId,
content: '',
idempotencyKey: 'read-only',
correlationId: context.correlationId,
context,
});
return snapshot;
}
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.recover(sessionId);
}
private assertOutboxScope(
entry: { kind: string; correlationId: string; channelId: string },
input: ProviderOutboxDto,
): void {
if (
entry.kind !== 'provider.send' ||
entry.correlationId !== input.correlationId ||
entry.channelId !== input.context.channelId
) {
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
}
}
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
if (
input.context.actorScope.userId !== ownerId ||
input.context.actorScope.tenantId !== tenantId ||
input.context.correlationId !== input.correlationId
) {
throw new ForbiddenException('Durable session scope or correlation mismatch');
}
}
}

View File

@@ -0,0 +1,176 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Global, Module } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentModule } from './agent.module.js';
import { AUTH } from '../auth/auth.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { BRAIN } from '../brain/brain.tokens.js';
import { DB } from '../database/database.module.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
} from './runtime-provider-registry.service.js';
import { DurableSessionService } from './durable-session.service.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
import { RoutingService } from './routing.service.js';
import { RoutingEngineService } from './routing/routing-engine.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' };
@Module({})
class EmptyAgentDependencyModule {}
@Global()
@Module({
providers: [
{
provide: AUTH,
useValue: {
api: {
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
headers.get('cookie') === 'session=trusted'
? { user: authenticatedUser, session: { id: 'session-1' } }
: null,
),
},
},
},
AuthGuard,
{ provide: BRAIN, useValue: {} },
{ provide: DB, useValue: {} },
],
exports: [AUTH, AuthGuard, BRAIN, DB],
})
class AuthenticatedRequestModule {}
/**
* This is deliberately an HTTP test rather than a controller unit test: it
* exercises AgentModule's actual provider factory, Nest DI, and AuthGuard.
*/
describe('Hermes runtime provider reachability', (): void => {
let app: NestFastifyApplication | undefined;
beforeAll(async (): Promise<void> => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const moduleRef = await Test.createTestingModule({
imports: [AuthenticatedRequestModule, AgentModule],
})
.overrideModule(CoordModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(McpClientModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(SkillsModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(GCModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(LogModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(CommandsModule)
.useModule(EmptyAgentDependencyModule)
.overrideProvider(RuntimeProviderAuditService)
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK)
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
.useValue({ consume: vi.fn().mockResolvedValue(false) })
.overrideProvider(DurableSessionService)
.useValue({})
.overrideProvider(DurableSessionRepository)
.useValue({})
.overrideProvider(AgentService)
.useValue({})
.overrideProvider(ProviderService)
.useValue({})
.overrideProvider(ProviderCredentialsService)
.useValue({})
.overrideProvider(RoutingService)
.useValue({})
.overrideProvider(RoutingEngineService)
.useValue({})
.overrideProvider(SkillLoaderService)
.useValue({})
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async (): Promise<void> => {
await app?.close();
});
it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise<void> => {
if (!app) throw new Error('Nest application did not initialize');
const attachDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/attach',
headers: { 'x-correlation-id': 'correlation-1' },
payload: { mode: 'read' },
});
expect(attachDenied.statusCode).toBe(401);
const sendDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/send',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
payload: {},
});
expect(sendDenied.statusCode).toBe(403);
expect(sendDenied.json()).toMatchObject({
message: 'Content and idempotency key are required',
});
const stopDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/stop',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
payload: {},
});
expect(stopDenied.statusCode).toBe(403);
expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' });
});
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
if (!app) throw new Error('Nest application did not initialize');
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider);
const denied = await app.inject({
method: 'GET',
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
headers: { 'x-correlation-id': 'correlation-1' },
});
expect(denied.statusCode).toBe(401);
const response = await app.inject({
method: 'GET',
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
const scope = {
actorId: 'owner-1',
tenantId: 'tenant-1',
channelId: 'cli',
correlationId: 'correlation-1',
};
describe('GatewayHermesRuntimeTransport', () => {
it('preserves a configured path prefix and authenticates the concrete runtime request', async () => {
const fetchFn = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 }));
const transport = new GatewayHermesRuntimeTransport(
'https://runtime.example.test/hermes',
'test-service-token',
fetchFn,
);
await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']);
expect(fetchFn).toHaveBeenCalledWith(
new URL('https://runtime.example.test/hermes/capabilities'),
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer test-service-token',
'x-mosaic-channel-id': 'cli',
}),
}),
);
});
it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => {
const fetchFn = vi.fn();
const transport = new GatewayHermesRuntimeTransport(
'http://runtime.example.test/hermes',
'test-service-token',
fetchFn,
);
await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS');
expect(fetchFn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,121 @@
import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent';
import type {
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeMessage,
RuntimeScope,
RuntimeStreamEvent,
} from '@mosaicstack/types';
/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */
export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport {
constructor(
private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(),
private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(),
private readonly fetchFn: typeof fetch = fetch,
) {}
async capabilities(scope: RuntimeScope): Promise<string[]> {
return this.request<string[]>('/capabilities', scope);
}
async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> {
return this.request<{ status: string; detail?: string }>('/health', scope);
}
async sessions(scope: RuntimeScope): Promise<HermesLegacySession[]> {
return this.request<HermesLegacySession[]>('/sessions', scope);
}
async *stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
const params = new URLSearchParams(cursor ? { cursor } : {});
const events = await this.request<RuntimeStreamEvent[]>(
`/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`,
scope,
);
yield* events;
}
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, {
method: 'POST',
body: message,
});
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
return this.request<RuntimeAttachHandle>(
`/sessions/${encodeURIComponent(sessionId)}/attach`,
scope,
{
method: 'POST',
body: { mode },
},
);
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, {
method: 'DELETE',
});
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, {
method: 'POST',
body: { approvalRef },
});
}
private async request<T>(
path: string,
scope: RuntimeScope,
init: { method?: string; body?: unknown } = {},
): Promise<T> {
if (!this.baseUrl || !this.serviceToken) {
throw new Error(
'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport',
);
}
const endpoint = new URL(this.baseUrl);
if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) {
throw new Error('Hermes runtime transport requires HTTPS outside loopback');
}
const response = await this.fetchFn(
new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`),
{
method: init.method ?? 'GET',
headers: {
accept: 'application/json',
authorization: `Bearer ${this.serviceToken}`,
'x-mosaic-actor-id': scope.actorId,
'x-mosaic-tenant-id': scope.tenantId,
'x-mosaic-channel-id': scope.channelId,
'x-correlation-id': scope.correlationId,
...(init.body ? { 'content-type': 'application/json' } : {}),
},
...(init.body ? { body: JSON.stringify(init.body) } : {}),
},
);
if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`);
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
}
function isLoopbackHttp(endpoint: URL): boolean {
return (
endpoint.protocol === 'http:' &&
(endpoint.hostname === 'localhost' ||
endpoint.hostname === '127.0.0.1' ||
endpoint.hostname === '::1')
);
}

View File

@@ -0,0 +1,263 @@
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
import { firstValueFrom } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
RuntimeApprovalDeniedError,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import { InteractionController } from './interaction.controller.js';
describe('InteractionController', (): void => {
afterEach(() => vi.restoreAllMocks());
it('maps a denied runtime approval to Fastify HTTP 403', () => {
const send = vi.fn();
const status = vi.fn().mockReturnValue({ send });
const response = { status };
const host = { switchToHttp: () => ({ getResponse: () => response }) };
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
expect(status).toHaveBeenCalledWith(403);
expect(send).toHaveBeenCalledWith({
statusCode: 403,
message: 'Runtime termination approval denied',
});
});
it('honors a differently named configured instance without a code change', async () => {
const prior = process.env['MOSAIC_AGENT_NAME'];
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
const controller = new InteractionController(runtime as never, {} as never);
await expect(
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
).resolves.toEqual([]);
await expect(
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
).rejects.toThrow('Interaction agent is not configured');
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prior;
});
it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const registry = createGatewayRuntimeProviderRegistry();
const runtime = new RuntimeProviderService(
registry,
{ record: vi.fn().mockResolvedValue(undefined) },
{ consume: vi.fn().mockResolvedValue(false) },
);
const controller = new InteractionController(runtime, {} as never);
await expect(
controller.transitionalCapabilities(
'Nova',
'runtime.hermes',
{ id: 'owner', tenantId: 'team' },
'corr-1',
),
).resolves.toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
it('rejects a request without the non-simple correlation header', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
'X-Correlation-Id is required',
);
});
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = {
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
};
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.enroll(
'Nova',
'conversation-1',
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
{ id: 'owner', tenantId: 'team' },
'corr-1',
),
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
expect(durable.enroll).toHaveBeenCalledWith(
{
agentName: 'Nova',
sessionId: 'conversation-1',
tenantId: 'team',
ownerId: 'owner',
providerId: 'fleet',
runtimeSessionId: 'runtime-1',
},
expect.objectContaining({ correlationId: 'corr-1' }),
);
});
it('rejects an invalid attach mode before invoking a provider', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
await expect(
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
).rejects.toThrow('Interaction attach mode is invalid');
});
it('resumes a durable session by attaching and streaming its runtime events', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtimeEvent = {
type: 'message.delta' as const,
sessionId: 'runtime-1',
cursor: 'cursor-1',
occurredAt: '2026-07-13T00:00:00.000Z',
content: 'resumed',
};
const runtime = {
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
streamSession: vi.fn(async function* () {
yield runtimeEvent;
}),
};
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
await expect(
firstValueFrom(
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
),
).resolves.toEqual({ data: runtimeEvent });
expect(runtime.attach).toHaveBeenCalledWith(
'fleet',
'runtime-1',
'read',
expect.objectContaining({ correlationId: 'corr-1' }),
);
expect(runtime.streamSession).toHaveBeenCalledWith(
'fleet',
'runtime-1',
undefined,
expect.objectContaining({ correlationId: 'corr-1' }),
);
});
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
resolveSnapshot = resolve;
});
const runtime = { streamSession: vi.fn() };
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
const controller = new InteractionController(runtime as never, durable as never);
const subscription = controller
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
.subscribe();
subscription.unsubscribe();
resolveSnapshot({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(runtime.streamSession).not.toHaveBeenCalled();
});
it.each([
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
[
'session-agent mismatch',
{
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
},
],
])('denies a CLI stop for %s', async (_reason, durable) => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
),
).rejects.toBeDefined();
expect(runtime.terminate).not.toHaveBeenCalled();
});
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = {
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
};
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
),
).rejects.toThrow('Runtime termination approval denied');
});
it('uses the durable session identity and runtime registry for an approved stop', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
);
expect(runtime.terminate).toHaveBeenCalledWith(
'fleet',
'runtime-1',
'approval-1',
expect.objectContaining({
correlationId: 'corr-1',
actorScope: { userId: 'owner', tenantId: 'owner' },
}),
);
});
});

View File

@@ -0,0 +1,293 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Sse,
Inject,
Param,
Post,
Query,
UseGuards,
UseFilters,
} from '@nestjs/common';
import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types';
import { Observable } from 'rxjs';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { DurableSessionService } from './durable-session.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Authenticated HTTP boundary for operator interaction clients. Identity is
* selected from deployment configuration, never a client-side command name.
*/
@Controller('api/interaction/:agentName')
@UseGuards(AuthGuard)
@UseFilters(RuntimeApprovalDeniedFilter)
export class InteractionController {
constructor(
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
) {}
@Get('sessions')
async sessions(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.listSessions(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
@Get('transitional-capabilities')
async transitionalCapabilities(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.transitionalCapabilityMatrix(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
@Get('tree')
async tree(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.getSessionTree(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
/**
* Bind an existing, authorized runtime session to the stable conversation ID.
* This is the lifecycle boundary where both runtime identifiers are known.
*/
@Post('sessions/:sessionId/enroll')
async enroll(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { providerId?: string; runtimeSessionId?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const providerId = this.requiredProvider(body.providerId ?? '');
const runtimeSessionId = body.runtimeSessionId?.trim();
if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required');
const context = this.context(user, correlationId);
const sessions = await this.runtime.listSessions(providerId, context);
if (!sessions.some((session): boolean => session.id === runtimeSessionId)) {
throw new ForbiddenException('Runtime session is not visible to this actor');
}
await this.durable.enroll(
{
agentName,
sessionId,
tenantId: context.actorScope.tenantId,
ownerId: context.actorScope.userId,
providerId,
runtimeSessionId,
},
context,
);
return { status: 'enrolled', sessionId };
}
@Post('sessions/:sessionId/attach')
async attach(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { mode?: RuntimeAttachMode } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
const mode = body.mode ?? 'read';
if (mode !== 'read' && mode !== 'control') {
throw new ForbiddenException('Interaction attach mode is invalid');
}
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
return this.runtime.attach(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
mode,
context,
);
}
@Sse('sessions/:sessionId/stream')
stream(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Query('cursor') cursor: string | undefined,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Observable<{ data: RuntimeStreamEvent }> {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
return new Observable((subscriber) => {
let iterator: AsyncIterator<RuntimeStreamEvent> | undefined;
let cancelled = false;
void (async (): Promise<void> => {
try {
const snapshot = await this.durable.getSnapshot(sessionId, context);
if (cancelled || subscriber.closed) return;
this.assertSessionAgent(snapshot.identity.agentName, agentName);
iterator = this.runtime
.streamSession(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
cursor?.trim() || undefined,
context,
)
[Symbol.asyncIterator]();
if (cancelled || subscriber.closed) {
await iterator.return?.();
return;
}
while (!cancelled && !subscriber.closed) {
const next = await iterator.next();
if (next.done || cancelled || subscriber.closed) break;
subscriber.next({ data: next.value });
}
if (!subscriber.closed) subscriber.complete();
} catch (error: unknown) {
if (!subscriber.closed) subscriber.error(error);
}
})();
return (): void => {
cancelled = true;
void iterator?.return?.();
};
});
}
@Post('sessions/:sessionId/send')
async send(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { content?: string; idempotencyKey?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
throw new ForbiddenException('Content and idempotency key are required');
}
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
const input = {
sessionId,
content: body.content,
idempotencyKey: body.idempotencyKey,
correlationId: context.correlationId,
context,
};
await this.durable.queueProviderSend(input);
await this.durable.dispatchProviderOutbox(sessionId, input);
return { status: 'queued', sessionId };
}
@Post('sessions/:sessionId/stop')
async stop(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { approvalRef?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
if (!body.approvalRef?.trim())
throw new ForbiddenException('Exact-action approval is required');
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
await this.runtime.terminate(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
body.approvalRef,
context,
);
return { status: 'stopped', sessionId };
}
@Post('sessions/:sessionId/recover')
async recover(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
await this.durable.recoverProviderSession(sessionId, {
sessionId,
content: '',
idempotencyKey: `recovery:${context.correlationId}`,
correlationId: context.correlationId,
context,
});
return { status: 'recovered', sessionId };
}
private context(
user: AuthenticatedUserLike,
correlationId?: string,
): RuntimeProviderRequestContext {
const requestCorrelationId = correlationId?.trim();
// This non-simple request header is mandatory for mutations. Browser
// cross-origin requests cannot set it without a CORS preflight, and the
// gateway's allowlist rejects untrusted origins before the handler runs.
if (!requestCorrelationId) {
throw new ForbiddenException('X-Correlation-Id is required');
}
return {
actorScope: scopeFromUser(user),
channelId: 'cli',
correlationId: requestCorrelationId,
};
}
private assertConfiguredAgent(agentName: string): void {
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!configured || configured !== agentName) {
throw new ForbiddenException('Interaction agent is not configured for this request');
}
}
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
if (sessionAgentName !== agentName)
throw new ForbiddenException('Interaction session identity mismatch');
}
private requiredProvider(providerId: string): string {
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
return providerId;
}
}

View File

@@ -1,4 +1,4 @@
import type { ModelCapability } from '@mosaic/types';
import type { ModelCapability } from '@mosaicstack/types';
/**
* Comprehensive capability matrix for all target models.

View File

@@ -1,62 +1,10 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import type { Db } from '@mosaic/db';
import { providerCredentials, eq, and } from '@mosaic/db';
import { seal, unseal } from '@mosaicstack/auth';
import type { Db } from '@mosaicstack/db';
import { providerCredentials, eq, and } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import type { ProviderCredentialSummaryDto } from './provider-credentials.dto.js';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit IV for GCM
const TAG_LENGTH = 16; // 128-bit auth tag
/**
* Derive a 32-byte AES-256 key from BETTER_AUTH_SECRET using SHA-256.
* The secret is assumed to be set in the environment.
*/
function deriveEncryptionKey(): Buffer {
const secret = process.env['BETTER_AUTH_SECRET'];
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is not set — cannot derive encryption key');
}
return createHash('sha256').update(secret).digest();
}
/**
* Encrypt a plain-text value using AES-256-GCM.
* Output format: base64(iv + authTag + ciphertext)
*/
function encrypt(plaintext: string): string {
const key = deriveEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
// Combine iv (12) + authTag (16) + ciphertext and base64-encode
const combined = Buffer.concat([iv, authTag, encrypted]);
return combined.toString('base64');
}
/**
* Decrypt a value encrypted by `encrypt()`.
* Throws on authentication failure (tampered data).
*/
function decrypt(encoded: string): string {
const key = deriveEncryptionKey();
const combined = Buffer.from(encoded, 'base64');
const iv = combined.subarray(0, IV_LENGTH);
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString('utf8');
}
@Injectable()
export class ProviderCredentialsService {
private readonly logger = new Logger(ProviderCredentialsService.name);
@@ -74,7 +22,7 @@ export class ProviderCredentialsService {
value: string,
metadata?: Record<string, unknown>,
): Promise<void> {
const encryptedValue = encrypt(value);
const encryptedValue = seal(value);
await this.db
.insert(providerCredentials)
@@ -122,7 +70,7 @@ export class ProviderCredentialsService {
}
try {
return decrypt(row.encryptedValue);
return unseal(row.encryptedValue);
} catch (err) {
this.logger.error(
`Failed to decrypt credential for user=${userId} provider=${provider}`,

View File

@@ -14,7 +14,7 @@ import type {
ModelInfo,
ProviderHealth,
ProviderInfo,
} from '@mosaic/types';
} from '@mosaicstack/types';
import {
AnthropicAdapter,
OllamaAdapter,
@@ -67,7 +67,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> {
const authStorage = AuthStorage.inMemory();
this.registry = new ModelRegistry(authStorage);
this.registry = ModelRegistry.inMemory(authStorage);
// Build the default set of adapters that rely on the registry
this.adapters = [
@@ -107,8 +107,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
* Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60).
*/
private startHealthCheckScheduler(): void {
const intervalSecs =
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS;
const intervalSecs = this.effectiveHealthCheckIntervalSecs();
const intervalMs = intervalSecs * 1000;
// Run an initial check immediately (non-blocking)
@@ -176,6 +175,28 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy {
});
}
/**
* Returns the effective provider operational policy without credentials,
* endpoints, request content, or provider error details.
*/
getEffectivePolicyStatus(): {
healthCheckIntervalSecs: number;
configuredProviders: string[];
availableModelCount: number;
} {
return {
healthCheckIntervalSecs: this.effectiveHealthCheckIntervalSecs(),
configuredProviders: this.adapters.map((adapter) => adapter.name),
availableModelCount: this.registry?.getAvailable().length ?? 0,
};
}
private effectiveHealthCheckIntervalSecs(): number {
return (
parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS
);
}
// ---------------------------------------------------------------------------
// Adapter-pattern API
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest';
import { ProvidersController } from './providers.controller.js';
describe('ProvidersController operational status', (): void => {
it('reports provider latency and effective policy without exposing provider error details', (): void => {
const providerService = {
getProvidersHealth: vi.fn(() => [
{
name: 'fleet',
status: 'down',
latencyMs: 42,
lastChecked: '2026-07-12T00:00:00.000Z',
modelCount: 0,
error: 'credential-canary=secret-value',
},
]),
getEffectivePolicyStatus: vi.fn(() => ({
healthCheckIntervalSecs: 60,
configuredProviders: ['fleet'],
availableModelCount: 0,
})),
};
const controller = new ProvidersController(providerService as never, {} as never, {} as never);
const status = controller.status();
expect(status).toEqual({
providers: [
{
name: 'fleet',
status: 'down',
latencyMs: 42,
lastChecked: '2026-07-12T00:00:00.000Z',
modelCount: 0,
errorCode: 'provider_unavailable',
},
],
effectivePolicy: {
healthCheckIntervalSecs: 60,
configuredProviders: ['fleet'],
availableModelCount: 0,
},
});
expect(JSON.stringify(status)).not.toContain('secret-value');
});
});

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, Inject, Param, Post, UseGuards } from '@nestjs/common';
import type { RoutingCriteria } from '@mosaic/types';
import type { RoutingCriteria } from '@mosaicstack/types';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { ProviderService } from './provider.service.js';
@@ -33,7 +33,20 @@ export class ProvidersController {
@Get('health')
health() {
return { providers: this.providerService.getProvidersHealth() };
return { providers: this.safeProviderHealth() };
}
/**
* Safe operational status for troubleshooting and readiness checks. Provider
* errors are reduced to a stable code so credentials and remote responses
* cannot leak through this endpoint.
*/
@Get('status')
status() {
return {
providers: this.safeProviderHealth(),
effectivePolicy: this.providerService.getEffectivePolicyStatus(),
};
}
@Post('test')
@@ -51,6 +64,13 @@ export class ProvidersController {
return this.routingService.rank(criteria);
}
private safeProviderHealth() {
return this.providerService.getProvidersHealth().map(({ error, ...provider }) => ({
...provider,
...(error ? { errorCode: 'provider_unavailable' } : {}),
}));
}
// ── Credential CRUD ──────────────────────────────────────────────────────
/**

View File

@@ -1,6 +1,6 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { ModelInfo } from '@mosaic/types';
import type { RoutingCriteria, RoutingResult, CostTier } from '@mosaic/types';
import type { ModelInfo } from '@mosaicstack/types';
import type { RoutingCriteria, RoutingResult, CostTier } from '@mosaicstack/types';
import { ProviderService } from './provider.service.js';
/** Per-million-token cost thresholds for tier classification */

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { routingRules, type Db, sql } from '@mosaic/db';
import { routingRules, type Db, sql } from '@mosaicstack/db';
import { DB } from '../../database/database.module.js';
import type { RoutingCondition, RoutingAction } from './routing.types.js';

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { routingRules, type Db, and, asc, eq, or } from '@mosaic/db';
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
import { DB } from '../../database/database.module.js';
import { ProviderService } from '../provider.service.js';
import { classifyTask } from './task-classifier.js';

View File

@@ -13,7 +13,7 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { routingRules, type Db, and, asc, eq, or, inArray } from '@mosaic/db';
import { routingRules, type Db, and, asc, eq, or, inArray } from '@mosaicstack/db';
import { DB } from '../../database/database.module.js';
import { AuthGuard } from '../../auth/auth.guard.js';
import { CurrentUser } from '../../auth/current-user.decorator.js';

View File

@@ -1,7 +1,7 @@
/**
* Routing engine types — M4-002 (condition types) and M4-003 (action types).
*
* These types are re-exported from `@mosaic/types` for shared use across packages.
* These types are re-exported from `@mosaicstack/types` for shared use across packages.
*/
// ─── Classification primitives ───────────────────────────────────────────────
@@ -23,7 +23,7 @@ export type Domain = 'frontend' | 'backend' | 'devops' | 'docs' | 'general';
/**
* Cost tier for model selection.
* Extends the existing `CostTier` in `@mosaic/types` with `local` for self-hosted models.
* Extends the existing `CostTier` in `@mosaicstack/types` with `local` for self-hosted models.
*/
export type CostTier = 'cheap' | 'standard' | 'premium' | 'local';

View File

@@ -0,0 +1,13 @@
import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common';
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
/** Maps a consumed/missing runtime approval to a stable HTTP authorization response. */
@Catch(RuntimeApprovalDeniedError)
export class RuntimeApprovalDeniedFilter implements ExceptionFilter {
catch(_exception: RuntimeApprovalDeniedError, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<{
status(code: number): { send(body: { statusCode: number; message: string }): void };
}>();
response.status(403).send({ statusCode: 403, message: 'Runtime termination approval denied' });
}
}

View File

@@ -0,0 +1,500 @@
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import {
createRuntimeAuditLogEntry,
type LogService,
type RuntimeAuditErrorCode,
} from '@mosaicstack/log';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
TransitionalCapabilityInventoryEntry,
TransitionalCapabilityInventoryProvider,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { LOG_SERVICE } from '../log/log.tokens.js';
export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
export type RuntimeProviderOperation =
| RuntimeCapability
| 'runtime.capabilities'
| 'runtime.health'
| 'runtime.transitional-capabilities';
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
export interface RuntimeProviderRequestContext {
actorScope: ActorTenantScope;
channelId: string;
correlationId: string;
}
/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */
export interface RuntimeAuditEvent {
providerId: string;
operation: RuntimeProviderOperation;
outcome: RuntimeProviderAuditOutcome;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
resourceId?: string;
durationMs?: number;
errorCode?: RuntimeAuditErrorCode;
}
export interface RuntimeAuditSink {
record(event: RuntimeAuditEvent): Promise<void>;
}
/** Exact action shape that a durable approval implementation must consume once. */
export interface RuntimeTerminationAction {
providerId: string;
sessionId: string;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
agentName: string;
}
export interface RuntimeApprovalVerifier {
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
}
function isTransitionalInventoryProvider(
provider: AgentRuntimeProvider,
): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider {
return (
typeof (provider as Partial<TransitionalCapabilityInventoryProvider>)
.transitionalCapabilityMatrix === 'function'
);
}
function configuredAgentName(): string {
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!agentName) throw new RuntimeApprovalDeniedError();
return agentName;
}
export class RuntimeApprovalDeniedError extends Error {
constructor() {
super('Runtime termination approval denied');
}
}
/**
* The default denies all runtime termination until a durable, exact-action
* approval implementation is configured. This is safer than a permissive stub.
*/
@Injectable()
export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise<boolean> {
return false;
}
}
/**
* Temporary metadata-only audit sink. M1 observability can replace this token
* with a durable audit writer without changing provider call sites.
*/
@Injectable()
export class RuntimeProviderAuditService implements RuntimeAuditSink {
private readonly logger = new Logger(RuntimeProviderAuditService.name);
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
async record(event: RuntimeAuditEvent): Promise<void> {
const entry = createRuntimeAuditLogEntry(event);
await this.logService.logs.ingest(entry);
this.logger.log(JSON.stringify({ event: entry.content, metadata: entry.metadata }));
}
}
@Injectable()
export class RuntimeProviderService {
private readonly logger = new Logger(RuntimeProviderService.name);
constructor(
@Inject(AGENT_RUNTIME_PROVIDER_REGISTRY)
private readonly registry: AgentRuntimeProviderRegistry,
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
private readonly audit: RuntimeAuditSink,
@Inject(RUNTIME_APPROVAL_VERIFIER)
private readonly approvals: RuntimeApprovalVerifier,
) {}
async capabilities(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeCapabilitySet> {
return this.execute(
providerId,
'runtime.capabilities',
undefined,
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeCapabilitySet> =>
provider.capabilities(scope),
);
}
async health(providerId: string, context: RuntimeProviderRequestContext): Promise<RuntimeHealth> {
return this.execute(
providerId,
'runtime.health',
undefined,
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeHealth> =>
provider.health(scope),
);
}
async transitionalCapabilityMatrix(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<TransitionalCapabilityInventoryEntry[]> {
return this.execute(
providerId,
'runtime.transitional-capabilities',
undefined,
undefined,
context,
async (provider: AgentRuntimeProvider, scope: RuntimeScope) => {
if (!isTransitionalInventoryProvider(provider)) {
throw new NotFoundException('Runtime provider has no transitional capability inventory');
}
return provider.transitionalCapabilityMatrix(scope);
},
);
}
async listSessions(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeSession[]> {
return this.execute(
providerId,
'session.list',
'session.list',
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSession[]> =>
provider.listSessions(scope),
);
}
async getSessionTree(
providerId: string,
context: RuntimeProviderRequestContext,
): Promise<RuntimeSessionTree[]> {
return this.execute(
providerId,
'session.tree',
'session.tree',
undefined,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSessionTree[]> =>
provider.getSessionTree(scope),
);
}
streamSession(
providerId: string,
sessionId: string,
cursor: string | undefined,
context: RuntimeProviderRequestContext,
): AsyncIterable<RuntimeStreamEvent> {
return this.stream(
providerId,
'session.stream',
'session.stream',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable<RuntimeStreamEvent> =>
provider.streamSession(sessionId, cursor, scope),
);
}
async sendMessage(
providerId: string,
sessionId: string,
message: RuntimeMessage,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.send',
'session.send',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
provider.sendMessage(sessionId, message, scope),
);
}
async attach(
providerId: string,
sessionId: string,
mode: RuntimeAttachMode,
context: RuntimeProviderRequestContext,
): Promise<RuntimeAttachHandle> {
return this.execute(
providerId,
'session.attach',
'session.attach',
sessionId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeAttachHandle> =>
provider.attach(sessionId, mode, scope),
);
}
async detach(
providerId: string,
attachmentId: string,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.attach',
'session.attach',
attachmentId,
context,
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
provider.detach(attachmentId, scope),
);
}
async terminate(
providerId: string,
sessionId: string,
approvalRef: string,
context: RuntimeProviderRequestContext,
): Promise<void> {
await this.execute(
providerId,
'session.terminate',
'session.terminate',
sessionId,
context,
async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> => {
const approved = await this.approvals.consume(approvalRef, {
providerId,
sessionId,
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
agentName: configuredAgentName(),
});
if (!approved) {
throw new RuntimeApprovalDeniedError();
}
await provider.terminate(sessionId, approvalRef, scope);
},
);
}
private async execute<T>(
providerId: string,
operation: RuntimeProviderOperation,
requiredCapability: RuntimeCapability | undefined,
resourceId: string | undefined,
context: RuntimeProviderRequestContext,
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
): Promise<T> {
const scope = this.deriveScope(context);
const startedAt = Date.now();
await this.record(providerId, operation, 'requested', scope, resourceId);
let invocationStarted = false;
try {
const provider = this.provider(providerId);
if (requiredCapability) {
await this.assertCapability(provider, requiredCapability, scope);
}
invocationStarted = true;
const result = await invoke(provider, scope);
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
return result;
} catch (error: unknown) {
const durationMs = Date.now() - startedAt;
if (invocationStarted && !this.isAuthorizationDenied(error)) {
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
} else {
await this.record(
providerId,
operation,
'denied',
scope,
resourceId,
durationMs,
'policy_denied',
);
}
throw error;
}
}
private async *stream(
providerId: string,
operation: RuntimeProviderOperation,
requiredCapability: RuntimeCapability,
resourceId: string,
context: RuntimeProviderRequestContext,
invoke: (
provider: AgentRuntimeProvider,
scope: RuntimeScope,
) => AsyncIterable<RuntimeStreamEvent>,
): AsyncIterable<RuntimeStreamEvent> {
const scope = this.deriveScope(context);
const startedAt = Date.now();
await this.record(providerId, operation, 'requested', scope, resourceId);
let invocationStarted = false;
try {
const provider = this.provider(providerId);
await this.assertCapability(provider, requiredCapability, scope);
invocationStarted = true;
for await (const event of invoke(provider, scope)) {
yield event;
}
await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt);
} catch (error: unknown) {
const durationMs = Date.now() - startedAt;
if (invocationStarted) {
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
} else {
await this.record(
providerId,
operation,
'denied',
scope,
resourceId,
durationMs,
'policy_denied',
);
}
throw error;
}
}
private isAuthorizationDenied(error: unknown): boolean {
return (
error instanceof RuntimeApprovalDeniedError ||
error instanceof ForbiddenException ||
(typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'forbidden')
);
}
private provider(providerId: string): AgentRuntimeProvider {
try {
return this.registry.require(providerId);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Runtime provider is not registered';
throw new NotFoundException(message);
}
}
private async assertCapability(
provider: AgentRuntimeProvider,
requiredCapability: RuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
const capabilities = await provider.capabilities(scope);
if (!capabilities.supported.includes(requiredCapability)) {
throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`);
}
}
private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope {
const actorId = context.actorScope.userId.trim();
const tenantId = context.actorScope.tenantId.trim();
const channelId = context.channelId.trim();
const correlationId = context.correlationId.trim();
if (!actorId || !tenantId || !channelId || !correlationId) {
throw new ForbiddenException(
'Authenticated runtime actor scope and correlation are required',
);
}
return Object.freeze({ actorId, tenantId, channelId, correlationId });
}
private async recordFailure(
providerId: string,
operation: RuntimeProviderOperation,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs: number,
): Promise<void> {
try {
await this.record(
providerId,
operation,
'failed',
scope,
resourceId,
durationMs,
'provider_error',
);
} catch {
this.logger.error(
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
);
}
}
private async recordCompletion(
providerId: string,
operation: RuntimeProviderOperation,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs: number,
): Promise<void> {
try {
await this.record(providerId, operation, 'succeeded', scope, resourceId, durationMs);
} catch {
this.logger.error(
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
);
}
}
private async record(
providerId: string,
operation: RuntimeProviderOperation,
outcome: RuntimeProviderAuditOutcome,
scope: RuntimeScope,
resourceId: string | undefined,
durationMs?: number,
errorCode?: RuntimeAuditErrorCode,
): Promise<void> {
await this.audit.record({
providerId,
operation,
outcome,
actorId: scope.actorId,
tenantId: scope.tenantId,
channelId: scope.channelId,
correlationId: scope.correlationId,
...(resourceId ? { resourceId } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
...(errorCode ? { errorCode } : {}),
});
}
}

View File

@@ -10,6 +10,8 @@ import {
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { AgentService } from './agent.service.js';
@Controller('api/sessions')
@@ -18,23 +20,24 @@ export class SessionsController {
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Get()
list() {
const sessions = this.agentService.listSessions();
list(@CurrentUser() user: AuthenticatedUserLike) {
const sessions = this.agentService.listSessions(scopeFromUser(user));
return { sessions, total: sessions.length };
}
@Get(':id')
findOne(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
if (!info) throw new NotFoundException('Session not found');
return info;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async destroy(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const scope = scopeFromUser(user);
const info = this.agentService.getSessionInfo(id, scope);
if (!info) throw new NotFoundException('Session not found');
await this.agentService.destroySession(id);
await this.agentService.destroySession(id, scope);
}
}

View File

@@ -1,6 +1,6 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaic/brain';
import type { Brain } from '@mosaicstack/brain';
export function createBrainTools(brain: Brain): ToolDefinition[] {
const listProjects: ToolDefinition = {

View File

@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { createMemoryTools } from './memory-tools.js';
describe('createMemoryTools operator retrieval binding', () => {
const memory = {
insights: { searchByEmbedding: vi.fn(), create: vi.fn() },
preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() },
};
const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' };
it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => {
const plugin = {
search: vi.fn(async () => []),
capture: vi.fn(async () => ({ id: 'insight-1' })),
};
const tools = createMemoryTools(memory as never, null, 'owner-a', {
plugin: plugin as never,
scope,
});
await tools
.find((tool) => tool.name === 'memory_search')!
.execute('call-1', { query: 'plans' }, undefined, undefined, {} as never);
await tools
.find((tool) => tool.name === 'memory_save_insight')!
.execute(
'call-2',
{ content: 'secret', category: 'decision' },
undefined,
undefined,
{} as never,
);
expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5);
expect(plugin.capture).toHaveBeenCalledWith(scope, {
content: 'secret',
source: 'agent',
category: 'decision',
});
});
});

View File

@@ -1,7 +1,11 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { Memory } from '@mosaic/memory';
import type { EmbeddingProvider } from '@mosaic/memory';
import type {
EmbeddingProvider,
Memory,
OperatorMemoryPlugin,
OperatorMemoryScope,
} from '@mosaicstack/memory';
/**
* Create memory tools bound to the session's authenticated userId.
@@ -13,8 +17,10 @@ import type { EmbeddingProvider } from '@mosaic/memory';
export function createMemoryTools(
memory: Memory,
embeddingProvider: EmbeddingProvider | null,
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
sessionUserId: string | undefined,
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
): ToolDefinition[] {
/** Return an error result when no session user is bound. */
function noUserError() {
@@ -46,6 +52,14 @@ export function createMemoryTools(
limit?: number;
};
if (operatorMemory) {
const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5);
return {
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
details: undefined,
};
}
if (!embeddingProvider) {
return {
content: [
@@ -158,6 +172,18 @@ export function createMemoryTools(
};
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
if (operatorMemory) {
const insight = await operatorMemory.plugin.capture(operatorMemory.scope, {
content,
source: 'agent',
category: category ?? 'learning',
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
details: undefined,
};
}
let embedding: number[] | null = null;
if (embeddingProvider) {
embedding = await embeddingProvider.embed(content);

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { HealthController } from './health/health.controller.js';
import { ConfigModule } from './config/config.module.js';
import { DatabaseModule } from './database/database.module.js';
import { AuthModule } from './auth/auth.module.js';
import { BrainModule } from './brain/brain.module.js';
@@ -23,11 +24,13 @@ import { GCModule } from './gc/gc.module.js';
import { ReloadModule } from './reload/reload.module.js';
import { WorkspaceModule } from './workspace/workspace.module.js';
import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 60 }]),
ConfigModule,
DatabaseModule,
AuthModule,
BrainModule,
@@ -50,6 +53,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
QueueModule,
ReloadModule,
WorkspaceModule,
FederationModule,
],
controllers: [HealthController],
providers: [

View File

@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { toNodeHandler } from 'better-auth/node';
import type { Auth } from '@mosaic/auth';
import type { Auth } from '@mosaicstack/auth';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { AUTH } from './auth.tokens.js';

View File

@@ -6,7 +6,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaic/auth';
import type { Auth } from '@mosaicstack/auth';
import type { FastifyRequest } from 'fastify';
import { AUTH } from './auth.tokens.js';

View File

@@ -1,6 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { createAuth, type Auth } from '@mosaic/auth';
import type { Db } from '@mosaic/db';
import { createAuth, type Auth } from '@mosaicstack/auth';
import type { Db } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { AUTH } from './auth.tokens.js';
import { SsoController } from './sso.controller.js';
@@ -14,7 +14,7 @@ import { SsoController } from './sso.controller.js';
useFactory: (db: Db): Auth =>
createAuth({
db,
baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:4000',
baseURL: process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242',
secret: process.env['BETTER_AUTH_SECRET'],
}),
inject: [DB],

View File

@@ -0,0 +1,24 @@
export interface AuthenticatedUserLike {
id: string;
tenantId?: string | null;
teamId?: string | null;
organizationId?: string | null;
orgId?: string | null;
}
export interface ActorTenantScope {
userId: string;
tenantId: string;
}
/**
* Build the immutable server-derived scope used for Tess session operations.
* Current Mosaic auth is user-scoped; future org/team claims can populate one
* of the tenant fields without allowing clients to choose another tenant.
*/
export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope {
return {
userId: user.id,
tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id,
};
}

View File

@@ -1,5 +1,5 @@
import { Controller, Get } from '@nestjs/common';
import { buildSsoDiscovery, type SsoProviderDiscovery } from '@mosaic/auth';
import { buildSsoDiscovery, type SsoProviderDiscovery } from '@mosaicstack/auth';
@Controller('api/sso/providers')
export class SsoController {

View File

@@ -1,6 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { createBrain, type Brain } from '@mosaic/brain';
import type { Db } from '@mosaic/db';
import { createBrain, type Brain } from '@mosaicstack/brain';
import type { Db } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { BRAIN } from './brain.tokens.js';

View File

@@ -12,7 +12,8 @@ describe('Chat controller source hardening', () => {
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect(source).toContain('@UseGuards(AuthGuard)');
expect(source).toContain('@CurrentUser() user: { id: string }');
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
expect(source).toContain('const scope = scopeFromUser(user);');
});
});

View File

@@ -3,8 +3,10 @@ import {
Post,
Body,
Logger,
ForbiddenException,
HttpException,
HttpStatus,
NotFoundException,
Inject,
UseGuards,
} from '@nestjs/common';
@@ -13,6 +15,7 @@ import { Throttle } from '@nestjs/throttler';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { v4 as uuid } from 'uuid';
import { ChatRequestDto } from './chat.dto.js';
@@ -32,16 +35,23 @@ export class ChatController {
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async chat(
@Body() body: ChatRequestDto,
@CurrentUser() user: { id: string },
@CurrentUser() user: AuthenticatedUserLike,
): Promise<ChatResponse> {
const conversationId = body.conversationId ?? uuid();
const scope = scopeFromUser(user);
try {
let agentSession = this.agentService.getSession(conversationId);
let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId);
agentSession = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
});
}
} catch (err) {
if (err instanceof ForbiddenException) {
throw new NotFoundException('Session not found');
}
this.logger.error(
`Session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
@@ -60,20 +70,27 @@ export class ChatController {
reject(new Error('Agent response timed out'));
}, 120_000);
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
clearTimeout(timer);
cleanup();
resolve();
}
});
const cleanup = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
if (
event.type === 'message_update' &&
event.assistantMessageEvent.type === 'text_delta'
) {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
clearTimeout(timer);
cleanup();
resolve();
}
},
scope,
);
});
try {
await this.agentService.prompt(conversationId, body.content);
await this.agentService.prompt(conversationId, body.content, scope);
await done;
} catch (err) {
if (err instanceof HttpException) throw err;

View File

@@ -1,3 +1,4 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto {
@@ -32,4 +33,7 @@ export class ChatSocketMessageDto {
@IsOptional()
@IsUUID()
agentId?: string;
/** Validated channel attachment references; binary content is not embedded. */
attachments?: readonly ChannelAttachmentDto[];
}

View File

@@ -1,3 +1,4 @@
import { timingSafeEqual } from 'node:crypto';
import type { IncomingHttpHeaders } from 'node:http';
import { fromNodeHeaders } from 'better-auth/node';
@@ -12,6 +13,19 @@ export interface SessionAuth {
};
}
export function validateDiscordServiceToken(
candidate: unknown,
expected: string | undefined,
): boolean {
if (typeof candidate !== 'string' || !expected) return false;
const candidateBuffer = Buffer.from(candidate);
const expectedBuffer = Buffer.from(expected);
return (
candidateBuffer.length === expectedBuffer.length &&
timingSafeEqual(candidateBuffer, expectedBuffer)
);
}
export async function validateSocketSession(
headers: IncomingHttpHeaders,
auth: SessionAuth,

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { ChatGateway } from './chat.gateway.js';
const payload: SlashCommandPayload = {
command: 'gc',
conversationId: 'conversation-1',
approvalId: 'approval-1',
};
function buildGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
return new ChatGateway(
{} as never,
{} as never,
{} as never,
{} as never,
commandExecutor as never,
{} as never,
);
}
describe('ChatGateway command approval ingress', () => {
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
createApproval: vi.fn(),
};
const gateway = buildGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, payload);
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, {
userId: 'admin-1',
tenantId: 'admin-1',
});
expect(client.emit).toHaveBeenCalledWith(
'command:result',
expect.objectContaining({ success: true }),
);
});
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn(),
createApproval: vi.fn().mockResolvedValue({
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
}),
};
const gateway = buildGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandApproval(client as never, {
command: 'gc',
conversationId: 'conversation-1',
});
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
{ command: 'gc', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith('command:approval', {
command: 'gc',
conversationId: 'conversation-1',
success: true,
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
});
});
});

View File

@@ -0,0 +1,221 @@
import { describe, expect, it, vi } from 'vitest';
import { ChatGateway } from './chat.gateway.js';
const CONVERSATION_ID = 'conversation-1';
const CANARY = 'sk_canary12345678';
function clientConversationKey(clientId: string, conversationId: string): string {
return `${clientId}\u0000${conversationId}`;
}
type GatewayInternals = {
clientSessions: Map<string, unknown>;
relayEvent(client: unknown, conversationId: string, event: unknown): void;
};
function buildGateway() {
const brain = {
conversations: {
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
return { gateway: gateway as unknown as GatewayInternals, brain };
}
describe('ChatGateway redaction boundary', (): void => {
it('redacts a secret split across assistant deltas before egress and persistence', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
const session = {
clientId: client.id,
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: '',
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
};
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session);
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' },
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary');
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET] ',
});
expect(session.assistantText).toBe(`${CANARY} `);
expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY);
});
it('retains a split secret label until its value can be redacted', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'token ' },
});
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET] ',
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123');
});
it('holds a streamed private key until it can be redacted', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' },
});
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_SECRET]',
});
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary');
});
it('drops an oversized unterminated stream fragment rather than retaining it', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) },
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: CONVERSATION_ID,
text: '[REDACTED_STREAM_OVERFLOW]',
});
});
it('isolates concurrent conversation streams sharing one Discord socket', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'discord-client',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
const firstConversation = 'Nova:discord:thread-1';
const secondConversation = 'Nova:discord:thread-2';
const createSession = (conversationId: string) => ({
clientId: client.id,
conversationId,
cleanup: vi.fn(),
assistantText: '',
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
});
const firstSession = createSession(firstConversation);
const secondSession = createSession(secondConversation);
gateway.clientSessions.set(clientConversationKey(client.id, firstConversation), firstSession);
gateway.clientSessions.set(clientConversationKey(client.id, secondConversation), secondSession);
gateway.relayEvent(client, firstConversation, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'first response ' },
});
gateway.relayEvent(client, secondConversation, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'second response ' },
});
expect(firstSession.assistantText).toBe('first response ');
expect(secondSession.assistantText).toBe('second response ');
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: firstConversation,
text: 'first response ',
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: secondConversation,
text: 'second response ',
});
});
it('persists only redacted assistant content with classifications', (): void => {
const { gateway, brain } = buildGateway();
const client = {
connected: true,
id: 'client-1',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), {
clientId: client.id,
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: CANARY,
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
});
gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' });
expect(brain.conversations.addMessage).toHaveBeenCalledWith(
expect.objectContaining({
content: '[REDACTED_SECRET]',
metadata: expect.objectContaining({ classifications: ['secret'] }),
}),
'user-1',
);
expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
import { CommandAuthorizationService } from './command-authorization.service.js';
const adminCommand: CommandDef = {
name: 'gc',
description: 'GC',
aliases: [],
scope: 'admin',
execution: 'socket',
available: true,
};
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
function createService(
role: string,
entries: Map<string, string> = new Map<string, string>(),
): CommandAuthorizationService {
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => {
entries.set(key, value);
},
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
describe('CommandAuthorizationService', () => {
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
const service = createService('admin');
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
expect(approval).not.toBeNull();
expect(
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
).toBe(true);
expect(
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
).toBe(false);
});
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
const service = createService('admin');
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
const mutated = { ...payload, conversationId: 'other-conversation' };
expect(approval).not.toBeNull();
expect(
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
).toBe(false);
});
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
const service = createService('member');
const approval = await service.createApproval(adminCommand, payload, 'member-1');
expect(approval).toBeNull();
expect(
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
).toBe(false);
});
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
providerId: 'fleet',
sessionId: 'nova',
actorId: 'admin-1',
tenantId: 'tenant-1',
channelId: 'discord:operator',
correlationId: 'correlation-malformed-expiry',
agentName: 'Nova',
};
const service = createService('admin', entries);
const approval = await service.createRuntimeTerminationApproval(action);
expect(approval).not.toBeNull();
const key = `agent:Nova:command-approval:${approval!.approvalId}`;
const stored = entries.get(key);
expect(stored).toBeDefined();
entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' }));
expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
false,
);
});
it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
providerId: 'fleet',
sessionId: 'nova',
actorId: 'admin-1',
tenantId: 'tenant-1',
channelId: 'discord:operator',
correlationId: 'correlation-1',
agentName: 'Nova',
};
const beforeRestart = createService('admin', entries);
const approval = await beforeRestart.createRuntimeTerminationApproval(action);
const afterRestart = createService('admin', entries);
expect(
await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, {
...action,
sessionId: 'forged-session',
}),
).toBe(false);
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
true,
);
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
false,
);
});
});

View File

@@ -0,0 +1,268 @@
import { createHash, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
import { DB } from '../database/database.module.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
export type CommandRole = 'admin' | 'member' | 'viewer';
export interface CommandApproval {
approvalId: string;
actionDigest: string;
actorId: string;
command: string;
expiresAt: string;
}
/** Exact immutable binding for a privileged runtime termination. */
export interface RuntimeTerminationApprovalAction {
providerId: string;
sessionId: string;
actorId: string;
tenantId: string;
channelId: string;
correlationId: string;
/** Provisioned roster identity; isolates approvals between interaction agents. */
agentName: string;
}
export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction {
approvalId: string;
actionDigest: string;
expiresAt: string;
}
export interface CommandAuthorizationResult {
allowed: boolean;
reason?: string;
}
@Injectable()
export class CommandAuthorizationService {
constructor(
@Inject(DB) private readonly db: Db,
@Inject(COMMANDS_REDIS)
private readonly redis: {
get(key: string): Promise<string | null>;
set(key: string, value: string, ...args: string[]): Promise<unknown>;
del(key: string): Promise<number>;
},
) {}
async authorize(
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
approvalId?: string,
): Promise<CommandAuthorizationResult> {
const role = await this.resolveRole(actorId);
if (!role || !this.hasScope(role, command.scope)) {
return { allowed: false, reason: 'not authorized for this command scope' };
}
if (command.scope !== 'admin') return { allowed: true };
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
const actionDigest = this.actionDigest(command.name, payload);
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
return approved
? { allowed: true }
: {
allowed: false,
reason: 'approval is invalid, expired, replayed, or does not match this action',
};
}
async createApproval(
command: CommandDef,
payload: SlashCommandPayload,
actorId: string,
): Promise<CommandApproval | null> {
const role = await this.resolveRole(actorId);
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
const approvalId = randomUUID();
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
const approval: CommandApproval = {
approvalId,
actionDigest: this.actionDigest(command.name, payload),
actorId,
command: command.name,
expiresAt,
};
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
return approval;
}
/**
* Uses the same `interaction:command-approval:*` store and one-time deletion rule as
* command approvals. This deliberately avoids a parallel approval database.
*/
async createRuntimeTerminationApproval(
action: RuntimeTerminationApprovalAction,
): Promise<RuntimeTerminationApproval | null> {
if (!this.hasRuntimeTerminationAction(action)) return null;
const role = await this.resolveRole(action.actorId);
if (role !== 'admin') return null;
const approval: RuntimeTerminationApproval = {
approvalId: randomUUID(),
actionDigest: this.runtimeActionDigest(action),
...action,
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
};
await this.redis.set(
this.runtimeKey(action.agentName, approval.approvalId),
JSON.stringify(approval),
'EX',
'300',
);
return approval;
}
async consumeRuntimeTerminationApproval(
approvalId: string,
action: RuntimeTerminationApprovalAction,
): Promise<boolean> {
const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId));
if (!encoded) return false;
let approval: unknown;
try {
approval = JSON.parse(encoded);
} catch {
return false;
}
if (
!this.isRuntimeTerminationApproval(approval) ||
approval.actionDigest !== this.runtimeActionDigest(action) ||
approval.actorId !== action.actorId ||
approval.tenantId !== action.tenantId ||
!this.isUnexpired(approval.expiresAt)
) {
return false;
}
if ((await this.resolveRole(approval.actorId)) !== 'admin') return false;
return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1;
}
private async resolveRole(actorId: string): Promise<CommandRole | null> {
const [user] = await this.db
.select({ role: usersTable.role })
.from(usersTable)
.where(eq(usersTable.id, actorId))
.limit(1);
const role = user?.role;
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
}
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
if (role === 'admin') return true;
return role === 'member' && (scope === 'core' || scope === 'agent');
}
private async consumeApproval(
approvalId: string,
actorId: string,
actionDigest: string,
): Promise<boolean> {
const key = this.key(approvalId);
const encoded = await this.redis.get(key);
if (!encoded) return false;
let parsed: unknown;
try {
parsed = JSON.parse(encoded);
} catch {
return false;
}
if (
!this.isCommandApproval(parsed) ||
parsed.actorId !== actorId ||
parsed.actionDigest !== actionDigest ||
!this.isUnexpired(parsed.expiresAt)
)
return false;
return (await this.redis.del(key)) === 1;
}
private actionDigest(command: string, payload: SlashCommandPayload): string {
return createHash('sha256')
.update(
JSON.stringify({
command,
args: payload.args?.trim() ?? '',
conversationId: payload.conversationId,
}),
)
.digest('hex');
}
private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean {
return [
action.providerId,
action.sessionId,
action.actorId,
action.tenantId,
action.channelId,
action.correlationId,
action.agentName,
].every((value: string): boolean => value.trim().length > 0);
}
private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string {
return createHash('sha256')
.update(
JSON.stringify({
providerId: action.providerId,
sessionId: action.sessionId,
actorId: action.actorId,
tenantId: action.tenantId,
channelId: action.channelId,
correlationId: action.correlationId,
agentName: action.agentName,
}),
)
.digest('hex');
}
private isUnexpired(expiresAt: unknown): expiresAt is string {
if (typeof expiresAt !== 'string') return false;
const expiresAtMs = Date.parse(expiresAt);
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
}
private isCommandApproval(value: unknown): value is CommandApproval {
return (
typeof value === 'object' &&
value !== null &&
'approvalId' in value &&
'actionDigest' in value &&
'actorId' in value &&
'expiresAt' in value &&
'command' in value
);
}
private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval {
return (
typeof value === 'object' &&
value !== null &&
'approvalId' in value &&
'actionDigest' in value &&
'actorId' in value &&
'tenantId' in value &&
'providerId' in value &&
'sessionId' in value &&
'channelId' in value &&
'correlationId' in value &&
'agentName' in value &&
'expiresAt' in value
);
}
private key(approvalId: string): string {
return `interaction:command-approval:${approvalId}`;
}
private runtimeKey(agentName: string, approvalId: string): string {
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
}
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CommandExecutorService } from './command-executor.service.js';
import type { SlashCommandPayload } from '@mosaic/types';
import type { SlashCommandPayload } from '@mosaicstack/types';
// Minimal mock implementations
const mockRegistry = {
@@ -89,6 +89,7 @@ function buildService(): CommandExecutorService {
describe('CommandExecutorService — P8-012 commands', () => {
let service: CommandExecutorService;
const userId = 'user-123';
const userScope = { userId, tenantId: userId };
const conversationId = 'conv-456';
beforeEach(() => {
@@ -99,31 +100,26 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider login — missing provider name
it('/provider login with no provider name returns usage error', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Usage: /provider login');
expect(result.command).toBe('provider');
});
// /provider login anthropic — success with URL containing poll token
it('/provider login <name> returns success with URL and poll token', async () => {
// /provider login anthropic — no bearer token or auth URL reaches chat output
it('/provider login <name> keeps its one-time token out of chat output', async () => {
const payload: SlashCommandPayload = {
command: 'provider',
args: 'login anthropic',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('provider');
expect(result.message).toContain('anthropic');
expect(result.message).toContain('http');
// data should contain loginUrl and pollToken
expect(result.data).toBeDefined();
const data = result.data as Record<string, unknown>;
expect(typeof data['loginUrl']).toBe('string');
expect(typeof data['pollToken']).toBe('string');
expect(data['loginUrl'] as string).toContain('anthropic');
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
expect(result.message).not.toContain('http');
expect(result.message).not.toContain('token=');
expect(result.data).toEqual({ provider: 'anthropic' });
// Verify Valkey was called
expect(mockRedis.set).toHaveBeenCalledOnce();
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
@@ -138,7 +134,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider with no args — returns usage
it('/provider with no args returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'provider', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage: /provider');
});
@@ -146,7 +142,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider list
it('/provider list returns success', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('provider');
});
@@ -154,7 +150,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /provider logout with no name — usage error
it('/provider logout with no name returns error', async () => {
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Usage: /provider logout');
});
@@ -166,7 +162,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'unknown',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Unknown subcommand');
});
@@ -174,7 +170,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /mission status
it('/mission status returns stub message', async () => {
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('mission');
expect(result.message).toContain('Mission status');
@@ -183,7 +179,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /mission with no args
it('/mission with no args returns status stub', async () => {
const payload: SlashCommandPayload = { command: 'mission', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Mission status');
});
@@ -195,7 +191,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'set my-mission-123',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('my-mission-123');
});
@@ -203,7 +199,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /agent list
it('/agent list returns stub message', async () => {
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('agent');
expect(result.message).toContain('agent');
@@ -212,7 +208,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /agent with no args
it('/agent with no args returns usage', async () => {
const payload: SlashCommandPayload = { command: 'agent', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage: /agent');
});
@@ -224,7 +220,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'my-agent-id',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('my-agent-id');
});
@@ -232,7 +228,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /prdy
it('/prdy returns PRD wizard message', async () => {
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('prdy');
expect(result.message).toContain('mosaic prdy');
@@ -241,7 +237,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
// /tools
it('/tools returns tools stub message', async () => {
const payload: SlashCommandPayload = { command: 'tools', conversationId };
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('tools');
expect(result.message).toContain('tools');

View File

@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js';
const registry = {
getManifest: vi.fn(() => ({
version: 1,
commands: [
{
name: 'gc',
description: 'System-wide garbage collection',
aliases: [],
scope: 'admin' as const,
execution: 'socket' as const,
available: true,
},
],
skills: [],
})),
};
const sessionGc = {
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }),
};
const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' });
const authorization = {
authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) =>
Promise.resolve(
actorId === 'member-1'
? { allowed: false, reason: 'durable approval is required' }
: { allowed: false, reason: 'not authorized for this command scope' },
),
),
};
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
return new CommandExecutorService(
registry as never,
{ getSession: vi.fn() } as never,
{ clear: vi.fn(), set: vi.fn() } as never,
sessionGc as never,
{ set: vi.fn() } as never,
{ agents: {} } as never,
null,
null,
null,
authorizationService as never,
);
}
function createDurableAuthorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
const db = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
};
const redis = {
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => {
entries.set(key, value);
},
del: async (key: string) => Number(entries.delete(key)),
};
return new CommandAuthorizationService(db as never, redis);
}
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
beforeEach((): void => {
vi.clearAllMocks();
});
it('denies a forged admin identity and does not execute a system-wide command', async (): Promise<void> => {
const result = await buildExecutor().execute(payload, scope('admin-forged-by-client'));
expect(result.success).toBe(false);
expect(result.message).toContain('not authorized');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
it('denies a privileged command without a server-bound durable approval', async (): Promise<void> => {
const result = await buildExecutor().execute(payload, scope('member-1'));
expect(result.success).toBe(false);
expect(result.message).toContain('approval');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
const executor = buildExecutor(createDurableAuthorization());
const adminScope = scope('admin-1');
const denied = await executor.execute(payload, adminScope);
const approval = await executor.createApproval(payload, adminScope);
const approved = await executor.execute(
{ ...payload, approvalId: approval?.approvalId },
adminScope,
);
expect(denied.success).toBe(false);
expect(denied.message).toContain('approval');
expect(approval).not.toBeNull();
// A valid durable approval is consumed, but cannot authorize an unimplemented
// global retention operation. Session-scoped cleanup remains lifecycle-only.
expect(approved.success).toBe(false);
expect(approved.message).toContain('Global GC is disabled');
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,9 @@
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
import type { QueueHandle } from '@mosaic/queue';
import type { Brain } from '@mosaic/brain';
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
import type { QueueHandle } from '@mosaicstack/queue';
import type { Brain } from '@mosaicstack/brain';
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
import { AgentService } from '../agent/agent.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { ChatGateway } from '../chat/chat.gateway.js';
import { SessionGCService } from '../gc/session-gc.service.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
@@ -10,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js';
import { McpClientService } from '../mcp-client/mcp-client.service.js';
import { BRAIN } from '../brain/brain.tokens.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandRegistryService } from './command-registry.service.js';
@Injectable()
@@ -32,10 +34,17 @@ export class CommandExecutorService {
@Optional()
@Inject(McpClientService)
private readonly mcpClient: McpClientService | null,
@Optional()
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService | null = null,
) {}
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
async execute(
payload: SlashCommandPayload,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
const { command, args, conversationId } = payload;
const userId = scope.userId;
const def = this.registry.getManifest().commands.find((c) => c.name === command);
if (!def) {
@@ -47,14 +56,24 @@ export class CommandExecutorService {
};
}
const authorization = await this.authorization?.authorize(
def,
payload,
userId,
payload.approvalId,
);
if (authorization && !authorization.allowed) {
return { command, conversationId, success: false, message: authorization.reason };
}
try {
switch (command) {
case 'model':
return await this.handleModel(args ?? null, conversationId);
return await this.handleModel(args ?? null, conversationId, scope);
case 'thinking':
return await this.handleThinking(args ?? null, conversationId);
case 'system':
return await this.handleSystem(args ?? null, conversationId);
return await this.handleSystem(args ?? null, conversationId, scope);
case 'new':
return {
command,
@@ -83,18 +102,17 @@ export class CommandExecutorService {
success: true,
message: 'Retry last message requested.',
};
case 'gc': {
// Admin-only: system-wide GC sweep across all sessions
const result = await this.sessionGC.sweepOrphans();
case 'gc':
// Global retention requires a separate, authorized and audited job.
// Session cleanup is performed only through the session lifecycle.
return {
command: 'gc',
success: true,
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
success: false,
message: 'Global GC is disabled pending an authorized retention job.',
conversationId,
};
}
case 'agent':
return await this.handleAgent(args ?? null, conversationId, userId);
return await this.handleAgent(args ?? null, conversationId, scope);
case 'provider':
return await this.handleProvider(args ?? null, userId, conversationId);
case 'mission':
@@ -143,13 +161,22 @@ export class CommandExecutorService {
}
}
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
const def = this.registry
.getManifest()
.commands.find((command) => command.name === payload.command);
if (!def || !this.authorization) return null;
return this.authorization.createApproval(def, payload, scope.userId);
}
private async handleModel(
args: string | null,
conversationId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
if (!args || args.trim().length === 0) {
// Show current override or usage hint
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
if (currentOverride) {
return {
command: 'model',
@@ -171,7 +198,7 @@ export class CommandExecutorService {
// /model clear removes the override and re-enables automatic routing
if (modelName === 'clear') {
this.chatGateway?.setModelOverride(conversationId, null);
this.chatGateway?.setModelOverride(conversationId, null, scope);
return {
command: 'model',
conversationId,
@@ -181,9 +208,9 @@ export class CommandExecutorService {
}
// Set the sticky per-session override (M4-007)
this.chatGateway?.setModelOverride(conversationId, modelName);
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
const session = this.agentService.getSession(conversationId);
const session = this.agentService.getSession(conversationId, scope);
if (!session) {
return {
command: 'model',
@@ -224,10 +251,11 @@ export class CommandExecutorService {
private async handleSystem(
args: string | null,
conversationId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
if (!args || args.trim().length === 0) {
// Clear the override when called with no args
await this.systemOverride.clear(conversationId);
await this.systemOverride.clear(conversationId, scope);
return {
command: 'system',
conversationId,
@@ -236,7 +264,7 @@ export class CommandExecutorService {
};
}
await this.systemOverride.set(conversationId, args.trim());
await this.systemOverride.set(conversationId, args.trim(), scope);
return {
command: 'system',
conversationId,
@@ -248,8 +276,9 @@ export class CommandExecutorService {
private async handleAgent(
args: string | null,
conversationId: string,
userId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
const userId = scope.userId;
if (!args) {
return {
command: 'agent',
@@ -338,11 +367,14 @@ export class CommandExecutorService {
conversationId,
agentConfig.id,
agentConfig.name,
scope,
agentConfig.model ?? undefined,
);
// Broadcast updated session:info so TUI TopBar reflects new agent/model
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
agentName: agentConfig.name,
});
this.logger.log(
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
@@ -403,22 +435,28 @@ export class CommandExecutorService {
};
}
const pollToken = crypto.randomUUID();
const key = `mosaic:auth:poll:${pollToken}`;
// Store pending state in Valkey (TTL 5 minutes)
const tokenDigest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(pollToken),
);
const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string =>
byte.toString(16).padStart(2, '0'),
).join('');
const key = `mosaic:auth:poll:${tokenHash}`;
// Persist only a short-lived token digest. The raw token is delivered only by
// the authenticated dashboard flow, never in chat output or command metadata.
await this.redis.set(
key,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
// In production this would construct an OAuth URL
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
return {
command: 'provider',
success: true,
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
conversationId,
data: { loginUrl, pollToken, provider: providerName },
data: { provider: providerName },
};
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CommandRegistryService } from './command-registry.service.js';
import type { CommandDef } from '@mosaic/types';
import type { CommandDef } from '@mosaicstack/types';
const mockCmd: CommandDef = {
name: 'test',

View File

@@ -1,5 +1,5 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import type { CommandDef, CommandManifest } from '@mosaic/types';
import type { CommandDef, CommandManifest } from '@mosaicstack/types';
@Injectable()
export class CommandRegistryService implements OnModuleInit {

View File

@@ -13,7 +13,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CommandRegistryService } from './command-registry.service.js';
import { CommandExecutorService } from './command-executor.service.js';
import type { SlashCommandPayload } from '@mosaic/types';
import type { SlashCommandPayload } from '@mosaicstack/types';
// ─── Mocks ───────────────────────────────────────────────────────────────────
@@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => {
let registry: CommandRegistryService;
let executor: CommandExecutorService;
const userId = 'user-integ-001';
const userScope = { userId, tenantId: userId };
const conversationId = 'conv-integ-001';
beforeEach(() => {
@@ -170,28 +171,26 @@ describe('CommandExecutorService — integration', () => {
// Unknown command returns error
it('unknown command returns success:false with descriptive message', async () => {
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('nonexistent');
expect(result.command).toBe('nonexistent');
});
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
it('/gc refuses an unaudited global sweep', async () => {
const payload: SlashCommandPayload = { command: 'gc', conversationId };
const result = await executor.execute(payload, userId);
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
expect(result.success).toBe(true);
expect(result.message).toContain('GC sweep complete');
expect(result.message).toContain('3 orphaned sessions');
const result = await executor.execute(payload, userScope);
expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled();
expect(result.success).toBe(false);
expect(result.message).toContain('disabled pending an authorized retention job');
});
// /system with args calls SystemOverrideService.set
it('/system with text calls SystemOverrideService.set', async () => {
const override = 'You are a helpful assistant.';
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
const result = await executor.execute(payload, userId);
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
const result = await executor.execute(payload, userScope);
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('override set');
});
@@ -199,8 +198,8 @@ describe('CommandExecutorService — integration', () => {
// /system with no args clears the override
it('/system with no args calls SystemOverrideService.clear', async () => {
const payload: SlashCommandPayload = { command: 'system', conversationId };
const result = await executor.execute(payload, userId);
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
const result = await executor.execute(payload, userScope);
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('cleared');
});
@@ -212,7 +211,7 @@ describe('CommandExecutorService — integration', () => {
args: 'claude-3-opus',
conversationId,
};
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('model');
expect(result.message).toContain('claude-3-opus');
@@ -221,7 +220,7 @@ describe('CommandExecutorService — integration', () => {
// /thinking with valid level returns success
it('/thinking with valid level returns success', async () => {
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('high');
});
@@ -229,7 +228,7 @@ describe('CommandExecutorService — integration', () => {
// /thinking with invalid level returns usage message
it('/thinking with invalid level returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage:');
});
@@ -237,7 +236,7 @@ describe('CommandExecutorService — integration', () => {
// /new command returns success
it('/new returns success', async () => {
const payload: SlashCommandPayload = { command: 'new', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('new');
});
@@ -245,7 +244,7 @@ describe('CommandExecutorService — integration', () => {
// /reload without reloadService returns failure
it('/reload without ReloadService returns failure', async () => {
const payload: SlashCommandPayload = { command: 'reload', conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('ReloadService');
});
@@ -255,7 +254,7 @@ describe('CommandExecutorService — integration', () => {
for (const cmd of stubCommands) {
it(`/${cmd} returns success (stub)`, async () => {
const payload: SlashCommandPayload = { command: cmd, conversationId };
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe(cmd);
});

View File

@@ -1,10 +1,12 @@
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaic/queue';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js';
import { CommandRegistryService } from './command-registry.service.js';
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
@@ -24,9 +26,16 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
inject: [COMMANDS_QUEUE_HANDLE],
},
CommandRegistryService,
CommandAuthorizationService,
CommandRuntimeApprovalVerifier,
CommandExecutorService,
],
exports: [
CommandRegistryService,
CommandAuthorizationService,
CommandRuntimeApprovalVerifier,
CommandExecutorService,
],
exports: [CommandRegistryService, CommandExecutorService],
})
export class CommandsModule implements OnApplicationShutdown {
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}

View File

@@ -0,0 +1,23 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
RuntimeApprovalVerifier,
RuntimeTerminationAction,
} from '../agent/runtime-provider-registry.service.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
/**
* Adapter from the provider registry's exact termination action to the shared,
* Redis-backed `interaction:command-approval:*` store. It has no separate approval
* persistence or replay semantics.
*/
@Injectable()
export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
constructor(
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService,
) {}
async consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean> {
return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action);
}
}

View File

@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
@Global()
@Module({
providers: [
{
provide: MOSAIC_CONFIG,
useFactory: (): MosaicConfig => loadConfig(),
},
],
exports: [MOSAIC_CONFIG],
})
export class ConfigModule {}

Some files were not shown because too many files have changed in this diff Show More