Compare commits

..
Author SHA1 Message Date
Jarvis c0539f4867 comms: homelab 20260726T165824Z 2026-07-26 11:58:24 -05:00
Jarvis e44f1bb7fb comms: homelab 20260726T164340Z 2026-07-26 11:43:40 -05:00
Jarvis 1ee71bea8b comms: homelab 20260726T162809Z 2026-07-26 11:28:09 -05:00
Jarvis 2be70d91ab comms: homelab 20260726T160150Z 2026-07-26 11:01:50 -05:00
Jarvis 8ffcc380f7 comms: homelab 20260726T154254Z 2026-07-26 10:42:54 -05:00
Jarvis 129f6c4051 comms: homelab 20260726T152805Z 2026-07-26 10:28:06 -05:00
Jarvis 142fbd22de comms: homelab 20260726T151235Z 2026-07-26 10:12:35 -05:00
Jarvis 07106b763b comms: homelab 20260726T145737Z 2026-07-26 09:57:37 -05:00
Jarvis d9c789e236 comms: homelab 20260726T142722Z 2026-07-26 09:27:22 -05:00
Jarvis 7e1f64128e comms: homelab 20260726T135725Z 2026-07-26 08:57:25 -05:00
Jarvis 3a5df0387e comms: homelab 20260726T134208Z 2026-07-26 08:42:08 -05:00
Jarvis 5d92d20af2 comms: homelab 20260726T132658Z 2026-07-26 08:26:58 -05:00
Jarvis 121ee6daee comms: homelab 20260726T131145Z 2026-07-26 08:11:45 -05:00
Jarvis fd8f8c37c1 comms: homelab 20260726T125638Z 2026-07-26 07:56:38 -05:00
Jarvis c41baaa3f9 comms: homelab 20260726T124137Z 2026-07-26 07:41:37 -05:00
Jarvis 5c1d72fa38 comms: homelab 20260726T122618Z 2026-07-26 07:26:18 -05:00
Jarvis 5a360a75b0 comms: homelab 20260726T115604Z 2026-07-26 06:56:04 -05:00
Jarvis 543ed721a3 comms: homelab 20260726T114051Z 2026-07-26 06:40:51 -05:00
Jarvis eafc455aef comms: homelab 20260726T111054Z 2026-07-26 06:10:54 -05:00
Jarvis 8919e4a899 comms: homelab 20260726T105532Z 2026-07-26 05:55:32 -05:00
Jarvis f02e4770db comms: homelab 20260726T104324Z 2026-07-26 05:43:24 -05:00
Jarvis 2e08eb3f2f comms: homelab 20260726T102505Z 2026-07-26 05:25:05 -05:00
Jarvis 8991aae330 comms: homelab 20260726T100951Z 2026-07-26 05:09:51 -05:00
Jarvis 1834f0e6c7 comms: homelab 20260726T090907Z 2026-07-26 04:09:07 -05:00
Jarvis 3ad65deeb1 comms: homelab 20260726T085420Z 2026-07-26 03:54:20 -05:00
Jarvis 96910e9eaf comms: homelab 20260726T083907Z 2026-07-26 03:39:07 -05:00
Jarvis 73760f1874 comms: homelab 20260726T082414Z 2026-07-26 03:24:14 -05:00
Jarvis 063614583a comms: homelab 20260726T080855Z 2026-07-26 03:08:55 -05:00
Jarvis b039e7f7f1 comms: homelab 20260726T075350Z 2026-07-26 02:53:50 -05:00
Jarvis 21c3042f74 comms: homelab 20260726T073840Z 2026-07-26 02:38:40 -05:00
Jarvis 74b391e642 comms: homelab 20260726T072351Z 2026-07-26 02:23:51 -05:00
Jarvis 6e7de21984 comms: homelab 20260726T070830Z 2026-07-26 02:08:30 -05:00
Jarvis d3467241bc comms: homelab 20260726T065341Z 2026-07-26 01:53:41 -05:00
Jarvis ee3fdaf8fb comms: homelab 20260726T063836Z 2026-07-26 01:38:36 -05:00
Jarvis c916e8ae79 comms: homelab 20260726T062332Z 2026-07-26 01:23:32 -05:00
Jarvis 15be5bd67e comms: homelab 20260726T061138Z 2026-07-26 01:11:38 -05:00
Jarvis af9ffca83a comms: homelab 20260726T055254Z 2026-07-26 00:52:54 -05:00
Jarvis 44dea90d3f comms: homelab 20260726T053742Z 2026-07-26 00:37:42 -05:00
Jarvis 506751decb comms: homelab 20260726T052239Z 2026-07-26 00:22:39 -05:00
Jarvis 6b330df612 comms: homelab 20260726T043720Z 2026-07-25 23:37:20 -05:00
Jarvis 5c39e133c2 comms: homelab 20260726T042222Z 2026-07-25 23:22:22 -05:00
Jarvis 31364a0e3a comms: homelab 20260726T040703Z 2026-07-25 23:07:03 -05:00
Jarvis d92f4f1193 comms: homelab 20260726T035152Z 2026-07-25 22:51:52 -05:00
Jarvis e01f24dd10 comms: homelab 20260726T033639Z 2026-07-25 22:36:39 -05:00
Jarvis 5bbb7c6c5c comms: homelab 20260726T032137Z 2026-07-25 22:21:37 -05:00
wjarvis mos-comms ca24e19443 comms: usc 20260726T025240Z 2026-07-25 21:52:41 -05:00
Jarvis 3345690710 comms: homelab 20260726T025124Z 2026-07-25 21:51:24 -05:00
Jarvis 1b90dc33e4 comms: homelab 20260726T023642Z 2026-07-25 21:36:42 -05:00
Jarvis ca578af5b2 comms: homelab 20260726T022123Z 2026-07-25 21:21:23 -05:00
Jarvis e4f863cebc comms: homelab 20260726T020635Z 2026-07-25 21:06:35 -05:00
Jarvis c2596fc5e1 comms: homelab 20260726T013951Z 2026-07-25 20:39:51 -05:00
Jarvis 4336aafa97 comms: homelab 20260726T012123Z 2026-07-25 20:21:23 -05:00
Jarvis 597d39d76e comms: homelab 20260726T010648Z 2026-07-25 20:06:49 -05:00
Jarvis 2d9cf0e4d1 comms: homelab 20260726T005133Z 2026-07-25 19:51:33 -05:00
Jarvis 5a864a5267 comms: homelab 20260726T003618Z 2026-07-25 19:36:18 -05:00
Jarvis 6a967068f8 comms: homelab 20260726T002043Z 2026-07-25 19:20:43 -05:00
Jarvis e1d14fd76c comms: homelab 20260726T000936Z 2026-07-25 19:09:37 -05:00
wjarvis mos-comms 89b43a33b7 comms: usc 20260726T000747Z 2026-07-25 19:07:48 -05:00
Jarvis 23b036d169 comms: homelab 20260726T000606Z 2026-07-25 19:06:06 -05:00
Jarvis 7c2281ae83 comms: homelab 20260725T235047Z 2026-07-25 18:50:47 -05:00
Jarvis e1434e3e99 comms: homelab 20260725T233547Z 2026-07-25 18:35:47 -05:00
Jarvis 180abbb35c comms: homelab 20260725T232042Z 2026-07-25 18:20:42 -05:00
Jarvis 7362d4c7c9 comms: homelab 20260725T230615Z 2026-07-25 18:06:15 -05:00
Jarvis 6dedf0df40 comms: homelab 20260725T225022Z 2026-07-25 17:50:22 -05:00
Jarvis e1ad3a3a66 comms: homelab 20260725T223512Z 2026-07-25 17:35:12 -05:00
Jarvis f2fc66f652 comms: homelab 20260725T222016Z 2026-07-25 17:20:17 -05:00
Jarvis ee6687d8b9 comms: homelab 20260725T220506Z 2026-07-25 17:05:06 -05:00
Jarvis c9d147f28f comms: homelab 20260725T215002Z 2026-07-25 16:50:02 -05:00
Jarvis f86c4ca16d comms: homelab 20260725T213453Z 2026-07-25 16:34:53 -05:00
Jarvis 2ab267c117 comms: homelab 20260725T212013Z 2026-07-25 16:20:13 -05:00
Jarvis 4998d1d9df comms: homelab 20260725T210552Z 2026-07-25 16:05:52 -05:00
wjarvis mos-comms 0265f1950c comms: usc 20260725T204825Z 2026-07-25 15:48:25 -05:00
wjarvis mos-comms 0475ae2b19 comms: usc 20260725T200924Z 2026-07-25 15:09:24 -05:00
wjarvis mos-comms 713bbaa060 comms: usc 20260725T183232Z 2026-07-25 13:32:33 -05:00
Jarvis 619ed38d01 comms: homelab 20260725T183033Z 2026-07-25 13:30:33 -05:00
wjarvis mos-comms 2adae48830 comms: usc 20260725T182546Z 2026-07-25 13:25:46 -05:00
ms-lead-reviewer b4c2af0969 briefs: heartbeat PACKAGING PLAN Rev 3 — add W-jarvis Gate A (manifest metadata-only, single ownership SSOT) + Gate B (schema range compat) 2026-07-25 13:25:34 -05:00
Jarvis 8a72f5f9f4 comms: homelab 20260725T182226Z 2026-07-25 13:22:27 -05:00
wjarvis mos-comms 89e59f9353 comms: usc 20260725T182026Z 2026-07-25 13:20:26 -05:00
ms-lead-reviewer c043e025a6 briefs: heartbeat-canon PACKAGING PLAN Rev 2 — address W-jarvis review (manifest/version, schema ordering, blank-reset target, fw/op boundaries) 2026-07-25 13:20:10 -05:00
Jarvis 55b4038283 comms: homelab 20260725T181626Z 2026-07-25 13:16:26 -05:00
wjarvis mos-comms bdad50cffd comms: usc 20260725T180916Z 2026-07-25 13:09:16 -05:00
ms-lead-reviewer cd11bbbc52 briefs: heartbeat-canon framework PACKAGING PLAN (MS-LEAD, item-5) — PROPOSED, pending Mos authority 2026-07-25 13:09:02 -05:00
Jarvis 44c05ae0a5 comms: homelab 20260725T180535Z 2026-07-25 13:05:35 -05:00
wjarvis mos-comms 29e98d6da5 comms: usc 20260725T180407Z 2026-07-25 13:04:07 -05:00
Jarvis c44325dcc4 comms: homelab 20260725T175300Z 2026-07-25 12:53:00 -05:00
wjarvis mos-comms a9d4f0c8e2 comms: usc 20260725T175123Z 2026-07-25 12:51:24 -05:00
Jarvis b99797614b comms: homelab 20260725T174829Z 2026-07-25 12:48:29 -05:00
Jarvis 15589cf9a5 comms: homelab 20260725T174638Z 2026-07-25 12:46:38 -05:00
wjarvis mos-comms 4c4f2fe265 comms: usc 20260725T174445Z 2026-07-25 12:44:46 -05:00
Jarvis ff78acc389 comms: homelab 20260725T174119Z 2026-07-25 12:41:20 -05:00
wjarvis mos-comms ed9039a760 comms: usc 20260725T173939Z 2026-07-25 12:39:39 -05:00
Jarvis 4d3e0abd80 comms: homelab 20260725T173738Z 2026-07-25 12:37:39 -05:00
wjarvis mos-comms 3458ae1f0b comms: usc 20260725T173627Z 2026-07-25 12:36:28 -05:00
Jarvis 91c51c93cc comms: homelab 20260725T173325Z 2026-07-25 12:33:26 -05:00
Jarvis bfaf2c2266 comms: homelab 20260725T173201Z 2026-07-25 12:32:01 -05:00
wjarvis mos-comms 3c4e9ace61 comms: usc 20260725T173015Z 2026-07-25 12:30:15 -05:00
Jarvis e96c2ba2de comms: homelab 20260725T173005Z 2026-07-25 12:30:06 -05:00
wjarvis mos-comms 40c836e5b8 comms: usc 20260725T172849Z 2026-07-25 12:28:50 -05:00
Jarvis 4046e2e8d2 comms: homelab 20260725T172436Z 2026-07-25 12:24:36 -05:00
wjarvis mos-comms 85fa0d48d5 comms: usc 20260725T172224Z 2026-07-25 12:22:25 -05:00
Jarvis 094709ca8b comms: homelab 20260725T013224Z 2026-07-24 20:32:24 -05:00
wjarvis mos-comms f0560004c5 comms: usc 20260725T013124Z 2026-07-24 20:31:25 -05:00
Jarvis bd467d6925 comms: homelab 20260725T012643Z 2026-07-24 20:26:43 -05:00
Jarvis d3a3eba636 comms: homelab 20260725T010740Z 2026-07-24 20:07:40 -05:00
wjarvis mos-comms 9883cd6f31 comms: usc 20260725T010612Z 2026-07-24 20:06:17 -05:00
Jarvis 4847d35f31 comms: homelab 20260725T010445Z 2026-07-24 20:04:45 -05:00
wjarvis mos-comms 024d0f00c5 comms: usc 20260725T010346Z 2026-07-24 20:03:52 -05:00
Jarvis 131e8d5d78 comms: homelab 20260725T005606Z 2026-07-24 19:56:06 -05:00
Jarvis 46453c475b comms: homelab 20260725T004609Z 2026-07-24 19:46:09 -05:00
wjarvis mos-comms 7954fb9b0b comms: usc 20260725T004445Z 2026-07-24 19:44:45 -05:00
Jarvis 5f3ab1c25c comms: homelab 20260725T004231Z 2026-07-24 19:42:32 -05:00
wjarvis mos-comms a7d3cb0dbf comms: usc 20260725T004156Z 2026-07-24 19:41:57 -05:00
Jarvis 8e1b229f0f comms: homelab 20260725T004106Z 2026-07-24 19:41:06 -05:00
Jarvis 8a47fb2179 comms: homelab 20260725T003706Z 2026-07-24 19:37:06 -05:00
wjarvis mos-comms 192c7204ad comms: usc 20260725T003526Z 2026-07-24 19:35:26 -05:00
Jarvis 53b90c0ffc comms: homelab 20260724T235833Z 2026-07-24 18:58:33 -05:00
wjarvis mos-comms 23800842a4 comms: usc 20260724T235736Z 2026-07-24 18:57:36 -05:00
Jarvis 0c1f1d84b0 comms: homelab 20260724T234845Z 2026-07-24 18:48:45 -05:00
wjarvis mos-comms 132a3fd969 comms: usc 20260724T234758Z 2026-07-24 18:47:58 -05:00
wjarvis mos-comms 3f93758b81 comms: usc 20260724T032741Z 2026-07-23 22:27:41 -05:00
wjarvis mos-comms 3c489ee778 comms: usc 20260724T002527Z 2026-07-23 19:25:27 -05:00
Jarvis 26b60a1a6c comms: homelab 20260723T235040Z 2026-07-23 18:50:40 -05:00
Jarvis 3df4ccd7c5 comms: homelab 20260723T235039Z 2026-07-23 18:50:39 -05:00
wjarvis mos-comms 9ca26d7988 comms: usc 20260723T234917Z 2026-07-23 18:49:17 -05:00
Jarvis 92e6f18ee3 comms: homelab 20260723T230555Z 2026-07-23 18:05:56 -05:00
wjarvis mos-comms d85565892d comms: usc 20260723T230446Z 2026-07-23 18:04:46 -05:00
Jarvis 22606c930f comms: homelab 20260723T224420Z 2026-07-23 17:44:21 -05:00
wjarvis mos-comms 330dbcd366 comms: usc 20260723T224305Z 2026-07-23 17:43:06 -05:00
wjarvis mos-comms 8257f2a8ec comms: usc 20260723T223319Z 2026-07-23 17:33:19 -05:00
wjarvis mos-comms f74831624d comms: usc 20260723T223240Z 2026-07-23 17:32:40 -05:00
Jarvis 01161e3674 comms: homelab 20260723T222915Z 2026-07-23 17:29:15 -05:00
Jarvis 2248463b7e comms: homelab 20260723T222913Z 2026-07-23 17:29:13 -05:00
Jarvis ce02c26641 comms: homelab 20260723T222437Z 2026-07-23 17:24:37 -05:00
Jarvis 3046c7e2ef comms: homelab 20260723T222435Z 2026-07-23 17:24:35 -05:00
wjarvis mos-comms d3a00c8d5f comms: usc 20260723T222323Z 2026-07-23 17:23:23 -05:00
wjarvis mos-comms b4fc939472 comms: usc 20260723T210042Z 2026-07-23 16:00:42 -05:00
wjarvis mos-comms 4153d962f9 comms: usc 20260723T205919Z 2026-07-23 15:59:19 -05:00
wjarvis mos-comms 8eebbc8902 comms: usc 20260723T203119Z 2026-07-23 15:31:19 -05:00
wjarvis mos-comms e3ac6a940d comms: usc 20260723T195550Z 2026-07-23 14:55:50 -05:00
Jarvis 7438fcc102 comms: homelab 20260723T195310Z 2026-07-23 14:53:10 -05:00
wjarvis mos-comms 2a6c50c6b8 comms: usc 20260723T195124Z 2026-07-23 14:51:24 -05:00
wjarvis mos-comms 853a57c47d comms: usc 20260723T194928Z 2026-07-23 14:49:28 -05:00
Jarvis 985a13fe4b comms: homelab 20260723T194321Z 2026-07-23 14:43:21 -05:00
Jarvis 6f9afca163 comms: homelab 20260723T194319Z 2026-07-23 14:43:20 -05:00
Jarvis 925d8f485e comms: homelab 20260723T191337Z 2026-07-23 14:13:37 -05:00
Jarvis 1ab215d25d comms: homelab 20260723T191336Z 2026-07-23 14:13:36 -05:00
Jarvis e7035e32eb comms: homelab 20260723T185849Z 2026-07-23 13:58:49 -05:00
Jarvis 17ec91dec4 comms: homelab 20260723T185839Z 2026-07-23 13:58:40 -05:00
Jarvis b603a1c608 comms: homelab 20260723T184313Z 2026-07-23 13:43:13 -05:00
Jarvis b2d6afb857 comms: homelab 20260723T184303Z 2026-07-23 13:43:03 -05:00
Jarvis 01cacefd81 comms: homelab 20260723T182831Z 2026-07-23 13:28:31 -05:00
Jarvis c7e40e2722 comms: homelab 20260723T182819Z 2026-07-23 13:28:19 -05:00
Jarvis 44ec1ee4ba comms: homelab 20260723T181303Z 2026-07-23 13:13:03 -05:00
Jarvis 9ed47e0d0c comms: homelab 20260723T181256Z 2026-07-23 13:12:56 -05:00
Jarvis f75e474de3 comms: homelab 20260723T175812Z 2026-07-23 12:58:12 -05:00
Jarvis 287e25393e comms: homelab 20260723T175803Z 2026-07-23 12:58:03 -05:00
Jarvis 95f9df120b comms: homelab 20260723T174338Z 2026-07-23 12:43:38 -05:00
Jarvis 7a5fc174e7 comms: homelab 20260723T174330Z 2026-07-23 12:43:30 -05:00
Jarvis 711d57ba70 comms: homelab 20260723T172740Z 2026-07-23 12:27:40 -05:00
Jarvis fd0f0c38d5 comms: homelab 20260723T172730Z 2026-07-23 12:27:30 -05:00
Jarvis 1f3df1e719 comms: homelab 20260723T171658Z 2026-07-23 12:16:58 -05:00
wjarvis mos-comms 654fa6d11e comms: usc 20260723T171508Z 2026-07-23 12:15:08 -05:00
Jarvis 8d1039a431 comms: homelab 20260723T171400Z 2026-07-23 12:14:00 -05:00
Jarvis 23b4bb700b comms: homelab 20260723T171349Z 2026-07-23 12:13:49 -05:00
Jarvis ad181d72e4 comms: homelab 20260723T165820Z 2026-07-23 11:58:20 -05:00
Jarvis 7875b71806 comms: homelab 20260723T165801Z 2026-07-23 11:58:01 -05:00
Jarvis eb09c01d85 comms: homelab 20260722T194433Z 2026-07-22 14:44:33 -05:00
Jarvis 25f7c8101b comms: homelab 20260722T193005Z 2026-07-22 14:30:06 -05:00
wjarvis mos-comms 1b6d6ffad8 comms: usc 20260722T191736Z 2026-07-22 14:17:36 -05:00
Jarvis 65d219350f comms: homelab 20260722T191432Z 2026-07-22 14:14:32 -05:00
wjarvis mos-comms 41a379c381 comms: usc 20260722T190150Z 2026-07-22 14:01:50 -05:00
Jarvis 23bd7e57d6 comms: homelab 20260722T190059Z 2026-07-22 14:00:59 -05:00
wjarvis mos-comms 4fa2ca6112 comms: usc 20260722T184813Z 2026-07-22 13:48:14 -05:00
Jarvis 7f79a3ad59 comms: homelab 20260722T184617Z 2026-07-22 13:46:17 -05:00
Jarvis 94eed819f5 comms: homelab 20260722T184543Z 2026-07-22 13:45:43 -05:00
wjarvis mos-comms 74a655f6f8 comms: usc 20260722T170420Z 2026-07-22 12:04:21 -05:00
wjarvis mos-comms a96a333c01 comms: usc 20260722T084427Z 2026-07-22 03:44:27 -05:00
Jarvis 8546fd4f04 comms: homelab 20260722T084137Z 2026-07-22 03:41:37 -05:00
wjarvis mos-comms 3b6278e09d comms: usc 20260722T081132Z 2026-07-22 03:11:33 -05:00
Jarvis 8176af89a4 comms: homelab 20260722T080237Z 2026-07-22 03:02:37 -05:00
wjarvis mos-comms 59a313cbe4 comms: usc 20260722T080151Z 2026-07-22 03:01:52 -05:00
Jarvis 0aa8cac23d comms: homelab 20260722T075800Z 2026-07-22 02:58:00 -05:00
wjarvis mos-comms a9d7e474f9 comms: usc 20260722T072817Z 2026-07-22 02:28:18 -05:00
Jarvis 8ac9a5f681 comms: homelab 20260722T065247Z 2026-07-22 01:52:48 -05:00
wjarvis mos-comms 2b364c8f1a comms: usc 20260722T065059Z 2026-07-22 01:50:59 -05:00
Jarvis 284f74d1e7 comms: homelab 20260722T064810Z 2026-07-22 01:48:10 -05:00
Jarvis 7e5d36902e comms: homelab 20260722T062936Z 2026-07-22 01:29:37 -05:00
wjarvis mos-comms f996bd5208 comms: usc 20260722T062605Z 2026-07-22 01:26:05 -05:00
wjarvis mos-comms 527f2ca7e3 comms: usc 20260722T060123Z 2026-07-22 01:01:24 -05:00
Jarvis eebedad810 comms: homelab 20260722T055755Z 2026-07-22 00:57:55 -05:00
Jarvis f7b42e1667 comms: homelab 20260722T054706Z 2026-07-22 00:47:07 -05:00
wjarvis mos-comms 57f3facaaa comms: usc 20260722T054305Z 2026-07-22 00:43:05 -05:00
wjarvis mos-comms 79e7efee47 comms: usc 20260722T052506Z 2026-07-22 00:25:06 -05:00
Jarvis 1a6f79b9cf comms: homelab 20260722T052113Z 2026-07-22 00:21:13 -05:00
Jarvis 17c3c84302 comms: homelab 20260722T051543Z 2026-07-22 00:15:43 -05:00
Jarvis 9edb16456a comms: homelab 20260722T051121Z 2026-07-22 00:11:22 -05:00
wjarvis mos-comms 62c967bb4f comms: usc 20260722T050835Z 2026-07-22 00:08:35 -05:00
wjarvis mos-comms 19fc2aaa83 comms: usc 20260722T041922Z 2026-07-21 23:19:22 -05:00
Jarvis 25e3438a15 comms: homelab 20260722T041710Z 2026-07-21 23:17:10 -05:00
Jarvis 6ffe38369e comms: homelab 20260722T040703Z 2026-07-21 23:07:04 -05:00
wjarvis mos-comms a73cb190f2 comms: usc 20260722T040433Z 2026-07-21 23:04:33 -05:00
wjarvis mos-comms 9757c9024a comms: usc 20260722T033246Z 2026-07-21 22:32:46 -05:00
wjarvis mos-comms 267436c4c4 comms: usc 20260722T033204Z 2026-07-21 22:32:05 -05:00
Jarvis d6fcb2dbbf comms: homelab 20260722T033125Z 2026-07-21 22:31:25 -05:00
Jarvis 0b0e2faf86 comms: homelab 20260722T032750Z 2026-07-21 22:27:50 -05:00
wjarvis mos-comms c60ea6b380 comms: usc 20260722T032435Z 2026-07-21 22:24:36 -05:00
Jarvis 3b2fdad204 comms: homelab 20260722T032230Z 2026-07-21 22:22:30 -05:00
wjarvis mos-comms 203a94d90c comms: usc 20260722T030500Z 2026-07-21 22:05:00 -05:00
wjarvis mos-comms 6412522c63 comms: usc 20260722T025318Z 2026-07-21 21:53:18 -05:00
wjarvis mos-comms 337de41157 comms: usc 20260722T023242Z 2026-07-21 21:32:42 -05:00
Jarvis f428eff373 comms: homelab 20260722T023043Z 2026-07-21 21:30:43 -05:00
wjarvis mos-comms ca6d3d6cc0 comms: usc 20260722T015336Z 2026-07-21 20:53:36 -05:00
wjarvis mos-comms ec7dd41448 comms: usc 20260722T012734Z 2026-07-21 20:27:34 -05:00
Jarvis ad52e7c4bb comms: homelab 20260722T012617Z 2026-07-21 20:26:18 -05:00
wjarvis mos-comms b44f5e0d88 comms: usc 20260722T012059Z 2026-07-21 20:20:59 -05:00
wjarvis mos-comms 29789df372 comms: usc 20260722T011048Z 2026-07-21 20:10:48 -05:00
wjarvis mos-comms 2e0171a205 comms: usc 20260722T005605Z 2026-07-21 19:56:05 -05:00
wjarvis mos-comms 5603e5f41a comms: usc 20260722T005042Z 2026-07-21 19:50:42 -05:00
wjarvis mos-comms cdf6b02614 comms: usc 20260722T001551Z 2026-07-21 19:15:51 -05:00
Jarvis 09de0904ca comms: homelab 20260722T001353Z 2026-07-21 19:13:53 -05:00
wjarvis mos-comms 807b369b9b comms: usc 20260722T000540Z 2026-07-21 19:05:40 -05:00
wjarvis mos-comms f3c6817ccb comms: usc 20260721T235939Z 2026-07-21 18:59:39 -05:00
wjarvis mos-comms f74bd8e677 comms: usc 20260721T235935Z 2026-07-21 18:59:35 -05:00
Jarvis 408764c237 comms: homelab 20260721T235545Z 2026-07-21 18:55:45 -05:00
wjarvis mos-comms 3a3adb6b71 comms: usc 20260721T235452Z 2026-07-21 18:54:52 -05:00
wjarvis mos-comms dff17525c9 comms: usc 20260721T235415Z 2026-07-21 18:54:16 -05:00
Jarvis 0993cf67e9 comms: homelab 20260721T235305Z 2026-07-21 18:53:06 -05:00
wjarvis mos-comms d33d6ec911 comms: usc 20260721T235118Z 2026-07-21 18:51:18 -05:00
wjarvis mos-comms cb1775cf56 comms: usc 20260721T235005Z 2026-07-21 18:50:06 -05:00
Jarvis 245c1019e1 comms: homelab 20260721T234835Z 2026-07-21 18:48:36 -05:00
wjarvis mos-comms caed94e522 comms: usc 20260721T234732Z 2026-07-21 18:47:32 -05:00
wjarvis mos-comms 7aed068f68 comms: usc 20260721T232306Z 2026-07-21 18:23:06 -05:00
wjarvis mos-comms 64a300e68a comms: usc 20260721T231337Z 2026-07-21 18:13:37 -05:00
Jarvis a647bce678 comms: homelab 20260721T231053Z 2026-07-21 18:10:53 -05:00
wjarvis mos-comms cfa5810e59 comms: usc 20260721T223214Z 2026-07-21 17:32:14 -05:00
wjarvis mos-comms 5f204934de comms: usc 20260721T220716Z 2026-07-21 17:07:16 -05:00
Jarvis 802ef7f033 comms: homelab 20260721T220621Z 2026-07-21 17:06:21 -05:00
wjarvis mos-comms c2c25f45b5 comms: usc 20260721T220515Z 2026-07-21 17:05:15 -05:00
Jarvis 09e167fc0c comms: homelab 20260721T220356Z 2026-07-21 17:03:56 -05:00
Jarvis e09887427d comms: homelab 20260721T215824Z 2026-07-21 16:58:24 -05:00
wjarvis mos-comms 7251b1cced comms: usc 20260721T215708Z 2026-07-21 16:57:08 -05:00
wjarvis mos-comms f93c14b646 comms: usc 20260721T215706Z 2026-07-21 16:57:06 -05:00
Jarvis 4afa572aef comms: homelab 20260721T215452Z 2026-07-21 16:54:52 -05:00
wjarvis mos-comms 68d672b631 comms: usc 20260721T214857Z 2026-07-21 16:48:57 -05:00
Jarvis e386525d19 comms: homelab 20260721T214713Z 2026-07-21 16:47:13 -05:00
Jarvis b720270505 comms: homelab 20260721T214219Z 2026-07-21 16:42:19 -05:00
wjarvis mos-comms ed752a08d5 comms: usc 20260721T213915Z 2026-07-21 16:39:33 -05:00
Jarvis f52e721f29 comms: homelab 20260721T213759Z 2026-07-21 16:38:00 -05:00
wjarvis mos-comms a7173d1c33 comms: usc 20260721T213558Z 2026-07-21 16:35:58 -05:00
wjarvis mos-comms e32b5ef561 comms: usc 20260721T213030Z 2026-07-21 16:30:30 -05:00
wjarvis mos-comms 9070c9d6b0 comms: usc 20260721T201551Z 2026-07-21 15:15:51 -05:00
wjarvis mos-comms cf8ec13e57 comms: usc 20260721T191940Z 2026-07-21 14:19:40 -05:00
wjarvis mos-comms e07675ae07 comms: usc 20260721T191935Z 2026-07-21 14:19:36 -05:00
Jarvis 682fc820ee comms: homelab 20260721T191639Z 2026-07-21 14:16:40 -05:00
wjarvis mos-comms b9f5c9f679 comms: usc 20260721T184546Z 2026-07-21 13:45:46 -05:00
Jarvis 93f012bdcf comms: homelab 20260721T184447Z 2026-07-21 13:44:48 -05:00
wjarvis mos-comms 40e5b8bbd8 comms: usc 20260721T183050Z 2026-07-21 13:30:50 -05:00
Jarvis df6a24e0b8 comms: homelab 20260721T182845Z 2026-07-21 13:28:45 -05:00
wjarvis mos-comms 2ae6d1f1bc comms: usc 20260721T182443Z 2026-07-21 13:24:43 -05:00
Jarvis d08106bd35 comms: homelab 20260721T175405Z 2026-07-21 12:54:05 -05:00
wjarvis mos-comms 52830a684c comms: usc 20260721T175245Z 2026-07-21 12:52:45 -05:00
Jarvis d4b16b004e comms: homelab 20260721T175101Z 2026-07-21 12:51:01 -05:00
wjarvis mos-comms af7fba2d34 comms: usc 20260721T174946Z 2026-07-21 12:49:46 -05:00
Jarvis c9222d5b56 comms: homelab 20260721T174817Z 2026-07-21 12:48:17 -05:00
wjarvis mos-comms 5d384bdf85 comms: usc 20260721T174343Z 2026-07-21 12:43:44 -05:00
Jarvis ba5006f7ef comms: homelab 20260721T174119Z 2026-07-21 12:41:19 -05:00
Jarvis bf7115aefa comms: homelab 20260721T174013Z 2026-07-21 12:40:13 -05:00
Jarvis 4b9ffbf5ea comms: homelab 20260721T170709Z 2026-07-21 12:07:09 -05:00
wjarvis mos-comms ed0c7da520 comms: usc 20260721T170324Z 2026-07-21 12:03:25 -05:00
Jarvis 667469e188 comms: homelab 20260721T170229Z 2026-07-21 12:02:30 -05:00
wjarvis mos-comms 340ea30930 comms: usc 20260721T170058Z 2026-07-21 12:00:58 -05:00
wjarvis mos-comms fad03b0e5d comms: usc 20260721T165755Z 2026-07-21 11:57:56 -05:00
Jarvis 7b71961ea1 comms: homelab 20260721T021353Z 2026-07-20 21:13:53 -05:00
wjarvis mos-comms 613ee3781f comms: usc 20260721T021317Z 2026-07-20 21:13:18 -05:00
Jarvis e67e6f4eac comms: homelab 20260721T012527Z 2026-07-20 20:25:27 -05:00
Jarvis 4e499d7f33 comms: homelab 20260721T005704Z 2026-07-20 19:57:04 -05:00
wjarvis mos-comms 3b6ebe6f9c comms: usc 20260721T005609Z 2026-07-20 19:56:10 -05:00
Jarvis 3dd0acb3d9 comms: homelab 20260721T005436Z 2026-07-20 19:54:36 -05:00
Jarvis cad5eac09f comms: homelab 20260720T232727Z 2026-07-20 18:27:27 -05:00
wjarvis mos-comms dcffa90a7a comms: usc 20260720T232506Z 2026-07-20 18:25:07 -05:00
Jarvis 2879036c64 comms: homelab 20260720T232309Z 2026-07-20 18:23:09 -05:00
wjarvis mos-comms 7bc7565559 comms: usc 20260720T231034Z 2026-07-20 18:10:35 -05:00
Jarvis 93785d4a12 comms: homelab 20260720T230903Z 2026-07-20 18:09:04 -05:00
wjarvis mos-comms bdce45de0a comms: usc 20260720T223931Z 2026-07-20 17:39:31 -05:00
wjarvis mos-comms 8329e9c9a5 from-mos: KBN rc.19 STOP-FINAL ruling + Jason escalation 2026-07-20 17:38:40 -05:00
wjarvis mos-comms de52ebf586 comms: usc 20260720T223109Z 2026-07-20 17:31:09 -05:00
Jarvis 274c9d9f06 comms: homelab 20260720T222947Z 2026-07-20 17:29:47 -05:00
wjarvis mos-comms 2d5eb2e1a2 comms: usc 20260720T222809Z 2026-07-20 17:28:09 -05:00
Jarvis 782a52c239 comms: homelab 20260720T222702Z 2026-07-20 17:27:02 -05:00
Jarvis 6955e3eef1 comms: homelab 20260720T220821Z 2026-07-20 17:08:21 -05:00
wjarvis mos-comms cd7c65044e comms: usc 20260720T220502Z 2026-07-20 17:05:02 -05:00
Jarvis c76c2f1445 comms: homelab 20260720T220425Z 2026-07-20 17:04:26 -05:00
wjarvis mos-comms d00d0b9ad4 comms: usc 20260720T220142Z 2026-07-20 17:01:42 -05:00
wjarvis mos-comms fa32460f12 comms: usc 20260720T220027Z 2026-07-20 17:00:27 -05:00
wjarvis mos-comms 6dd5fb9c51 comms: usc 20260720T220018Z 2026-07-20 17:00:18 -05:00
wjarvis mos-comms bae6370eb9 mos: rc.19 re-gate authorized — MS-LEAD route Gate A re-review + Gate B red-team, author!=reviewer 2026-07-20 16:59:46 -05:00
Jarvis 1214e50186 comms: homelab 20260720T215753Z 2026-07-20 16:57:54 -05:00
wjarvis mos-comms 0dca729870 comms: usc 20260720T215703Z 2026-07-20 16:57:03 -05:00
Jarvis 71964563d6 comms: homelab 20260720T212302Z 2026-07-20 16:23:02 -05:00
wjarvis mos-comms 507e97384d comms: usc 20260720T212108Z 2026-07-20 16:21:08 -05:00
Jarvis 6ec5cc3a48 comms: homelab 20260720T212051Z 2026-07-20 16:20:51 -05:00
wjarvis mos-comms 5417838ec2 mos: KBN rc.18→rc.19 ruling (continue; 5 in-envelope residuals; tightened 2-round within-class stop-condition) 2026-07-20 16:19:37 -05:00
wjarvis mos-comms 141b7f2587 comms: usc 20260720T211154Z 2026-07-20 16:11:54 -05:00
wjarvis mos-comms b3a7960200 comms: usc 20260720T204036Z 2026-07-20 15:40:36 -05:00
Jarvis db13762a44 comms: homelab 20260720T204004Z 2026-07-20 15:40:04 -05:00
wjarvis mos-comms 8d9d5c7e9b comms: usc 20260720T202437Z 2026-07-20 15:24:37 -05:00
wjarvis mos-comms e103c4d308 comms: usc 20260720T202424Z 2026-07-20 15:24:24 -05:00
Jarvis 2c692e150d comms: homelab 20260720T202221Z 2026-07-20 15:22:21 -05:00
Jarvis 36d7173493 comms: homelab 20260720T195743Z 2026-07-20 14:57:43 -05:00
wjarvis mos-comms 9825024d8d comms: usc 20260720T195415Z 2026-07-20 14:54:15 -05:00
Jarvis 9237a19e57 comms: homelab 20260720T195324Z 2026-07-20 14:53:25 -05:00
wjarvis mos-comms 853fc42b02 from-mos: rc.18 wider-amendment ruling (B1 status-writer completeness + B2 migrator identity + PLAN majors; route sol; re-gate fresh; convergence stop-condition) 2026-07-20 14:51:52 -05:00
wjarvis mos-comms 3536c8b70b comms: usc 20260720T194117Z 2026-07-20 14:41:17 -05:00
Jarvis 8af85e045a comms: homelab 20260720T194016Z 2026-07-20 14:40:16 -05:00
wjarvis mos-comms 18b60e02d9 comms: usc 20260720T193916Z 2026-07-20 14:39:16 -05:00
wjarvis mos-comms 54a599057b mos: rc.17 re-review NO-GO ACK — hold rc.18 authoring for combined verdict; A-1 migrate-tier.ts writer leak noted 2026-07-20 14:38:54 -05:00
Jarvis ae46a91098 comms: homelab 20260720T193749Z 2026-07-20 14:37:49 -05:00
Jarvis 4282c54ea6 comms: homelab 20260720T193426Z 2026-07-20 14:34:27 -05:00
wjarvis mos-comms dda13f3d4e comms: usc 20260720T192520Z 2026-07-20 14:25:20 -05:00
wjarvis mos-comms acdd7efb39 comms: usc 20260720T192501Z 2026-07-20 14:25:01 -05:00
wjarvis mos-comms bd5688cded comms: usc 20260720T192457Z 2026-07-20 14:24:57 -05:00
Jarvis 1baa7d23e0 comms: homelab 20260720T192208Z 2026-07-20 14:22:08 -05:00
Jarvis c9831c8546 comms: homelab 20260720T190516Z 2026-07-20 14:05:17 -05:00
wjarvis mos-comms 66cd0586c8 comms: usc 20260720T190238Z 2026-07-20 14:02:39 -05:00
wjarvis mos-comms a8da84b5da comms: usc 20260720T190215Z 2026-07-20 14:02:15 -05:00
Jarvis 8952876247 comms: homelab 20260720T185955Z 2026-07-20 13:59:55 -05:00
wjarvis mos-comms 34ef620a39 mos: KBN both-gates NO-GO ruling — authorize scoped rc.16->rc.17 amendment (A-1 writer-owner, A-2 container surface) + re-gate 2026-07-20 13:59:19 -05:00
wjarvis mos-comms 7bba4ac906 comms: usc 20260720T185704Z 2026-07-20 13:57:04 -05:00
Jarvis ad67baf8b2 comms: homelab 20260720T185556Z 2026-07-20 13:55:56 -05:00
wjarvis mos-comms 5147ddfe95 comms: usc 20260720T184029Z 2026-07-20 13:40:29 -05:00
Jarvis 8a6296972f comms: homelab 20260720T184005Z 2026-07-20 13:40:05 -05:00
wjarvis mos-comms aef8f91eb6 mos: authorize fresh gpt-sol red-team + gpt-terra-HIGH re-review targets, bounded fleet-add exception; note 2 Claude roles capacity-stranded 2026-07-20 13:39:32 -05:00
wjarvis mos-comms 5276e4cbb8 comms: usc 20260720T183926Z 2026-07-20 13:39:26 -05:00
wjarvis mos-comms 98efdaf8eb comms: usc 20260720T183851Z 2026-07-20 13:38:52 -05:00
Jarvis bd69328270 comms: homelab 20260720T183831Z 2026-07-20 13:38:31 -05:00
wjarvis mos-comms 3f6eaf50b9 comms: usc 20260720T183231Z 2026-07-20 13:32:31 -05:00
wjarvis mos-comms 4d08a58325 comms: usc 20260720T183226Z 2026-07-20 13:32:26 -05:00
wjarvis mos-comms 5023619549 comms: usc 20260720T183221Z 2026-07-20 13:32:22 -05:00
Jarvis 269fa5149b comms: homelab 20260720T183156Z 2026-07-20 13:31:56 -05:00
wjarvis mos-comms 1a86bbf12e mos: KBN rulings — accept sol artifact, own rc.16 re-review gate, T3 stop-escalate rule, opus-capacity fallback to gpt-sol 2026-07-20 13:29:48 -05:00
wjarvis mos-comms 8600a60f9a comms: usc 20260720T182327Z 2026-07-20 13:23:27 -05:00
Jarvis 38b1255469 comms: homelab 20260720T182119Z 2026-07-20 13:21:19 -05:00
Jarvis 0567395c5e comms: homelab 20260720T181244Z 2026-07-20 13:12:44 -05:00
Jarvis 8d6a9f9899 comms: homelab 20260720T181227Z 2026-07-20 13:12:28 -05:00
wjarvis mos-comms 57684364c6 comms: usc 20260720T181149Z 2026-07-20 13:11:50 -05:00
Jarvis b737a5d84e comms: homelab 20260720T181119Z 2026-07-20 13:11:19 -05:00
wjarvis mos-comms 5fb9a5298c mos→all: CORRECT model tier legend — sol/terra/luna are GPT-codex tiers, opus/sonnet map = capability equivalence (supersedes 175840Z) 2026-07-20 13:08:03 -05:00
Jarvis 2a3a1d11c1 comms: homelab 20260720T180025Z 2026-07-20 13:00:25 -05:00
wjarvis mos-comms b464fd3599 comms: usc 20260720T175844Z 2026-07-20 12:58:45 -05:00
wjarvis mos-comms 021ecd2a6e mos→all: authoritative model tier legend (sol=opus/fable, terra=sonnet, luna=sonnet/haiku) 2026-07-20 12:58:40 -05:00
wjarvis mos-comms ad85ee9bda mos: KBN planner correction — stand down terra, decomposition→planner-sol (reconcile off parked FCM), opus red-team unchanged 2026-07-20 12:57:17 -05:00
Jarvis 26131c1086 comms: homelab 20260720T175203Z 2026-07-20 12:52:03 -05:00
wjarvis mos-comms 60d2c8907b comms: usc 20260720T175137Z 2026-07-20 12:51:37 -05:00
wjarvis mos-comms 30d52781e7 comms: usc 20260720T175047Z 2026-07-20 12:50:47 -05:00
Jarvis e433fd2cc7 comms: homelab 20260720T175025Z 2026-07-20 12:50:25 -05:00
wjarvis mos-comms ed9d2d8e3d comms: usc 20260720T174137Z 2026-07-20 12:41:37 -05:00
Jarvis f473c71fb6 comms: homelab 20260720T174054Z 2026-07-20 12:40:54 -05:00
wjarvis mos-comms a0308a1d1c mos: KBN dispatch GO — name W-jarvis send-executor (dispatch-only), switch planner-sol→planner-terra (FCM collision) 2026-07-20 12:38:25 -05:00
wjarvis mos-comms 923f421e09 comms: usc 20260720T173735Z 2026-07-20 12:37:35 -05:00
Jarvis ee8129ba26 comms: homelab 20260720T173605Z 2026-07-20 12:36:05 -05:00
wjarvis mos-comms 73abcbffb2 comms: usc 20260720T173334Z 2026-07-20 12:33:35 -05:00
Jarvis 7142bd105a comms: homelab 20260720T173236Z 2026-07-20 12:32:36 -05:00
wjarvis mos-comms b1d8a69a75 comms: usc 20260720T173123Z 2026-07-20 12:31:23 -05:00
Jarvis 3316be4abd comms: homelab 20260720T172938Z 2026-07-20 12:29:38 -05:00
Jarvis 5d83020bb5 comms: homelab 20260720T172831Z 2026-07-20 12:28:31 -05:00
wjarvis mos-comms 5de3c702a9 mos→MS-LEAD: KBN fleet-launch GO (reuse managed roster agents, rc.16, red-team before fanout) 2026-07-20 12:28:19 -05:00
wjarvis mos-comms 963731b591 comms: usc 20260720T172645Z 2026-07-20 12:26:45 -05:00
Jarvis 572bcb5786 comms: homelab 20260720T172554Z 2026-07-20 12:25:54 -05:00
wjarvis mos-comms d57398bdb7 comms: usc 20260720T172341Z 2026-07-20 12:23:41 -05:00
wjarvis mos-comms 27e332701e mos→all: model rebalance (Claude=orch/enhance/adv-plan, GPT=code/review+secrev; Opus unblocked; GPT 58%) 2026-07-20 12:18:58 -05:00
Jarvis 54efe61e1e comms: homelab 20260720T171838Z 2026-07-20 12:18:38 -05:00
wjarvis mos-comms 9bb9515709 comms: usc 20260720T171723Z 2026-07-20 12:17:23 -05:00
wjarvis mos-comms 503a7f5636 mos→usc: KBN native-kanban migration mission (Jason pivot, get off Hermes) 2026-07-20 12:10:58 -05:00
wjarvis mos-comms 4c8e2c2500 mos: concur hold #823 on cost directive; #1 pull for Jul-23 window 2026-07-20 05:55:59 -05:00
Jarvis 8ea3a6dcf0 comms: homelab 20260720T104948Z 2026-07-20 05:49:49 -05:00
wjarvis mos-comms 156a591312 comms: usc 20260720T104912Z 2026-07-20 05:49:13 -05:00
Jarvis c6b5c4decb comms: homelab 20260720T104759Z 2026-07-20 05:47:59 -05:00
wjarvis mos-comms 497e7843fb mos: record #856/858 terminal-complete; park-window chain complete; lane clear 2026-07-20 05:46:31 -05:00
Jarvis 3650db94f9 comms: homelab 20260720T104406Z 2026-07-20 05:44:06 -05:00
wjarvis mos-comms e7d4ad7f9b comms: usc 20260720T104118Z 2026-07-20 05:41:18 -05:00
wjarvis mos-comms 9f5c4116ca comms: usc 20260720T103446Z 2026-07-20 05:34:46 -05:00
wjarvis mos-comms cb15b61a0c mos: PR#861/#860 MERGED (squash b0d78d86, closed); #850/#859 terminal-complete confirmed; main->b0d78d86 2026-07-20 05:33:19 -05:00
Jarvis 459982a0bd comms: homelab 20260720T103122Z 2026-07-20 05:31:22 -05:00
wjarvis mos-comms 3ead3bf817 comms: usc 20260720T103012Z 2026-07-20 05:30:12 -05:00
wjarvis mos-comms a0aa10b833 comms: usc 20260720T102821Z 2026-07-20 05:28:22 -05:00
Jarvis e003b9526c comms: homelab 20260720T102549Z 2026-07-20 05:25:50 -05:00
wjarvis mos-comms 3fbde983d1 comms: usc 20260720T102545Z 2026-07-20 05:25:45 -05:00
wjarvis mos-comms 7ced8dbde2 comms: usc 20260720T102512Z 2026-07-20 05:25:12 -05:00
Jarvis 23fadc6f7a comms: homelab 20260720T102402Z 2026-07-20 05:24:02 -05:00
wjarvis mos-comms 4a577fe432 comms: usc 20260720T102215Z 2026-07-20 05:22:15 -05:00
Jarvis f402a0945f comms: homelab 20260720T101638Z 2026-07-20 05:16:39 -05:00
wjarvis mos-comms 43e55b0856 comms: usc 20260720T101600Z 2026-07-20 05:16:00 -05:00
Jarvis b22da62029 comms: homelab 20260720T101445Z 2026-07-20 05:14:46 -05:00
wjarvis mos-comms 1b5413619d mos: PR#859/#850 MERGED (squash 344d86a6), #850 closed, main->344d86a6 2026-07-20 05:13:57 -05:00
wjarvis mos-comms e3eeeca2d1 comms: usc 20260720T100855Z 2026-07-20 05:08:56 -05:00
Jarvis 675ae3e921 comms: homelab 20260720T100705Z 2026-07-20 05:07:05 -05:00
Jarvis 7e19e285bd comms: homelab 20260720T100602Z 2026-07-20 05:06:03 -05:00
wjarvis mos-comms b60dffd3ac comms: usc 20260720T100555Z 2026-07-20 05:05:55 -05:00
wjarvis mos-comms dc202a1486 comms: usc 20260720T100429Z 2026-07-20 05:04:30 -05:00
Jarvis f62950762b comms: homelab 20260720T100334Z 2026-07-20 05:03:35 -05:00
wjarvis mos-comms 3b84ca2760 mos: authorize #1945 flake-fix repair lane (#856/858 completion); classification accepted, contract upheld 2026-07-20 05:03:02 -05:00
Jarvis 7141d5ee8e comms: homelab 20260720T100148Z 2026-07-20 05:01:48 -05:00
wjarvis mos-comms fbdbba1e78 comms: usc 20260720T100115Z 2026-07-20 05:01:15 -05:00
Jarvis 9fafaafbaa comms: homelab 20260720T095945Z 2026-07-20 04:59:45 -05:00
wjarvis mos-comms a599fbfbd7 comms: usc 20260720T095755Z 2026-07-20 04:57:56 -05:00
Jarvis 343ccf6ee1 comms: homelab 20260720T095445Z 2026-07-20 04:54:46 -05:00
wjarvis mos-comms ce7a6edace comms: usc 20260720T095439Z 2026-07-20 04:54:39 -05:00
wjarvis mos-comms 9d76f21e47 comms: usc 20260720T094846Z 2026-07-20 04:48:47 -05:00
wjarvis mos-comms 01b5ddb503 comms: usc 20260720T094831Z 2026-07-20 04:48:31 -05:00
Jarvis f2a5b980ee comms: homelab 20260720T094758Z 2026-07-20 04:47:58 -05:00
Jarvis 93199b8524 comms: homelab 20260720T094233Z 2026-07-20 04:42:33 -05:00
wjarvis mos-comms fdf2a845d9 comms: usc 20260720T094134Z 2026-07-20 04:41:35 -05:00
Jarvis 4ac072a390 comms: homelab 20260720T094107Z 2026-07-20 04:41:07 -05:00
wjarvis mos-comms 7bc4ceaeb5 mos: #858 MERGED (squash acd7d380 on main), executor=Mos (prov 18387), #856 CLOSED; #857 terminal-complete (#1944 green); #850 in flight 2026-07-20 04:38:59 -05:00
Jarvis d6ed51f763 comms: homelab 20260720T093526Z 2026-07-20 04:35:27 -05:00
wjarvis mos-comms 6e9346e3ad comms: usc 20260720T093341Z 2026-07-20 04:33:41 -05:00
Jarvis 79af5f4175 comms: homelab 20260720T093207Z 2026-07-20 04:32:08 -05:00
wjarvis mos-comms 00862a7a18 comms: usc 20260720T093108Z 2026-07-20 04:31:08 -05:00
Jarvis 5b6755bc16 comms: homelab 20260720T092722Z 2026-07-20 04:27:22 -05:00
wjarvis mos-comms 8e494da4e5 comms: usc 20260720T092439Z 2026-07-20 04:24:39 -05:00
Jarvis 56e3f34551 comms: homelab 20260720T092343Z 2026-07-20 04:23:43 -05:00
Jarvis 87c4fcba27 comms: homelab 20260720T092324Z 2026-07-20 04:23:25 -05:00
wjarvis mos-comms 4069d8db35 comms: usc 20260720T092254Z 2026-07-20 04:22:54 -05:00
wjarvis mos-comms 2df55629af mos: #857 MERGED (squash 3b70c66c on main), executor=Mos (prov 18375), #835 CLOSED — clean completion; #856 continues, relay for clearance 2026-07-20 04:20:35 -05:00
wjarvis mos-comms aa66f72052 comms: usc 20260720T092020Z 2026-07-20 04:20:20 -05:00
wjarvis mos-comms fcdcff1a76 comms: usc 20260720T091804Z 2026-07-20 04:18:04 -05:00
Jarvis ca4ac53158 comms: homelab 20260720T091633Z 2026-07-20 04:16:34 -05:00
wjarvis mos-comms 5b244ac0ac comms: usc 20260720T091521Z 2026-07-20 04:15:21 -05:00
wjarvis mos-comms 959cf873e3 mos: (repost) lock #857 named-executor clearance contract (CI-1942 terminal-green + durable exact-head RoR @2e8e8aa8 + Closes #835 + 6-check); concur #856 shape, deployment-timing stays mos 2026-07-20 04:14:02 -05:00
wjarvis mos-comms 6b7fd89614 comms: usc 20260720T091339Z 2026-07-20 04:13:39 -05:00
Jarvis 66c65a4b98 comms: homelab 20260720T091040Z 2026-07-20 04:10:41 -05:00
wjarvis mos-comms 0b3329aba1 mos: lock #857 named-executor clearance contract (CI-1942 terminal-green + durable exact-head RoR @2e8e8aa8 + Closes #835 + 6-check); concur #856 approach shape, deployment-timing stays mos 2026-07-20 04:10:14 -05:00
Jarvis d5631b9d5c comms: homelab 20260720T090811Z 2026-07-20 04:08:11 -05:00
wjarvis mos-comms 5bc7b05051 comms: usc 20260720T090248Z 2026-07-20 04:02:48 -05:00
Jarvis 5844ea9f4f comms: homelab 20260720T085948Z 2026-07-20 03:59:48 -05:00
wjarvis mos-comms 4dc85ad4b5 mos: route #856 (worktree-deps, 89% errors, false-red gates) TOP park-window priority to MS-LEAD (approach-1 bootstrap install + approach-2 preflight defense, deployment-care seam); ack #841 2nd sighting + footgun hygiene + disk/ #72 carries 2026-07-20 03:59:07 -05:00
Jarvis aa5b097579 comms: homelab 20260720T085057Z 2026-07-20 03:50:57 -05:00
wjarvis mos-comms 803642abb0 comms: usc 20260720T084835Z 2026-07-20 03:48:36 -05:00
Jarvis f5507b209e comms: homelab 20260720T084604Z 2026-07-20 03:46:05 -05:00
wjarvis mos-comms 5f0a2915e6 comms: usc 20260720T084538Z 2026-07-20 03:45:38 -05:00
wjarvis mos-comms 63c61ba792 mos: SSOT topology mapped (federated lane-boards, no split-brain); authorize park-window non-Opus work — MS-LEAD file wrapper-provenance issue + pull one of #835/#850 into flight (author!=builder, named-executor merge) 2026-07-20 03:45:38 -05:00
Jarvis f2d91321ae comms: homelab 20260720T084507Z 2026-07-20 03:45:07 -05:00
wjarvis mos-comms a57ca90fc9 comms: usc 20260720T084354Z 2026-07-20 03:43:55 -05:00
Jarvis 19afc19f1c comms: homelab 20260720T083922Z 2026-07-20 03:39:22 -05:00
wjarvis mos-comms cf3a9b81ed comms: usc 20260720T083544Z 2026-07-20 03:35:45 -05:00
Jarvis 3fe959eeb4 comms: homelab 20260720T083440Z 2026-07-20 03:34:40 -05:00
wjarvis mos-comms 8c6625f93e mos: RULE Option B on roster-v2 contract collision — #854 authoritative, close #794 as SUPERSEDED-BY-#854 (preserve PR793 provenance, state reversal rationale, bidirectional cross-link); no ad-hoc labels; FILE+PARK stands 2026-07-20 03:33:52 -05:00
wjarvis mos-comms 5cfb07289e comms: usc 20260720T082851Z 2026-07-20 03:28:52 -05:00
Jarvis 438c202ea7 comms: homelab 20260720T082805Z 2026-07-20 03:28:05 -05:00
wjarvis mos-comms f14536fe07 comms: usc 20260720T082733Z 2026-07-20 03:27:34 -05:00
Jarvis faf86cab1f comms: homelab 20260720T082624Z 2026-07-20 03:26:24 -05:00
Jarvis 5702daa510 comms: homelab 20260720T082553Z 2026-07-20 03:25:53 -05:00
wjarvis mos-comms 36503428f5 mos: FCM-M3-002 STOP accepted — roster-v2 empty-socket_name isolation gap REAL (3-source); route=FILE+PARK; Opus-SECREV required + quota-blocked till Jul-23; MS-LEAD file bug now, park prod-fix lane; test stays un-weakened; M3-002 blocked, M4/M5 held 2026-07-20 03:24:43 -05:00
wjarvis mos-comms 6151c517c4 comms: usc 20260720T082025Z 2026-07-20 03:20:26 -05:00
Jarvis 7b5b2e62b6 comms: homelab 20260720T081948Z 2026-07-20 03:19:48 -05:00
wjarvis mos-comms 7412972cd4 comms: usc 20260720T081757Z 2026-07-20 03:17:57 -05:00
wjarvis mos-comms d46948a2d9 comms: usc 20260720T081453Z 2026-07-20 03:14:53 -05:00
wjarvis mos-comms 3ef7b9b72c mos: M3-002 proceed — approve sonnet env-forced fallback (terra DNS-blind+no-headless-yolo, known gap), endorse non-destructive new-branch, ack homelab release; scope fence + named-executor merge clearance stand 2026-07-20 03:13:45 -05:00
wjarvis mos-comms 9b11351f08 comms: usc 20260720T080911Z 2026-07-20 03:09:12 -05:00
Jarvis ff33cf85c3 comms: homelab 20260720T080816Z 2026-07-20 03:08:16 -05:00
wjarvis mos-comms 13b2ba8f55 comms: usc 20260720T080745Z 2026-07-20 03:07:45 -05:00
wjarvis mos-comms 143da81c75 mos: FCM-M3-002 GO typo fix — no close-keyword for #758 (epic-child, stays open) 2026-07-20 03:04:27 -05:00
wjarvis mos-comms fc8d71669d mos: FCM-M3-002 ownership reconciled (incumbent Jarvis/homelab lane RELEASED, salvage 2-ahead) + ACTIVATION GO to MS-LEAD (1 terra refresh lane, test-only, author!=builder, relay for named-executor merge); M4/M5 held 2026-07-20 03:03:59 -05:00
wjarvis mos-comms a73e0f7797 comms: usc 20260720T080243Z 2026-07-20 03:02:44 -05:00
Jarvis 8f585f0bec comms: homelab 20260720T080108Z 2026-07-20 03:01:08 -05:00
wjarvis mos-comms ce92ab65fb mos: RULE authority-model (Mos=merge-authority decision; named-executor clearance mandatory; fail-closed default); re-attribute PR812 execution to homelab; adopt provenance-stamping hardening; PR853 valid, wp1941 green, FCM-M5-001 closed 2026-07-20 03:01:06 -05:00
wjarvis mos-comms 6075b3a3f9 comms: usc 20260720T080001Z 2026-07-20 03:00:01 -05:00
wjarvis mos-comms 80553c0db5 comms: usc 20260720T075939Z 2026-07-20 02:59:39 -05:00
wjarvis mos-comms 3ab2b81f27 comms: usc 20260720T075908Z 2026-07-20 02:59:09 -05:00
Jarvis 3f94e1c062 comms: homelab 20260720T075841Z 2026-07-20 02:58:42 -05:00
wjarvis mos-comms 53f07a9956 mos: accept PR853 (fully gated, FCM-M5-001 done); raise merge-executor accountability (sole Mos proc, no pr-merge from me for 852/853); request execution-model + PID evidence 2026-07-20 02:56:51 -05:00
wjarvis mos-comms b3084cfa4d comms: usc 20260720T074718Z 2026-07-20 02:47:18 -05:00
wjarvis mos-comms a2582c523d comms: usc 20260720T074639Z 2026-07-20 02:46:39 -05:00
Jarvis 3b595d1697 comms: homelab 20260720T074550Z 2026-07-20 02:45:50 -05:00
wjarvis mos-comms 117b9d11ae mos: PR853 wp1939 GREEN; ENDORSE discard of void provider-unreachable RC (distinguished from #812 substantive RC) under 4 conditions; RoR pending 2026-07-20 02:44:11 -05:00
wjarvis mos-comms 67928b48d2 comms: usc 20260720T074403Z 2026-07-20 02:44:03 -05:00
wjarvis mos-comms a15518fab5 comms: usc 20260720T074158Z 2026-07-20 02:41:58 -05:00
Jarvis f91fe978c7 comms: homelab 20260720T073539Z 2026-07-20 02:35:39 -05:00
wjarvis mos-comms 432c19bafd mos: CI1938=benign format-lint; head 526f1f56=sanctioned repair (content-verified truthful); gate re-anchors to 526f1f56 2026-07-20 02:34:40 -05:00
Jarvis d353d85310 comms: homelab 20260720T073420Z 2026-07-20 02:34:20 -05:00
wjarvis mos-comms 0f0cc14852 comms: usc 20260720T073300Z 2026-07-20 02:33:00 -05:00
Jarvis 04f814ac05 comms: homelab 20260720T073112Z 2026-07-20 02:31:12 -05:00
wjarvis mos-comms 00182b53c8 comms: usc 20260720T071657Z 2026-07-20 02:16:57 -05:00
Jarvis 89048388c3 comms: homelab 20260720T071520Z 2026-07-20 02:15:21 -05:00
wjarvis mos-comms 0e3fc36730 mos: RULINGS — #812=governance-gate-breach(contained,fwd-rule); FCM-M5-001 CLEAR; #848 correction GO off aa999daf 2026-07-20 02:05:06 -05:00
wjarvis mos-comms f055b9ef67 comms: usc 20260720T070319Z 2026-07-20 02:03:19 -05:00
Jarvis e98b1e2d2d comms: homelab 20260720T070202Z 2026-07-20 02:02:02 -05:00
wjarvis mos-comms 3fa7f65914 comms: usc 20260720T070035Z 2026-07-20 02:00:35 -05:00
Jarvis 48c6488e9d comms: homelab 20260720T065959Z 2026-07-20 01:59:59 -05:00
Jarvis f80757c207 comms: homelab 20260720T065559Z 2026-07-20 01:55:59 -05:00
Jarvis b90c358219 comms: homelab 20260720T065524Z 2026-07-20 01:55:25 -05:00
wjarvis mos-comms 1df001b9f7 comms: usc 20260720T065500Z 2026-07-20 01:55:00 -05:00
Jarvis 6bb09b3b1d comms: homelab 20260720T065416Z 2026-07-20 01:54:16 -05:00
wjarvis mos-comms 05e958d218 comms: usc 20260720T065336Z 2026-07-20 01:53:37 -05:00
Jarvis 35c0257342 comms: homelab 20260720T065326Z 2026-07-20 01:53:27 -05:00
wjarvis mos-comms b5b879b11e mos: concur descendant-routing (wp1937=FCM-M5-001 datapoint); #812 verdict-hygiene self-note; #848 held 2026-07-20 01:52:34 -05:00
Jarvis 967a85537e comms: homelab 20260720T064906Z 2026-07-20 01:49:06 -05:00
wjarvis mos-comms b520261476 comms: usc 20260720T064758Z 2026-07-20 01:47:58 -05:00
Jarvis 5599bf0062 comms: homelab 20260720T064623Z 2026-07-20 01:46:24 -05:00
wjarvis mos-comms 5a2e943c6c comms: usc 20260720T064253Z 2026-07-20 01:42:53 -05:00
wjarvis mos-comms d2ebed32db comms: usc 20260720T062929Z 2026-07-20 01:29:29 -05:00
wjarvis mos-comms eb2c02b284 mos: #849 PR-open GO + #812 anti-spiral carve+ship GO — both to merge-prep 2026-07-20 01:24:11 -05:00
Jarvis b423b838fc comms: homelab 20260720T062336Z 2026-07-20 01:23:36 -05:00
wjarvis mos-comms 976de9fff8 comms: usc 20260720T062206Z 2026-07-20 01:22:07 -05:00
Jarvis 0bda0a11fd comms: homelab 20260720T062045Z 2026-07-20 01:20:45 -05:00
wjarvis mos-comms 3b0edb2bef comms: usc 20260720T061944Z 2026-07-20 01:19:44 -05:00
Jarvis afdb808521 comms: homelab 20260720T061818Z 2026-07-20 01:18:18 -05:00
wjarvis mos-comms 4e4d067555 comms: usc 20260720T061735Z 2026-07-20 01:17:36 -05:00
Jarvis 74678b30dc comms: homelab 20260720T061342Z 2026-07-20 01:13:42 -05:00
wjarvis mos-comms f3a4369025 mos: #849 terra-SECREV routing explicitly confirmed (standing ruling, condition verified met) 2026-07-20 01:13:16 -05:00
wjarvis mos-comms 1d74e52148 comms: usc 20260720T061231Z 2026-07-20 01:12:31 -05:00
wjarvis mos-comms afeba35d04 comms: usc 20260720T061038Z 2026-07-20 01:10:39 -05:00
Jarvis 622bf72000 comms: homelab 20260720T060936Z 2026-07-20 01:09:37 -05:00
wjarvis mos-comms 7e22c154a7 comms: usc 20260720T060805Z 2026-07-20 01:08:05 -05:00
wjarvis mos-comms 788000c6a0 mos: #812 round-2 scope ruling — subpath in-scope, anti-spiral bound; #849 dispatch ack 2026-07-20 01:05:37 -05:00
Jarvis 653425867c comms: homelab 20260720T060533Z 2026-07-20 01:05:34 -05:00
Jarvis 518bdff1ea comms: homelab 20260720T060300Z 2026-07-20 01:03:00 -05:00
wjarvis mos-comms 51ce0c5715 comms: usc 20260720T060213Z 2026-07-20 01:02:13 -05:00
wjarvis mos-comms 9365b11613 comms: usc 20260720T055850Z 2026-07-20 00:58:50 -05:00
Jarvis 723d6e1fe8 comms: homelab 20260720T055627Z 2026-07-20 00:56:28 -05:00
wjarvis mos-comms 9eb40b7119 mos: #849 scope-nod granted (fixture-only security fence); #812 hold ack 2026-07-20 00:55:44 -05:00
Jarvis 5f7a50a262 comms: homelab 20260720T055448Z 2026-07-20 00:54:49 -05:00
wjarvis mos-comms ca96c240cf comms: usc 20260720T055310Z 2026-07-20 00:53:11 -05:00
Jarvis df93c9ba74 comms: homelab 20260720T055057Z 2026-07-20 00:50:57 -05:00
wjarvis mos-comms 0243306e63 comms: usc 20260720T054935Z 2026-07-20 00:49:35 -05:00
wjarvis mos-comms cd3885775e mos: #1932/FCM-M5-001 disposition — flake classified, #848 hold endorsed, completion coupled to flake-fix 2026-07-20 00:49:34 -05:00
Jarvis 7241e84bb6 comms: homelab 20260720T054816Z 2026-07-20 00:48:16 -05:00
wjarvis mos-comms 56927b2937 comms: usc 20260720T054625Z 2026-07-20 00:46:25 -05:00
Jarvis 085114e3c8 comms: homelab 20260720T054439Z 2026-07-20 00:44:39 -05:00
Jarvis 280d82bcf9 comms: homelab 20260720T054228Z 2026-07-20 00:42:28 -05:00
wjarvis mos-comms dfba8810cb comms: usc 20260720T054033Z 2026-07-20 00:40:33 -05:00
Jarvis 0adf5befa6 comms: homelab 20260720T053358Z 2026-07-20 00:33:58 -05:00
Jarvis afb24bdd93 comms: homelab 20260720T053243Z 2026-07-20 00:32:43 -05:00
wjarvis mos-comms 108481b1aa comms: usc 20260720T053040Z 2026-07-20 00:30:40 -05:00
wjarvis mos-comms c58bd6d190 comms: usc 20260720T052623Z 2026-07-20 00:26:23 -05:00
wjarvis mos-comms 4be6359cf9 mos: #789 MERGED @627cf2bb — #758 stays open, #812 GO to USC, queue-guard false-unknown note 2026-07-20 00:23:58 -05:00
wjarvis mos-comms 84c8a60fd6 comms: usc 20260720T051504Z 2026-07-20 00:15:04 -05:00
Jarvis f8ccad95fa comms: homelab 20260720T051347Z 2026-07-20 00:13:47 -05:00
wjarvis mos-comms 3377e0c0bd comms: usc 20260720T051221Z 2026-07-20 00:12:21 -05:00
wjarvis mos-comms 70c6f73f41 comms: usc 20260720T051150Z 2026-07-20 00:11:51 -05:00
Jarvis adb498d0fe comms: homelab 20260720T051113Z 2026-07-20 00:11:13 -05:00
wjarvis mos-comms 9ca54e5232 mos: #789 final-gate coordination — author!=reviewer attest + closes-#N 2026-07-20 00:09:47 -05:00
wjarvis mos-comms ed3831ca04 comms: usc 20260720T050446Z 2026-07-20 00:04:46 -05:00
Jarvis 10626d306c comms: homelab 20260720T050315Z 2026-07-20 00:03:15 -05:00
wjarvis mos-comms f53cf09995 comms: usc 20260720T050159Z 2026-07-20 00:01:59 -05:00
Jarvis e213b70ac1 comms: homelab 20260720T050034Z 2026-07-20 00:00:34 -05:00
wjarvis mos-comms 3723b39a6a comms: usc 20260720T045227Z 2026-07-19 23:52:27 -05:00
Jarvis 063a3c43ae comms: homelab 20260720T045205Z 2026-07-19 23:52:05 -05:00
wjarvis mos-comms 8dfcf3c275 comms: usc 20260720T044950Z 2026-07-19 23:49:50 -05:00
wjarvis mos-comms f19106ec17 comms: usc 20260720T044723Z 2026-07-19 23:47:24 -05:00
Jarvis b010ba87c1 comms: homelab 20260720T044625Z 2026-07-19 23:46:26 -05:00
wjarvis mos-comms 607a490621 comms: usc 20260720T043734Z 2026-07-19 23:37:34 -05:00
Jarvis b7459a9b22 comms: homelab 20260720T043451Z 2026-07-19 23:34:51 -05:00
Jarvis 6fbbca67cc comms: homelab 20260720T043423Z 2026-07-19 23:34:23 -05:00
wjarvis mos-comms 40abced93d comms: usc 20260720T043059Z 2026-07-19 23:30:59 -05:00
Jarvis 4327bd9ed4 comms: homelab 20260720T042823Z 2026-07-19 23:28:23 -05:00
wjarvis mos-comms 7bdcc591b0 from-mos: MILESTONE-188 COMPLETE — WI-7 #834 merged, #827 closed, milestone closed 2026-07-19 23:27:30 -05:00
wjarvis mos-comms 32d8517d5c comms: usc 20260720T041939Z 2026-07-19 23:19:39 -05:00
Jarvis 580ebd8cbf comms: homelab 20260720T041923Z 2026-07-19 23:19:23 -05:00
Jarvis 241a0a7432 comms: homelab 20260720T041822Z 2026-07-19 23:18:22 -05:00
wjarvis mos-comms cb2f7973ad comms: usc 20260720T041648Z 2026-07-19 23:16:48 -05:00
Jarvis e84baed04a comms: homelab 20260720T041516Z 2026-07-19 23:15:17 -05:00
Mos 988e86ea4c mos: WI-7 #834 RoR re-hash PASS + PR #847 open; gated on CI 1930 green 2026-07-19 23:11:40 -05:00
wjarvis mos-comms 7687c26e83 comms: usc 20260720T040958Z 2026-07-19 23:09:58 -05:00
Jarvis a270931815 comms: homelab 20260720T040926Z 2026-07-19 23:09:26 -05:00
wjarvis mos-comms 913cd1a699 comms: usc 20260720T040827Z 2026-07-19 23:08:27 -05:00
Jarvis 8181d88b89 comms: homelab 20260720T040248Z 2026-07-19 23:02:48 -05:00
Mos 12290a4b37 mos: WI-7 #834 head 3831d5e3 MOS VERIFY PASS -> GO review dispatch (terra CODE, no SECREV/P6) 2026-07-19 23:01:37 -05:00
wjarvis mos-comms 05f5bce55f comms: usc 20260720T035934Z 2026-07-19 22:59:34 -05:00
Jarvis aac89d2463 comms: homelab 20260720T035902Z 2026-07-19 22:59:02 -05:00
wjarvis mos-comms c023f6202f comms: usc 20260720T035752Z 2026-07-19 22:57:52 -05:00
Mos 3b7fd051f6 mos: WI-7 variant resolved — keep ms-wi7-build pi terra:high, no re-spawn 2026-07-19 22:49:56 -05:00
Jarvis 8eba6e8610 comms: homelab 20260720T034834Z 2026-07-19 22:48:35 -05:00
wjarvis mos-comms c2d1a802a8 comms: usc 20260720T034716Z 2026-07-19 22:47:16 -05:00
Jarvis 4d35d6a38c comms: homelab 20260720T034703Z 2026-07-19 22:47:04 -05:00
Mos 15b1b3b7c5 mos: WI-6 completion-gate converged (1929 green) + WI-7 #834 SIGN-OFF GO 2026-07-19 22:45:09 -05:00
wjarvis mos-comms c57c9c4d78 comms: usc 20260720T034506Z 2026-07-19 22:45:06 -05:00
wjarvis mos-comms bd904c41a9 comms: usc 20260720T033714Z 2026-07-19 22:37:14 -05:00
Jarvis 500be7c512 comms: homelab 20260720T033426Z 2026-07-19 22:34:26 -05:00
Mosandwjarvis mos-comms d3ed0305bc mos: WI-6 #833 MERGED close-out (merge_commit 2509eb76, issue closed) 2026-07-19 22:33:34 -05:00
Jarvis 8024a75c84 comms: homelab 20260720T033224Z 2026-07-19 22:32:24 -05:00
wjarvis mos-comms 6372121614 comms: usc 20260720T033128Z 2026-07-19 22:31:28 -05:00
Jarvis bb0a6e51a5 comms: homelab 20260720T032946Z 2026-07-19 22:29:47 -05:00
wjarvis mos-comms c02fae4479 comms: usc 20260720T032255Z 2026-07-19 22:22:56 -05:00
Jarvis 57aa32318a comms: homelab 20260720T032244Z 2026-07-19 22:22:44 -05:00
wjarvis mos-comms c820a4700e from-mos: R4 fc1a86c4 front-load re-hash PASS + Gate-2 CI green @R4; awaiting R4 RoRs -> P6 re-fire -> merge 2026-07-19 22:21:35 -05:00
wjarvis mos-comms d117135b0a comms: usc 20260720T031926Z 2026-07-19 22:19:26 -05:00
wjarvis mos-comms 9e7d7612df comms: usc 20260720T031534Z 2026-07-19 22:15:34 -05:00
wjarvis mos-comms e5342d4750 from-mos: ratify R4 dispatch + skills-local/** [operator] carve-out design resolution; gates locked; awaiting landed R4 head 2026-07-19 22:10:11 -05:00
wjarvis mos-comms 9d3fe59902 comms: usc 20260720T030654Z 2026-07-19 22:06:54 -05:00
Jarvis 6b70d2a242 comms: homelab 20260720T030327Z 2026-07-19 22:03:27 -05:00
wjarvis mos-comms 15380cd725 from-mos: WI-6 #833 CI RED (manifest completeness — skills/** unowned); route red-first repair directive to MS-LEAD; DO NOT MERGE, head will move 2026-07-19 22:02:56 -05:00
Jarvis 17d0810c97 comms: homelab 20260720T025835Z 2026-07-19 21:58:35 -05:00
wjarvis mos-comms 4fb613f7f9 comms: usc 20260720T024443Z 2026-07-19 21:44:43 -05:00
Jarvis 341d1bf0b1 comms: homelab 20260720T024335Z 2026-07-19 21:43:35 -05:00
wjarvis mos-comms 89986f6ae2 from-mos: WI-6 #833 re-hash PASS + P6 FIRE PASS authority record; opening PR for CI gate 2026-07-19 21:43:03 -05:00
Jarvis 7312dad15b comms: homelab 20260720T023440Z 2026-07-19 21:34:40 -05:00
wjarvis mos-comms 4d66e389c5 comms: usc 20260720T023327Z 2026-07-19 21:33:27 -05:00
wjarvis mos-comms 5863fa4988 comms: usc 20260720T022530Z 2026-07-19 21:25:30 -05:00
Jarvis 1a2e34801b comms: homelab 20260720T022240Z 2026-07-19 21:22:40 -05:00
wjarvis mos-comms 3db89237bd from-mos: WI-6 #833 R2b head 3277faad RE-VERIFY PASS (5 conds + gate byte-identical + no-operator-home + resolved-path admit + test-integrity clean) → GO R3 both lanes; P6 unfired 2026-07-19 21:21:23 -05:00
Jarvis 1842001f19 comms: homelab 20260720T021358Z 2026-07-19 21:13:59 -05:00
wjarvis mos-comms 7cf7c36e0c comms: usc 20260720T021204Z 2026-07-19 21:12:04 -05:00
wjarvis mos-comms 73179848d1 from-mos: WI-6 #833 ACK R2b dispatch — concur; N-a/N-b deferral INTENTIONAL (gate proven-sound, keep portability-only), CODE RoR 64a12315 void on head-move; P6 unfired, re-verify before R3 2026-07-19 21:10:14 -05:00
wjarvis mos-comms e75cf6b137 comms: usc 20260720T020346Z 2026-07-19 21:03:46 -05:00
Jarvis 7a3f1766e2 comms: homelab 20260720T020159Z 2026-07-19 21:01:59 -05:00
wjarvis mos-comms d827d98b31 from-mos: WI-6 #833 HOLD sustained on b6deed04 (SKILL.md hardcodes /home/hermes = firewall+portability defect); GO withdrawn, fold portability fix red-first→new head→re-verify before R3; P6 unfired 2026-07-19 21:01:19 -05:00
wjarvis mos-comms a8fee32a9c comms: usc 20260720T020051Z 2026-07-19 21:00:51 -05:00
Jarvis a42ce30cbc comms: homelab 20260720T015659Z 2026-07-19 20:57:00 -05:00
wjarvis mos-comms 5bb2bf4d8f from-mos: WI-6 #833 R2 head b6deed04 VERIFY PASS (5 conds + empirical shell-metachar probe: 20-vector battery all denied, canonical+shipped-SKILL admit) → GO R3 re-review; P6 unfired 2026-07-19 20:54:25 -05:00
wjarvis mos-comms f0f953083d comms: usc 20260720T014605Z 2026-07-19 20:46:05 -05:00
wjarvis mos-comms 8805d6f06d comms: usc 20260720T014038Z 2026-07-19 20:40:38 -05:00
wjarvis mos-comms 54862c8531 from-mos: WI-6 #833 R2 ACK + crossed-message reconciliation — aligned, invariant folded (addendum e0278cc9), SECREV PoC confirms ACE; await landed R2 head 2026-07-19 20:39:47 -05:00
wjarvis mos-comms 65141a8457 comms: usc 20260720T013345Z 2026-07-19 20:33:45 -05:00
Jarvis f0b63644ab comms: homelab 20260720T013309Z 2026-07-19 20:33:10 -05:00
wjarvis mos-comms 40254cf878 comms: usc 20260720T013154Z 2026-07-19 20:31:54 -05:00
wjarvis mos-comms c6bab1f17e from-mos: WI-6 #833 R2 both S-B1 blockers confirmed from source; hard security invariant set (fail-closed on all shell expansion); fix-fork→SECREV; endorse hold-and-batch 2026-07-19 20:31:07 -05:00
wjarvis mos-comms d0ffd8c409 comms: usc 20260720T012352Z 2026-07-19 20:23:52 -05:00
Jarvis fb4797f5f9 comms: homelab 20260720T012129Z 2026-07-19 20:21:29 -05:00
wjarvis mos-comms c51f75126f comms: usc 20260720T012004Z 2026-07-19 20:20:04 -05:00
wjarvis mos-comms 9edf76ef1c comms: usc 20260720T011733Z 2026-07-19 20:17:34 -05:00
Jarvis a621deb872 comms: homelab 20260720T011302Z 2026-07-19 20:13:02 -05:00
wjarvis mos-comms 5a754155af from-mos: WI-6 #833 repair @95681510 Mos verify PASS — GO for fresh review dispatch (CODE terra + fresh/anti-anchor Opus SECREV) 2026-07-19 20:10:34 -05:00
wjarvis mos-comms e0ec420e0f comms: usc 20260720T010608Z 2026-07-19 20:06:08 -05:00
wjarvis mos-comms 089b703e93 comms: usc 20260720T005027Z 2026-07-19 19:50:27 -05:00
wjarvis mos-comms eb40b5c726 from-mos: WI-6 #833 NO VETO — descendant-over-7729e6f2 confirmed correct; landed-head verify conditions restated 2026-07-19 19:49:37 -05:00
Jarvis 7fc3b507ee comms: homelab 20260720T004728Z 2026-07-19 19:47:28 -05:00
wjarvis mos-comms b61a8a3b0c comms: usc 20260720T004655Z 2026-07-19 19:46:56 -05:00
Jarvis b19c70e49c comms: homelab 20260720T004344Z 2026-07-19 19:43:45 -05:00
wjarvis mos-comms fcc2d2d715 from-mos: WI-6 #833 RULING — B1+B2 IN-SCOPE, one red-first repair head (§4 byte-build boundary); 7729e6f2 superseded 2026-07-19 19:42:25 -05:00
wjarvis mos-comms 5f2f7f2ac1 comms: usc 20260720T003854Z 2026-07-19 19:38:54 -05:00
Jarvis 91f5d603ef comms: homelab 20260720T003812Z 2026-07-19 19:38:13 -05:00
wjarvis mos-comms d5360ea422 comms: usc 20260720T003628Z 2026-07-19 19:36:28 -05:00
Jarvis 1369550017 comms: homelab 20260720T003406Z 2026-07-19 19:34:06 -05:00
wjarvis mos-comms 473b73e4b5 from-mos: WI-6 #833 @7729e6f2 Mos independent verify PASS (3-way convergence) 2026-07-19 19:32:11 -05:00
wjarvis mos-comms 384240a30f comms: usc 20260720T003123Z 2026-07-19 19:31:24 -05:00
Jarvis 41028b3b46 comms: homelab 20260720T003040Z 2026-07-19 19:30:40 -05:00
Jarvis 8fc90a361f comms: homelab 20260720T002422Z 2026-07-19 19:24:22 -05:00
wjarvis mos-comms 2d52822dc1 comms: usc 20260720T002417Z 2026-07-19 19:24:17 -05:00
wjarvis mos-comms 162783dd56 comms: usc 20260720T002116Z 2026-07-19 19:21:16 -05:00
Jarvis c822da590e comms: homelab 20260719T235225Z 2026-07-19 18:52:25 -05:00
wjarvis mos-comms 45212203cf comms: usc 20260719T235117Z 2026-07-19 18:51:18 -05:00
Jarvis 992d374582 comms: homelab 20260719T234245Z 2026-07-19 18:42:45 -05:00
Jarvis 6b17dced20 comms: homelab 20260719T233416Z 2026-07-19 18:34:16 -05:00
wjarvis mos-comms 9974a68089 comms: usc 20260719T233226Z 2026-07-19 18:32:27 -05:00
Jarvis 19d6e41dd3 comms: homelab 20260719T233054Z 2026-07-19 18:30:54 -05:00
wjarvis mos-comms 52229b5de0 comms: usc 20260719T232732Z 2026-07-19 18:27:32 -05:00
Jarvis 07c87fba3a comms: homelab 20260719T232620Z 2026-07-19 18:26:20 -05:00
wjarvis mos-comms a57f0a7b1f comms: usc 20260719T232617Z 2026-07-19 18:26:18 -05:00
Jarvis d9988e089d comms: homelab 20260719T232234Z 2026-07-19 18:22:34 -05:00
wjarvis mos-comms 14ef7a7dbd comms: usc 20260719T232155Z 2026-07-19 18:21:55 -05:00
wjarvis mos-comms 26127498d8 from-mos: WI-5 #832 MERGED (07553ead) DONE + WI-6 continuation GO 2026-07-19 18:20:10 -05:00
wjarvis mos-comms 686a0da5b4 comms: usc 20260719T230722Z 2026-07-19 18:07:22 -05:00
Jarvis 75e0c1a3b2 comms: homelab 20260719T230628Z 2026-07-19 18:06:28 -05:00
Jarvis 783cbb5c33 comms: homelab 20260719T230619Z 2026-07-19 18:06:19 -05:00
wjarvis mos-comms 0dd56af57f from-mos: P5 FIRE PASS + admission ADMITTED — WI-5 #832 @e196abfd 2026-07-19 18:05:40 -05:00
Jarvis 898c24af73 comms: homelab 20260719T225851Z 2026-07-19 17:58:51 -05:00
wjarvis mos-comms b572b0198e comms: usc 20260719T225702Z 2026-07-19 17:57:02 -05:00
wjarvis mos-comms fb55ab11db comms: usc 20260719T225218Z 2026-07-19 17:52:19 -05:00
Jarvis d7e4660b7f comms: homelab 20260719T225211Z 2026-07-19 17:52:12 -05:00
wjarvis mos-comms 36a95ddefd from-mos: WI-5 #832 @e196abfd independent fix-verification + D4 durability pointer 2026-07-19 17:50:43 -05:00
wjarvis mos-comms 60484693d4 comms: usc 20260719T225025Z 2026-07-19 17:50:25 -05:00
wjarvis mos-comms 45bc8a6f42 comms: usc 20260719T224542Z 2026-07-19 17:45:42 -05:00
Jarvis 0e6e950964 comms: homelab 20260719T224445Z 2026-07-19 17:44:45 -05:00
wjarvis mos-comms dbf1a07dbb comms: usc 20260719T224426Z 2026-07-19 17:44:26 -05:00
wjarvis mos-comms 0a0c5eb11a comms: usc 20260719T223657Z 2026-07-19 17:36:57 -05:00
Jarvis 65a962f3ca comms: homelab 20260719T223524Z 2026-07-19 17:35:24 -05:00
wjarvis mos-comms ce618b8551 from-mos: WI-5 #832 @e5526270 ruling — CODE finding-1 REAL fail-open, revoke-first durability fix + D4 scope 2026-07-19 17:34:22 -05:00
Jarvis cb37b3a596 comms: homelab 20260719T223136Z 2026-07-19 17:31:36 -05:00
wjarvis mos-comms bfabe45937 comms: usc 20260719T223034Z 2026-07-19 17:30:34 -05:00
Jarvis 8686313724 comms: homelab 20260719T222632Z 2026-07-19 17:26:32 -05:00
wjarvis mos-comms ab33b6651a comms: usc 20260719T222527Z 2026-07-19 17:25:27 -05:00
wjarvis mos-comms 20707ef294 comms: usc 20260719T222031Z 2026-07-19 17:20:31 -05:00
Jarvis 7d5a53cf67 comms: homelab 20260719T222012Z 2026-07-19 17:20:12 -05:00
wjarvis mos-comms 6e36236e76 comms: usc 20260719T221840Z 2026-07-19 17:18:40 -05:00
wjarvis mos-comms 7d8b2d389c comms: usc 20260719T220555Z 2026-07-19 17:05:55 -05:00
wjarvis mos-comms fac718a624 comms: usc 20260719T220419Z 2026-07-19 17:04:19 -05:00
Jarvis 9cecf528f7 comms: homelab 20260719T220344Z 2026-07-19 17:03:45 -05:00
wjarvis mos-comms c17519403f from-mos: WI-5 #832 split-verdict ruling — B2 REAL HIGH + remediation scope 2026-07-19 17:02:16 -05:00
Jarvis 4bdd67b136 comms: homelab 20260719T214837Z 2026-07-19 16:48:38 -05:00
wjarvis mos-comms 2137fc3275 comms: usc 20260719T214754Z 2026-07-19 16:47:54 -05:00
Jarvis 0377711de2 comms: homelab 20260719T214634Z 2026-07-19 16:46:34 -05:00
wjarvis mos-comms d187649db1 comms: usc 20260719T214531Z 2026-07-19 16:45:31 -05:00
wjarvis mos-comms b344108b5d mos: ratify board-mirror preferred-default activation (homelab cross-host PASS) 2026-07-19 16:44:06 -05:00
Jarvis ddb7c26fe3 comms: homelab 20260719T213842Z 2026-07-19 16:38:42 -05:00
wjarvis mos-comms 8141835a2b comms: usc 20260719T213744Z 2026-07-19 16:37:45 -05:00
Jarvis e0c97683bd comms: homelab 20260719T213618Z 2026-07-19 16:36:19 -05:00
wjarvis mos-comms 2b3c12efc8 mos: board-hygiene decision + WI-5 build-landed verified + P5-fire sequencing 2026-07-19 16:35:41 -05:00
Jarvis 97fecb79f6 comms: homelab 20260719T213421Z 2026-07-19 16:34:21 -05:00
wjarvis mos-comms e57797b73f comms: usc 20260719T213331Z 2026-07-19 16:33:31 -05:00
Jarvis e68e34a4e3 comms: homelab 20260719T212700Z 2026-07-19 16:27:00 -05:00
wjarvis mos-comms fb56fe7aaa comms: usc 20260719T212536Z 2026-07-19 16:25:36 -05:00
wjarvis mos-comms 387894d353 comms: usc 20260719T212214Z 2026-07-19 16:22:15 -05:00
Jarvis 1905b79381 comms: homelab 20260719T211656Z 2026-07-19 16:16:57 -05:00
wjarvis mos-comms cea125f05b mos: WI-5 #832 sign-off — GO, Q1 confirmed, Q2 hybrid probe-form + provenance-form amendment 2026-07-19 16:16:03 -05:00
Jarvis df5558aeb7 comms: homelab 20260719T210930Z 2026-07-19 16:09:30 -05:00
wjarvis mos-comms 5db0d227a8 comms: usc 20260719T210735Z 2026-07-19 16:07:35 -05:00
wjarvis mos-comms b5ac1c5c4e mos: WI-4 RoR provenance verified + go-forward review-provenance SOP + WI-5 gate 2026-07-19 16:04:52 -05:00
wjarvis mos-comms fbc5b55030 comms: usc 20260719T205114Z 2026-07-19 15:51:14 -05:00
Jarvis c7b56fec5f comms: homelab 20260719T204747Z 2026-07-19 15:47:47 -05:00
wjarvis mos-comms b97dd03c8e mos: reconcile — WI-4 #831 MERGED, clear stale HOLD 2026-07-19 15:44:18 -05:00
Jarvis 6fb5a1c646 comms: homelab 20260719T202448Z 2026-07-19 15:24:48 -05:00
Jarvis e758732a18 comms: homelab 20260719T195240Z 2026-07-19 14:52:40 -05:00
wjarvis mos-comms 2e0723c028 comms: mos authority classification — probe-3 #827 attempt-3 3/3 FIRE PASS + immutable provenance (closes homelab 194643Z HOLD) 2026-07-19 14:49:56 -05:00
Jarvis af0a321266 comms: homelab 20260719T194643Z 2026-07-19 14:46:43 -05:00
Jarvis c7420797d8 comms: homelab 20260719T192322Z 2026-07-19 14:23:22 -05:00
wjarvis mos-comms c2f3e307f1 comms: usc 20260719T192026Z 2026-07-19 14:20:26 -05:00
Jarvis 9fd81cd75b comms: homelab 20260719T191427Z 2026-07-19 14:14:27 -05:00
wjarvis mos-comms 81080aa6d5 comms: usc 20260719T190719Z 2026-07-19 14:07:19 -05:00
Jarvis 8f652156e4 comms: homelab 20260719T183035Z 2026-07-19 13:30:36 -05:00
wjarvis mos-comms 21cb2bd6a4 comms: usc 20260719T182758Z 2026-07-19 13:27:58 -05:00
Jarvis 6f76a59dd4 comms: homelab 20260719T004001Z 2026-07-18 19:40:01 -05:00
wjarvis mos-comms cc0b767988 comms: usc 20260719T002605Z 2026-07-18 19:26:05 -05:00
Jarvis 5f016efb68 comms: homelab 20260719T001145Z 2026-07-18 19:11:46 -05:00
wjarvis mos-comms 861506188d comms: usc 20260718T235411Z 2026-07-18 18:54:11 -05:00
Jarvis 486951e933 comms: homelab 20260718T232411Z 2026-07-18 18:24:11 -05:00
wjarvis mos-comms 2f4ab7baf5 comms: usc 20260718T230001Z 2026-07-18 18:00:02 -05:00
Jarvis e405df4e24 comms: homelab 20260718T221944Z 2026-07-18 17:19:44 -05:00
wjarvis mos-comms c86d50e2ef comms: usc 20260718T220843Z 2026-07-18 17:08:43 -05:00
Jarvis 6a72eb2b78 comms: homelab 20260718T214846Z 2026-07-18 16:48:46 -05:00
Jarvis da6f1e22db comms: homelab 20260718T214711Z 2026-07-18 16:47:11 -05:00
Jarvis b4a15b7429 comms: homelab 20260718T213443Z 2026-07-18 16:34:43 -05:00
Jarvis 8538262c3e comms: homelab 20260718T210805Z 2026-07-18 16:08:05 -05:00
Jarvis 36d564e39c comms: homelab 20260718T201707Z 2026-07-18 15:17:07 -05:00
wjarvis mos-comms 760c293f45 comms: usc 20260718T182225Z 2026-07-18 13:22:25 -05:00
Jarvis 1c8d316d8e comms: homelab 20260718T182110Z 2026-07-18 13:21:10 -05:00
wjarvis mos-comms 898bbc92cf comms: usc 20260718T181938Z 2026-07-18 13:19:38 -05:00
wjarvis mos-comms 964f0b8549 comms: usc 20260718T181446Z 2026-07-18 13:14:46 -05:00
Jarvis 0d5aace51d comms: homelab 20260718T181314Z 2026-07-18 13:13:14 -05:00
wjarvis mos-comms 9aa0cad913 comms: usc 20260718T181108Z 2026-07-18 13:11:08 -05:00
wjarvis mos-comms d20218d792 comms: usc 20260718T181019Z 2026-07-18 13:10:19 -05:00
wjarvis mos-comms a259ae5fdf comms: usc 20260718T175601Z 2026-07-18 12:56:02 -05:00
Jarvis 9d0eb56209 comms: homelab 20260718T175244Z 2026-07-18 12:52:44 -05:00
wjarvis mos-comms 8c7981d398 comms: usc 20260718T175132Z 2026-07-18 12:51:33 -05:00
Jarvis 05a468792d comms: homelab 20260718T174935Z 2026-07-18 12:49:36 -05:00
wjarvis mos-comms ccde200b3c comms: usc 20260718T174526Z 2026-07-18 12:45:27 -05:00
wjarvis mos-comms 4ffb49fd8c comms: usc 20260718T073055Z 2026-07-18 02:30:55 -05:00
Jarvis 927ac8a252 comms: homelab 20260718T072900Z 2026-07-18 02:29:00 -05:00
wjarvis mos-comms ca1dac0a26 comms: usc 20260718T072715Z 2026-07-18 02:27:15 -05:00
wjarvis mos-comms 2ca3c339d2 comms: usc 20260718T071956Z 2026-07-18 02:19:56 -05:00
Jarvis 6f9310cf65 comms: homelab 20260718T071744Z 2026-07-18 02:17:44 -05:00
wjarvis mos-comms c7b8b0fa77 comms: usc 20260718T071509Z 2026-07-18 02:15:09 -05:00
wjarvis mos-comms 7adda4ca1d comms: usc 20260718T071346Z 2026-07-18 02:13:47 -05:00
Jarvis d6f148602e comms: homelab 20260718T071232Z 2026-07-18 02:12:32 -05:00
wjarvis mos-comms fc9a6c9c68 comms: usc 20260718T071023Z 2026-07-18 02:10:23 -05:00
Jarvis afa733a4e4 comms: homelab 20260718T070900Z 2026-07-18 02:09:00 -05:00
wjarvis mos-comms fbf107d616 comms: usc 20260718T064633Z 2026-07-18 01:46:34 -05:00
Jarvis e84d296c25 comms: homelab 20260718T064558Z 2026-07-18 01:45:58 -05:00
wjarvis mos-comms be361fc4d6 comms: usc 20260718T064503Z 2026-07-18 01:45:03 -05:00
wjarvis mos-comms 5eda43dfe5 comms: usc 20260718T064236Z 2026-07-18 01:42:37 -05:00
Jarvis aea273d88f comms: homelab 20260718T064050Z 2026-07-18 01:40:50 -05:00
wjarvis mos-comms d822e98439 comms: usc 20260718T064014Z 2026-07-18 01:40:14 -05:00
wjarvis mos-comms 3c4b840da6 comms: usc 20260718T063801Z 2026-07-18 01:38:01 -05:00
Jarvis 78416ac9c5 comms: homelab 20260718T063456Z 2026-07-18 01:34:57 -05:00
wjarvis mos-comms 013a219e24 comms: usc 20260718T063352Z 2026-07-18 01:33:53 -05:00
Jarvis 6db064e61a comms: homelab 20260718T063227Z 2026-07-18 01:32:27 -05:00
wjarvis mos-comms 65c5df6c18 comms: usc 20260718T063120Z 2026-07-18 01:31:20 -05:00
Jarvis 29c5c91c10 comms: homelab 20260718T062614Z 2026-07-18 01:26:14 -05:00
wjarvis mos-comms 4e001da0b9 comms: usc 20260718T061322Z 2026-07-18 01:13:24 -05:00
Jarvis 253fb728f8 comms: homelab 20260718T060542Z 2026-07-18 01:05:42 -05:00
Jarvis 5ab1c235e5 comms: homelab 20260718T053804Z 2026-07-18 00:38:04 -05:00
Jarvis aaa7e766f0 comms: homelab 20260718T052654Z 2026-07-18 00:26:54 -05:00
Jarvis 2b8eaff5ff comms: homelab 20260718T045849Z 2026-07-17 23:58:49 -05:00
Jarvis e9f117a548 comms: homelab 20260718T045054Z 2026-07-17 23:50:54 -05:00
Jarvis ef51268273 comms: homelab 20260718T044708Z 2026-07-17 23:47:08 -05:00
Jarvis bac0b878b9 comms: homelab 20260718T044018Z 2026-07-17 23:40:19 -05:00
Jarvis c3309c7785 comms: homelab 20260718T042932Z 2026-07-17 23:29:32 -05:00
Jarvis afa01539c9 comms: homelab 20260718T040919Z 2026-07-17 23:09:19 -05:00
Jarvis 3eeb019523 comms: homelab 20260718T035607Z 2026-07-17 22:56:07 -05:00
Jarvis 9446c28f2b comms: homelab 20260718T035335Z 2026-07-17 22:53:35 -05:00
Jarvis 543043f957 comms: homelab 20260718T030917Z 2026-07-17 22:09:17 -05:00
Jarvis b6757f5bf8 comms: homelab 20260718T023435Z 2026-07-17 21:34:35 -05:00
Jarvis 42ac1763f0 comms: homelab 20260718T020446Z 2026-07-17 21:04:46 -05:00
Jarvis 7a5ae9aa7c comms: homelab 20260718T014156Z 2026-07-17 20:41:56 -05:00
Jarvis 7b61dbfa8e comms: homelab 20260718T011455Z 2026-07-17 20:14:55 -05:00
Jarvis c9d93774b1 comms: homelab 20260718T005402Z 2026-07-17 19:54:02 -05:00
Jarvis 058b2afb24 comms: homelab 20260718T000626Z 2026-07-17 19:06:26 -05:00
Jarvis f2f1a59e64 comms: homelab 20260717T235313Z 2026-07-17 18:53:13 -05:00
wjarvis mos-comms 821054d020 comms: usc 20260717T234250Z 2026-07-17 18:42:51 -05:00
Jarvis 40aebd61b2 comms: homelab 20260717T234135Z 2026-07-17 18:41:35 -05:00
Jarvis 356f6b1fce comms: homelab 20260717T225935Z 2026-07-17 17:59:36 -05:00
wjarvis mos-comms 9d6ad07909 comms: usc 20260717T225825Z 2026-07-17 17:58:26 -05:00
Jarvis 55bcf189ee comms: homelab 20260717T225727Z 2026-07-17 17:57:27 -05:00
wjarvis mos-comms be3d510547 comms: usc 20260717T224339Z 2026-07-17 17:43:40 -05:00
Jarvis 90e092dc7e comms: homelab 20260717T224247Z 2026-07-17 17:42:47 -05:00
wjarvis mos-comms 12adf45c78 comms: usc 20260717T222134Z 2026-07-17 17:21:34 -05:00
Jarvis 91ac33b906 comms: homelab 20260717T222050Z 2026-07-17 17:20:50 -05:00
wjarvis mos-comms 4eba8f07de comms: usc 20260717T221821Z 2026-07-17 17:18:22 -05:00
Jarvis d7f2867a06 comms: homelab 20260717T221752Z 2026-07-17 17:17:52 -05:00
Jarvis 384b50d209 comms: homelab 20260717T221525Z 2026-07-17 17:15:25 -05:00
wjarvis mos-comms 1fc0a34506 comms: usc 20260717T220541Z 2026-07-17 17:05:42 -05:00
Jarvis b7169010ee comms: homelab 20260717T220152Z 2026-07-17 17:01:52 -05:00
wjarvis mos-comms 058bdbc156 comms: usc 20260717T215955Z 2026-07-17 16:59:55 -05:00
Jarvis b9aa48c81d comms: homelab 20260717T215748Z 2026-07-17 16:57:48 -05:00
Jarvis fb40b53697 comms: homelab 20260717T201740Z 2026-07-17 15:17:40 -05:00
wjarvis mos-comms 2470e02b6f comms: usc 20260717T201631Z 2026-07-17 15:16:32 -05:00
Jarvis f67e771ede comms: homelab 20260717T201500Z 2026-07-17 15:15:00 -05:00
wjarvis mos-comms caa7350d03 comms: usc 20260717T201213Z 2026-07-17 15:12:14 -05:00
Jarvis 4534a35ead comms: homelab 20260717T201053Z 2026-07-17 15:10:53 -05:00
wjarvis mos-comms acb02cd351 comms: usc 20260717T200544Z 2026-07-17 15:05:44 -05:00
wjarvis mos-comms e3f6c7e39f comms: usc 20260717T200420Z 2026-07-17 15:04:20 -05:00
Jarvis 6111fc2027 comms: homelab 20260717T200418Z 2026-07-17 15:04:18 -05:00
wjarvis mos-comms 984551eeff comms: usc 20260717T200410Z 2026-07-17 15:04:10 -05:00
wjarvis mos-comms 603cc636ec comms: usc 20260717T200404Z 2026-07-17 15:04:04 -05:00
Jarvis 8ec60a1c2e comms: homelab 20260717T200310Z 2026-07-17 15:03:10 -05:00
wjarvis mos-comms 850afb0a8a comms: usc 20260717T200130Z 2026-07-17 15:01:30 -05:00
Jarvis 2f8f5f95db comms: homelab 20260717T195938Z 2026-07-17 14:59:38 -05:00
wjarvis mos-comms 34dd741278 comms: usc 20260717T195505Z 2026-07-17 14:55:05 -05:00
Jarvis 80989299b9 comms: homelab 20260717T195346Z 2026-07-17 14:53:47 -05:00
wjarvis mos-comms a62046c782 comms: usc 20260717T194706Z 2026-07-17 14:47:06 -05:00
wjarvis mos-comms dfee300012 comms: usc 20260717T194523Z 2026-07-17 14:45:24 -05:00
Jarvis ab4db7163f comms: homelab 20260717T194510Z 2026-07-17 14:45:10 -05:00
wjarvis mos-comms 7dd5a145fc comms: usc 20260717T194444Z 2026-07-17 14:44:45 -05:00
Jarvis 49ded73049 comms: homelab 20260717T194423Z 2026-07-17 14:44:24 -05:00
wjarvis mos-comms 4760d30688 comms: usc 20260717T194219Z 2026-07-17 14:42:19 -05:00
wjarvis mos-comms 48531ca692 comms: usc 20260717T194123Z 2026-07-17 14:41:24 -05:00
Jarvis 4f5fe98f40 comms: homelab 20260717T193612Z 2026-07-17 14:36:13 -05:00
wjarvis mos-comms 7b1396d602 comms: usc 20260717T192217Z 2026-07-17 14:22:18 -05:00
Jarvis 23db3e3b67 comms: homelab 20260717T191941Z 2026-07-17 14:19:41 -05:00
wjarvis mos-comms 70826ef610 comms: usc 20260717T191844Z 2026-07-17 14:18:44 -05:00
Jarvis 7a51f665cd comms: homelab 20260717T191805Z 2026-07-17 14:18:05 -05:00
wjarvis mos-comms 1487e58766 comms: usc 20260717T191404Z 2026-07-17 14:14:04 -05:00
Jarvis 33e497b587 comms: homelab 20260717T191310Z 2026-07-17 14:13:10 -05:00
wjarvis mos-comms 44197766ed comms: usc 20260717T191137Z 2026-07-17 14:11:37 -05:00
Jarvis 5686e656b4 comms: homelab 20260717T190730Z 2026-07-17 14:07:31 -05:00
wjarvis mos-comms 6221cd3899 comms: usc 20260717T185423Z 2026-07-17 13:54:23 -05:00
wjarvis mos-comms adc3d2b1e6 comms: usc 20260717T184343Z 2026-07-17 13:43:43 -05:00
wjarvis mos-comms 058a6aaa18 comms: usc 20260717T184244Z 2026-07-17 13:42:44 -05:00
Jarvis c91639e09f comms: homelab 20260717T184100Z 2026-07-17 13:41:00 -05:00
wjarvis mos-comms 025798b823 comms: usc 20260717T183700Z 2026-07-17 13:37:01 -05:00
wjarvis mos-comms b6fbbc0d58 comms: usc 20260717T182756Z 2026-07-17 13:27:56 -05:00
Jarvis 0f932f3035 comms: homelab 20260717T182340Z 2026-07-17 13:23:40 -05:00
wjarvis mos-comms b081ba4df4 comms: usc 20260717T181654Z 2026-07-17 13:16:55 -05:00
wjarvis mos-comms d3481f026b comms: usc 20260717T180959Z 2026-07-17 13:09:59 -05:00
Jarvis 4e6ddd59ef comms: homelab 20260717T180506Z 2026-07-17 13:05:06 -05:00
wjarvis mos-comms f00705b026 comms: usc 20260717T175924Z 2026-07-17 12:59:25 -05:00
Jarvis 1e4a15db59 comms: homelab 20260717T175901Z 2026-07-17 12:59:01 -05:00
Jarvis 37b6603d33 comms: homelab 20260717T175828Z 2026-07-17 12:58:28 -05:00
wjarvis mos-comms 50f8a2a59f comms: usc 20260717T175638Z 2026-07-17 12:56:38 -05:00
Jarvis a99d429f34 comms: homelab 20260717T175222Z 2026-07-17 12:52:23 -05:00
wjarvis mos-comms 6deb5eb7f8 comms: usc 20260717T174634Z 2026-07-17 12:46:34 -05:00
Jarvis d168f54dfd comms: homelab 20260717T173859Z 2026-07-17 12:38:59 -05:00
Jarvis 1a03d60287 comms: homelab 20260717T172938Z 2026-07-17 12:29:38 -05:00
wjarvis mos-comms b2958bbed7 comms: usc 20260717T172744Z 2026-07-17 12:27:44 -05:00
wjarvis mos-comms 8c3bb2f3a5 comms: usc 20260717T172245Z 2026-07-17 12:22:46 -05:00
Jarvis e9f015ace4 comms: homelab 20260717T172124Z 2026-07-17 12:21:25 -05:00
wjarvis mos-comms 0a94e37dbe comms: usc 20260717T171933Z 2026-07-17 12:19:33 -05:00
wjarvis mos-comms 719862de83 comms: usc 20260717T171452Z 2026-07-17 12:14:52 -05:00
Jarvis 22bdaac379 comms: homelab 20260717T171446Z 2026-07-17 12:14:46 -05:00
wjarvis mos-comms 912ad391ff comms: usc 20260717T171346Z 2026-07-17 12:13:46 -05:00
Jarvis ea73655e04 comms: homelab 20260717T171301Z 2026-07-17 12:13:01 -05:00
wjarvis mos-comms 22c1752c30 comms: usc 20260717T171127Z 2026-07-17 12:11:27 -05:00
Jarvis b298a4a7ad comms: homelab 20260717T171059Z 2026-07-17 12:11:00 -05:00
wjarvis mos-comms a8edd975a9 comms: usc 20260717T171030Z 2026-07-17 12:10:30 -05:00
wjarvis mos-comms 64d6b982f9 comms: usc 20260717T171010Z 2026-07-17 12:10:10 -05:00
Jarvis 76c34e54d3 comms: homelab 20260717T170945Z 2026-07-17 12:09:45 -05:00
wjarvis mos-comms 47a865f943 comms: usc 20260717T170903Z 2026-07-17 12:09:03 -05:00
wjarvis mos-comms d7fad74595 comms: usc 20260717T170818Z 2026-07-17 12:08:18 -05:00
Jarvis dab8657af1 comms: homelab 20260717T170329Z 2026-07-17 12:03:30 -05:00
wjarvis mos-comms c116447631 comms: usc 20260717T170211Z 2026-07-17 12:02:11 -05:00
Jarvis 3d4fa20adf comms: homelab 20260717T165952Z 2026-07-17 11:59:52 -05:00
wjarvis mos-comms 4466e2d82c comms: usc 20260717T165845Z 2026-07-17 11:58:45 -05:00
Jarvis ab0e15afb2 comms: homelab 20260717T165710Z 2026-07-17 11:57:10 -05:00
wjarvis mos-comms 5cbda2df31 comms: usc 20260717T165422Z 2026-07-17 11:54:23 -05:00
Jarvis 3259aef924 comms: homelab 20260717T164743Z 2026-07-17 11:47:43 -05:00
Jarvis 21766447d7 comms: homelab 20260717T163919Z 2026-07-17 11:39:19 -05:00
Jarvis 924cbd7f4b comms: homelab 20260717T163848Z 2026-07-17 11:38:49 -05:00
wjarvis mos-comms ea12623026 comms: usc 20260717T163703Z 2026-07-17 11:37:05 -05:00
Jarvis df91a4e888 comms: homelab 20260717T163226Z 2026-07-17 11:32:26 -05:00
wjarvis mos-comms bd68956622 comms: usc 20260717T163131Z 2026-07-17 11:31:31 -05:00
Jarvis 8e6939f321 comms: homelab 20260717T163055Z 2026-07-17 11:30:56 -05:00
wjarvis mos-comms 693fd9aaa0 comms: usc 20260717T162912Z 2026-07-17 11:29:13 -05:00
Jarvis a064859482 comms: homelab 20260717T162537Z 2026-07-17 11:25:37 -05:00
wjarvis mos-comms f631a3c76a comms: usc 20260717T162402Z 2026-07-17 11:24:03 -05:00
Jarvis d9bd8fab26 comms: homelab 20260717T161951Z 2026-07-17 11:19:52 -05:00
wjarvis mos-comms 4d0ba9fe49 comms: usc 20260717T161358Z 2026-07-17 11:13:58 -05:00
Jarvis 8cc3460b93 comms: homelab 20260717T054411Z 2026-07-17 00:44:11 -05:00
Jarvis 5a62453e1f comms: homelab 20260717T054002Z 2026-07-17 00:40:02 -05:00
Jarvis afa325c0e5 comms: homelab 20260717T051302Z 2026-07-17 00:13:02 -05:00
Jarvis cc2ea4d78e comms: homelab 20260717T050342Z 2026-07-17 00:03:42 -05:00
Jarvis a4573475ca comms: homelab 20260717T045530Z 2026-07-16 23:55:30 -05:00
Jarvis 9d238f7009 comms: homelab 20260717T044622Z 2026-07-16 23:46:22 -05:00
Jarvis 84088ee3c8 comms: homelab 20260717T043324Z 2026-07-16 23:33:24 -05:00
Jarvis 558a6af7e8 comms: homelab 20260717T042528Z 2026-07-16 23:25:28 -05:00
Jarvis 6ff8983230 comms: homelab 20260717T040522Z 2026-07-16 23:05:22 -05:00
Jarvis 838a90b51e comms: homelab 20260717T034955Z 2026-07-16 22:49:55 -05:00
Jarvis d28f4ba980 comms: homelab 20260717T034658Z 2026-07-16 22:46:59 -05:00
wjarvis mos-comms a93b168522 comms: usc 20260717T034229Z 2026-07-16 22:42:29 -05:00
Jarvis 8e402b0827 comms: homelab 20260717T034041Z 2026-07-16 22:40:41 -05:00
wjarvis mos-comms 6599ae33b9 comms: usc 20260717T032630Z 2026-07-16 22:26:30 -05:00
wjarvis mos-comms c5306ed7a5 comms: usc 20260717T031936Z 2026-07-16 22:19:36 -05:00
Jarvis cd0a0b223d comms: homelab 20260717T024629Z 2026-07-16 21:46:29 -05:00
wjarvis mos-comms 954bc46de7 comms: usc 20260717T023738Z 2026-07-16 21:37:38 -05:00
Jarvis 8b3297c3e8 comms: homelab 20260717T023645Z 2026-07-16 21:36:45 -05:00
Jarvis 8fae9b12fb comms: homelab 20260717T021019Z 2026-07-16 21:10:19 -05:00
Jarvis a81005672a comms: homelab 20260717T020606Z 2026-07-16 21:06:06 -05:00
Jarvis bff8c49253 comms: homelab 20260717T011533Z 2026-07-16 20:15:33 -05:00
Jarvis fd924d30ba comms: homelab 20260717T010556Z 2026-07-16 20:05:56 -05:00
wjarvis mos-comms 9b332f1a8d comms: usc 20260717T010236Z 2026-07-16 20:02:36 -05:00
Jarvis ea4fd1b65b comms: homelab 20260717T010120Z 2026-07-16 20:01:20 -05:00
Jarvis ca66ffaa33 comms: homelab 20260717T005321Z 2026-07-16 19:53:21 -05:00
wjarvis mos-comms 58a65886a9 comms: usc 20260717T005310Z 2026-07-16 19:53:10 -05:00
Jarvis 81aaf995ae comms: homelab 20260717T005121Z 2026-07-16 19:51:21 -05:00
wjarvis mos-comms 1539b6f5bf comms: usc 20260717T005048Z 2026-07-16 19:50:48 -05:00
Jarvis 6288aade27 comms: homelab 20260717T005007Z 2026-07-16 19:50:07 -05:00
wjarvis mos-comms b1d248c2a0 comms: usc 20260717T004800Z 2026-07-16 19:48:00 -05:00
Jarvis 7d334c0e60 comms: homelab 20260717T004659Z 2026-07-16 19:46:59 -05:00
wjarvis mos-comms 0a700445a3 comms: usc 20260717T004423Z 2026-07-16 19:44:23 -05:00
Jarvis e52ef3d3c6 comms: homelab 20260717T003948Z 2026-07-16 19:39:49 -05:00
wjarvis mos-comms 5c9af05691 comms: usc 20260717T003558Z 2026-07-16 19:35:58 -05:00
Jarvis 2aa669a026 comms: homelab 20260717T003303Z 2026-07-16 19:33:03 -05:00
wjarvis mos-comms 03bd84db1d comms: usc 20260717T002517Z 2026-07-16 19:25:17 -05:00
Jarvis bce06f9336 comms: homelab 20260717T002045Z 2026-07-16 19:20:45 -05:00
wjarvis mos-comms 267fc9a787 comms: usc 20260717T001955Z 2026-07-16 19:19:56 -05:00
Jarvis 7652bc7f3d comms: homelab 20260717T001825Z 2026-07-16 19:18:25 -05:00
wjarvis mos-comms e7e1c486e9 comms: usc 20260717T001737Z 2026-07-16 19:17:38 -05:00
Jarvis 6efb95ed07 comms: homelab 20260717T001619Z 2026-07-16 19:16:19 -05:00
wjarvis mos-comms 5a6ef27481 comms: usc 20260717T001525Z 2026-07-16 19:15:25 -05:00
Jarvis bf2adbf5a2 comms: homelab 20260717T000921Z 2026-07-16 19:09:21 -05:00
Jarvis 33236774d1 comms: homelab 20260717T000614Z 2026-07-16 19:06:14 -05:00
wjarvis mos-comms c77e4c9e35 comms: usc 20260717T000444Z 2026-07-16 19:04:44 -05:00
wjarvis mos-comms a7920439a7 comms: usc 20260717T000144Z 2026-07-16 19:01:45 -05:00
Jarvis 958bc7d369 comms: homelab 20260716T235901Z 2026-07-16 18:59:01 -05:00
wjarvis mos-comms 3b24bb8c62 comms: usc 20260716T232048Z 2026-07-16 18:20:49 -05:00
Jarvis 5e443f5a0c comms: homelab 20260716T231934Z 2026-07-16 18:19:34 -05:00
wjarvis mos-comms 93dc894e69 comms: usc 20260716T231223Z 2026-07-16 18:12:23 -05:00
Jarvis 597b4e4322 comms: homelab 20260716T231157Z 2026-07-16 18:11:58 -05:00
wjarvis mos-comms a58f9d58d7 comms: usc 20260716T230921Z 2026-07-16 18:09:21 -05:00
Jarvis 0f2852a4ed comms: homelab 20260716T230349Z 2026-07-16 18:03:50 -05:00
wjarvis mos-comms 84310c81f4 comms: usc 20260716T230203Z 2026-07-16 18:02:03 -05:00
Jarvis 412c03468f comms: homelab 20260716T225914Z 2026-07-16 17:59:14 -05:00
wjarvis mos-comms a2fcefa35d comms: usc 20260716T225854Z 2026-07-16 17:58:54 -05:00
wjarvis mos-comms c29208fda1 comms: usc 20260716T225547Z 2026-07-16 17:55:48 -05:00
Jarvis 65ed453443 comms: homelab 20260716T225411Z 2026-07-16 17:54:11 -05:00
wjarvis mos-comms 15cca9db1c comms: usc 20260716T224351Z 2026-07-16 17:43:52 -05:00
Jarvis 1d1fd17a33 comms: homelab 20260716T224154Z 2026-07-16 17:41:54 -05:00
Jarvis 80a4096f75 comms: homelab 20260716T222617Z 2026-07-16 17:26:17 -05:00
wjarvis mos-comms 87dae08286 comms: usc 20260716T222138Z 2026-07-16 17:21:38 -05:00
wjarvis mos-comms a6de7801ff comms: usc 20260716T221912Z 2026-07-16 17:19:12 -05:00
Jarvis af70995755 comms: homelab 20260716T221852Z 2026-07-16 17:18:53 -05:00
wjarvis mos-comms e37abcfe77 comms: usc 20260716T221638Z 2026-07-16 17:16:38 -05:00
Jarvis 860ac0dfb1 comms: homelab 20260716T221035Z 2026-07-16 17:10:36 -05:00
Jarvis 3b84dd4d1e comms: homelab 20260716T220558Z 2026-07-16 17:05:58 -05:00
wjarvis mos-comms 69f1ea0fb4 comms: usc 20260716T220445Z 2026-07-16 17:04:45 -05:00
wjarvis mos-comms 490aa76415 comms: usc 20260716T215656Z 2026-07-16 16:56:56 -05:00
wjarvis mos-comms 05edd269a8 comms: usc 20260716T213201Z 2026-07-16 16:32:02 -05:00
Jarvis c719228d39 comms: homelab 20260716T213036Z 2026-07-16 16:30:36 -05:00
wjarvis mos-comms 53d0a9ba93 comms: usc 20260716T212948Z 2026-07-16 16:29:48 -05:00
Jarvis a8baa9f96b comms: homelab 20260716T212605Z 2026-07-16 16:26:05 -05:00
wjarvis mos-comms 8b3d937899 comms: usc 20260716T212356Z 2026-07-16 16:23:57 -05:00
Jarvis 1d43d35c47 comms: homelab 20260716T212201Z 2026-07-16 16:22:01 -05:00
wjarvis mos-comms 40413fb9c7 comms: usc 20260716T211842Z 2026-07-16 16:18:43 -05:00
Jarvis 77d5cd0ccb comms: homelab 20260716T210741Z 2026-07-16 16:07:41 -05:00
wjarvis mos-comms 91ed0d035a comms: usc 20260716T210612Z 2026-07-16 16:06:12 -05:00
wjarvis mos-comms 218da96b55 comms: usc 20260716T210540Z 2026-07-16 16:05:40 -05:00
Jarvis a4fe329ffc comms: homelab 20260716T210300Z 2026-07-16 16:03:00 -05:00
Jarvis 45da900080 comms: homelab 20260716T202942Z 2026-07-16 15:29:43 -05:00
Jarvis bd38ce9a61 comms: homelab 20260716T201301Z 2026-07-16 15:13:01 -05:00
wjarvis mos-comms 3a12be5e07 comms: usc 20260716T200756Z 2026-07-16 15:07:57 -05:00
Jarvis fcff1e6a19 comms: homelab 20260716T200217Z 2026-07-16 15:02:17 -05:00
Jarvis c46901d713 comms: homelab 20260716T193700Z 2026-07-16 14:37:00 -05:00
Jarvis 5b4615c9ea comms: homelab 20260716T191954Z 2026-07-16 14:19:54 -05:00
Jarvis 868458b5aa comms: homelab 20260716T185400Z 2026-07-16 13:54:00 -05:00
Jarvis 86dc2d69e3 comms: homelab 20260716T184048Z 2026-07-16 13:40:48 -05:00
Jarvis ef88212328 comms: homelab 20260716T182238Z 2026-07-16 13:22:39 -05:00
Jarvis 3e59e12316 comms: homelab 20260716T180558Z 2026-07-16 13:05:58 -05:00
Jarvis 570e27035f comms: homelab 20260716T173841Z 2026-07-16 12:38:41 -05:00
Jarvis 40bc9a0980 comms: homelab 20260716T170145Z 2026-07-16 12:01:45 -05:00
Jarvis 77c5e0e3be comms: homelab 20260716T164456Z 2026-07-16 11:44:56 -05:00
Jarvis b482f882c2 comms: homelab 20260716T163616Z 2026-07-16 11:36:16 -05:00
Jarvis a008e25308 comms: homelab 20260716T162531Z 2026-07-16 11:25:31 -05:00
Jarvis cd30f8c66d comms: homelab 20260716T160753Z 2026-07-16 11:07:53 -05:00
Jarvis 36a87a51e4 comms: homelab 20260716T160521Z 2026-07-16 11:05:21 -05:00
Jarvis 74ad5848d0 comms: homelab 20260716T155603Z 2026-07-16 10:56:03 -05:00
Jarvis 49cfe1604f comms: homelab 20260716T152713Z 2026-07-16 10:27:14 -05:00
Jarvis 77158132de comms: homelab 20260716T151028Z 2026-07-16 10:10:28 -05:00
Jarvis a66459c1ef comms: homelab 20260716T144847Z 2026-07-16 09:48:47 -05:00
Jarvis 382b59bfab comms: homelab 20260716T143547Z 2026-07-16 09:35:47 -05:00
Jarvis eb663d6b3c comms: homelab 20260716T133525Z 2026-07-16 08:35:26 -05:00
wjarvis mos-comms 7508070be1 comms: usc 20260716T132814Z 2026-07-16 08:28:14 -05:00
Jarvis 72ce456046 comms: homelab 20260716T132635Z 2026-07-16 08:26:35 -05:00
Jarvis 13e83821a2 comms: homelab 20260716T122032Z 2026-07-16 07:20:32 -05:00
Jarvis 7a5f499b25 comms: homelab 20260716T114624Z 2026-07-16 06:46:24 -05:00
Jarvis bf71e86084 comms: homelab 20260716T112046Z 2026-07-16 06:20:46 -05:00
Jarvis 07a4c8f9df comms: homelab 20260716T105629Z 2026-07-16 05:56:29 -05:00
Jarvis a474f447bf comms: homelab 20260716T103837Z 2026-07-16 05:38:37 -05:00
Jarvis 22b85eb2cd comms: homelab 20260716T095940Z 2026-07-16 04:59:40 -05:00
Jarvis ec9ba6e18b comms: homelab 20260716T091416Z 2026-07-16 04:14:16 -05:00
Jarvis 093e2e9815 comms: homelab 20260716T081621Z 2026-07-16 03:16:21 -05:00
Jarvis bbf367bd28 comms: homelab 20260716T074951Z 2026-07-16 02:49:51 -05:00
Jarvis e28041ce03 comms: homelab 20260716T073343Z 2026-07-16 02:33:43 -05:00
Jarvis 8ce238379c comms: homelab 20260716T072115Z 2026-07-16 02:21:15 -05:00
Jarvis c5dea41cd1 comms: homelab 20260716T065305Z 2026-07-16 01:53:05 -05:00
Jarvis 31604d7066 comms: homelab 20260716T064713Z 2026-07-16 01:47:13 -05:00
Jarvis dc6df621d7 comms: homelab 20260716T062711Z 2026-07-16 01:27:11 -05:00
Jarvis 0e5740c2c4 comms: homelab 20260716T062300Z 2026-07-16 01:23:00 -05:00
Jarvis f847211bc7 comms: homelab 20260716T061207Z 2026-07-16 01:12:07 -05:00
Jarvis e2b9c7c588 comms: homelab 20260716T060157Z 2026-07-16 01:01:57 -05:00
Jarvis f4028c5608 comms: homelab 20260716T055721Z 2026-07-16 00:57:21 -05:00
Jarvis 24a503c4d6 comms: homelab 20260716T054839Z 2026-07-16 00:48:39 -05:00
Jarvis 29b0954170 comms: homelab 20260716T053646Z 2026-07-16 00:36:46 -05:00
Jarvis 450e4b25de comms: homelab 20260716T051703Z 2026-07-16 00:17:03 -05:00
Jarvis b9788d79b0 comms: homelab 20260716T043841Z 2026-07-15 23:38:41 -05:00
Jarvis af08bb6ed7 comms: homelab 20260716T043609Z 2026-07-15 23:36:09 -05:00
Jarvis ff09d723c2 comms: homelab 20260716T040256Z 2026-07-15 23:02:56 -05:00
Jarvis 08b6f68bbe comms: homelab 20260716T034008Z 2026-07-15 22:40:08 -05:00
Jarvis 39b944a7d9 comms: homelab 20260716T030959Z 2026-07-15 22:10:00 -05:00
Jarvis ccf872c28b comms: homelab 20260716T022655Z 2026-07-15 21:26:55 -05:00
Jarvis f5fc61dafa comms: homelab 20260716T020202Z 2026-07-15 21:02:02 -05:00
Jarvis 9a60e43f89 comms: homelab 20260716T013435Z 2026-07-15 20:34:35 -05:00
wjarvis mos-comms 8ba5e5c5d4 comms: usc 20260716T004701Z 2026-07-15 19:47:01 -05:00
Jarvis a896d0d072 comms: homelab 20260716T004626Z 2026-07-15 19:46:26 -05:00
wjarvis mos-comms 1cf1c1eef1 comms: usc 20260716T004019Z 2026-07-15 19:40:19 -05:00
Jarvis 255bf5c33e comms: homelab 20260716T003919Z 2026-07-15 19:39:19 -05:00
wjarvis mos-comms c5c24cf200 comms: usc 20260716T003657Z 2026-07-15 19:36:57 -05:00
wjarvis mos-comms 8bce99c875 comms: usc 20260716T003556Z 2026-07-15 19:35:56 -05:00
Jarvis d19c6feaed comms: homelab 20260716T003508Z 2026-07-15 19:35:08 -05:00
Jarvis c5be9e793a comms: homelab 20260716T002830Z 2026-07-15 19:28:30 -05:00
Jarvis 73010662a0 comms: homelab 20260716T002621Z 2026-07-15 19:26:21 -05:00
wjarvis mos-comms b15d537da3 comms: usc 20260716T002224Z 2026-07-15 19:22:24 -05:00
Jarvis 4d489dc20f comms: homelab 20260716T002015Z 2026-07-15 19:20:15 -05:00
wjarvis mos-comms 29f1d2810f comms: usc 20260716T001525Z 2026-07-15 19:15:25 -05:00
wjarvis mos-comms c36cdcd6f1 comms: usc 20260716T001310Z 2026-07-15 19:13:10 -05:00
wjarvis mos-comms 650dd65417 comms: usc 20260716T001135Z 2026-07-15 19:11:35 -05:00
Jarvis 49bfc434e1 comms: homelab 20260716T001001Z 2026-07-15 19:10:01 -05:00
wjarvis mos-comms f030641c4d comms: usc 20260716T000228Z 2026-07-15 19:02:28 -05:00
wjarvis mos-comms e1b5deab21 comms: usc 20260715T235819Z 2026-07-15 18:58:19 -05:00
wjarvis mos-comms 1e0fed09e0 comms: usc 20260715T235710Z 2026-07-15 18:57:10 -05:00
Jarvis e4b53c5fed comms: homelab 20260715T235559Z 2026-07-15 18:55:59 -05:00
wjarvis mos-comms b1958f6c2d comms: usc 20260715T234940Z 2026-07-15 18:49:40 -05:00
wjarvis mos-comms 278b08137c comms: usc 20260715T234642Z 2026-07-15 18:46:43 -05:00
wjarvis mos-comms fb2ef45f7a comms: usc 20260715T234546Z 2026-07-15 18:45:46 -05:00
Jarvis a6b8c3ad94 comms: homelab 20260715T234419Z 2026-07-15 18:44:19 -05:00
wjarvis mos-comms 79f21964f2 comms: usc 20260715T233559Z 2026-07-15 18:35:59 -05:00
wjarvis mos-comms 3c54c7773d comms: usc 20260715T233323Z 2026-07-15 18:33:23 -05:00
wjarvis mos-comms 413a704609 comms: usc 20260715T233234Z 2026-07-15 18:32:34 -05:00
Jarvis f93996af39 comms: homelab 20260715T233102Z 2026-07-15 18:31:03 -05:00
wjarvis mos-comms cc404ad8d4 comms: usc 20260715T232146Z 2026-07-15 18:21:46 -05:00
wjarvis mos-comms a05dfcb360 comms: usc 20260715T231907Z 2026-07-15 18:19:07 -05:00
wjarvis mos-comms c59b3fa53d comms: usc 20260715T231817Z 2026-07-15 18:18:17 -05:00
Jarvis c249d9e172 comms: homelab 20260715T231733Z 2026-07-15 18:17:34 -05:00
wjarvis mos-comms 91202653e4 comms: usc 20260715T230454Z 2026-07-15 18:04:54 -05:00
wjarvis mos-comms a662901ff0 comms: usc 20260715T230253Z 2026-07-15 18:02:53 -05:00
wjarvis mos-comms b72c19f87d comms: usc 20260715T230159Z 2026-07-15 18:01:59 -05:00
Jarvis 0a696ec2b3 comms: homelab 20260715T230102Z 2026-07-15 18:01:03 -05:00
wjarvis mos-comms 8e3f973905 comms: usc 20260715T224627Z 2026-07-15 17:46:27 -05:00
wjarvis mos-comms 66bdffab81 comms: usc 20260715T224257Z 2026-07-15 17:42:57 -05:00
wjarvis mos-comms e49fc9d782 comms: usc 20260715T224145Z 2026-07-15 17:41:45 -05:00
Jarvis 52870b3e0e comms: homelab 20260715T224054Z 2026-07-15 17:40:54 -05:00
Jarvis c806d75200 comms: homelab 20260715T223624Z 2026-07-15 17:36:24 -05:00
wjarvis mos-comms 566ba5a913 comms: usc 20260715T223527Z 2026-07-15 17:35:28 -05:00
Jarvis 21d2e94baf comms: homelab 20260715T223027Z 2026-07-15 17:30:27 -05:00
wjarvis mos-comms dd1730c7f2 comms: usc 20260715T222913Z 2026-07-15 17:29:13 -05:00
Jarvis 7ac93508d5 comms: homelab 20260715T222747Z 2026-07-15 17:27:47 -05:00
Jarvis 1e140979df comms: homelab 20260715T213632Z 2026-07-15 16:36:33 -05:00
wjarvis mos-comms 74b8b19e87 comms: usc 20260715T210002Z 2026-07-15 16:00:02 -05:00
Jarvis 8c1fc60fa5 comms: homelab 20260715T205856Z 2026-07-15 15:58:56 -05:00
Jarvis b29cd82237 comms: homelab 20260715T205616Z 2026-07-15 15:56:16 -05:00
Jarvis 5dce007464 comms: homelab 20260715T205055Z 2026-07-15 15:50:55 -05:00
wjarvis mos-comms ecd991bfb0 comms: usc 20260715T204215Z 2026-07-15 15:42:15 -05:00
Jarvis f54cda33bc comms: homelab 20260715T203959Z 2026-07-15 15:39:59 -05:00
wjarvis mos-comms e733dcf3f1 comms: usc 20260715T203832Z 2026-07-15 15:38:32 -05:00
Jarvis 1b0d605a7e comms: homelab 20260715T203725Z 2026-07-15 15:37:25 -05:00
wjarvis mos-comms a9202518a4 comms: usc 20260715T202510Z 2026-07-15 15:25:10 -05:00
Jarvis 4306421eae comms: homelab 20260715T202253Z 2026-07-15 15:22:53 -05:00
wjarvis mos-comms 86fac8f22a comms: usc 20260715T201615Z 2026-07-15 15:16:16 -05:00
Jarvis 755b4ca9ac comms: homelab 20260715T201506Z 2026-07-15 15:15:06 -05:00
wjarvis mos-comms eecb01a978 comms: usc 20260715T200842Z 2026-07-15 15:08:42 -05:00
wjarvis mos-comms e456baea66 comms: usc 20260715T200231Z 2026-07-15 15:02:31 -05:00
Jarvis cce17bb689 comms: homelab 20260715T195810Z 2026-07-15 14:58:10 -05:00
Jarvis 72d087bee1 comms: homelab 20260715T195004Z 2026-07-15 14:50:04 -05:00
wjarvis mos-comms 3856a67749 comms: usc 20260715T194752Z 2026-07-15 14:47:52 -05:00
Jarvis c3bc77a233 comms: homelab 20260715T194625Z 2026-07-15 14:46:25 -05:00
wjarvis mos-comms b8e0f364b3 comms: usc 20260715T194426Z 2026-07-15 14:44:26 -05:00
Jarvis 081803e9e5 comms: homelab 20260715T194153Z 2026-07-15 14:41:53 -05:00
Jarvis 52994c3ec9 comms: homelab 20260715T191727Z 2026-07-15 14:17:27 -05:00
Jarvis fe07a0b5c1 comms: homelab 20260715T190349Z 2026-07-15 14:03:49 -05:00
Jarvis 5eff418a84 comms: homelab 20260715T185731Z 2026-07-15 13:57:31 -05:00
Jarvis 2519828566 comms: homelab 20260715T184308Z 2026-07-15 13:43:09 -05:00
Jarvis 4348e44b2e comms: homelab 20260715T180046Z 2026-07-15 13:00:46 -05:00
Jarvis d6246909ae comms: homelab 20260715T174141Z 2026-07-15 12:41:41 -05:00
Jarvis ab02ead32e comms: homelab 20260715T172156Z 2026-07-15 12:21:57 -05:00
wjarvis mos-comms d7e5b409c1 comms: usc 20260715T170232Z 2026-07-15 12:02:32 -05:00
JarvisandClaude Opus 4.8 204b09c672 comms: relay M3 completion and M4 start
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 12:01:44 -05:00
JarvisandClaude Opus 4.8 92a03906e5 comms: relay reviewed M3-002 head
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 11:40:20 -05:00
Jarvis 5f185e41ea comms: homelab 20260715T161510Z 2026-07-15 11:15:32 -05:00
Jarvis 666f6c9024 comms: homelab 20260715T160422Z 2026-07-15 11:04:48 -05:00
Jarvis ea98598c81 comms: homelab 20260715T155411Z 2026-07-15 10:54:29 -05:00
wjarvis mos-comms 634f4369af comms: usc 20260715T151932Z 2026-07-15 10:19:33 -05:00
Jarvis 5a06d65e2e comms: homelab 20260715T151844Z 2026-07-15 10:18:44 -05:00
Jarvis 4c1035a521 comms: homelab 20260715T150439Z 2026-07-15 10:04:39 -05:00
Jarvis 054b442296 comms: homelab 20260715T143907Z 2026-07-15 09:39:07 -05:00
Jarvis 653684c26d comms: homelab 20260715T143519Z 2026-07-15 09:35:19 -05:00
Jarvis 33fa790e3c comms: homelab 20260715T142701Z 2026-07-15 09:27:01 -05:00
Jarvis 2bc5cf6ab8 comms: homelab 20260715T142058Z 2026-07-15 09:20:58 -05:00
Jarvis 891461ea0d comms: homelab 20260715T141358Z 2026-07-15 09:13:58 -05:00
Jarvis ec334a6b3b comms: homelab 20260715T135717Z 2026-07-15 08:57:17 -05:00
Jarvis c87b894743 comms: homelab 20260715T135437Z 2026-07-15 08:54:37 -05:00
Jarvis 969581590f comms: homelab 20260715T134627Z 2026-07-15 08:46:27 -05:00
wjarvis mos-comms 26da717842 comms: usc 20260715T134511Z 2026-07-15 08:45:12 -05:00
Jarvis 7142d165c6 comms: homelab 20260715T134139Z 2026-07-15 08:41:39 -05:00
Jarvis d212f357b8 comms: homelab 20260715T133926Z 2026-07-15 08:39:26 -05:00
Jarvis 21de4b5eb5 comms: homelab 20260715T132843Z 2026-07-15 08:28:43 -05:00
Jarvis 252b40d1a3 comms: homelab 20260715T132547Z 2026-07-15 08:25:47 -05:00
Jarvis 5e291ad9ba comms: homelab 20260715T131939Z 2026-07-15 08:19:39 -05:00
Jarvis 9ca3271e9e comms: homelab 20260715T131705Z 2026-07-15 08:17:05 -05:00
Jarvis 33321f31e6 comms: homelab 20260715T130941Z 2026-07-15 08:09:42 -05:00
Jarvis 4c070533c0 comms: homelab 20260715T125423Z 2026-07-15 07:54:23 -05:00
Jarvis bac5b67a52 comms: homelab 20260715T123922Z 2026-07-15 07:39:22 -05:00
Jarvis 7dd4f6409a comms: homelab 20260715T121539Z 2026-07-15 07:15:40 -05:00
wjarvis mos-comms 15feb6cd1e comms: usc 20260715T121334Z 2026-07-15 07:13:35 -05:00
Jarvis aefa13c755 comms: homelab 20260715T121311Z 2026-07-15 07:13:11 -05:00
Jarvis eb6a792499 comms: homelab 20260715T120339Z 2026-07-15 07:03:39 -05:00
Jarvis bc12bd1b6e comms: homelab 20260715T115602Z 2026-07-15 06:56:02 -05:00
Jarvis cd4d67e96e comms: homelab 20260715T115125Z 2026-07-15 06:51:25 -05:00
Jarvis 9690f95a3e comms: homelab 20260715T113159Z 2026-07-15 06:31:59 -05:00
Jarvis f814ab1322 comms: homelab 20260715T112519Z 2026-07-15 06:25:19 -05:00
Jarvis 422cbdf237 comms: homelab 20260715T111410Z 2026-07-15 06:14:10 -05:00
Jarvis a062fab0fe comms: homelab 20260715T110805Z 2026-07-15 06:08:05 -05:00
Jarvis c679fa8a75 comms: homelab 20260715T105236Z 2026-07-15 05:52:36 -05:00
Jarvis d441e4e204 comms: homelab 20260715T102331Z 2026-07-15 05:23:32 -05:00
Jarvis a08a8dd22f comms: homelab 20260715T101607Z 2026-07-15 05:16:07 -05:00
Jarvis 155cb142bc comms: homelab 20260715T100803Z 2026-07-15 05:08:03 -05:00
Jarvis 0cb3e75833 comms: homelab 20260715T092347Z 2026-07-15 04:23:47 -05:00
Jarvis 8281ab0c3e comms: homelab 20260715T092251Z 2026-07-15 04:22:51 -05:00
wjarvis mos-comms 89759b5f46 comms: usc 20260715T085727Z 2026-07-15 03:57:27 -05:00
Jarvis 8449dcc2cc comms: homelab 20260715T085637Z 2026-07-15 03:56:37 -05:00
Jarvis e019db3371 comms: homelab 20260715T084149Z 2026-07-15 03:41:49 -05:00
Jarvis bb9447f004 comms: homelab 20260715T083606Z 2026-07-15 03:36:06 -05:00
Jarvis 5fdc88a7c7 comms: homelab 20260715T082510Z 2026-07-15 03:25:10 -05:00
Jarvis 68f2f80669 comms: homelab 20260715T081457Z 2026-07-15 03:14:57 -05:00
Jarvis 4bb2c749c3 comms: homelab 20260715T075153Z 2026-07-15 02:51:53 -05:00
Jarvis 92170dcc4c comms: homelab 20260715T073238Z 2026-07-15 02:32:38 -05:00
Jarvis 90aed6220f comms: homelab 20260715T065251Z 2026-07-15 01:52:51 -05:00
Jarvis 1a91a13edb comms: homelab 20260715T062559Z 2026-07-15 01:25:59 -05:00
Jarvis 9fd779bc66 comms: homelab 20260715T060856Z 2026-07-15 01:08:56 -05:00
Jarvis 5460e5c63f comms: homelab 20260715T055934Z 2026-07-15 00:59:35 -05:00
Jarvis 4ee9b473e9 comms: homelab 20260715T054844Z 2026-07-15 00:48:44 -05:00
Jarvis bb2704527e comms: homelab 20260715T054515Z 2026-07-15 00:45:16 -05:00
Jarvis 35cdaf0d81 comms: homelab 20260715T053708Z 2026-07-15 00:37:08 -05:00
Jarvis 3c47980919 comms: homelab 20260715T053137Z 2026-07-15 00:31:37 -05:00
Jarvis 0087a7d6dc comms: homelab 20260715T052710Z 2026-07-15 00:27:10 -05:00
Jarvis 75b6a1a719 comms: homelab 20260715T044456Z 2026-07-14 23:44:56 -05:00
wjarvis mos-comms cb4853a5e4 comms: usc 20260715T044358Z 2026-07-14 23:43:58 -05:00
Jarvis 1ffc4b982c comms: homelab 20260715T043902Z 2026-07-14 23:39:02 -05:00
Jarvis 2bb1304e5d comms: homelab 20260715T040852Z 2026-07-14 23:08:52 -05:00
Jarvis e56b7c2114 comms: homelab 20260715T022820Z 2026-07-14 21:28:21 -05:00
Jarvis 6c338b8c34 comms: homelab 20260715T014941Z 2026-07-14 20:49:41 -05:00
wjarvis mos-comms d80bbaf335 comms: usc 20260715T014709Z 2026-07-14 20:47:10 -05:00
Jarvis 52fa2ae0bb comms: homelab 20260715T014556Z 2026-07-14 20:45:56 -05:00
Jarvis 9752175cf4 comms: homelab 20260715T012719Z 2026-07-14 20:27:19 -05:00
Jarvis 19abfc0efd comms: homelab 20260715T010915Z 2026-07-14 20:09:15 -05:00
Jarvis b938f54ae1 comms: homelab 20260715T010355Z 2026-07-14 20:03:55 -05:00
Jarvis 9ba1cb992f comms: homelab 20260715T003554Z 2026-07-14 19:35:54 -05:00
Jarvis 5253689bfc comms: homelab 20260715T001819Z 2026-07-14 19:18:19 -05:00
wjarvis mos-comms fd424de30e comms: usc 20260715T001533Z 2026-07-14 19:15:34 -05:00
Jarvis a482545412 comms: homelab 20260715T001300Z 2026-07-14 19:13:00 -05:00
Jarvis b0e9c92f6c docs: publish reviewed North Star rev4 artifact 2026-07-14 19:12:47 -05:00
Jarvis 26c464d68a comms: homelab 20260715T000906Z 2026-07-14 19:09:06 -05:00
Jarvis 283d44a707 comms: homelab 20260715T000144Z 2026-07-14 19:01:45 -05:00
wjarvis mos-comms 840da12ad5 comms: usc 20260714T235749Z 2026-07-14 18:57:49 -05:00
Jarvis 944598dc52 comms: homelab 20260714T235020Z 2026-07-14 18:50:20 -05:00
Jarvis 2b4c3643c3 comms: homelab 20260714T234258Z 2026-07-14 18:42:58 -05:00
Jarvis 5b925faa6d comms: homelab 20260714T233100Z 2026-07-14 18:31:00 -05:00
JarvisandClaude Opus 4.8 ea0541f435 comms: publish North Star rev3 artifact
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-14 18:30:44 -05:00
Jarvis 77a3beeb50 comms: homelab 20260714T233025Z 2026-07-14 18:30:25 -05:00
Jarvis 2953a58f55 comms: homelab 20260714T231003Z 2026-07-14 18:10:03 -05:00
wjarvis mos-comms baf0545cd2 comms: usc 20260714T230329Z 2026-07-14 18:03:30 -05:00
Jarvis 72a3bae6b0 comms: homelab 20260714T230224Z 2026-07-14 18:02:24 -05:00
wjarvis mos-comms 497dd6c0a6 comms: usc 20260714T225953Z 2026-07-14 17:59:54 -05:00
Jarvis 834f0126eb comms: homelab 20260714T225207Z 2026-07-14 17:52:07 -05:00
wjarvis mos-comms e8780f20ab comms: usc 20260714T224821Z 2026-07-14 17:48:21 -05:00
wjarvis mos-comms 3c1b3eb9bd comms: usc 20260714T223945Z 2026-07-14 17:39:45 -05:00
Jarvis 6233ba297d comms: homelab 20260714T222313Z 2026-07-14 17:23:13 -05:00
Jarvis b4be5b4d3f comms: homelab 20260714T221926Z 2026-07-14 17:19:26 -05:00
Jarvis 174a28af75 comms: publish web control-plane rev2 planning artifact 2026-07-14 17:19:16 -05:00
wjarvis mos-comms 25e5269ff3 comms: usc 20260714T221820Z 2026-07-14 17:18:20 -05:00
Jarvis 245fc723c2 comms: homelab 20260714T221751Z 2026-07-14 17:17:51 -05:00
wjarvis mos-comms 8c3b402ef0 comms: usc 20260714T220425Z 2026-07-14 17:04:25 -05:00
Jarvis 3c8d295ecf comms: homelab 20260714T220151Z 2026-07-14 17:01:51 -05:00
Jarvis 44b5c3aa52 comms: homelab 20260714T215708Z 2026-07-14 16:57:08 -05:00
wjarvis mos-comms c9f6e97a80 comms: usc 20260714T215454Z 2026-07-14 16:54:55 -05:00
Jarvis f37e069a94 comms: homelab 20260714T214833Z 2026-07-14 16:48:33 -05:00
Jarvis 19ffe3d93d comms: homelab 20260714T214458Z 2026-07-14 16:44:58 -05:00
Jarvis d22f08d324 comms: homelab 20260714T212528Z 2026-07-14 16:25:28 -05:00
Jarvis 303b9a6025 comms: homelab 20260714T211055Z 2026-07-14 16:10:56 -05:00
wjarvis mos-comms d8f2ac23ac comms: usc 20260714T205720Z 2026-07-14 15:57:20 -05:00
Jarvis 5742d76877 comms: homelab 20260714T205546Z 2026-07-14 15:55:46 -05:00
Jarvis 507139e34c comms: transmit frozen control-plane planning artifact 2026-07-14 15:55:37 -05:00
wjarvis mos-comms 3683aba627 comms: usc 20260714T205417Z 2026-07-14 15:54:17 -05:00
Jarvis aa831a785a comms: homelab 20260714T205313Z 2026-07-14 15:53:14 -05:00
wjarvis mos-comms 558696e539 comms: usc 20260714T205016Z 2026-07-14 15:50:17 -05:00
Jarvis d181ba6c22 comms: homelab 20260714T204515Z 2026-07-14 15:45:15 -05:00
Jarvis 7bbe34ee16 comms: homelab 20260714T203927Z 2026-07-14 15:39:27 -05:00
Jarvis c6e1afdda4 comms: homelab 20260714T203140Z 2026-07-14 15:31:41 -05:00
Jarvis c32314236a comms: homelab 20260714T203037Z 2026-07-14 15:30:37 -05:00
wjarvis mos-comms 2c3eb60441 comms: usc 20260714T202535Z 2026-07-14 15:25:35 -05:00
Jarvis c0ec6bfcbf comms: homelab 20260714T202403Z 2026-07-14 15:24:04 -05:00
Jarvis 05a2482aeb comms: homelab 20260714T202111Z 2026-07-14 15:21:11 -05:00
wjarvis mos-comms c2b866b37c comms: usc 20260714T202014Z 2026-07-14 15:20:14 -05:00
Jarvis cd13c6aa15 comms: homelab 20260714T201717Z 2026-07-14 15:17:18 -05:00
wjarvis mos-comms 067e2eced1 comms: usc 20260714T201537Z 2026-07-14 15:15:37 -05:00
Jarvis 4b7edd7a81 comms: homelab 20260714T201339Z 2026-07-14 15:13:39 -05:00
wjarvis mos-comms b5daf988a2 comms: usc 20260714T201245Z 2026-07-14 15:12:46 -05:00
Jarvis f50ed8f86f comms: homelab 20260714T201107Z 2026-07-14 15:11:07 -05:00
Jarvis 8b68c11da2 comms: homelab 20260714T200349Z 2026-07-14 15:03:50 -05:00
wjarvis mos-comms cdaa32d648 comms: usc 20260714T200246Z 2026-07-14 15:02:46 -05:00
wjarvis mos-comms dd49c3ba57 comms: usc 20260714T200237Z 2026-07-14 15:02:37 -05:00
Jarvis e4e98e97d9 comms: homelab 20260714T200114Z 2026-07-14 15:01:14 -05:00
Jarvis 3a7dd7d430 comms: homelab 20260714T195722Z 2026-07-14 14:57:22 -05:00
wjarvis mos-comms 062a2f1c97 comms: usc 20260714T195643Z 2026-07-14 14:56:44 -05:00
Jarvis a961bdda47 comms: homelab 20260714T195418Z 2026-07-14 14:54:18 -05:00
wjarvis mos-comms 1dfc68531f comms: usc 20260714T194043Z 2026-07-14 14:40:43 -05:00
wjarvis mos-comms 1658a29abd comms: usc 20260714T193952Z 2026-07-14 14:39:52 -05:00
Jarvis f29d7bc86d comms: homelab 20260714T193858Z 2026-07-14 14:38:58 -05:00
wjarvis mos-comms eecc6e3606 comms: usc 20260714T193803Z 2026-07-14 14:38:03 -05:00
wjarvis mos-comms 71042ad674 comms: usc 20260713T215109Z 2026-07-13 16:51:09 -05:00
Jarvis c20437b289 comms: homelab 20260713T215012Z 2026-07-13 16:50:12 -05:00
Jarvis dcd8e41d5d comms: homelab 20260713T214937Z 2026-07-13 16:49:37 -05:00
wjarvis mos-comms e047d33767 comms: usc 20260713T214921Z 2026-07-13 16:49:21 -05:00
wjarvis mos-comms 88a7822dfd comms: usc 20260713T214747Z 2026-07-13 16:47:47 -05:00
Jarvis 2dcc490dbc comms: homelab 20260713T214552Z 2026-07-13 16:45:52 -05:00
wjarvis mos-comms 52b4ccf399 comms: usc 20260713T214234Z 2026-07-13 16:42:34 -05:00
Jarvis 1156555f9d comms: homelab 20260713T214216Z 2026-07-13 16:42:17 -05:00
wjarvis mos-comms 0602dcdef3 comms: usc 20260713T214035Z 2026-07-13 16:40:37 -05:00
Jarvis 37e72bb29a comms: homelab 20260713T213922Z 2026-07-13 16:39:22 -05:00
homelab mos-comms 32443452fa brief: Decision #3 RATIFIED (Option A) + owner flexibility amendment (configurable recovery posture) 2026-07-13 16:39:21 -05:00
Jarvis f4eb75dc6b comms: homelab 20260713T213206Z 2026-07-13 16:32:06 -05:00
homelab mos-comms e8806df728 brief: Kanban SOT Decision #3 — independent verdict (Option A) 2026-07-13 16:32:06 -05:00
Jarvis f1a149a7f5 comms: homelab 20260713T212934Z 2026-07-13 16:29:34 -05:00
wjarvis mos-comms 7f700d4ca1 comms: usc 20260713T212611Z 2026-07-13 16:26:11 -05:00
wjarvis mos-comms fe71a42b28 comms: usc 20260713T212142Z 2026-07-13 16:21:42 -05:00
Jarvis fb373d5c88 comms: homelab 20260713T211947Z 2026-07-13 16:19:47 -05:00
Jarvis ae4ce99f4d comms: wjarvis 20260713T211701Z 2026-07-13 16:17:01 -05:00
wjarvis mos-comms a70fccccfa comms: wjarvis 20260713T211213Z 2026-07-13 16:12:13 -05:00
wjarvis mos-comms 9c820e2eb8 comms: wjarvis 20260713T211121Z 2026-07-13 16:11:21 -05:00
1902 changed files with 30081 additions and 126452 deletions
-1
View File
@@ -8,7 +8,6 @@ coverage
.env.local
*.tsbuildinfo
.pnpm-store
__pycache__/
docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example
+1 -1
View File
@@ -1 +1 @@
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
pnpm typecheck && pnpm lint && pnpm format:check
+4 -4
View File
@@ -1,5 +1,5 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
# Non-root checkouts use their own HOME. Override without editing this file via
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
store-dir=${HOME}/.local/share/pnpm/store
# 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
-9
View File
@@ -4,15 +4,6 @@ pnpm-lock.yaml
**/node_modules
**/drizzle
**/.next
# Python build/test artifacts — same category as node_modules/dist/.next above.
# Prettier must never scan generated trees; without these a local venv poisons
# `pnpm format:check` with thousands of third-party files.
**/venv
**/__pycache__
**/.mypy_cache
**/.pytest_cache
**/htmlcov
.claude/
docs/tess/TASKS.md
docs/scratchpads/
packages/mosaic/src/fleet/testdata/documentation-publication-v1/inline-migration-v1.json
-43
View File
@@ -41,32 +41,6 @@ steps:
# (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
# Test-membership guard (#1017): also first link of test:framework-shell.
# Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link
# [0] of the pnpm chain, so severing that chain would silence it together
# with everything it guards; this direct line keeps one instrument running.
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
# keep mode is a single cp-based path that must not depend on rsync), and that a
# corrupt/empty/missing manifest aborts fail-closed leaving operator files
# untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
# from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
# the retained, operator-scoped pre-update backup is taken before any mutation
# (0700/0600, secret never logged, retention-pruned) and that the post-sync
# verify net restores any operator file a manifest bug lets the sync touch. The
# migration matrix pins the v2→v3 contract-file semantics. Pure bash, no
# node_modules — runs early alongside sanitization.
upgrade-guard:
image: *node_image
commands:
- apk add --no-cache bash rsync
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
typecheck:
image: *node_image
@@ -76,7 +50,6 @@ steps:
depends_on:
- install
- sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck
lint:
@@ -103,22 +76,6 @@ steps:
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
commands:
- *enable_pnpm
# openssl (#912) is the wake HMAC signer: the digest H1/H2, beacon B12,
# and install I8 legs hard-require it in CI. It is baked into ci-base via
# Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this
# `apk add` guarantees openssl is present on PR pipelines too (and is a
# fast no-op once the rebuilt image already ships it).
- apk add --no-cache openssl
# Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an
# installed `pi` binary at exactly this measured version — the test
# boots Pi's real tool registry to prove the read-only carve-out
# resolves to real, unshadowed builtins, and fails loud (by design)
# when the runtime is absent or drifts. The canonical Pi is
# @earendil-works/[email protected] exactly (@mariozechner/* is
# embedded-legacy). Step-level install because ci-base image publishes
# are currently blocked on registry auth; fold into Dockerfile.ci once
# that is fixed, keeping this as a fast no-op guard.
- npm install -g @earendil-works/[email protected]
# 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.
- |
+5 -104
View File
@@ -1,5 +1,5 @@
# Build, publish npm packages, and push Docker images
# Runs on main for stable publishes and on next for integration-line prereleases/images
# Runs only on main branch push/tag
variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
@@ -23,21 +23,9 @@ variables:
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main, next]
- branch: [main]
event: [push, manual, tag]
steps:
@@ -115,84 +103,6 @@ steps:
depends_on:
- build
publish-next-npm:
image: *node_image
# Durable @next integration-line publish. Runs only on next; never writes
# the latest dist-tag and never commits the computed prerelease versions.
when:
- event: [push, manual]
branch: next
environment:
NPM_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
commands:
- *enable_pnpm
- |
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
exit 1
fi
if [ -z "$CI_PIPELINE_NUMBER" ]; then
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
exit 1
fi
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins'];
const updated = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
}
}
}
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
}
const [, major, minor, patch] = stableMatch;
const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
}
for (const root of roots) walk(root);
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line);
NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
exit 1
fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
# image: *node_image
@@ -224,17 +134,8 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next gateway publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
@@ -245,7 +146,7 @@ steps:
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
@@ -271,7 +172,7 @@ steps:
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
+31 -70
View File
@@ -11,87 +11,48 @@
## Project Context
Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture.
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
### Stack
## Package Map
- **API:** NestJS with Fastify (`apps/gateway`)
- **Web:** Next.js 16 with React 19 (`apps/web`)
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
- **Authentication:** BetterAuth (`packages/auth`)
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
- **Queue:** Valkey 8 (`packages/queue`)
- **Build:** pnpm workspaces and Turborepo
- **CI:** Woodpecker CI
- **Observability:** OpenTelemetry and Jaeger
| Package | Purpose | Key Dependencies |
| ------------------ | ------------------------------- | -------------------------------- |
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
| `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, @mosaicstack/db |
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
| `packages/queue` | Valkey task queue + MCP | ioredis |
| `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 |
### Package Map
## Architecture Rules
| Package | Purpose | Key Dependencies |
| ------------------ | ----------------------------- | -------------------------------- |
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
| `apps/web` | Next.js dashboard | React 19, Tailwind |
| `packages/types` | Shared TypeScript contracts | class-validator |
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
| `packages/brain` | Structured data layer | @mosaicstack/db |
| `packages/queue` | Valkey task queue and MCP | ioredis |
| `packages/coord` | Mission coordination | @mosaicstack/queue |
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
| `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf |
## Architecture and Code Conventions
1. Gateway is the single API surface; all clients connect through it.
2. Pi SDK is ESM-only; gateway and CLI code must remain ESM.
3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports.
4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts.
5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`).
6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not emit decorator metadata.
7. Keep DTOs in `*.dto.ts` files at module boundaries.
8. BetterAuth owns authentication tables; their schema is defined in `@mosaicstack/db`.
9. Create a task-specific scratchpad for non-trivial work.
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 `@mosaicstack/types` enforce compile-time contracts
4. OTEL auto-instrumentation loads before NestJS bootstrap
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)
## Development Workflow
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
```bash
pnpm install --frozen-lockfile
pnpm preflight
# Optional local queue service only; do not start the full Compose stack.
docker compose up -d valkey
docker compose up -d # Infrastructure
pnpm install # Dependencies
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
```
The pre-push hook requires:
## Repo-Specific Notes
```bash
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
```
Software delivery also requires the applicable tests. Common repository commands are:
```bash
pnpm typecheck # TypeScript checks across the workspace
pnpm lint # ESLint across the workspace
pnpm test # Checkout tests and package Vitest suites
pnpm format:check # Prettier check
pnpm build # Build all packages and applications
```
## Database and Local Runtime Safety
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
- Do not start Gateway/Web or run root `pnpm dev` as a local PGlite route. The current dotenv loader can inherit a daemon PostgreSQL DSN; KBN-101-02 must make that path fail closed first.
- Migration artifact generation is offline and does not authorize PostgreSQL access:
```bash
pnpm --filter @mosaicstack/db db:generate
```
- DTOs in `*.dto.ts` files at module boundaries
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution in all tsconfigs
- Scratchpads are mandatory for non-trivial tasks
## docs/TASKS.md — Schema (CANONICAL)
+43 -3
View File
@@ -1,5 +1,45 @@
# Claude Compatibility Pointer
# CLAUDE.md — Mosaic Stack
@AGENTS.md
## Project
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
## Stack
- **API**: NestJS + Fastify adapter (`apps/gateway`)
- **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/mosaic`)
- **Queue**: Valkey 8 (`packages/queue`)
- **Build**: pnpm workspaces + Turborepo
- **CI**: Woodpecker CI
- **Observability**: OpenTelemetry → Jaeger
## Commands
```bash
pnpm typecheck # TypeScript check (all packages)
pnpm lint # ESLint (all packages)
pnpm format:check # Prettier check
pnpm test # Vitest (all packages)
pnpm build # Build all packages
# Database
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 @mosaicstack/gateway exec tsx src/main.ts # Start gateway
```
## Conventions
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
- NodeNext module resolution
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
- DTOs in `*.dto.ts` files at module boundaries
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
- All three gates must pass before push: typecheck, lint, format:check
+4 -7
View File
@@ -22,13 +22,10 @@
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`,
# `git`, and `jq` are baked here too — framework shell tests and the shipped
# Codex review wrappers require them without per-run installation in ci.yml.
# `openssl` (#912) is the non-circular HMAC signer for the wake trust layer:
# the digest H1/H2, beacon B12, and install I8 legs hard-require it in CI so the
# §4 G6 evidence comes from an actually-run HMAC leg, not a skipped one.
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq openssl
# 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 [email protected] --activate
+23 -66
View File
@@ -30,16 +30,6 @@ This installs both components:
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
After install, the wizard runs automatically or you can invoke it manually:
```bash
@@ -48,13 +38,9 @@ mosaic wizard # Full guided setup (gateway install → verify)
### Requirements
- Node.js ≥ 22
- Node.js ≥ 20
- 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)
- [Pi](https://pi.dev)
- 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
@@ -111,10 +97,7 @@ mosaic config path # Print config file path
```bash
mosaic doctor # Health audit — detect drift and missing files
mosaic sync # Sync skills from canonical source
mosaic skill list # Audit Claude skill registrations and conflicts
mosaic skill register <name> # Register one canonical skill with Claude Code
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
mosaic update # Update CLI/framework and auto-register canonical skills
mosaic update # Check for and install CLI updates
mosaic wizard # Full guided setup wizard
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
mosaic coord init # Initialize a new orchestration mission
@@ -174,12 +157,7 @@ mosaic storage status
mosaic storage tier
mosaic storage export
mosaic storage import
# Schema migration is unavailable in this release. The current storage wrapper shells
# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1,
# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
# Future schema migration is non-operative: external bootstrap → TLS/roles → runner
# --run → runner --verify → readiness. Tier copy uses only the separately held secure
# migrate-tier route.
mosaic storage migrate
```
### Telemetry
@@ -204,7 +182,7 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
### Prerequisites
- Node.js ≥ 22
- Node.js ≥ 20
- pnpm 10.6+
- Docker & Docker Compose
@@ -214,50 +192,33 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
git clone [email protected]:mosaicstack/stack.git
cd stack
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
# Start infrastructure (Postgres, Valkey, Jaeger)
docker compose up -d
# Install dependencies
pnpm install
# Verify dependencies and generated state before running source-quality gates.
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
# The web build certifies its exact standalone symlink manifest; added, removed,
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
# a five-month-stale .next that produced 19 phantom TS2307 errors.
# It does NOT defend against a same-UID actor that can rewrite both manifest and
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
# trust anchor outside worktree authority.
pnpm preflight
# Run migrations
pnpm --filter @mosaicstack/db run db:migrate
# Optional local queue service only. This does not start PostgreSQL.
docker compose up -d valkey
# The current Gateway/Web local process is held; see docs/guides/dev-guide.md.
# Do not start it until KBN-101-02 makes inherited dotenv/DSN state fail closed.
# Start all services in dev mode
pnpm dev
```
### Held future procedure
### Infrastructure
The checked-in Compose PostgreSQL service mounts legacy initialization SQL and is **not** a
current PostgreSQL, standalone, or federated developer route. Do not start it with Compose,
invoke initialization SQL, or treat the planned migrator as currently executable.
Docker Compose provides:
**Held future activation procedure — non-operative and no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05
land:** external bootstrap → TLS/roles → `mosaic-db-migrator --run`
`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
this README—will provide the reviewed commands and secret-consumer interface.
For local data-layer work, PGlite needs no PostgreSQL service. The optional Compose command above
starts only Valkey; OTEL Collector and Jaeger may likewise be started individually if needed,
without starting PostgreSQL. A Gateway/Web local process is not currently a safe PGlite route:
its unguarded dotenv loader may inherit a daemon PostgreSQL DSN. Do not use root `pnpm dev` or a
Gateway start command until KBN-101-02 makes that state fail closed.
| Service | Port | Purpose |
| --------------------- | --------- | ---------------------- |
| PostgreSQL (pgvector) | 5433 | Primary database |
| Valkey | 6380 | Task queue + caching |
| Jaeger | 16686 | Distributed tracing UI |
| OTEL Collector | 4317/4318 | Telemetry ingestion |
### Quality Gates
```bash
pnpm preflight # Checkout/dependency/generated-state validation
pnpm typecheck # TypeScript type checking (all packages)
pnpm lint # ESLint (all packages)
pnpm test # Vitest (all packages)
@@ -270,7 +231,7 @@ pnpm format # Prettier auto-fix
Woodpecker CI runs on every push:
- `pnpm install --frozen-lockfile`
- **Legacy N-1 CI status only — active, uncertified, and non-authorizing as an operator route:** the checked-in job currently invokes `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` against an isolated disposable PostgreSQL CI database. It performs direct DDL in that CI database, is not approved ordinary behavior or an operator route, and remains a known exception pending KBN-101-06 removal/replacement by the certified runner-backed CI path.
- Database migration against a fresh Postgres
- `pnpm test` (Turbo-orchestrated across all packages)
npm packages are published to the Gitea package registry on main merges.
@@ -375,15 +336,11 @@ The CLI also performs a background update check on every invocation (cached for
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 --next # Prerelease lane: source build from next
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
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
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
## Contributing
```bash
@@ -417,7 +417,7 @@ describe('ConversationsController — search endpoint', () => {
},
];
brain = createMockBrain({ searchResults });
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
controller = new ConversationsController(brain as never);
});
it('returns matching messages for a valid search query', async () => {
@@ -479,7 +479,7 @@ describe('ConversationsController — search endpoint', () => {
describe('ConversationsController — message CRUD', () => {
it('listMessages returns 404 when conversation is not owned by user', async () => {
const brain = createMockBrain({ conversation: undefined });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
NotFoundException,
@@ -489,7 +489,7 @@ describe('ConversationsController — message CRUD', () => {
it('listMessages returns the messages for an owned conversation', async () => {
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
@@ -500,7 +500,7 @@ describe('ConversationsController — message CRUD', () => {
it('addMessage returns the persisted message', async () => {
const brain = createMockBrain({ conversation: makeConversation() });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
const result = await controller.addMessage(
CONV_ID,
@@ -1,519 +0,0 @@
/**
* Federation M3 single-gateway integration tests (FED-M3-10).
*
* Covers MILESTONES.md M3 acceptance:
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
* - #7: max_rows_per_query caps list results.
*
* Strategy:
* - Real PostgreSQL via @mosaicstack/db.
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
*
* Run:
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
* src/__tests__/integration/federation-m3-list.integration.test.ts
*/
import 'reflect-metadata';
import * as crypto from 'node:crypto';
import type { ExecutionContext } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
and,
createDb,
eq,
federationGrants,
federationPeers,
inArray,
missionTasks,
missions,
projects,
tasks,
teamMembers,
teams,
type Db,
type DbHandle,
users,
} from '@mosaicstack/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
import { FederationScopeService } from '../../federation/server/scope.service.js';
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
import { ListController } from '../../federation/server/verbs/list.controller.js';
import {
makeMosaicIssuedCert,
makeSelfSignedCert,
} from '../../federation/__tests__/helpers/test-cert.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
interface TestIds {
readonly subjectUserId: string;
readonly otherUserId: string;
readonly peerId: string;
readonly revokedPeerId: string;
readonly activeGrantId: string;
readonly revokedGrantId: string;
readonly subjectProjectId: string;
readonly subjectMissionId: string;
readonly otherProjectId: string;
readonly teamId: string;
readonly unauthorizedTeamId: string;
readonly teamProjectId: string;
readonly taskIds: readonly string[];
readonly excludedTaskIds: readonly string[];
readonly subjectNoteId: string;
readonly otherUserNoteId: string;
}
function pemToDer(pem: string): Buffer {
return Buffer.from(
pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, ''),
'base64',
);
}
function makeFederationRequest(certPem: string): FastifyRequest {
return {
raw: {
socket: {
getPeerCertificate: () => ({
raw: pemToDer(certPem),
serialNumber: CERT_SERIAL_HEX,
}),
},
},
} as unknown as FastifyRequest;
}
function makeGuardContext(request: FastifyRequest): {
readonly context: ExecutionContext;
readonly sent: { statusCode?: number; payload?: unknown };
} {
const sent: { statusCode?: number; payload?: unknown } = {};
const reply = {
status: (statusCode: number) => {
sent.statusCode = statusCode;
return {
header: () => ({
send: (payload: unknown) => {
sent.payload = payload;
},
}),
};
},
} as unknown as FastifyReply;
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext;
return { context, sent };
}
async function insertUser(db: Db, id: string, label: string): Promise<void> {
await db.insert(users).values({
id,
name: `${RUN_ID}-${label}`,
email: `${RUN_ID}-${label}@federation-test.invalid`,
emailVerified: false,
});
}
async function seedFixtures(db: Db): Promise<TestIds> {
const subjectUserId = `${RUN_ID}-subject`;
const otherUserId = `${RUN_ID}-other`;
const peerId = crypto.randomUUID();
const revokedPeerId = crypto.randomUUID();
const activeGrantId = crypto.randomUUID();
const revokedGrantId = crypto.randomUUID();
const subjectProjectId = crypto.randomUUID();
const subjectMissionId = crypto.randomUUID();
const otherProjectId = crypto.randomUUID();
const teamId = crypto.randomUUID();
const unauthorizedTeamId = crypto.randomUUID();
const teamProjectId = crypto.randomUUID();
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
const subjectNoteId = crypto.randomUUID();
const otherUserNoteId = crypto.randomUUID();
await insertUser(db, subjectUserId, 'subject');
await insertUser(db, otherUserId, 'other');
await db.insert(teams).values([
{
id: teamId,
name: `${RUN_ID} allowed team`,
slug: `${RUN_ID}-allowed-team`,
ownerId: subjectUserId,
managerId: subjectUserId,
},
{
id: unauthorizedTeamId,
name: `${RUN_ID} unauthorized team`,
slug: `${RUN_ID}-unauthorized-team`,
ownerId: otherUserId,
managerId: otherUserId,
},
]);
await db.insert(teamMembers).values([
{ teamId, userId: subjectUserId, role: 'member' },
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
]);
await db.insert(projects).values([
{
id: subjectProjectId,
name: `${RUN_ID} subject personal project`,
ownerType: 'user',
ownerId: subjectUserId,
},
{
id: otherProjectId,
name: `${RUN_ID} other personal project`,
ownerType: 'user',
ownerId: otherUserId,
},
{
id: teamProjectId,
name: `${RUN_ID} unauthorized team project`,
ownerType: 'team',
teamId: unauthorizedTeamId,
},
]);
await db.insert(missions).values({
id: subjectMissionId,
name: `${RUN_ID} subject mission`,
projectId: subjectProjectId,
userId: subjectUserId,
});
await db.insert(tasks).values([
{
id: taskIds[0],
title: `${RUN_ID} visible task 1`,
missionId: subjectMissionId,
createdAt: new Date('2026-06-25T03:00:00.000Z'),
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
},
{
id: taskIds[1],
title: `${RUN_ID} visible task 2`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T02:00:00.000Z'),
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
},
{
id: taskIds[2],
title: `${RUN_ID} visible task 3`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T01:00:00.000Z'),
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
},
{
id: excludedTaskIds[0],
title: `${RUN_ID} other user task`,
projectId: otherProjectId,
createdAt: new Date('2026-06-25T04:00:00.000Z'),
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
},
{
id: excludedTaskIds[1],
title: `${RUN_ID} unauthorized team task`,
projectId: teamProjectId,
createdAt: new Date('2026-06-25T05:00:00.000Z'),
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
},
]);
await db.insert(missionTasks).values([
{
id: subjectNoteId,
missionId: subjectMissionId,
userId: subjectUserId,
notes: `${RUN_ID} subject visible note`,
createdAt: new Date('2026-06-25T03:30:00.000Z'),
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
},
{
id: otherUserNoteId,
missionId: subjectMissionId,
userId: otherUserId,
notes: `${RUN_ID} other user note on subject mission`,
createdAt: new Date('2026-06-25T04:30:00.000Z'),
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
},
]);
await db.insert(federationPeers).values([
{
id: peerId,
commonName: `${RUN_ID}-active-peer`,
displayName: `${RUN_ID} Active Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: CERT_SERIAL_HEX,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
{
id: revokedPeerId,
commonName: `${RUN_ID}-revoked-peer`,
displayName: `${RUN_ID} Revoked Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
]);
await db.insert(federationGrants).values([
{
id: activeGrantId,
peerId,
subjectUserId,
status: 'active',
scope: {
resources: ['tasks', 'notes'],
excluded_resources: [],
filters: {
tasks: { include_personal: true, include_teams: [] },
notes: { include_personal: true, include_teams: [] },
},
max_rows_per_query: 2,
},
},
{
id: revokedGrantId,
peerId,
subjectUserId,
status: 'revoked',
revokedAt: new Date(),
revokedReason: `${RUN_ID} revoked grant fixture`,
scope: {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 2,
},
},
]);
return {
subjectUserId,
otherUserId,
peerId,
revokedPeerId,
activeGrantId,
revokedGrantId,
subjectProjectId,
subjectMissionId,
otherProjectId,
teamId,
unauthorizedTeamId,
teamProjectId,
taskIds,
excludedTaskIds,
subjectNoteId,
otherUserNoteId,
};
}
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
if (!ids) {
return;
}
await db
.delete(missionTasks)
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
.catch(() => {});
await db
.delete(tasks)
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
.catch(() => {});
await db
.delete(missions)
.where(eq(missions.id, ids.subjectMissionId))
.catch(() => {});
await db
.delete(projects)
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
.catch(() => {});
await db
.delete(teamMembers)
.where(
and(
eq(teamMembers.userId, ids.subjectUserId),
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
),
)
.catch(() => {});
await db
.delete(teams)
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
.catch(() => {});
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
.catch(() => {});
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
.catch(() => {});
await db
.delete(users)
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
.catch(() => {});
}
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
let handle: DbHandle;
let db: Db;
let moduleRef: TestingModule;
let guard: FederationAuthGuard;
let listController: ListController;
let ids: TestIds | undefined;
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
ids = await seedFixtures(db);
moduleRef = await Test.createTestingModule({
controllers: [ListController],
providers: [
{ provide: DB, useValue: db },
GrantsService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
}).compile();
guard = moduleRef.get(FederationAuthGuard);
listController = moduleRef.get(ListController);
}, 30_000);
afterAll(async () => {
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
});
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
const malformedOidCert = await makeSelfSignedCert();
const request = makeFederationRequest(malformedOidCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(401);
expect(sent.payload).toMatchObject({
error: {
code: 'unauthorized',
message: expect.stringContaining('missing required OID'),
},
});
expect(request.federationContext).toBeUndefined();
});
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
expect(ids).toBeDefined();
const revokedCert = await makeMosaicIssuedCert({
grantId: ids!.revokedGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(revokedCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(403);
expect(sent.payload).toMatchObject({
error: {
code: 'forbidden',
message: 'Federation access denied',
},
});
expect(request.federationContext).toBeUndefined();
});
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('tasks', request, { limit: 100 });
const returnedIds = response.items.map((item) => item['id']);
expect(response.items).toHaveLength(2);
expect(response._truncated).toBe(true);
expect(response.nextCursor).toEqual(expect.any(String));
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
expect(returnedIds).not.toContain(ids!.taskIds[2]);
for (const excludedId of ids!.excludedTaskIds) {
expect(returnedIds).not.toContain(excludedId);
}
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('excludes another user mission task notes on the same authorized mission', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('notes', request, { limit: 10 });
const returnedIds = response.items.map((item) => item['id']);
expect(returnedIds).toEqual([ids!.subjectNoteId]);
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('fails closed for unsupported list resources', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is not supported',
},
},
status: 403,
});
});
});
@@ -35,25 +35,6 @@ function payload(content: string, messageId: string, correlationId: string): Dis
};
}
/**
* The chat runtime router must never be exercised on the Discord approval/stop control paths —
* those paths run entirely through the command-authorization, runtime-provider and durable-session
* dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService`
* slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because
* approval/stop never resolve a chat runtime, this fixture is never triggered and the integration
* stays a GREEN cross-surface control.
*/
function failIfUsedChatRuntimeRouter() {
return {
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the Discord control path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the Discord approval/stop path');
},
};
}
function authorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
return new CommandAuthorizationService(
@@ -91,7 +72,6 @@ describe('interaction Discord/CLI durable-session integration', () => {
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
@@ -132,7 +112,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
},
);
const gateway = new ChatGateway(
failIfUsedChatRuntimeRouter() as never,
{} as never,
{} as never,
{} as never,
{} as never,
@@ -153,7 +133,6 @@ describe('interaction Discord/CLI durable-session integration', () => {
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
@@ -1,332 +0,0 @@
import { type Type } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { describe, expect, it, vi } from 'vitest';
import { AgentService, type AgentSession } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
import { AppModule } from '../app.module.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { CommandExecutorService } from '../commands/command-executor.service.js';
import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { PreferencesModule } from '../preferences/preferences.module.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
const fakeDb = {
$client: { exec: async (): Promise<void> => {} },
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
select: () => ({
from: () => ({
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
}),
}),
insert: () => ({ values: async (): Promise<void> => {} }),
};
const fakeProviderService = {
onModuleInit: async (): Promise<void> => {},
onModuleDestroy: (): void => {},
getRegistry: () => ({ getAvailable: () => [], getAll: () => [], find: () => undefined }),
getDefaultModel: () => undefined,
listAvailableModels: () => [],
listProviders: () => [],
getAdapter: () => undefined,
getProvidersHealth: () => [],
};
function compileRealAppGraph(): Promise<TestingModule> {
return Test.createTestingModule({ imports: [AppModule] })
.overrideProvider('DB_HANDLE')
.useValue({ db: fakeDb, close: async (): Promise<void> => {} })
.overrideProvider('DB')
.useValue(fakeDb)
.overrideProvider('STORAGE_ADAPTER')
.useValue({
name: 'required-security-wiring-test',
migrate: async (): Promise<void> => {},
close: async (): Promise<void> => {},
})
.overrideProvider('AUTH')
.useValue({})
.overrideProvider('BRAIN')
.useValue({ conversations: {}, agents: {} })
.overrideProvider('LOG_SERVICE')
.useValue({})
.overrideProvider('MEMORY')
.useValue({})
.overrideProvider('MEMORY_ADAPTER')
.useValue({})
.overrideProvider(ProviderService)
.useValue(fakeProviderService)
.compile();
}
function providerToken(provider: unknown): unknown {
return typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
}
interface MaskingConsumer {
moduleType: Type<unknown>;
token: Type<unknown>;
useValue: object;
}
async function compileWithoutProvider(
moduleType: Type<unknown>,
missingToken: Type<unknown>,
maskingConsumer: MaskingConsumer,
): Promise<{ error: unknown; moduleRef: TestingModule | undefined }> {
const touchedModules = new Set([moduleType, maskingConsumer.moduleType]);
const originals = Array.from(touchedModules, (touchedModule: Type<unknown>) => ({
moduleType: touchedModule,
providers: (Reflect.getMetadata('providers', touchedModule) ?? []) as unknown[],
exports: (Reflect.getMetadata('exports', touchedModule) ?? []) as unknown[],
}));
for (const original of originals) {
const providers = original.providers.flatMap((provider: unknown): unknown[] => {
const token = providerToken(provider);
if (original.moduleType === moduleType && token === missingToken) return [];
if (original.moduleType === maskingConsumer.moduleType && token === maskingConsumer.token) {
return [{ provide: maskingConsumer.token, useValue: maskingConsumer.useValue }];
}
return [provider];
});
const exports = original.exports.filter(
(exported: unknown): boolean =>
original.moduleType !== moduleType || providerToken(exported) !== missingToken,
);
Reflect.defineMetadata('providers', providers, original.moduleType);
Reflect.defineMetadata('exports', exports, original.moduleType);
}
let moduleRef: TestingModule | undefined;
let error: unknown;
try {
moduleRef = await compileRealAppGraph();
} catch (caught: unknown) {
error = caught;
} finally {
for (const original of originals) {
Reflect.defineMetadata('providers', original.providers, original.moduleType);
Reflect.defineMetadata('exports', original.exports, original.moduleType);
}
}
return { error, moduleRef };
}
async function closeIfCompiled(moduleRef: TestingModule | undefined): Promise<void> {
if (moduleRef) await moduleRef.close();
}
describe('required security wiring — real AppModule startup refusal', () => {
it('FL-01 positive control: the real graph compiles when CommandAuthorizationService is bound', async () => {
const moduleRef = await compileRealAppGraph();
try {
expect(moduleRef.get(CommandAuthorizationService, { strict: false })).toBeInstanceOf(
CommandAuthorizationService,
);
} finally {
await moduleRef.close();
}
});
it('FL-01 negative control: absence read as permission is refused at module compilation', async () => {
const { error, moduleRef } = await compileWithoutProvider(
CommandsModule,
CommandAuthorizationService,
{
moduleType: CommandsModule,
token: CommandRuntimeApprovalVerifier,
useValue: {},
},
);
await closeIfCompiled(moduleRef);
expect(
error,
'absence read as permission: AppModule compilation accepted a missing CommandAuthorizationService binding',
).toBeInstanceOf(Error);
if (!(error instanceof Error)) return;
expect(error.message).toContain('CommandExecutorService');
expect(error.message).toContain('CommandAuthorizationService');
});
it('FL-11 positive control: the real graph compiles when SystemOverrideService is bound', async () => {
const moduleRef = await compileRealAppGraph();
try {
expect(moduleRef.get(SystemOverrideService, { strict: false })).toBeInstanceOf(
SystemOverrideService,
);
} finally {
await moduleRef.close();
}
});
it('FL-11 negative control: absence read as permission is refused at module compilation', async () => {
const { error, moduleRef } = await compileWithoutProvider(
PreferencesModule,
SystemOverrideService,
{
moduleType: CommandsModule,
token: CommandExecutorService,
useValue: {},
},
);
await closeIfCompiled(moduleRef);
expect(
error,
'absence read as permission: AppModule compilation accepted a missing SystemOverrideService binding',
).toBeInstanceOf(Error);
if (!(error instanceof Error)) return;
expect(error.message).toContain('AgentService');
expect(error.message).toContain('SystemOverrideService');
});
});
const actorScope = { userId: 'security-user', tenantId: 'security-tenant' };
const conversationId = 'security-conversation';
function directExecutorWithoutAuthorization(systemOverrideSet: ReturnType<typeof vi.fn>) {
const registry = {
getManifest: vi.fn(() => ({
version: 1,
commands: [
{
name: 'system',
aliases: [],
description: 'Set instruction authority',
scope: 'agent' as const,
execution: 'socket' as const,
available: true,
},
],
skills: [],
})),
};
return new CommandExecutorService(
registry as never,
{ getSession: vi.fn() } as never,
{ set: systemOverrideSet, clear: vi.fn() } as never,
{ collect: vi.fn() } as never,
null,
{ agents: {} } as never,
null,
null,
{ getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []) } as never,
undefined as never,
);
}
function directAgentWithoutSystemOverride(piPrompt: ReturnType<typeof vi.fn>): {
service: AgentService;
session: AgentSession;
} {
const service = 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,
undefined as never,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
null,
);
const session = {
id: conversationId,
provider: 'test-provider',
modelId: 'test-model',
piSession: { prompt: piPrompt },
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: process.cwd(),
allowedTools: null,
userId: actorScope.userId,
tenantId: actorScope.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date(0).toISOString(),
},
} as unknown as AgentSession;
const internals = service as unknown as { sessions: Map<string, AgentSession> };
internals.sessions.set(conversationId, session);
return { service, session };
}
describe('required security wiring — malformed direct absence has zero effects', () => {
it('FL-01 refuses command execution before any command effect when authorization is absent', async () => {
const systemOverrideSet = vi.fn().mockResolvedValue(undefined);
const executor = directExecutorWithoutAuthorization(systemOverrideSet);
const payload: SlashCommandPayload = {
command: 'system',
args: 'authority that must not be stored',
conversationId,
};
let error: unknown;
try {
await executor.execute(payload, actorScope);
} catch (caught: unknown) {
error = caught;
}
expect
.soft(
error,
'absence read as permission: direct executor accepted missing command authorization',
)
.toBeInstanceOf(Error);
expect
.soft(
systemOverrideSet,
'absence read as permission: command effect occurred without command authorization',
)
.not.toHaveBeenCalled();
});
it('FL-11 refuses prompt execution before any provider or session effect when system override authority is absent', async () => {
const piPrompt = vi.fn().mockResolvedValue(undefined);
const { service, session } = directAgentWithoutSystemOverride(piPrompt);
let error: unknown;
try {
await service.prompt(conversationId, 'must not reach provider', actorScope);
} catch (caught: unknown) {
error = caught;
}
expect
.soft(
error,
'absence read as permission: direct session accepted missing system override authority',
)
.toBeInstanceOf(Error);
expect
.soft(
piPrompt,
'absence read as permission: provider prompt occurred without system override authority',
)
.not.toHaveBeenCalled();
expect
.soft(
session.promptCount,
'absence read as permission: session state changed without system override authority',
)
.toBe(0);
});
});
@@ -60,7 +60,7 @@ describe('Resource ownership checks', () => {
// The repo enforces ownership via the WHERE clause; it returns undefined when the
// conversation does not belong to the requesting user.
brain.conversations.findById.mockResolvedValue(undefined);
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
NotFoundException,
@@ -1,11 +1,9 @@
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@@ -16,9 +14,6 @@ export class AdminHealthController {
@Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {}
@Get()
@@ -60,14 +55,6 @@ export class AdminHealthController {
}
private async checkCache(): Promise<ServiceStatusDto> {
// On Local tier there is no Redis. The cache is intentionally absent, which
// is a healthy state for this tier — report 'ok' rather than opening a new
// ioredis connection on every admin health check (which would spam
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
// signals "no cache backend to measure" for this tier.
if (this.mosaicConfig?.queue?.type === 'local') {
return { status: 'ok', latencyMs: 0 };
}
const start = Date.now();
const handle = createQueue();
try {
@@ -26,7 +26,7 @@ function makeService(operatorMemory: unknown = null): AgentService {
{} as never,
{ getToolDefinitions: vi.fn(() => []) } as never,
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
@@ -115,18 +115,6 @@ describe('AgentService owner/tenant scope enforcement', () => {
).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,
@@ -1,8 +1,6 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
@@ -14,25 +12,10 @@ vi.mock('../routing/routing-engine.service.js', () => ({
}));
import { SessionsController } from '../sessions.controller.js';
import { AgentService } from '../agent.service.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';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AuthGuard } from '../../auth/auth.guard.js';
import { AUTH } from '../../auth/auth.tokens.js';
import { BRAIN } from '../../brain/brain.tokens.js';
import { CommandRegistryService } from '../../commands/command-registry.service.js';
import { CommandExecutorService } from '../../commands/command-executor.service.js';
import { RoutingEngineService } from '../routing/routing-engine.service.js';
import { ChatRuntimeRouter } from '../../chat/chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../../chat/embedded-chat.runtime.js';
import { ownConversation } from '../../chat/chat-runtime.js';
import type { LegacyRuntimeStream } from '../../chat/chat-runtime.js';
import { HarnessChatRuntime } from '../../chat/harness-chat.runtime.js';
import { HarnessRegistry } from '../../harness/harness.registry.js';
import { HARNESS_CONVERSATION_SERVICE_UNAVAILABLE } from '../../harness/harness.tokens.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
@@ -91,12 +74,6 @@ function makeAgentSession(owner = USER_A): AgentSession {
};
}
/**
* A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing
* USER_A's conversation id) is never granted the session. Because every method exists and no method
* throws for a wrong shape, production runs to its real ownership decision — the RED never comes from
* a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch.
*/
function makeScopedAgentService() {
const foreign = makeAgentSession(USER_A);
return {
@@ -110,7 +87,7 @@ function makeScopedAgentService() {
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')),
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
@@ -119,201 +96,6 @@ function makeScopedAgentService() {
};
}
type ScopedAgentService = ReturnType<typeof makeScopedAgentService>;
/**
* A structurally-complete harness conversation service that throws if any method is invoked.
* Fronted behind the legacy runtime's harness slot: the legacy path must never reach it.
*/
const failIfUsedConversationService = {
attach: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
detach: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
send: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
subscribeFrom: async function* () {
throw new Error('harness conversation service must not be reached on the legacy path');
},
} as unknown as HarnessConversationService;
/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */
const boundConversationService = {
attach: () => Promise.reject(new Error('unused')),
detach: () => Promise.reject(new Error('unused')),
send: () => Promise.reject(new Error('unused')),
subscribeFrom: async function* () {
throw new Error('unused');
},
} as unknown as HarnessConversationService;
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
const registry = new HarnessRegistry();
for (const id of adapterIds) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return registry;
}
/**
* Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime}
* that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to
* reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService.
* The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a
* call landing on it proves the router-delegation redesign is live rather than the old direct path.
*/
function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter {
const embedded = new EmbeddedChatRuntime(agentService as never);
const harness = new HarnessChatRuntime(failIfUsedConversationService);
const router = new ChatRuntimeRouter(
new HarnessRegistry(),
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
embedded,
harness,
'legacy',
);
router.onModuleInit();
return router;
}
/**
* The AgentService method names the controller/gateway must NEVER drive on the runtime at the
* delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record
* one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime
* call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text.
*/
const FORBIDDEN_AGENT_OPS = [
'getSession',
'createSession',
'onEvent',
'addChannel',
'prompt',
'setThinking',
'abort',
] as const;
/**
* Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields
* an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at
* INVOCATION time and forwards to the real method (bound to the real target, so the router's internal
* delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING
* properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a
* canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim.
*
* The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router
* at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the
* production source cannot. This replaces the earlier `source.toContain('<frozen op>')` proof — which
* a dead declaration could satisfy while production still executed a shim — with invocation evidence.
*/
function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter {
return new Proxy(target, {
get(t, prop) {
const value = Reflect.get(t, prop);
if (typeof value === 'function' && typeof prop === 'string') {
return (...args: unknown[]) => {
calls.push(prop);
return (value as (...a: unknown[]) => unknown).apply(t, args);
};
}
return value;
},
}) as ChatRuntimeRouter;
}
/**
* Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts).
*
* BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider
* (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production
* resolves whichever its constructor declares:
* - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the
* router (and its embedded fake) is never reached.
* - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never
* touched (stays at zero) and scope is observed inside the embedded fake behind the router.
* The SAME test body reds today and greens later; a method-for-method AgentService shim on the router
* records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and
* restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed
* scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link
* makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings.
*/
function buildRestModule(
directAgentService: ScopedAgentService,
embeddedAgentService: ScopedAgentService,
routerCalls: string[],
): Promise<TestingModule> {
return (
Test.createTestingModule({
controllers: [ChatController],
providers: [
{ provide: AgentService, useValue: directAgentService },
{
provide: ChatRuntimeRouter,
useFactory: () =>
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
},
],
})
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects
// AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph
// resolves and the test reds on BEHAVIOUR, not on a DI collection error.
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile()
);
}
function buildGatewayModule(
directAgentService: ScopedAgentService,
embeddedAgentService: ScopedAgentService,
routerCalls: string[],
): Promise<TestingModule> {
const brain = {
conversations: {
// The sender OWNS this durable conversation, so the browser-send admission gate lets the turn
// reach the router seam. Foreignness is asserted downstream at the in-memory agent session
// (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection
// property has its own dedicated coverage.
findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }),
},
};
return Test.createTestingModule({
providers: [
ChatGateway,
{ provide: AgentService, useValue: directAgentService },
{ provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } },
{ provide: BRAIN, useValue: brain },
{ provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } },
{ provide: CommandExecutorService, useValue: { execute: vi.fn() } },
{
provide: RoutingEngineService,
useValue: {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
},
},
{
provide: ChatRuntimeRouter,
useFactory: () =>
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
},
],
}).compile();
}
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');
@@ -370,66 +152,50 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => {
// TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot
// swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived
// scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path.
it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency
const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation
const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam
const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls);
try {
const controller = moduleRef.get(ChatController, { strict: false });
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);
// Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN.
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toBeDefined();
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toMatchObject({ status: 404 });
// Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail.
// RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router.
// Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the
// controller @Inject(AgentService) and never calls the router). GREEN once it drives the op.
expect
.soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router')
.toContain('completeLegacyRestTurn');
// RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on
// the router. An AgentService-shaped router shim records one of these → RED, defeating the shim
// on invocation evidence (not source text). A dead named method added alongside the shim does not
// help: it is never invoked, so it never enters routerCalls while a forbidden op still does.
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today;
// restoring the direct injection keeps it failing).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the
// router (fails today; the router path is never taken).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Zero foreign mutation on either path (holds today and at GREEN).
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
// Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the
// controller no longer declares the direct embedded AgentService dependency. A negative source
// check cannot be satisfied by dead text — it only fails when the injection is present.
const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect.soft(controllerSource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
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 (router-delegated legacy runtime)', () => {
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',
@@ -440,519 +206,57 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router
};
}
// TESS test B — WebSocket send/attach.
it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
}),
).catch(() => undefined);
// RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router.
expect
.soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router')
.toContain('prepareLegacySocketTurn');
// RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN).
expect.soft(directAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(directAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
// Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency.
const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
expect.soft(gatewaySource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
});
// TESS test C — WebSocket set:thinking.
it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleSetThinking(socket as never, {
conversationId: CONVERSATION_ID,
level: 'high',
}),
).catch(() => undefined);
// RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router.
expect
.soft(routerCalls, 'gateway must invoke setLegacyThinking on the router')
.toContain('setLegacyThinking');
// RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test D — WebSocket abort.
it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }),
).catch(() => undefined);
// RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router.
expect
.soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router')
.toContain('abortLegacyTurn');
// RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal.
it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => {
// pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and
// must be refused with a fixed typed code, touching neither the embedded AgentService nor the
// harness conversation service.
const agentService = makeScopedAgentService();
const embedded = new EmbeddedChatRuntime(agentService as never);
const harnessConversation = {
attach: vi.fn(),
detach: vi.fn(),
send: vi.fn(),
subscribeFrom: vi.fn(),
};
const harness = new HarnessChatRuntime(harnessConversation as never);
const router = new ChatRuntimeRouter(
registryWith(['pi']),
boundConversationService,
embedded,
harness,
'pi-rpc',
);
router.onModuleInit();
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 gateway = new ChatGateway(
router as never,
{} as never,
brain as never,
{ getManifest: vi.fn().mockReturnValue([]) } as never,
{ execute: vi.fn() } as never,
{ resolve: vi.fn() } as never,
);
const socket = {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'route me',
}),
).catch(() => undefined);
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({ code: 'runtime_unsupported' }),
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
expect(agentService.getSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(harnessConversation.attach).not.toHaveBeenCalled();
expect(harnessConversation.send).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5).
// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService
// fake (every touched method exists, so a RED can only come from behavior, never a
// `getSession is not a function` TypeError). Ownership context is minted through the
// real `ownConversation` factory — the only sanctioned way to reach a port op.
// ---------------------------------------------------------------------------
const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId };
const CONVERSATION_UNAVAILABLE_RESULT = {
ok: false,
code: 'conversation_unavailable',
retryable: false,
} as const;
/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */
function makeStream(): LegacyRuntimeStream {
return { channelId: 'websocket:test-1', onEvent: vi.fn() };
}
/**
* getSession → undefined (session missing), createSession → rejects with `err`. Exercises the
* `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves
* the turn short-circuited before any dispatch.
*/
function makeCollapsingAgentService(err: Error) {
return {
getSession: vi.fn(() => undefined),
createSession: vi.fn().mockRejectedValue(err),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
}
/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */
function makeLeaseAgentService() {
const session = makeAgentSession(USER_A);
const unsubscribe = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => unsubscribe),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
return { svc, unsubscribe, session };
}
/**
* getSession → a live owned session (REST resolveOrCreate succeeds), onEvent returns a `detach`
* spy, and `prompt` REJECTS with a non-timeout error. Drives the REST-turn catch path so the single
* idempotent teardown must clear the 120s timeout and detach the listener exactly once.
*/
function makeRejectingPromptAgentService() {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockRejectedValue(new Error('agent backend exploded')),
recordTokenUsage: vi.fn(),
};
return { svc, detach };
}
describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner'));
const runtime = new EmbeddedChatRuntime(svc as never);
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new NotFoundException('no such conversation'));
const runtime = new EmbeddedChatRuntime(svc as never);
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' });
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => {
const foreign = new EmbeddedChatRuntime(
makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never,
);
const missing = new EmbeddedChatRuntime(
makeCollapsingAgentService(new NotFoundException('no such conversation')) as never,
);
const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' });
const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' });
expect(foreignResult).toEqual(missingResult);
expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
});
});
describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => {
const { svc } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
const first = await lease.dispatch();
expect(first).toEqual({ ok: true, value: undefined });
expect(svc.prompt).toHaveBeenCalledTimes(1);
const second = await lease.dispatch();
expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false });
// Zero additional effect — the second dispatch must not prompt again.
expect(svc.prompt).toHaveBeenCalledTimes(1);
});
it('disposes once; a second dispose is a silent no-op that never re-detaches or destroys the session', async () => {
const { svc, unsubscribe, session } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
await lease.dispose();
await lease.dispose();
// Listener + channel torn down exactly once across two dispose calls.
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(svc.removeChannel).toHaveBeenCalledTimes(1);
// Disposal never terminates the underlying session or process.
expect(session.piSession.abort).not.toHaveBeenCalled();
expect(session.piSession.dispose).not.toHaveBeenCalled();
});
it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => {
const { svc, unsubscribe } = makeLeaseAgentService();
svc.addChannel = vi.fn(() => {
throw new Error('channel attach failed');
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
const runtime = new EmbeddedChatRuntime(svc as never);
// Must NOT throw out of the port — a partial setup collapses to a total safe failure.
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(false);
// Exactly what was acquired (the event listener) is rolled back.
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => {
const { svc, detach } = makeRejectingPromptAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
// A rejected `done` promise firing after completeLegacyRestTurn has already returned would
// surface as an unhandledRejection — the leak this test fences. Capture any that escape.
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const result = await runtime.completeLegacyRestTurn(ctx, {
content: 'trigger a backend failure',
});
// The rejection collapses to a total safe failure (not a timeout) — never throws out of the port.
expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false });
// The single idempotent dispose ran in the catch: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second
// detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
// Let any scheduled rejection surface on a real macrotask, then confirm none did.
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
// The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential
// `await prompt()` the timer could never even be observed, so the turn hung forever.
prompt: vi.fn(() => new Promise<void>(() => undefined)),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'a prompt that never returns',
});
// No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all
// installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
// The single idempotent dispose ran on the timeout path: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// Advancing far past the deadline fires nothing more: dispose cleared the timer.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
let rejectPrompt: (reason: unknown) => void = () => undefined;
const prompting = new Promise<void>((_resolve, reject) => {
rejectPrompt = reject;
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn(() => prompting),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'prompt settles after the deadline',
});
// The timeout wins the race while prompt is still pending.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
expect(detach).toHaveBeenCalledTimes(1);
// The prompt now rejects LATE — after the turn already returned its timeout result. Because
// Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late
// rejection is already observed and must not escape as an unhandledRejection.
rejectPrompt(new Error('late backend failure'));
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
});
-14
View File
@@ -21,12 +21,6 @@ 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 { ConnectorLeaseRepository } from './connector-lease.repository.js';
import {
CONNECTOR_LEASE_POLICY,
ConnectorLeaseService,
DenyConnectorLeasePolicy,
} from './connector-lease.service.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
@@ -52,13 +46,6 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
SkillLoaderService,
DurableSessionRepository,
DurableSessionService,
ConnectorLeaseRepository,
DenyConnectorLeasePolicy,
{
provide: CONNECTOR_LEASE_POLICY,
useExisting: DenyConnectorLeasePolicy,
},
ConnectorLeaseService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry,
@@ -91,7 +78,6 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
SkillLoaderService,
DurableSessionService,
RuntimeProviderService,
ConnectorLeaseService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
],
})
+14 -46
View File
@@ -15,7 +15,6 @@ import {
type ToolDefinition,
} from '@mariozechner/pi-coding-agent';
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';
@@ -44,8 +43,6 @@ 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 {
@@ -132,8 +129,9 @@ export class AgentService implements OnModuleDestroy {
@Inject(CoordService) private readonly coordService: CoordService,
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
@Optional()
@Inject(SystemOverrideService)
private readonly systemOverride: SystemOverrideService,
private readonly systemOverride: SystemOverrideService | null,
@Optional()
@Inject(PreferencesService)
private readonly preferencesService: PreferencesService | null,
@@ -430,7 +428,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}${this.attachmentContext(msg.attachments ?? [])}`;
return `**${roleLabel}:** ${msg.content}`;
};
const formatted = history.map((msg) => formatMessage(msg));
@@ -489,21 +487,6 @@ 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;
@@ -690,40 +673,25 @@ export class AgentService implements OnModuleDestroy {
session.channels.delete(channel);
}
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> {
async prompt(sessionId: string, message: string, scope: ActorTenantScope): 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).
// Required instruction-authority wiring is consulted before session/provider effects.
let effectiveMessage = `${message}${attachmentContext}`;
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
// Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = message;
if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}
}
session.promptCount += 1;
try {
await session.piSession.prompt(effectiveMessage);
} catch (err) {
@@ -1,341 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import {
connectorLeaseAuditLog,
createPgliteDb,
eq,
runPgliteMigrations,
type DbHandle,
} from '@mosaicstack/db';
import type { ConnectorExecutionContext, FencedConnectorAdapter } from '@mosaicstack/types';
import { DB } from '../database/database.module.js';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
import {
CONNECTOR_LEASE_POLICY,
ConnectorLeaseService,
type ConnectorLeasePolicy,
type ConnectorLeasePolicySubject,
} from './connector-lease.service.js';
const authorize = vi.fn().mockResolvedValue(true);
const policy: ConnectorLeasePolicy = { authorize };
const context = {
actorScope: { userId: 'operator-a', tenantId: 'tenant-a' },
correlationId: 'correlation-acquire',
};
describe('gateway connector lease fencing integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let service: ConnectorLeaseService;
let repository: ConnectorLeaseRepository;
beforeAll(async (): Promise<void> => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-14T17:00:00.000Z'));
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-connector-lease-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
moduleRef = await Test.createTestingModule({
providers: [
ConnectorLeaseRepository,
ConnectorLeaseService,
{ provide: DB, useValue: handle.db },
{ provide: CONNECTOR_LEASE_POLICY, useValue: policy },
],
}).compile();
service = moduleRef.get(ConnectorLeaseService);
repository = moduleRef.get(ConnectorLeaseRepository);
});
afterAll(async (): Promise<void> => {
vi.useRealTimers();
await moduleRef.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
it('derives tenant authority at the gateway and validates a grant before side effects', async (): Promise<void> => {
const lease = await service.acquire(
{
logicalAgentId: 'Mos',
bindingId: 'operator-chat',
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
context,
);
const grant = await service.issueGrant(
{ lease, scopes: ['runtime.send'], ttlMs: 30_000 },
{ ...context, correlationId: 'correlation-grant' },
);
const execute = vi.fn(async (_message: string, leaseContext: ConnectorExecutionContext) => {
return leaseContext.leaseEpoch;
});
const adapter: FencedConnectorAdapter<string, string> = { execute };
await expect(service.executeGrant(grant, 'runtime.send', 'hello', adapter)).resolves.toBe('1');
expect(execute).toHaveBeenCalledOnce();
expect(authorize).toHaveBeenCalledWith(
expect.objectContaining({
action: 'grant.issue',
requestedScopes: ['runtime.send'],
requestedTtlMs: 30_000,
}),
);
expect(execute.mock.calls[0]?.[1]).toMatchObject({
identity: { tenantId: 'tenant-a', logicalAgentId: 'mos' },
bindingId: 'operator-chat',
connectorId: 'pi-worker-a',
});
});
it('normalizes lease-derived policy subjects before authorization', async (): Promise<void> => {
const lease = await service.acquire(
{
logicalAgentId: 'mos',
bindingId: 'operator-chat-policy',
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
{ ...context, correlationId: 'correlation-policy-setup' },
);
const aliasedLease = {
...lease,
identity: { ...lease.identity, logicalAgentId: ' MOS ' },
bindingId: ' Operator-Chat-Policy ',
connectorId: ' PI-Worker-A ',
scopes: [' Runtime.Send '],
leaseEpoch: `00${lease.leaseEpoch}`,
};
await service.heartbeat(aliasedLease, 30_000, {
...context,
correlationId: 'correlation-policy-heartbeat',
});
expect(authorize).toHaveBeenLastCalledWith(
expect.objectContaining({
action: 'lease.heartbeat',
logicalAgentId: 'mos',
bindingId: 'operator-chat-policy',
connectorId: 'pi-worker-a',
requestedScopes: ['runtime.send'],
}),
);
await service.issueGrant(
{ lease: aliasedLease, scopes: [' Runtime.Send '], ttlMs: 1_000 },
{ ...context, correlationId: 'correlation-policy-grant' },
);
expect(authorize).toHaveBeenLastCalledWith(
expect.objectContaining({
action: 'grant.issue',
logicalAgentId: 'mos',
bindingId: 'operator-chat-policy',
connectorId: 'pi-worker-a',
requestedScopes: ['runtime.send'],
}),
);
await service.release(aliasedLease, {
...context,
correlationId: 'correlation-policy-release',
});
expect(authorize).toHaveBeenLastCalledWith(
expect.objectContaining({
action: 'lease.release',
logicalAgentId: 'mos',
bindingId: 'operator-chat-policy',
connectorId: 'pi-worker-a',
requestedScopes: ['runtime.send'],
}),
);
});
it('denies stale, forged, expired, cross-tenant, and cross-binding grants before effects', async (): Promise<void> => {
const bindingId = 'operator-chat-denials';
const current = await service.acquire(
{
logicalAgentId: 'mos',
bindingId,
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
{ ...context, correlationId: 'correlation-denial-setup' },
);
const stale = await service.issueGrant(
{ lease: current, scopes: ['runtime.send'], ttlMs: 30_000 },
{ ...context, correlationId: 'correlation-stale' },
);
await service.takeover(
{
logicalAgentId: 'mos',
bindingId,
connectorId: 'pi-worker-b',
scopes: ['runtime.send'],
ttlMs: 60_000,
expectedEpoch: current.leaseEpoch,
},
{ ...context, correlationId: 'correlation-takeover' },
);
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
await expect(service.executeGrant(stale, 'runtime.send', undefined, adapter)).rejects.toThrow();
const active = await service.current('mos', bindingId, context);
if (!active) throw new Error('active lease fixture is unavailable');
const grant = await service.issueGrant(
{ lease: active, scopes: ['runtime.send'], ttlMs: 1_000 },
{ ...context, correlationId: 'correlation-active' },
);
await expect(
service.executeGrant({ ...grant }, 'runtime.send', undefined, adapter),
).rejects.toThrow();
await expect(
service.executeGrant(
{ ...grant, bindingId: 'other-binding' },
'runtime.send',
undefined,
adapter,
),
).rejects.toThrow();
await expect(
service.issueGrant(
{ lease: active, scopes: ['runtime.send'], ttlMs: 30_000 },
{
actorScope: { userId: 'operator-b', tenantId: 'tenant-b' },
correlationId: 'correlation-cross-tenant',
},
),
).rejects.toThrow();
const crossTenantAudit = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-cross-tenant'));
expect(crossTenantAudit).toHaveLength(1);
expect(crossTenantAudit[0]).toMatchObject({
tenantId: 'tenant-b',
logicalAgentId: 'untrusted',
bindingId: 'untrusted',
connectorId: 'untrusted',
reason: 'policy_denied',
});
vi.setSystemTime(new Date('2026-07-14T17:00:02.000Z'));
await expect(service.executeGrant(grant, 'runtime.send', undefined, adapter)).rejects.toThrow();
expect(adapter.execute).not.toHaveBeenCalled();
});
it('rejects submitted lifecycle scopes that differ from durable authority before policy or mutation', async (): Promise<void> => {
authorize.mockResolvedValue(true);
const heartbeatLease = await service.acquire(
{
logicalAgentId: 'mos',
bindingId: 'operator-chat-heartbeat-scope',
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
{ ...context, correlationId: 'correlation-heartbeat-scope-setup' },
);
const releaseLease = await service.acquire(
{
logicalAgentId: 'mos',
bindingId: 'operator-chat-release-scope',
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
{ ...context, correlationId: 'correlation-release-scope-setup' },
);
const forgedHeartbeat = { ...heartbeatLease, scopes: ['tool.execute'] };
const forgedRelease = { ...releaseLease, scopes: ['tool.execute'] };
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'tool.execute';
});
authorize.mockClear();
await expect(
service.heartbeat(forgedHeartbeat, 30_000, {
...context,
correlationId: 'correlation-heartbeat-scope-forgery',
}),
).rejects.toThrow('Connector authority policy denied');
await expect(
service.release(forgedRelease, {
...context,
correlationId: 'correlation-release-scope-forgery',
}),
).rejects.toThrow('Connector authority policy denied');
expect(authorize).not.toHaveBeenCalled();
const currentHeartbeat = await repository.findCurrent({
identity: heartbeatLease.identity,
bindingId: heartbeatLease.bindingId,
});
const currentRelease = await repository.findCurrent({
identity: releaseLease.identity,
bindingId: releaseLease.bindingId,
});
expect(currentHeartbeat).toMatchObject({
leaseId: heartbeatLease.leaseId,
scopes: ['runtime.send'],
heartbeatAt: heartbeatLease.heartbeatAt,
expiresAt: heartbeatLease.expiresAt,
});
expect(currentRelease).toMatchObject({
leaseId: releaseLease.leaseId,
scopes: ['runtime.send'],
});
expect(currentRelease?.releasedAt).toBeUndefined();
const forgedAudits = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-heartbeat-scope-forgery'));
expect(forgedAudits).toHaveLength(1);
expect(forgedAudits[0]).toMatchObject({
bindingId: heartbeatLease.bindingId,
connectorId: heartbeatLease.connectorId,
event: 'reject',
outcome: 'denied',
reason: 'policy_denied',
});
const forgedReleaseAudits = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-release-scope-forgery'));
expect(forgedReleaseAudits).toHaveLength(1);
expect(forgedReleaseAudits[0]).toMatchObject({
bindingId: releaseLease.bindingId,
connectorId: releaseLease.connectorId,
event: 'reject',
outcome: 'denied',
reason: 'policy_denied',
});
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'runtime.send';
});
await expect(
service.heartbeat(heartbeatLease, 30_000, {
...context,
correlationId: 'correlation-heartbeat-scope-canonical',
}),
).resolves.toMatchObject({ scopes: ['runtime.send'] });
await expect(
service.release(releaseLease, {
...context,
correlationId: 'correlation-release-scope-canonical',
}),
).resolves.toBeUndefined();
});
});
@@ -1,76 +0,0 @@
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
connectorLeaseAuditLog,
createDb,
eq,
logicalAgentConnectorLeases,
type DbHandle,
} from '@mosaicstack/db';
import { ConnectorLeaseCoordinator } from '@mosaicstack/agent';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
const hasPostgres = Boolean(process.env['DATABASE_URL']);
const tenantId = `lease-test-${randomUUID()}`;
const identity = { tenantId, logicalAgentId: 'mos' } as const;
describe.skipIf(!hasPostgres)('ConnectorLeaseRepository real PostgreSQL integration', (): void => {
let handle: DbHandle;
beforeAll((): void => {
handle = createDb(process.env['DATABASE_URL']);
});
afterAll(async (): Promise<void> => {
if (!handle) return;
await handle.db
.delete(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.tenantId, tenantId));
await handle.db
.delete(logicalAgentConnectorLeases)
.where(eq(logicalAgentConnectorLeases.tenantId, tenantId));
await handle.close();
});
it('preserves the exclusive CAS fence across a real pool close/reopen', async (): Promise<void> => {
const command = {
identity,
bindingId: 'operator-chat',
scopes: ['runtime.send'],
ttlMs: 60_000,
} as const;
const firstCoordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
const contenders = await Promise.allSettled([
firstCoordinator.acquire({
...command,
connectorId: 'connector-a',
correlationId: 'postgres-acquire-a',
}),
firstCoordinator.acquire({
...command,
connectorId: 'connector-b',
correlationId: 'postgres-acquire-b',
}),
]);
const acquired = contenders.find((result) => result.status === 'fulfilled');
if (!acquired || acquired.status !== 'fulfilled') throw new Error('no lease contender won');
expect(contenders.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
await handle.close();
handle = createDb(process.env['DATABASE_URL']);
const reopened = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
const persisted = await reopened.current({ identity, bindingId: 'operator-chat' });
expect(persisted).toMatchObject({
leaseId: acquired.value.leaseId,
leaseEpoch: '1',
});
const takeover = await reopened.takeover({
...command,
connectorId: 'connector-c',
correlationId: 'postgres-takeover',
expectedEpoch: acquired.value.leaseEpoch,
});
expect(takeover).toMatchObject({ connectorId: 'connector-c', leaseEpoch: '2' });
});
});
@@ -1,149 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
connectorLeaseAuditLog,
createPgliteDb,
eq,
runPgliteMigrations,
type DbHandle,
} from '@mosaicstack/db';
import { ConnectorLeaseCoordinator, ConnectorLeaseError } from '@mosaicstack/agent';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
function acquireCommand(connectorId: string, correlationId: string) {
return {
identity,
bindingId: 'operator-chat',
connectorId,
scopes: ['runtime.send', 'tool.execute'],
ttlMs: 60_000,
correlationId,
};
}
describe('ConnectorLeaseRepository PostgreSQL semantics', (): void => {
let dataDir: string;
let handle: DbHandle;
let now: Date;
let coordinator: ConnectorLeaseCoordinator;
beforeEach(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-connector-lease-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
now = new Date('2026-07-14T17:00:00.000Z');
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
now: (): Date => now,
});
});
afterEach(async (): Promise<void> => {
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
it('allows only one concurrent contender to acquire a binding', async (): Promise<void> => {
const outcomes = await Promise.allSettled([
coordinator.acquire(acquireCommand('connector-a', 'correlation-a')),
coordinator.acquire(acquireCommand('connector-b', 'correlation-b')),
]);
expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
const rejected = outcomes.find((result) => result.status === 'rejected');
expect(rejected).toMatchObject({
reason: { code: 'lease_held' } satisfies Partial<ConnectorLeaseError>,
});
});
it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
const results = await Promise.allSettled([
coordinator.takeover({
...acquireCommand('connector-b', 'correlation-b'),
expectedEpoch: acquired.leaseEpoch,
}),
coordinator.takeover({
...acquireCommand('connector-c', 'correlation-c'),
expectedEpoch: acquired.leaseEpoch,
}),
]);
const winner = results.find((result) => result.status === 'fulfilled');
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(winner?.status === 'fulfilled' ? winner.value.leaseEpoch : null).toBe('2');
expect(results.find((result) => result.status === 'rejected')).toMatchObject({
reason: { code: 'cas_mismatch' } satisfies Partial<ConnectorLeaseError>,
});
});
it('heartbeats and releases only the current connector epoch', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
now = new Date('2026-07-14T17:00:30.000Z');
const renewed = await coordinator.heartbeat({
lease: acquired,
ttlMs: 120_000,
correlationId: 'correlation-renew',
});
expect(renewed.expiresAt).toBe('2026-07-14T17:02:30.000Z');
await coordinator.release({ lease: renewed, correlationId: 'correlation-release' });
await expect(
coordinator.heartbeat({
lease: renewed,
ttlMs: 120_000,
correlationId: 'correlation-stale',
}),
).rejects.toMatchObject({ code: 'lease_released' } satisfies Partial<ConnectorLeaseError>);
});
it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
await handle.close();
now = new Date('2026-07-14T17:02:00.000Z');
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
now: (): Date => now,
});
await expect(
coordinator.acquire(acquireCommand('connector-b', 'correlation-plain-acquire')),
).rejects.toMatchObject({ code: 'takeover_required' } satisfies Partial<ConnectorLeaseError>);
const recovered = await coordinator.takeover({
...acquireCommand('connector-b', 'correlation-takeover'),
expectedEpoch: acquired.leaseEpoch,
});
expect(recovered).toMatchObject({ connectorId: 'connector-b', leaseEpoch: '2' });
});
it('writes credential-safe lifecycle and rejection audit records', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
await coordinator.heartbeat({
lease: acquired,
ttlMs: 60_000,
correlationId: 'correlation-renew',
});
await expect(
coordinator.acquire(acquireCommand('connector-b', 'correlation-reject')),
).rejects.toBeInstanceOf(ConnectorLeaseError);
const rows = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.tenantId, identity.tenantId));
expect(rows.map((row) => row.event)).toEqual(
expect.arrayContaining(['acquire', 'renew', 'reject']),
);
const serialized = JSON.stringify(rows, (_key: string, value: unknown): unknown =>
typeof value === 'bigint' ? value.toString(10) : value,
);
expect(serialized).not.toContain('tool.execute');
expect(serialized).not.toContain('runtime.send');
expect(serialized).not.toMatch(/token|secret|credential/i);
});
});
@@ -1,354 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
and,
connectorLeaseAuditLog,
eq,
gt,
isNull,
logicalAgentConnectorLeases,
sql,
type Db,
} from '@mosaicstack/db';
import { ConnectorLeaseError } from '@mosaicstack/agent';
import type {
ConnectorLease,
ConnectorLeaseAcquireMutation,
ConnectorLeaseAuditEvent,
ConnectorLeaseHeartbeatMutation,
ConnectorLeaseRejectReason,
ConnectorLeaseReleaseMutation,
ConnectorLeaseStore,
ConnectorLeaseTakeoverMutation,
LogicalAgentBinding,
} from '@mosaicstack/types';
import { DB } from '../database/database.module.js';
interface SuccessfulMutation {
readonly ok: true;
readonly lease: ConnectorLease;
}
interface FailedMutation {
readonly ok: false;
readonly reason: ConnectorLeaseRejectReason;
}
type MutationResult = SuccessfulMutation | FailedMutation;
@Injectable()
export class ConnectorLeaseRepository implements ConnectorLeaseStore {
constructor(@Inject(DB) private readonly db: Db) {}
async acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const inserted = await tx
.insert(logicalAgentConnectorLeases)
.values({
leaseId: input.leaseId,
tenantId: input.identity.tenantId,
logicalAgentId: input.identity.logicalAgentId,
bindingId: input.bindingId,
connectorId: input.connectorId,
scopes: [...input.scopes],
leaseEpoch: 1n,
acquiredAt: new Date(input.now),
heartbeatAt: new Date(input.now),
expiresAt: new Date(input.expiresAt),
updatedAt: new Date(input.now),
})
.onConflictDoNothing()
.returning();
const row = inserted[0];
if (row) {
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'acquire'));
return { ok: true, lease };
}
const current = await findRow(tx, input);
if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
const reason: ConnectorLeaseRejectReason =
current && (current.releasedAt || current.expiresAt <= new Date(input.now))
? 'takeover_required'
: 'lease_held';
await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason));
return { ok: false, reason };
},
);
return unwrap(result);
}
async takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const current = await findRow(tx, input);
if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) {
await insertAudit(
tx,
rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'),
);
return { ok: false, reason: 'cas_mismatch' };
}
if (current.expiresAt <= new Date(input.now) && !current.releasedAt) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
const updated = await tx
.update(logicalAgentConnectorLeases)
.set({
leaseId: input.leaseId,
connectorId: input.connectorId,
scopes: [...input.scopes],
leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`,
acquiredAt: new Date(input.now),
heartbeatAt: new Date(input.now),
expiresAt: new Date(input.expiresAt),
releasedAt: null,
updatedAt: new Date(input.now),
})
.where(
and(
bindingPredicate(input),
eq(logicalAgentConnectorLeases.leaseId, current.leaseId),
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)),
),
)
.returning();
const row = updated[0];
if (!row) {
await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch'));
return { ok: false, reason: 'cas_mismatch' };
}
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'takeover'));
return { ok: true, lease };
},
);
return unwrap(result);
}
async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const updated = await tx
.update(logicalAgentConnectorLeases)
.set({
heartbeatAt: new Date(input.now),
expiresAt: new Date(input.expiresAt),
updatedAt: new Date(input.now),
})
.where(
and(
bindingPredicate(input.lease),
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
isNull(logicalAgentConnectorLeases.releasedAt),
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
),
)
.returning();
const row = updated[0];
if (row) {
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'renew'));
return { ok: true, lease };
}
const current = await findRow(tx, input.lease);
const reason = classifyAuthorityFailure(
current ? toLease(current) : null,
input.lease,
input.now,
);
if (reason === 'lease_expired' && current) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
await insertAudit(
tx,
rejectionAudit(
{ ...input.lease, correlationId: input.correlationId, now: input.now },
current ? toLease(current) : null,
reason,
),
);
return { ok: false, reason };
},
);
return unwrap(result);
}
async release(input: ConnectorLeaseReleaseMutation): Promise<void> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const updated = await tx
.update(logicalAgentConnectorLeases)
.set({
releasedAt: new Date(input.now),
expiresAt: new Date(input.now),
updatedAt: new Date(input.now),
})
.where(
and(
bindingPredicate(input.lease),
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
isNull(logicalAgentConnectorLeases.releasedAt),
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
),
)
.returning();
const row = updated[0];
if (row) {
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'release'));
return { ok: true, lease };
}
const current = await findRow(tx, input.lease);
const reason = classifyAuthorityFailure(
current ? toLease(current) : null,
input.lease,
input.now,
);
if (reason === 'lease_expired' && current) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
await insertAudit(
tx,
rejectionAudit(
{ ...input.lease, correlationId: input.correlationId, now: input.now },
current ? toLease(current) : null,
reason,
),
);
return { ok: false, reason };
},
);
unwrap(result);
}
async findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
const row = await findRow(this.db, binding);
return row ? toLease(row) : null;
}
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
await insertAudit(this.db, event);
}
}
function unwrap(result: MutationResult): ConnectorLease {
if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason));
return result.lease;
}
function safeErrorMessage(reason: ConnectorLeaseRejectReason): string {
return `Connector lease mutation denied: ${reason}`;
}
function bindingPredicate(binding: LogicalAgentBinding) {
return and(
eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId),
eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId),
eq(logicalAgentConnectorLeases.bindingId, binding.bindingId),
);
}
async function findRow(
db: Pick<Db, 'select'>,
binding: LogicalAgentBinding,
): Promise<typeof logicalAgentConnectorLeases.$inferSelect | null> {
const rows = await db
.select()
.from(logicalAgentConnectorLeases)
.where(bindingPredicate(binding))
.limit(1);
return rows[0] ?? null;
}
function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease {
return Object.freeze({
identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }),
bindingId: row.bindingId,
leaseId: row.leaseId,
connectorId: row.connectorId,
scopes: Object.freeze([...row.scopes]),
leaseEpoch: row.leaseEpoch.toString(10),
acquiredAt: row.acquiredAt.toISOString(),
heartbeatAt: row.heartbeatAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}),
});
}
function classifyAuthorityFailure(
current: ConnectorLease | null,
claimed: ConnectorLease,
now: string,
): ConnectorLeaseRejectReason {
if (!current) return 'lease_missing';
if (current.releasedAt) return 'lease_released';
if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired';
if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch';
return 'connector_mismatch';
}
function lifecycleAudit(
input: { readonly correlationId: string; readonly now: string },
lease: ConnectorLease,
event: Exclude<ConnectorLeaseAuditEvent['event'], 'reject'>,
): ConnectorLeaseAuditEvent {
return {
identity: lease.identity,
bindingId: lease.bindingId,
connectorId: lease.connectorId,
leaseId: lease.leaseId,
leaseEpoch: lease.leaseEpoch,
event,
outcome: 'succeeded',
correlationId: input.correlationId,
occurredAt: input.now,
};
}
function rejectionAudit(
input: {
readonly identity: ConnectorLease['identity'];
readonly bindingId: string;
readonly connectorId: string;
readonly correlationId: string;
readonly now: string;
},
current: ConnectorLease | null,
reason: ConnectorLeaseRejectReason,
): ConnectorLeaseAuditEvent {
return {
identity: input.identity,
bindingId: input.bindingId,
connectorId: input.connectorId,
event: 'reject',
outcome: 'denied',
correlationId: input.correlationId,
occurredAt: input.now,
...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}),
reason,
};
}
async function insertAudit(db: Pick<Db, 'insert'>, event: ConnectorLeaseAuditEvent): Promise<void> {
await db.insert(connectorLeaseAuditLog).values({
tenantId: event.identity.tenantId,
logicalAgentId: event.identity.logicalAgentId,
bindingId: event.bindingId,
connectorId: event.connectorId,
...(event.leaseId ? { leaseId: event.leaseId } : {}),
...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}),
event: event.event,
outcome: event.outcome,
...(event.reason ? { reason: event.reason } : {}),
correlationId: event.correlationId,
occurredAt: new Date(event.occurredAt),
});
}
@@ -1,285 +0,0 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { ConnectorLeaseCoordinator, normalizeConnectorLease } from '@mosaicstack/agent';
import {
normalizeConnectorId,
normalizeConnectorScopes,
normalizeCorrelationId,
normalizeLogicalAgentIdentity,
normalizeLogicalBindingId,
type AcquireConnectorLeaseInput,
type ConnectorExecutionGrant,
type ConnectorLease,
type ConnectorLeaseAuditEvent,
type FencedConnectorAdapter,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
export const CONNECTOR_LEASE_POLICY = Symbol('CONNECTOR_LEASE_POLICY');
export type ConnectorLeasePolicyAction =
| 'lease.acquire'
| 'lease.takeover'
| 'lease.heartbeat'
| 'lease.release'
| 'lease.read'
| 'grant.issue';
export interface ConnectorLeaseRequestContext {
readonly actorScope: ActorTenantScope;
readonly correlationId: string;
}
export interface GatewayConnectorLeaseRequest {
readonly logicalAgentId: string;
readonly bindingId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly ttlMs: number;
}
export interface GatewayConnectorLeaseTakeoverRequest extends GatewayConnectorLeaseRequest {
readonly expectedEpoch: string;
}
export interface GatewayConnectorGrantRequest {
readonly lease: ConnectorLease;
readonly scopes: readonly string[];
readonly ttlMs: number;
}
export interface ConnectorLeasePolicySubject {
readonly action: ConnectorLeasePolicyAction;
readonly actorId: string;
readonly tenantId: string;
readonly logicalAgentId: string;
readonly bindingId: string;
readonly connectorId: string;
readonly requestedScopes: readonly string[];
readonly requestedTtlMs: number | null;
}
export interface ConnectorLeasePolicy {
authorize(subject: ConnectorLeasePolicySubject): Promise<boolean>;
}
/** M1 has no concrete cutover policy: unconfigured production use fails closed. */
@Injectable()
export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy {
async authorize(_subject: ConnectorLeasePolicySubject): Promise<boolean> {
return false;
}
}
/** Gateway-owned policy surface for durable connector authority and fenced effects. */
@Injectable()
export class ConnectorLeaseService {
private readonly coordinator: ConnectorLeaseCoordinator;
constructor(
@Inject(ConnectorLeaseRepository) private readonly repository: ConnectorLeaseRepository,
@Inject(CONNECTOR_LEASE_POLICY) private readonly policy: ConnectorLeasePolicy,
) {
this.coordinator = new ConnectorLeaseCoordinator(repository);
}
async acquire(
request: GatewayConnectorLeaseRequest,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease> {
const command = this.command(request, context);
await this.assertPolicy('lease.acquire', command, context, command.scopes, command.ttlMs);
return this.coordinator.acquire({ ...command, correlationId: this.correlation(context) });
}
async takeover(
request: GatewayConnectorLeaseTakeoverRequest,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease> {
const command = this.command(request, context);
await this.assertPolicy('lease.takeover', command, context, command.scopes, command.ttlMs);
return this.coordinator.takeover({
...command,
expectedEpoch: request.expectedEpoch,
correlationId: this.correlation(context),
});
}
async heartbeat(
lease: ConnectorLease,
ttlMs: number,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease> {
const normalizedLease = normalizeConnectorLease(lease);
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
await this.assertPolicy('lease.heartbeat', durableLease, context, durableLease.scopes, ttlMs);
return this.coordinator.heartbeat({
lease: durableLease,
ttlMs,
correlationId: this.correlation(context),
});
}
async release(lease: ConnectorLease, context: ConnectorLeaseRequestContext): Promise<void> {
const normalizedLease = normalizeConnectorLease(lease);
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
await this.assertPolicy('lease.release', durableLease, context, durableLease.scopes, null);
await this.coordinator.release({
lease: durableLease,
correlationId: this.correlation(context),
});
}
async current(
logicalAgentId: string,
bindingId: string,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease | null> {
const binding = {
identity: normalizeLogicalAgentIdentity({
tenantId: context.actorScope.tenantId,
logicalAgentId,
}),
bindingId: normalizeLogicalBindingId(bindingId),
connectorId: 'gateway',
};
await this.assertPolicy('lease.read', binding, context, [], null);
return this.coordinator.current(binding);
}
async issueGrant(
request: GatewayConnectorGrantRequest,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorExecutionGrant> {
const lease = normalizeConnectorLease(request.lease);
await this.assertTenant(lease, context);
const scopes = normalizeConnectorScopes(request.scopes);
await this.assertPolicy('grant.issue', lease, context, scopes, request.ttlMs);
return this.coordinator.issueGrant({
lease,
scopes,
ttlMs: request.ttlMs,
correlationId: this.correlation(context),
});
}
async executeGrant<TInput, TOutput>(
grant: ConnectorExecutionGrant,
requiredScope: string,
input: TInput,
adapter: FencedConnectorAdapter<TInput, TOutput>,
): Promise<TOutput> {
return this.coordinator.executeGrant(grant, requiredScope, input, adapter);
}
private command(
request: GatewayConnectorLeaseRequest,
context: ConnectorLeaseRequestContext,
): Omit<AcquireConnectorLeaseInput, 'correlationId'> {
return {
identity: normalizeLogicalAgentIdentity({
tenantId: context.actorScope.tenantId,
logicalAgentId: request.logicalAgentId,
}),
bindingId: normalizeLogicalBindingId(request.bindingId),
connectorId: normalizeConnectorId(request.connectorId),
scopes: normalizeConnectorScopes(request.scopes),
ttlMs: request.ttlMs,
};
}
private async assertTenant(
lease: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
context: ConnectorLeaseRequestContext,
): Promise<void> {
if (lease.identity.tenantId !== context.actorScope.tenantId) {
await this.recordPolicyDenial(
{
identity: {
tenantId: context.actorScope.tenantId,
logicalAgentId: 'untrusted',
},
bindingId: 'untrusted',
connectorId: 'untrusted',
},
context,
);
throw new ForbiddenException('Connector authority tenant scope denied');
}
}
private async durableLifecycleLease(
submittedLease: ConnectorLease,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease> {
await this.assertTenant(submittedLease, context);
const durableLease = await this.coordinator.current(submittedLease);
if (!durableLease || !hasSameLifecycleAuthority(submittedLease, durableLease)) {
await this.recordPolicyDenial(durableLease ?? submittedLease, context);
throw new ForbiddenException('Connector authority policy denied');
}
return durableLease;
}
private async assertPolicy(
action: ConnectorLeasePolicyAction,
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
context: ConnectorLeaseRequestContext,
requestedScopes: readonly string[],
requestedTtlMs: number | null,
): Promise<void> {
const allowed = await this.policy.authorize({
action,
actorId: context.actorScope.userId,
tenantId: subject.identity.tenantId,
logicalAgentId: subject.identity.logicalAgentId,
bindingId: subject.bindingId,
connectorId: subject.connectorId,
requestedScopes: Object.freeze([...requestedScopes]),
requestedTtlMs,
});
if (!allowed) {
await this.recordPolicyDenial(subject, context);
throw new ForbiddenException('Connector authority policy denied');
}
}
private async recordPolicyDenial(
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
context: ConnectorLeaseRequestContext,
): Promise<void> {
const event: ConnectorLeaseAuditEvent = {
identity: subject.identity,
bindingId: subject.bindingId,
connectorId: subject.connectorId,
event: 'reject',
outcome: 'denied',
reason: 'policy_denied',
correlationId: this.correlation(context),
occurredAt: new Date().toISOString(),
};
await this.repository.recordAudit(event);
}
private correlation(context: ConnectorLeaseRequestContext): string {
return normalizeCorrelationId(context.correlationId);
}
}
function hasSameLifecycleAuthority(
submittedLease: ConnectorLease,
durableLease: ConnectorLease,
): boolean {
return (
submittedLease.identity.tenantId === durableLease.identity.tenantId &&
submittedLease.identity.logicalAgentId === durableLease.identity.logicalAgentId &&
submittedLease.bindingId === durableLease.bindingId &&
submittedLease.leaseId === durableLease.leaseId &&
submittedLease.connectorId === durableLease.connectorId &&
submittedLease.leaseEpoch === durableLease.leaseEpoch &&
submittedLease.scopes.length === durableLease.scopes.length &&
submittedLease.scopes.every((scope: string, index: number): boolean => {
return scope === durableLease.scopes[index];
})
);
}
@@ -8,7 +8,6 @@
* to avoid real I/O — they verify the complete classify → match → decide path.
*/
import { describe, it, expect, vi } from 'vitest';
import type { ProviderHealthStatus } from '@mosaicstack/types';
import { RoutingEngineService } from './routing-engine.service.js';
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
import type { RoutingRule } from './routing.types.js';
@@ -18,7 +17,7 @@ import type { RoutingRule } from './routing.types.js';
/** Build a RoutingEngineService backed by the given rule set and health map. */
function makeService(
rules: RoutingRule[],
healthMap: Record<string, { status: ProviderHealthStatus }>,
healthMap: Record<string, { status: string }>,
): RoutingEngineService {
const mockDb = {
select: vi.fn().mockReturnValue({
@@ -68,11 +67,11 @@ function defaultRules(): RoutingRule[] {
}
/** A health map where anthropic, openai, and zai are all healthy. */
const allHealthy: Record<string, { status: ProviderHealthStatus }> = {
anthropic: { status: 'healthy' },
openai: { status: 'healthy' },
zai: { status: 'healthy' },
ollama: { status: 'healthy' },
const allHealthy: Record<string, { status: string }> = {
anthropic: { status: 'up' },
openai: { status: 'up' },
zai: { status: 'up' },
ollama: { status: 'up' },
};
// ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
@@ -213,10 +212,10 @@ describe('M4-013: routing end-to-end pipeline', () => {
// Let's use a simple coding message to target Simple coding → Codex (openai)
const message = 'implement a sort function';
const unhealthyHealth: Record<string, { status: ProviderHealthStatus }> = {
const unhealthyHealth = {
anthropic: { status: 'down' },
openai: { status: 'healthy' },
zai: { status: 'healthy' },
openai: { status: 'up' },
zai: { status: 'up' },
ollama: { status: 'down' },
};
@@ -1,6 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
import type { ProviderHealthStatus } from '@mosaicstack/types';
import { DB } from '../../database/database.module.js';
import { ProviderService } from '../provider.service.js';
import { classifyTask } from './task-classifier.js';
@@ -50,7 +49,7 @@ export class RoutingEngineService {
async resolve(
message: string,
userId?: string,
availableProviders?: Record<string, { status: ProviderHealthStatus }>,
availableProviders?: Record<string, { status: string }>,
): Promise<RoutingDecision> {
const classification = classifyTask(message);
this.logger.debug(
@@ -70,8 +69,9 @@ export class RoutingEngineService {
if (!this.matchConditions(rule, classification)) continue;
const providerStatus = health[rule.action.provider]?.status;
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
if (!this.isRoutable(providerStatus)) {
if (!isHealthy) {
this.logger.debug(
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
);
@@ -111,10 +111,6 @@ export class RoutingEngineService {
// ─── Private helpers ───────────────────────────────────────────────────────
private isRoutable(status: ProviderHealthStatus | undefined): boolean {
return status === 'healthy' || status === 'degraded';
}
private evaluateCondition(
condition: RoutingCondition,
classification: TaskClassification,
@@ -190,12 +186,11 @@ export class RoutingEngineService {
* Walk the fallback chain and return the first healthy provider/model pair.
* If none are healthy, return the first entry unconditionally (last resort).
*/
private applyFallbackChain(
health: Record<string, { status: ProviderHealthStatus }>,
): RoutingDecision {
private applyFallbackChain(health: Record<string, { status: string }>): RoutingDecision {
for (const candidate of FALLBACK_CHAIN) {
const providerStatus = health[candidate.provider]?.status;
if (this.isRoutable(providerStatus)) {
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
if (isHealthy) {
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
return {
provider: candidate.provider,
@@ -1,5 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ProviderHealthStatus } from '@mosaicstack/types';
import { RoutingEngineService } from './routing-engine.service.js';
import type { RoutingRule, TaskClassification } from './routing.types.js';
@@ -30,7 +29,7 @@ function makeClassification(overrides: Partial<TaskClassification> = {}): TaskCl
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
function makeService(
rules: RoutingRule[] = [],
healthMap: Record<string, { status: ProviderHealthStatus }> = {},
healthMap: Record<string, { status: string }> = {},
): RoutingEngineService {
const mockDb = {
select: vi.fn().mockReturnValue({
@@ -218,10 +217,7 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
}),
];
const service = makeService(rules, {
anthropic: { status: 'healthy' },
openai: { status: 'healthy' },
});
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
const decision = await service.resolve('implement a function');
expect(decision.ruleName).toBe('high priority');
@@ -245,10 +241,7 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
}),
];
const service = makeService(rules, {
anthropic: { status: 'healthy' },
openai: { status: 'healthy' },
});
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
const decision = await service.resolve('implement a function');
expect(decision.ruleName).toBe('coding rule');
@@ -277,7 +270,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
const service = makeService(rules, {
anthropic: { status: 'down' }, // primary is unhealthy
openai: { status: 'healthy' },
openai: { status: 'up' },
});
const decision = await service.resolve('implement a function');
@@ -297,7 +290,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
];
const service2 = makeService(unhealthyRules, {
anthropic: { status: 'healthy' },
anthropic: { status: 'up' },
openai: { status: 'down' },
});
@@ -313,7 +306,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
const service = makeService(rules, {
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
ollama: { status: 'healthy' }, // Haiku is also on anthropic — use Ollama as next
ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next
});
const decision = await service.resolve('hello there');
@@ -352,7 +345,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
}),
];
const service = makeService(rules, { anthropic: { status: 'healthy' } });
const service = makeService(rules, { anthropic: { status: 'up' } });
const decision = await service.resolve('completely unrelated message xyz');
expect(decision.ruleName).toBe('catch-all');
@@ -376,7 +369,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
}),
];
const service = makeService(rules, { anthropic: { status: 'healthy' } });
const service = makeService(rules, { anthropic: { status: 'up' } });
const codingDecision = await service.resolve('implement a function');
expect(codingDecision.ruleName).toBe('specific coding rule');
@@ -408,7 +401,7 @@ describe('RoutingEngineService.resolve — disabled rules', () => {
}),
];
const service = makeService(rules, { anthropic: { status: 'healthy' } });
const service = makeService(rules, { anthropic: { status: 'up' } });
const decision = await service.resolve('implement a function');
expect(decision.ruleName).toBe('enabled fallback');
@@ -459,45 +452,9 @@ describe('RoutingEngineService.resolve — availableProviders override', () => {
ps: unknown,
) => RoutingEngineService)(mockDb, mockProviderService);
const preSupplied: Record<string, { status: ProviderHealthStatus }> = {
anthropic: { status: 'healthy' },
};
const preSupplied = { anthropic: { status: 'up' } };
await service.resolve('implement a function', undefined, preSupplied);
expect(mockHealthCheckAll).not.toHaveBeenCalled();
});
});
// ─── resolve — canonical ProviderHealthStatus values ──────────────────────────
describe('RoutingEngineService.resolve — canonical health status routing', () => {
it('routes healthy and degraded providers by rule, and falls through to fallback when down', async () => {
const codingRule = makeRule({
name: 'coding rule',
priority: 1,
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
action: { provider: 'openai', model: 'gpt-4o' },
});
// healthy → selected by its own rule, not the fallback chain
const healthyService = makeService([codingRule], { openai: { status: 'healthy' } });
const healthyDecision = await healthyService.resolve('implement a function');
expect(healthyDecision.ruleName).toBe('coding rule');
expect(healthyDecision.provider).toBe('openai');
// down → rule is skipped as unroutable, falls through to the fallback chain
const downService = makeService([codingRule], {
openai: { status: 'down' },
anthropic: { status: 'healthy' },
});
const downDecision = await downService.resolve('implement a function');
expect(downDecision.ruleName).toBe('fallback');
expect(downDecision.provider).toBe('anthropic');
// degraded → still routable, selected by its own rule, not the fallback chain
const degradedService = makeService([codingRule], { openai: { status: 'degraded' } });
const degradedDecision = await degradedService.resolve('implement a function');
expect(degradedDecision.ruleName).toBe('coding rule');
expect(degradedDecision.provider).toBe('openai');
});
});
-624
View File
@@ -1,624 +0,0 @@
import 'reflect-metadata';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import * as nodeOs from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import * as nodeUrl from 'node:url';
import { MODULE_METADATA } from '@nestjs/common/constants.js';
import { describe, expect, it, vi } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
interface ComposedModuleGraph {
imports: readonly unknown[];
federationModule: unknown;
bootLogLines: readonly string[];
mosaicConfig: MosaicConfig;
resolvedConfigPath: string;
}
type StorageTier = 'local' | 'standalone' | 'federated';
interface ModuleGraphFixture {
tempRoot: string;
anchor: string;
homePath: string;
cwdPath: string;
monorepoRootEnvPath: string;
gatewayLocalEnvPath: string;
daemonEnvPath: string;
monorepoRootConfigPath: string;
gatewayLocalConfigPath: string;
}
interface ModuleGraphFixtureOptions {
rootEnvMode?: 'present' | 'absent';
rootTier?: StorageTier;
rootEnvContents?: string;
redactionMarker?: string;
gatewayLocalTier?: StorageTier;
gatewayLocalEnvContents?: string;
daemonEnvContents?: string;
inheritedTier?: StorageTier;
expectedProcessTier?: string;
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
}
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
const DAEMON_DOTENV_LABEL = 'daemon .env';
function configJson(tier: StorageTier): string {
if (tier === 'local') {
return JSON.stringify({
tier,
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' },
});
}
return JSON.stringify({
tier,
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
queue: { type: 'bullmq' },
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
});
}
function snapshotProcessEnv(): Record<string, string | undefined> {
return { ...process.env };
}
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
for (const key of Object.keys(process.env)) {
if (!(key in snapshot)) {
delete process.env[key];
}
}
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
continue;
}
process.env[key] = value;
}
}
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
const relativePath = relative(tempRoot, path);
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
true,
);
}
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
expectPathUnderTempRoot(path, tempRoot);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf8');
}
interface ConfigModuleProvider {
provide: string;
useFactory: () => MosaicConfig;
}
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
if (typeof value !== 'object' || value === null) {
return false;
}
if (!('provide' in value) || typeof value.provide !== 'string') {
return false;
}
return 'useFactory' in value && typeof value.useFactory === 'function';
}
function singleBootLogLine(bootLogLines: readonly string[]): string {
expect(bootLogLines).toHaveLength(1);
const [bootLogLine] = bootLogLines;
if (bootLogLine === undefined) {
throw new Error('Expected a single boot log line');
}
return bootLogLine;
}
function expectBootLogLine(
bootLogLines: readonly string[],
tier: StorageTier,
source: string,
): void {
const bootLogLine = singleBootLogLine(bootLogLines);
expect(bootLogLine).toContain(`storage tier=${tier}`);
expect(bootLogLine).toContain(`source=${source}`);
}
async function loadModuleGraphFromDotenv(
options: ModuleGraphFixtureOptions,
): Promise<ComposedModuleGraph> {
const originalEnv = snapshotProcessEnv();
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
try {
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
const homePath = join(tempRoot, 'home');
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
const fixture: ModuleGraphFixture = {
tempRoot,
anchor,
homePath,
cwdPath,
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
};
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
for (const path of Object.values(fixture)) {
expectPathUnderTempRoot(path, tempRoot);
}
await mkdir(anchor, { recursive: true });
await mkdir(cwdPath, { recursive: true });
if ((options.rootEnvMode ?? 'present') === 'absent') {
if (
options.rootEnvContents !== undefined ||
options.rootTier !== undefined ||
options.redactionMarker !== undefined
) {
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
}
} else {
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
throw new Error('Expected rootTier or rootEnvContents');
}
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
const rootFixtureWithMarker = options.redactionMarker
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
: rootFixture;
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
}
if (options.daemonEnvContents !== undefined) {
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
}
if (options.gatewayLocalEnvContents !== undefined) {
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
} else if (options.gatewayLocalTier !== undefined) {
await writeFixture(
fixture.gatewayLocalEnvPath,
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
tempRoot,
);
}
process.env['HOME'] = homePath;
delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['DATABASE_URL'];
delete process.env['VALKEY_URL'];
delete process.env['MOSAIC_GATEWAY_HOME'];
await options.setup?.(fixture);
if (options.inheritedTier !== undefined) {
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
}
vi.resetModules();
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
vi.doMock('node:url', () => ({
...nodeUrl,
fileURLToPath: (url: string | URL): string => {
const actualPath = nodeUrl.fileURLToPath(url);
if (
actualPath.endsWith('/apps/gateway/src/env.ts') ||
actualPath.endsWith('/apps/gateway/src/env.js')
) {
return join(anchor, 'env.ts');
}
return actualPath;
},
}));
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
if (options.inheritedTier === undefined) {
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
} else {
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
}
const envModule = await import('./env.js');
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
options.expectedProcessTier ?? options.rootTier,
);
const { AppModule } = await import('./app.module.js');
const { FederationModule } = await import('./federation/federation.module.js');
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
if (!Array.isArray(imports)) {
throw new Error('AppModule imports metadata is not an array');
}
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
if (!Array.isArray(providers)) {
throw new Error('ConfigModule providers metadata is not an array');
}
const configProvider = providers
.filter(isConfigModuleProvider)
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
if (!configProvider) {
throw new Error('MOSAIC_CONFIG provider factory not found');
}
return {
imports,
federationModule: FederationModule,
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
args.map((value: unknown): string => String(value)).join(' '),
),
mosaicConfig: configProvider.useFactory(),
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
};
} finally {
cwdSpy?.mockRestore();
vi.doUnmock('node:url');
vi.doUnmock('node:os');
vi.resetModules();
consoleInfoSpy?.mockRestore();
restoreProcessEnv(originalEnv);
await rm(tempRoot, { recursive: true, force: true });
}
}
describe('AppModule federation gating', (): void => {
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
const envImportIndex = mainSource.indexOf("import './env.js';");
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
expect(envImportIndex).toBeGreaterThan(-1);
expect(envImportIndex).toBeLessThan(tracingImportIndex);
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
});
it(
'ignores ambient cwd/.env and cwd/../.env files',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
join(fixture.cwdPath, '.env'),
'MOSAIC_STORAGE_TIER=federated\n',
fixture.tempRoot,
);
await writeFixture(
resolve(fixture.cwdPath, '..', '.env'),
'MOSAIC_STORAGE_TIER=federated\n',
fixture.tempRoot,
);
},
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'ignores an ambient cwd/mosaic.config.json federated config',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
join(fixture.cwdPath, 'mosaic.config.json'),
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'ignores an ambient cwd/../../mosaic.config.json federated config',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'anchored gateway-local config wins monorepo-root config and registers FederationModule',
async (): Promise<void> => {
let gatewayLocalConfigPath = '';
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
await writeFixture(
fixture.gatewayLocalConfigPath,
configJson('federated'),
fixture.tempRoot,
);
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
},
});
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
expect(graph.mosaicConfig.tier).toBe('federated');
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
async (): Promise<void> => {
let daemonConfigPath = '';
const graph = await loadModuleGraphFromDotenv({
rootEnvMode: 'absent',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
await writeFixture(
fixture.gatewayLocalConfigPath,
configJson('standalone'),
fixture.tempRoot,
);
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
},
});
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
expect(graph.mosaicConfig.tier).toBe('federated');
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'logs mosaic.config.json when anchored config and env tiers are both federated',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'federated',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
fixture.monorepoRootConfigPath,
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'attributes an invalid monorepo-root dotenv tier to the default',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
expectedProcessTier: 'invalid',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', 'default');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootEnvMode: 'absent',
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
inheritedTier: 'local',
expectedProcessTier: 'local',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'daemon .env wins over monorepo-root and gateway-local tier values',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
gatewayLocalTier: 'federated',
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
expectedProcessTier: 'standalone',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
gatewayLocalTier: 'federated',
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
inheritedTier: 'standalone',
expectedProcessTier: 'standalone',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootEnvMode: 'absent',
gatewayLocalTier: 'federated',
expectedProcessTier: 'federated',
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'monorepo-root .env wins over gateway-local tier values',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'standalone',
gatewayLocalTier: 'federated',
});
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it.each(['local', 'standalone'] as const)(
'does not register FederationModule for the %s tier',
async (tier): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
async (): Promise<void> => {
const redactionMarker = 'redaction-fixture-marker';
const graph = await loadModuleGraphFromDotenv({
rootTier: 'federated',
redactionMarker,
});
expect(graph.imports).toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
join(fixture.cwdPath, 'mosaic.config.json'),
JSON.stringify({
tier: 'federated',
storage: {
type: 'postgres',
url: 'postgresql://ambient-attacker.invalid/mosaic',
enableVector: true,
},
queue: { type: 'bullmq' },
memory: { type: 'pgvector' },
}),
fixture.tempRoot,
);
},
});
expect(graph.mosaicConfig.tier).toBe('local');
expect(graph.mosaicConfig.storage).not.toEqual(
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
);
},
MODULE_IMPORT_TIMEOUT_MS,
);
it(
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
rootTier: 'local',
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
await writeFixture(
fixture.monorepoRootConfigPath,
configJson('federated'),
fixture.tempRoot,
);
},
});
expect(graph.mosaicConfig.tier).toBe('federated');
expect(graph.mosaicConfig.storage).toEqual(
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
);
},
MODULE_IMPORT_TIMEOUT_MS,
);
});
+1 -13
View File
@@ -21,22 +21,11 @@ import { AdminModule } from './admin/admin.module.js';
import { CommandsModule } from './commands/commands.module.js';
import { PreferencesModule } from './preferences/preferences.module.js';
import { GCModule } from './gc/gc.module.js';
import { HarnessModule } from './harness/harness.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';
import { loadConfig } from '@mosaicstack/config';
import { resolveGatewayConfigPath } from './env.js';
// Federation (step-ca client, enrollment, federation verbs) is only wired for
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
// must not gate standalone/local boots (docker-compose.federated.yml: the
// federation profile "must not start in non-federated dev"). The gateway
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
// configuration is visible here.
const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'federated';
@Module({
imports: [
@@ -61,11 +50,10 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
PreferencesModule,
CommandsModule,
GCModule,
HarnessModule,
QueueModule,
ReloadModule,
WorkspaceModule,
...(federationEnabled ? [FederationModule] : []),
FederationModule,
],
controllers: [HealthController],
providers: [
File diff suppressed because it is too large Load Diff
@@ -1,920 +0,0 @@
import 'reflect-metadata';
import { Global, Module } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CommandsModule } from '../commands/commands.module.js';
import { HarnessModule } from '../harness/harness.module.js';
import { ChatModule } from './chat.module.js';
import { ChatGateway } from './chat.gateway.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
HARNESS_REGISTRY,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import {
ChatRuntimeUnavailableError,
ownConversation,
type ChatRuntime,
type ChatRuntimeMode,
type LegacyEmbeddedChatPort,
type LegacyRuntimeStream,
type LegacySessionPresentation,
type LegacySocketTurnLease,
type OwnedConversationContext,
} from './chat-runtime.js';
import { AppModule } from '../app.module.js';
import { ProviderService } from '../agent/provider.service.js';
/**
* Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one
* runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and
* never downgrades `pi-rpc` to embedded execution. Red-first: the router is an
* unimplemented stub, so every behavioural assertion below fails until Step Three.
*/
const embedded: ChatRuntime = { kind: 'embedded' };
const harness: ChatRuntime = { kind: 'harness' };
/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */
const boundConversationService = {
attach: () => Promise.reject(new Error('unused')),
detach: () => Promise.reject(new Error('unused')),
send: () => Promise.reject(new Error('unused')),
subscribeFrom: async function* () {
throw new Error('unused');
},
} as unknown as HarnessConversationService;
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
const registry = new HarnessRegistry();
for (const id of adapterIds) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return registry;
}
function buildRouter(
mode: ChatRuntimeMode,
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
): ChatRuntimeRouter {
return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode);
}
/**
* Tear down a module that was deliberately driven to a fail-closed init.
* `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing
* (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed
* startup error, this time into teardown. Each caller here has already captured and asserted that
* exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise —
* swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly.
*/
async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise<void> {
await moduleRef.close().catch((err: unknown) => {
if (err instanceof ChatRuntimeUnavailableError) return;
throw err;
});
}
describe('ChatRuntimeRouter', () => {
it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => {
const router = buildRouter('pi-rpc', {
adapters: ['pi'],
service: boundConversationService,
});
expect(() => router.onModuleInit()).not.toThrow();
expect(router.active).toBe(harness);
expect(router.active.kind).toBe('harness');
});
it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => {
// Empty registry + unavailable service: legacy must ignore both and still start.
const router = buildRouter('legacy', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
expect(() => router.onModuleInit()).not.toThrow();
expect(router.active).toBe(embedded);
expect(router.active.kind).toBe('embedded');
});
it('fails closed at init when pi-rpc mode has no registered pi adapter', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: boundConversationService,
});
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw when the pi adapter is absent');
} catch (err) {
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
}
});
it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => {
const router = buildRouter('pi-rpc', {
adapters: ['pi'],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw when the conversation service is unbound');
} catch (err) {
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
}
});
it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
// A failed pi-rpc init must not silently expose the embedded runtime.
expect(() => router.active).toThrow();
let leaked: ChatRuntime | undefined;
try {
leaked = router.active;
} catch {
leaked = undefined;
}
expect(leaked).not.toBe(embedded);
});
it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw');
} catch (err) {
const message = (err as ChatRuntimeUnavailableError).message;
expect(message).toBe(
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
);
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/);
}
});
});
/**
* Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input).
*
* The unit suite above constructs the router but never invokes a legacy port operation, so the
* six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that
* deletes one of them SURVIVES for lack of a test that drives that operation. This group closes
* that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a
* valid branded {@link OwnedConversationContext} and valid input, against a recording embedded
* stub whose method returns a distinguishable `ok:true` success and increments a per-op counter.
*
* For each operation:
* - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }`
* result AND that the embedded stub was touched zero times (no effects);
* - the paired legacy test proves that same stub method IS reached and returns its distinguishable
* success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful,
* not vacuously true because the stub could never be called.
*
* Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED
* (the router returns the embedded `ok:true` value and records the call), with every outer guard
* and the other five inner guards intact. `next` is untouched; nothing here changes production.
*/
describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => {
const RUNTIME_UNSUPPORTED = {
ok: false,
code: 'runtime_unsupported',
retryable: false,
} as const;
const PRESENTATION: LegacySessionPresentation = {
provider: 'embedded-provider',
modelId: 'embedded-model',
thinkingLevel: 'low',
availableThinkingLevels: ['low', 'high'],
};
const stream: LegacyRuntimeStream = {
channelId: 'websocket:test-socket',
onEvent: () => {},
};
const ctx = (): OwnedConversationContext =>
ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' });
/**
* Per-operation invocation counters with declared keys (not an index signature) so each
* `calls.<op>` is definitely `number` under `noUncheckedIndexedAccess`.
*/
type LegacyPortCallCounts = {
completeLegacyRestTurn: number;
prepareLegacySocketTurn: number;
setLegacyThinking: number;
abortLegacyTurn: number;
applyLegacyModelOverride: number;
readLegacySessionPresentation: number;
dispatchVerifiedDiscordIngress: number;
};
/**
* An embedded port that records every invocation and returns a distinguishable `ok:true`
* value per operation. If a router op reaches it (its guard removed), both the recorded call
* count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result.
*/
function recordingEmbeddedPort(): {
port: ChatRuntime & LegacyEmbeddedChatPort;
calls: LegacyPortCallCounts;
} {
const calls: LegacyPortCallCounts = {
completeLegacyRestTurn: 0,
prepareLegacySocketTurn: 0,
setLegacyThinking: 0,
abortLegacyTurn: 0,
applyLegacyModelOverride: 0,
readLegacySessionPresentation: 0,
dispatchVerifiedDiscordIngress: 0,
};
const lease: LegacySocketTurnLease = {
presentation: PRESENTATION,
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
dispose: () => Promise.resolve(),
};
const port: ChatRuntime & LegacyEmbeddedChatPort = {
kind: 'embedded',
completeLegacyRestTurn: () => {
calls.completeLegacyRestTurn += 1;
return Promise.resolve({
ok: true,
value: { text: 'EMBEDDED-REST', presentation: PRESENTATION },
});
},
prepareLegacySocketTurn: () => {
calls.prepareLegacySocketTurn += 1;
return Promise.resolve({ ok: true, value: lease });
},
setLegacyThinking: () => {
calls.setLegacyThinking += 1;
return { ok: true, value: PRESENTATION };
},
abortLegacyTurn: () => {
calls.abortLegacyTurn += 1;
return Promise.resolve({ ok: true, value: undefined });
},
applyLegacyModelOverride: () => {
calls.applyLegacyModelOverride += 1;
return { ok: true, value: PRESENTATION };
},
readLegacySessionPresentation: () => {
calls.readLegacySessionPresentation += 1;
return { ok: true, value: PRESENTATION };
},
dispatchVerifiedDiscordIngress: () => {
calls.dispatchVerifiedDiscordIngress += 1;
return Promise.resolve({
ok: true,
value: {
presentation: PRESENTATION,
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
dispose: () => Promise.resolve(),
},
});
},
};
return { port, calls };
}
function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
return new ChatRuntimeRouter(
registryWith(['pi']),
boundConversationService,
port,
harness,
'pi-rpc',
);
}
function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
return new ChatRuntimeRouter(
registryWith([]),
boundConversationService,
port,
harness,
'legacy',
);
}
// completeLegacyRestTurn ---------------------------------------------------
it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.completeLegacyRestTurn).toBe(0);
});
it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
expect(result.ok).toBe(true);
expect(calls.completeLegacyRestTurn).toBe(1);
});
// prepareLegacySocketTurn --------------------------------------------------
it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).prepareLegacySocketTurn(
ctx(),
{ content: 'hello' },
stream,
);
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.prepareLegacySocketTurn).toBe(0);
});
it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).prepareLegacySocketTurn(
ctx(),
{ content: 'hello' },
stream,
);
expect(result.ok).toBe(true);
expect(calls.prepareLegacySocketTurn).toBe(1);
});
// setLegacyThinking (sync) -------------------------------------------------
it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).setLegacyThinking(ctx(), 'high');
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.setLegacyThinking).toBe(0);
});
it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).setLegacyThinking(ctx(), 'high');
expect(result.ok).toBe(true);
expect(calls.setLegacyThinking).toBe(1);
});
// abortLegacyTurn ----------------------------------------------------------
it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).abortLegacyTurn(ctx());
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.abortLegacyTurn).toBe(0);
});
it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).abortLegacyTurn(ctx());
expect(result.ok).toBe(true);
expect(calls.abortLegacyTurn).toBe(1);
});
// applyLegacyModelOverride (sync) ------------------------------------------
it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.applyLegacyModelOverride).toBe(0);
});
it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
expect(result.ok).toBe(true);
expect(calls.applyLegacyModelOverride).toBe(1);
});
// readLegacySessionPresentation (sync) -------------------------------------
it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).readLegacySessionPresentation(ctx());
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.readLegacySessionPresentation).toBe(0);
});
it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).readLegacySessionPresentation(ctx());
expect(result.ok).toBe(true);
expect(calls.readLegacySessionPresentation).toBe(1);
});
// dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) ---------
it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => {
const { port, calls } = recordingEmbeddedPort();
const discordCtx = ctx() as unknown as Parameters<
ChatRuntimeRouter['dispatchVerifiedDiscordIngress']
>[0];
const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream);
expect(result.ok).toBe(true);
expect(calls.dispatchVerifiedDiscordIngress).toBe(1);
});
});
/**
* Task Five, Step Two — group 1 (real Nest module-graph readiness).
*
* The unit suite above constructs the router directly. This group drives the SAME contract
* through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting
* idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry`
* via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest
* lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose
* `onModuleInit` throws a generic Error, so:
* - readiness cases fail because the graph never comes up (init rejects), and
* - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed
* `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that
* "throws anything" cannot mask these greens.
* The router is NOT wired into a production module yet, so it is provided here via a factory
* over the real registry token. Importing the real `ChatModule` bare is deliberately avoided:
* it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a
* collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router.
*/
describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => {
async function bootRouterGraph(
mode: ChatRuntimeMode,
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
) {
const moduleRef = await Test.createTestingModule({
imports: [HarnessModule],
providers: [
{
provide: ChatRuntimeRouter,
useFactory: (registry: HarnessRegistry) =>
new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode),
inject: [HARNESS_REGISTRY],
},
],
})
// The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern,
// never exercised here); stub it so the graph resolves. The registry is NOT overridden —
// group 1 asserts against the genuine production HarnessRegistry.
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
// Resolve the production registry singleton and register the requested adapters ON IT, so
// the router (which injects the same singleton) sees them when its lifecycle hook runs.
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
for (const id of opts.adapters) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return moduleRef;
}
// Capture an init rejection without letting a resolved init masquerade as success.
const initError = (moduleRef: { init(): Promise<unknown> }): Promise<unknown> =>
moduleRef.init().then(
() => new Error('module init resolved but the contract requires it to reject'),
(err: unknown) => err,
);
it('brings the graph up and resolves only the harness runtime in pi-rpc mode (pi adapter + bound service)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: ['pi'],
service: boundConversationService,
});
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active).toBe(harness);
expect(router.active.kind).toBe('harness');
} finally {
await moduleRef.close();
}
});
it('brings the graph up in legacy mode over the REAL empty HarnessRegistry and resolves only the embedded runtime', async () => {
const moduleRef = await bootRouterGraph('legacy', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
// Defense-in-depth: the production module wires the genuine registry, empty by default —
// guards against a test-double registry silently satisfying the readiness check.
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
expect(registry).toBeInstanceOf(HarnessRegistry);
expect(registry.list()).toHaveLength(0);
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active).toBe(embedded);
expect(router.active.kind).toBe('embedded');
} finally {
await moduleRef.close();
}
});
it('fails closed at module init when pi-rpc mode has no registered pi adapter (specific typed error, not a stub throw)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('fails closed at module init when pi-rpc mode has the unavailable conversation-service sentinel', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: ['pi'],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('surfaces only fixed, browser-safe failure text when the graph fails closed (no stub/exception detail)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
const message = (err as ChatRuntimeUnavailableError).message;
expect(message).toBe(
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
);
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(|not implemented/);
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
});
/**
* Task Five, Step Two — group 1b (production ChatModule wiring, declaration proof).
*
* Correction #1 (Scrappy fe3e02) asked for a red that imports the real `ChatModule` and calls
* `module.init()`. Investigated and found impractical/masking-prone: `ChatModule` provides
* `ChatGateway`, whose 10-argument constructor injects app-global providers (AgentService, AUTH,
* BRAIN, RoutingEngineService) plus the Commands/GC/Mcp/Reload subsystems across a forwardRef
* cycle. Booting it in isolation is a full-app integration boot — "override only unrelated
* dependencies" balloons into faking ~4 subsystems, and `overrideProvider` cannot even grant the
* cross-module export-scope visibility ChatGateway needs (probe: `ChatGateway` unresolved at
* `CommandExecutorService`). That is exactly the STOP-and-return branch of the directive.
*
* The faithful, unmaskable cover instead of a fragile boot: read the PRODUCTION `ChatModule`'s own
* Nest `@Module` metadata to prove it DECLARES the exclusive router provider and imports the real
* `HarnessModule` (the genuine registry source). This inspects the actual module object — not
* source text, not a test factory — so nothing can mask it. Group 1 above separately proves the
* router RESOLVES against the real, empty `HarnessRegistry` through the Nest lifecycle; the union
* of the two covers "the router is wired through ChatModule to the real registry" without the
* impractical single-graph boot. RED today (ChatModule provides only ChatGateway and imports only
* CommandsModule); GREEN once Step Three registers the router and imports HarnessModule.
*/
describe('ChatModule production wiring (Task Five, Step Two group 1b — declaration proof)', () => {
// Unwrap a forwardRef(() => Module) import to the module it references; pass others through.
const resolveImport = (imp: unknown): unknown =>
imp &&
typeof imp === 'object' &&
typeof (imp as { forwardRef?: unknown }).forwardRef === 'function'
? (imp as { forwardRef: () => unknown }).forwardRef()
: imp;
// A provider entry is either a class (shorthand) or a { provide, ... } object; take its token.
const providerToken = (provider: unknown): unknown =>
typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
it('declares the exclusive ChatRuntimeRouter as a provider on the production ChatModule', () => {
const providers: unknown[] = Reflect.getMetadata('providers', ChatModule) ?? [];
expect(providers.map(providerToken)).toContain(ChatRuntimeRouter);
});
it('imports the real HarnessModule into the production ChatModule (registry source, not a test double)', () => {
const imports: unknown[] = Reflect.getMetadata('imports', ChatModule) ?? [];
expect(imports.map(resolveImport)).toContain(HarnessModule);
});
});
/**
* Task Five, Step Two — group 1c (bounded real-`ChatModule` boot).
*
* Scrappy adjudication d67d2b (option c): boot the ACTUAL production `ChatModule` as the SUT and
* assert the exclusive router resolves THROUGH it — the single-graph proof group 1 (router over the
* real registry) and group 1b (production-module metadata) each cover only a half of. The heavy,
* UNRELATED cycle is the only thing bounded away, per the established isolation pattern in
* `apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`:
* - `CommandsModule` (drags the Commands <-> Reload <-> Chat forwardRef cycle plus GC/Mcp/queue)
* is replaced wholesale with an empty module via `.overrideModule(...).useModule(...)`;
* - `ChatGateway` (10-arg constructor, an HTTP/socket concern never exercised here) is replaced
* with an inert value;
* - the sole legacy-controller dependency, `AgentService`, is supplied by a tiny `@Global()` stub;
* - the HTTP-only `AuthGuard` is stubbed.
* Nothing about the router, `HarnessModule`, the registry, or the conversation-service binding is
* faked in the production-legacy case — those are retrieved from the REAL `ChatModule` graph. Mode
* is driven only through the production `CHAT_HARNESS_RUNTIME` env contract (`resolveChatRuntimeMode`).
*
* Red-first: today `ChatModule` neither imports `HarnessModule` nor provides `ChatRuntimeRouter`, so
* the booted graph contains no router/registry/conversation-service tokens. `init()` may resolve
* (there is no router lifecycle hook yet to reject), so every case fails on the MISSING actual
* router/registry/service wiring — not on unrelated DI, which is bounded away. GREEN at Step Three
* once `ChatModule` imports `HarnessModule`, provides the exclusive router, and binds the
* conversation-service token (defaulting to the unavailable sentinel).
*/
describe('ChatModule bounded real boot (Task Five, Step Two group 1c)', () => {
// The unrelated heavy cycle, replaced wholesale — not stubbed provider-by-provider.
@Module({})
class EmptyCommandsModule {}
// The ONLY genuine legacy dependency of the real ChatController, supplied inertly and globally so
// the pre-refactor controller instantiates without dragging AgentModule into the graph.
@Global()
@Module({
providers: [{ provide: AgentService, useValue: {} }],
exports: [AgentService],
})
class LegacyControllerDepsModule {}
const ORIGINAL_RUNTIME_ENV = process.env['CHAT_HARNESS_RUNTIME'];
afterEach(() => {
if (ORIGINAL_RUNTIME_ENV === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
else process.env['CHAT_HARNESS_RUNTIME'] = ORIGINAL_RUNTIME_ENV;
});
/**
* Boot the real ChatModule with only the unrelated cycle bounded away. `mode` is set through the
* genuine production env contract before providers instantiate. The optional overrides replace
* the registry / conversation-service the router injects, exercising the pi-rpc precondition
* branches through the ACTUAL module (they are no-ops today because those tokens are not yet in
* the graph — which is exactly why the router-retrieval assertions go red).
*/
async function bootChatModule(
mode: ChatRuntimeMode,
overrides: {
registryAdapters?: readonly string[];
conversationService?: HarnessConversationServiceBinding;
} = {},
): Promise<TestingModule> {
if (mode === 'pi-rpc') process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
else delete process.env['CHAT_HARNESS_RUNTIME'];
let builder = Test.createTestingModule({
imports: [LegacyControllerDepsModule, ChatModule],
})
.overrideModule(CommandsModule)
.useModule(EmptyCommandsModule)
.overrideProvider(ChatGateway)
.useValue({})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true });
if (overrides.registryAdapters) {
builder = builder
.overrideProvider(HARNESS_REGISTRY)
.useValue(registryWith(overrides.registryAdapters));
}
if (overrides.conversationService !== undefined) {
builder = builder
.overrideProvider(HARNESS_CONVERSATION_SERVICE)
.useValue(overrides.conversationService);
}
return builder.compile();
}
// Capture an init rejection without letting a resolved init masquerade as success.
const initError = (moduleRef: TestingModule): Promise<unknown> =>
moduleRef.init().then(
() => new Error('module init resolved but the contract requires it to reject'),
(err: unknown) => err,
);
it('legacy mode: the actual router resolves the embedded runtime, the actual registry is empty, and the conversation-service token is the unavailable sentinel', async () => {
const moduleRef = await bootChatModule('legacy');
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('embedded');
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
expect(registry).toBeInstanceOf(HarnessRegistry);
expect(registry.list()).toHaveLength(0);
const service = moduleRef.get<HarnessConversationServiceBinding>(
HARNESS_CONVERSATION_SERVICE,
{
strict: false,
},
);
expect(service).toBe(HARNESS_CONVERSATION_SERVICE_UNAVAILABLE);
} finally {
await moduleRef.close();
}
});
it('pi-rpc mode over the REAL empty registry fails closed at init with the typed adapter-unavailable error', async () => {
const moduleRef = await bootChatModule('pi-rpc');
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('pi-rpc mode with a pi adapter present but the sentinel conversation service fails closed with the typed conversation-service-unavailable error', async () => {
const moduleRef = await bootChatModule('pi-rpc', {
registryAdapters: ['pi'],
conversationService: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('pi-rpc mode with a pi adapter and a bound conversation service: the actual router selects the harness runtime', async () => {
const moduleRef = await bootChatModule('pi-rpc', {
registryAdapters: ['pi'],
conversationService: boundConversationService,
});
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('harness');
} finally {
await moduleRef.close();
}
});
});
/**
* Task Five, Step Two — group 2 (WHOLE production `AppModule` boot, legacy end-to-end wiring).
*
* The groups above bound away the heavy cycle to isolate the router. This group instead boots the
* ACTUAL production `AppModule` (the exact graph `main.ts` runs) in the default LEGACY chat-runtime
* mode, overriding ONLY the storage/network side-effect adapters so the boot is bounded and offline
* — never the chat/router/harness/reload/commands surface under test. The bounded fakes are exactly
* the disk/network leaves:
* - `ProviderService` (the #1 hang risk: its real `onModuleInit` starts an unref'd health-check
* `setInterval` and fetches Ollama over HTTP) → inert no-op instance;
* - `DB_HANDLE`/`DB` → a fake Drizzle-shaped handle that satisfies `runPgliteMigrations` (the local
* tier's `DatabaseModule.onModuleInit`) AND `DefaultRoutingRulesSeed.onModuleInit` (which reads a
* system-rule count — the fake reports rules already present so the seed insert is skipped),
* opening no real database;
* - `STORAGE_ADAPTER`/`MEMORY`/`MEMORY_ADAPTER`/`AUTH`/`BRAIN`/`LOG_SERVICE` → inert fakes so no
* storage/auth/log backend is contacted.
* Local tier (the repo's `mosaic.config.json`) already disables BullMQ/Redis and the queue handles;
* Discord/Telegram/MCP plugins are env-gated and disarmed by deleting their tokens. Nothing about the
* router, `ChatModule`, `HarnessModule`, or `ChatGateway` is faked — those come from the REAL graph.
*
* The boot+init MUST SUCCEED cleanly (proven by `beforeAll` completing and the ChatGateway test
* passing). Red-first: on this branch `ChatRuntimeRouter` is registered in NO module (ChatModule
* provides only ChatGateway), so `moduleRef.get(ChatRuntimeRouter)` throws `UnknownElementException`
* — a WIRING gap, NOT an init failure. That single retrieval is the intended behavioural red; it
* flips green once Step Three registers the exclusive router. The ChatGateway retrieval and its
* browser-facing method surface are asserted alongside and pass today, pinning that the boot itself
* is healthy so the router failure cannot be mistaken for a mis-shaped fake or an unbounded side
* effect.
*/
describe('AppModule production boot — legacy ChatRuntimeRouter wiring (Task Five, Step Two group 2)', () => {
// A Drizzle-shaped fake that satisfies both DB consumers reached during a local-tier init:
// • runPgliteMigrations(): reads handle.db.$client.exec + handle.db.execute(SELECT hashes);
// exec is a no-op and execute yields an empty ledger, so migration statements no-op through.
// • DefaultRoutingRulesSeed.seedDefaultRules(): db.select().from().where() must resolve to a
// row set — we report a non-zero system-rule count so the seeding INSERT branch is skipped.
const fakeDb = {
$client: { exec: async (): Promise<void> => {} },
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
select: () => ({
from: () => ({
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
}),
}),
insert: () => ({ values: async (): Promise<void> => {} }),
};
const fakeDbHandle = { db: fakeDb, close: async (): Promise<void> => {} };
const fakeStorageAdapter = {
name: 'fake',
migrate: async (): Promise<void> => {},
close: async (): Promise<void> => {},
};
// Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch.
const fakeProviderService = {
onModuleInit: async (): Promise<void> => {},
onModuleDestroy: (): void => {},
getRegistry: () => ({
getAvailable: () => [],
getAll: () => [],
find: () => undefined,
}),
getDefaultModel: () => undefined,
listAvailableModels: () => [],
listProviders: () => [],
getAdapter: () => undefined,
getProvidersHealth: () => [],
};
const fakeBrain = { conversations: {}, agents: {} };
const BOOT_TIMEOUT_MS = 120_000;
let moduleRef: TestingModule;
let envSnapshot: Record<string, string | undefined>;
beforeAll(async () => {
envSnapshot = { ...process.env };
// Env hygiene: disarm the network-facing plugins/adapters and pin the legacy runtime mode.
delete process.env['DATABASE_URL'];
delete process.env['DISCORD_BOT_TOKEN'];
delete process.env['TELEGRAM_BOT_TOKEN'];
delete process.env['MCP_SERVERS'];
delete process.env['CHAT_HARNESS_RUNTIME']; // resolveChatRuntimeMode → 'legacy'
process.env['MOSAIC_STORAGE_TIER'] = 'local';
moduleRef = await Test.createTestingModule({ imports: [AppModule] })
// Storage/network side-effect adapters ONLY — never the router/chat/harness surface under test.
.overrideProvider('DB_HANDLE')
.useValue(fakeDbHandle)
.overrideProvider('DB')
.useValue(fakeDb)
.overrideProvider('STORAGE_ADAPTER')
.useValue(fakeStorageAdapter)
.overrideProvider('AUTH')
.useValue({})
.overrideProvider('BRAIN')
.useValue(fakeBrain)
.overrideProvider('LOG_SERVICE')
.useValue({})
.overrideProvider('MEMORY')
.useValue({})
.overrideProvider('MEMORY_ADAPTER')
.useValue({})
.overrideProvider(ProviderService)
.useValue(fakeProviderService)
.compile();
// The boot itself MUST succeed cleanly — a rejection here is a bounding failure, not the red.
await moduleRef.init();
}, BOOT_TIMEOUT_MS);
afterAll(async () => {
if (moduleRef) await moduleRef.close();
for (const key of Object.keys(process.env)) {
if (!(key in envSnapshot)) delete process.env[key];
}
for (const [key, value] of Object.entries(envSnapshot)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
// Passes TODAY: the real ChatGateway is provided by the real ChatModule and its browser-facing
// surface exists. This pins that the whole-AppModule boot came up healthy, so the router failure
// below is unambiguously a wiring gap and not a mis-shaped fake or an unbounded side effect.
it('boots the whole AppModule and exposes the real ChatGateway with its browser-facing methods', () => {
const gateway = moduleRef.get(ChatGateway, { strict: false });
expect(typeof gateway.broadcastReload).toBe('function');
expect(typeof gateway.getModelOverride).toBe('function');
expect(typeof gateway.setModelOverride).toBe('function');
expect(typeof gateway.broadcastSessionInfo).toBe('function');
});
// RED TODAY: ChatRuntimeRouter is registered in no module on this branch, so this retrieval throws
// UnknownElementException — the intended red-first wiring failure. GREEN once Step Three registers
// the exclusive router in the production graph, where legacy mode resolves the embedded runtime.
it('resolves the exclusive ChatRuntimeRouter to the embedded runtime in legacy mode', () => {
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('embedded');
});
});
@@ -1,173 +0,0 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
isHarnessConversationServiceAvailable,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import type {
ChatRuntime,
ChatRuntimeMode,
LegacyBrowserMessagePayload,
LegacyEmbeddedChatPort,
LegacyRuntimeResult,
LegacyRuntimeStream,
LegacySessionPresentation,
LegacySocketTurnLease,
OwnedConversationContext,
VerifiedDiscordIngressContext,
VerifiedDiscordTurnLease,
} from './chat-runtime.js';
import { ChatRuntimeUnavailableError, resolveChatRuntimeMode } from './chat-runtime.js';
/** The fixed fail-closed result for a legacy browser operation issued under `pi-rpc`. */
const RUNTIME_UNSUPPORTED = {
ok: false as const,
code: 'runtime_unsupported' as const,
retryable: false as const,
};
/**
* Resolves the one live {@link ChatRuntime} for this process and enforces the
* `pi-rpc` readiness preconditions at module init — before the gateway accepts
* traffic. It never falls back from `pi-rpc` to embedded execution: an unmet
* `pi-rpc` precondition is a typed startup failure ({@link ChatRuntimeUnavailableError}),
* and until `onModuleInit` selects a runtime, {@link active} throws rather than
* exposing any runtime — a failed `pi-rpc` init can never leak the embedded one.
*/
@Injectable()
export class ChatRuntimeRouter implements OnModuleInit, LegacyEmbeddedChatPort {
private readonly mode: ChatRuntimeMode;
/** The single resolved runtime. Undefined until a successful `onModuleInit`. */
private resolved: ChatRuntime | undefined;
constructor(
private readonly harnessRegistry: HarnessRegistry,
private readonly conversationService: HarnessConversationServiceBinding,
private readonly embedded: ChatRuntime,
private readonly harness: ChatRuntime,
mode: ChatRuntimeMode = resolveChatRuntimeMode(),
) {
this.mode = mode;
}
onModuleInit(): void {
if (this.mode === 'legacy') {
// Legacy ignores the pi-rpc preconditions entirely and always runs embedded.
this.resolved = this.embedded;
return;
}
// pi-rpc: both preconditions are hard startup failures, checked in a fixed order.
if (!this.harnessRegistry.has('pi')) {
this.resolved = undefined;
throw new ChatRuntimeUnavailableError('adapter_unavailable');
}
if (!isHarnessConversationServiceAvailable(this.conversationService)) {
this.resolved = undefined;
throw new ChatRuntimeUnavailableError('conversation_service_unavailable');
}
this.resolved = this.harness;
}
get active(): ChatRuntime {
if (this.resolved === undefined) {
// Reached only if init has not run or failed closed; never expose a runtime here.
throw new Error('The chat runtime is not available: startup did not resolve a runtime.');
}
return this.resolved;
}
/**
* The process-wide mode, available before {@link onModuleInit}. Production handlers read
* this to fail a legacy browser turn closed under `pi-rpc` *before* parsing the payload as
* either browser-legacy input or a Discord envelope — never to branch into a fallback.
*/
get runtimeMode(): ChatRuntimeMode {
return this.mode;
}
/**
* The embedded runtime narrowed to its port. Only reached on the legacy path (and for the
* verified-Discord op in both modes), where the injected runtime is always a real
* `EmbeddedChatRuntime`. The router spec constructs the router with a bare `{ kind }` stub
* but never invokes a port op, so this narrowing is never exercised against the stub.
*/
private get embeddedPort(): LegacyEmbeddedChatPort {
return this.embedded as unknown as LegacyEmbeddedChatPort;
}
// --- LegacyEmbeddedChatPort: legacy browser operations fail closed under pi-rpc ---
completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.completeLegacyRestTurn(context, input);
}
prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.prepareLegacySocketTurn(context, input, stream);
}
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.setLegacyThinking(context, level);
}
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.abortLegacyTurn(context);
}
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.applyLegacyModelOverride(context, modelId);
}
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.readLegacySessionPresentation(context);
}
/**
* Verified Discord ingress bypasses browser mode: it is embedded-only in BOTH modes and
* never reaches the harness or routing-engine selection. It is reached only through a
* {@link VerifiedDiscordIngressContext}, which exists only after every ingress check.
*/
dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream);
}
}
-273
View File
@@ -1,273 +0,0 @@
import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types';
/**
* The single chat execution strategy resolved by {@link ChatRuntimeRouter}.
*
* Exactly one runtime is live per process. There is no union that lets a
* `pi-rpc` deployment silently fall back to embedded execution: an unmet
* `pi-rpc` precondition is a typed startup failure, never a downgrade.
*/
export type ChatRuntimeMode = 'legacy' | 'pi-rpc';
export type ChatRuntimeKind = 'embedded' | 'harness';
/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */
export interface ChatRuntime {
readonly kind: ChatRuntimeKind;
}
/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */
export type ChatRuntimeUnavailableReason =
| 'adapter_unavailable'
| 'conversation_service_unavailable';
/**
* Raised at module init when `pi-rpc` mode is selected but its preconditions are
* unmet. Carries only fixed, browser-safe text — never a raw exception message,
* stack, or provider detail — and reports the frozen ack code `runtime_unsupported`.
*/
export class ChatRuntimeUnavailableError extends Error {
readonly code = 'runtime_unsupported' as const;
readonly reason: ChatRuntimeUnavailableReason;
constructor(reason: ChatRuntimeUnavailableReason) {
super(
reason === 'adapter_unavailable'
? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.'
: 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.',
);
this.name = 'ChatRuntimeUnavailableError';
this.reason = reason;
}
}
/**
* Resolves the process-wide chat runtime mode from the environment. Anything other
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
*/
export function resolveChatRuntimeMode(
env: Record<string, string | undefined> = process.env,
): ChatRuntimeMode {
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
}
// ---------------------------------------------------------------------------
// Transitional embedded chat port (Task Five).
//
// The legacy embedded browser behaviour is moved behind this exact interface so
// neither the controller nor the gateway retains AgentService, RoutingEngine,
// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime`
// implements the port; `ChatRuntimeRouter` exposes the same narrowly named
// operations and returns `runtime_unsupported` before touching Embedded for legacy
// browser operations when the mode is `pi-rpc`.
//
// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion,
// legacy Socket streaming, P3 harness turns, and verified Discord are distinct
// transport/trust capabilities — there is deliberately no generic
// `sendConversationTurn` nor an AgentService-shaped mirror on the router.
// ---------------------------------------------------------------------------
/**
* Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser
* DTOs are never structurally assignable to it. The factory that mints one may be called
* only after authentication with `scopeFromUser(...)`, never with payload authority fields.
*/
declare const ownedConversationContextBrand: unique symbol;
/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */
export interface OwnedConversationContext {
readonly [ownedConversationContextBrand]: true;
readonly conversationId: string;
readonly scope: Readonly<{ userId: string; tenantId: string }>;
}
/**
* Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned
* conversations all collapse to `conversation_unavailable`. Ownership/mode/validation
* failures are total results and never throw.
*/
export type LegacyRuntimeFailure =
| { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false }
| { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false }
| { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false }
| {
readonly ok: false;
readonly code: 'thinking_level_invalid';
readonly retryable: false;
readonly availableThinkingLevels: readonly string[];
}
| { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true }
| { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false }
| { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean }
| { readonly ok: false; readonly code: 'timeout'; readonly retryable: true };
/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */
export type LegacyRuntimeResult<T> =
| { readonly ok: true; readonly value: T }
| LegacyRuntimeFailure;
/** User-facing session projection. Carries no session object, handle, or credential path. */
export interface LegacySessionPresentation {
readonly provider: string;
readonly modelId: string;
readonly thinkingLevel: string;
readonly availableThinkingLevels: readonly string[];
readonly agentName?: string;
readonly routingDecision?: RoutingDecisionInfo;
}
/** Terminal usage stats, normalized by Embedded from AgentService metrics. */
export interface LegacyUsage {
readonly provider: string;
readonly modelId: string;
readonly thinkingLevel: string;
readonly tokens: Readonly<{
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
}>;
readonly cost: number;
readonly context: Readonly<{ percent: number | null; window: number }>;
}
/**
* Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw
* exception, tool arguments, or credential-bearing path — the gateway sees only these.
*/
export type LegacyRuntimeEvent =
| { readonly type: 'started' }
| { readonly type: 'text_delta'; readonly text: string }
| { readonly type: 'thinking_delta'; readonly text: string }
| {
readonly type: 'tool_started';
readonly toolCallId: string;
readonly toolName: string;
}
| {
readonly type: 'tool_finished';
readonly toolCallId: string;
readonly toolName: string;
readonly isError: boolean;
}
| { readonly type: 'settled'; readonly usage?: LegacyUsage };
/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */
export interface LegacyBrowserMessagePayload {
readonly content: string;
readonly provider?: string;
readonly modelId?: string;
readonly agentId?: string;
readonly attachments?: readonly ChannelAttachmentDto[];
}
/** A prepared-but-not-yet-dispatched legacy socket turn. */
export interface LegacySocketTurnLease {
readonly presentation: LegacySessionPresentation;
/**
* Atomically one-shot and scope-rechecking. A second call returns
* `turn_already_dispatched` and performs zero prompt/tool effects.
*/
dispatch(): Promise<LegacyRuntimeResult<void>>;
/** Idempotent, non-throwing. Removes listener and channel, including partial setup. */
dispose(): Promise<void>;
}
/**
* Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token
* auth plus signature, allowlist, binding, expected-route, replay, configured-agent,
* forced-scope, and attachment-normalization checks.
*/
declare const verifiedDiscordIngressContextBrand: unique symbol;
/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */
export interface VerifiedDiscordIngressContext {
readonly [verifiedDiscordIngressContextBrand]: true;
readonly conversationId: string;
readonly scope: Readonly<{ userId: string; tenantId: string }>;
readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>;
readonly content: string;
readonly attachments?: readonly ChannelAttachmentDto[];
readonly correlationId: string;
readonly discordMessageId: string;
readonly discordUserId: string;
}
/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */
export interface VerifiedDiscordTurnLease {
readonly presentation: LegacySessionPresentation;
dispatch(): Promise<LegacyRuntimeResult<void>>;
dispose(): Promise<void>;
}
/** Server-owned egress projection the runtime pushes normalized events into. */
export interface LegacyRuntimeStream {
/** Server-derived, e.g. `websocket:<socket-id>`. Never client-supplied. */
readonly channelId: string;
onEvent(event: LegacyRuntimeEvent): void;
}
/**
* The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter`
* mirrors the operation names and fails closed with `runtime_unsupported` for legacy
* browser operations under `pi-rpc`.
*/
export interface LegacyEmbeddedChatPort {
completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
>;
prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>>;
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation>;
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>>;
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation>;
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation>;
dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>>;
}
/**
* Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass
* a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied
* authority field. The brand is phantom, so this is the only way to obtain the branded type.
*/
export function ownConversation(
conversationId: string,
scope: Readonly<{ userId: string; tenantId: string }>,
): OwnedConversationContext {
return { conversationId, scope } as unknown as OwnedConversationContext;
}
/**
* Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every
* ingress check (service-token auth, signature, allowlist, binding, expected-route, replay,
* configured-agent, forced-scope, attachment normalization) before calling this.
*/
export function verifyDiscordIngress(
fields: Omit<VerifiedDiscordIngressContext, typeof verifiedDiscordIngressContextBrand>,
): VerifiedDiscordIngressContext {
return { ...fields } as unknown as VerifiedDiscordIngressContext;
}
+63 -32
View File
@@ -3,20 +3,21 @@ import {
Post,
Body,
Logger,
ForbiddenException,
HttpException,
HttpStatus,
NotFoundException,
Inject,
UseGuards,
} from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
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';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import { ownConversation } from './chat-runtime.js';
import type { LegacyRuntimeFailure } from './chat-runtime.js';
interface ChatResponse {
conversationId: string;
@@ -28,7 +29,7 @@ interface ChatResponse {
export class ChatController {
private readonly logger = new Logger(ChatController.name);
constructor(private readonly runtime: ChatRuntimeRouter) {}
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Post()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@@ -39,38 +40,68 @@ export class ChatController {
const conversationId = body.conversationId ?? uuid();
const scope = scopeFromUser(user);
try {
let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) {
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),
);
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
}
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
// The one exclusive runtime owns execution. In legacy mode this reaches the embedded runtime;
// in pi-rpc it fails closed with `runtime_unsupported` before ever touching embedded execution.
const result = await this.runtime.completeLegacyRestTurn(
ownConversation(conversationId, scope),
{ content: body.content },
);
let responseText = '';
if (result.ok) {
return { conversationId, text: result.value.text };
const done = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
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();
}
},
scope,
);
});
try {
await this.agentService.prompt(conversationId, body.content, scope);
await done;
} catch (err) {
if (err instanceof HttpException) throw err;
const message = err instanceof Error ? err.message : String(err);
if (message.includes('timed out')) {
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
}
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
throw this.toHttpException(result, conversationId);
}
/** Maps a total {@link LegacyRuntimeFailure} to the fixed browser-safe HTTP surface. */
private toHttpException(failure: LegacyRuntimeFailure, conversationId: string): HttpException {
switch (failure.code) {
case 'conversation_unavailable':
return new NotFoundException('Session not found');
case 'request_invalid':
case 'thinking_level_invalid':
return new HttpException('Invalid chat request', HttpStatus.BAD_REQUEST);
case 'timeout':
return new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
case 'runtime_unsupported':
case 'runtime_unavailable':
return new HttpException('Agent runtime unavailable', HttpStatus.SERVICE_UNAVAILABLE);
default:
this.logger.error(`Chat turn failed for conversation=${conversationId}: ${failure.code}`);
return new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
return { conversationId, text: responseText };
}
}
+1 -67
View File
@@ -1,14 +1,4 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { Transform, Type } from 'class-transformer';
import {
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto {
@IsOptional()
@@ -42,60 +32,4 @@ export class ChatSocketMessageDto {
@IsOptional()
@IsUUID()
agentId?: string;
/** Validated channel attachment references; binary content is not embedded. */
attachments?: readonly ChannelAttachmentDto[];
}
/**
* Task Five, group 2 — the frozen pi-rpc `turn:send` selection triple.
*
* Each id is a required, non-empty, bounded string. There is no `@IsOptional` and no extra
* field: under `forbidNonWhitelisted` an unknown selection key is rejected, and a missing id
* fails `@IsString` (undefined is not a string) rather than silently passing.
*/
export class HarnessTurnSelectionDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
harnessId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
providerId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
modelId!: string;
}
/**
* Task Five, group 2 — the frozen wire contract for a pi-rpc `turn:send`.
*
* Validated through the production `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`:
* a UUID conversation id; `content` trimmed then bounded to 1..10_000 characters (whitespace-only
* collapses to empty and fails `@IsNotEmpty`); a nested `selection` object recursed with an
* explicit `@Type` (a bare `@ValidateNested` is masked green by class-validator's empty-metadata
* `unknownValue`); and a UUID-v4 idempotency key. No `provider`/`modelId`/`attachments` or other
* authority field is declared, so `forbidNonWhitelisted` rejects every unknown top-level key.
*/
export class HarnessTurnSendDto {
@IsUUID()
conversationId!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty()
@MaxLength(10_000)
content!: string;
@IsObject()
@ValidateNested()
@Type(() => HarnessTurnSelectionDto)
selection!: HarnessTurnSelectionDto;
@IsUUID('4')
idempotencyKey!: string;
}
@@ -8,31 +8,12 @@ const payload: SlashCommandPayload = {
approvalId: 'approval-1',
};
/**
* Task 5 fence (F, existing control): gateway-owned command authorization/approval must
* cause ZERO chat-runtime dispatch. Placed in the gateway's chat-runtime-router slot (the
* former direct `AgentService` slot) so any accidental chat-runtime resolution throws
* loudly instead of silently passing. Because execute/approval run entirely through the
* command executor dependency and never resolve a chat runtime, this fixture is never
* triggered and the ingress stays a GREEN control.
*/
function failIfUsedChatRuntimeRouter() {
return {
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the command approval path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the command approval path');
},
};
}
function buildGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
return new ChatGateway(
failIfUsedChatRuntimeRouter() as never,
{} as never,
{} as never,
{} as never,
{} as never,
@@ -91,114 +72,3 @@ describe('ChatGateway command approval ingress', () => {
});
});
});
/**
* Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so
* embedded slash-commands (/model, /agent, and every other non-audited command) are fixed
* "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent
* fall-through to embedded execution. Only runtime-independent audited system commands
* (/reload) pass through as a positive control, and the approval path stays runtime-independent.
* The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so
* a fence bypass surfaces as a thrown error rather than a silent embedded dispatch.
*/
function buildPiRpcGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
const piRpcRouter = {
runtimeMode: 'pi-rpc' as const,
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the pi-rpc command path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the pi-rpc command path');
},
};
return new ChatGateway(
piRpcRouter as never,
{} as never,
{} as never,
{} as never,
commandExecutor as never,
{} as never,
);
}
describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => {
const UNSUPPORTED = 'Slash commands are not available on this deployment.';
it.each(['model', 'agent', 'gc'])(
'fails /%s closed before the executor under pi-rpc (execute never called)',
async (command): Promise<void> => {
const commandExecutor = {
execute: vi
.fn()
.mockResolvedValue({ command, conversationId: 'conversation-1', success: true }),
createApproval: vi.fn(),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, {
command,
conversationId: 'conversation-1',
});
expect(commandExecutor.execute).toHaveBeenCalledTimes(0);
expect(client.emit).toHaveBeenCalledWith('command:result', {
command,
conversationId: 'conversation-1',
success: false,
message: UNSUPPORTED,
});
},
);
it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise<void> => {
const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true };
const commandExecutor = {
execute: vi.fn().mockResolvedValue(reloadResult),
createApproval: vi.fn(),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, {
command: 'reload',
conversationId: 'conversation-1',
});
expect(commandExecutor.execute).toHaveBeenCalledTimes(1);
expect(commandExecutor.execute).toHaveBeenCalledWith(
{ command: 'reload', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult);
});
it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn(),
createApproval: vi.fn().mockResolvedValue({
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
}),
};
const gateway = buildPiRpcGateway(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',
expect.objectContaining({ success: true, approvalId: 'approval-1' }),
);
});
});
Binary file not shown.
File diff suppressed because it is too large Load Diff
+3 -50
View File
@@ -1,59 +1,12 @@
import { forwardRef, Module } from '@nestjs/common';
import { CommandsModule } from '../commands/commands.module.js';
import { HarnessModule } from '../harness/harness.module.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_REGISTRY,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import type { HarnessConversationService } from '@mosaicstack/types';
import { ChatGateway } from './chat.gateway.js';
import { ChatController } from './chat.controller.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import { EmbeddedChatRuntime } from './embedded-chat.runtime.js';
import { HarnessChatRuntime } from './harness-chat.runtime.js';
/**
* Task Five wiring. The exclusive {@link ChatRuntimeRouter} is the single chat-execution
* authority: the controller and gateway inject only the router, never `AgentService`,
* `RoutingEngineService`, or a session/`piSession` handle. The router resolves exactly one
* runtime at module init — {@link EmbeddedChatRuntime} in legacy mode, {@link HarnessChatRuntime}
* in `pi-rpc` — over the REAL {@link HarnessModule} registry and conversation-service binding.
*
* The router and the harness runtime are constructed through factories because their
* dependencies are interface/union types with no runtime injection token (the registry and
* conversation-service arrive via the string tokens exported by `HarnessModule`); the embedded
* runtime injects the class-typed `AgentService` and is provided directly.
*/
@Module({
imports: [forwardRef(() => CommandsModule), HarnessModule],
imports: [forwardRef(() => CommandsModule)],
controllers: [ChatController],
providers: [
ChatGateway,
EmbeddedChatRuntime,
{
provide: HarnessChatRuntime,
useFactory: (conversationService: HarnessConversationServiceBinding) =>
new HarnessChatRuntime(conversationService as HarnessConversationService),
inject: [HARNESS_CONVERSATION_SERVICE],
},
{
provide: ChatRuntimeRouter,
useFactory: (
registry: HarnessRegistry,
conversationService: HarnessConversationServiceBinding,
embedded: EmbeddedChatRuntime,
harness: HarnessChatRuntime,
) => new ChatRuntimeRouter(registry, conversationService, embedded, harness),
inject: [
HARNESS_REGISTRY,
HARNESS_CONVERSATION_SERVICE,
EmbeddedChatRuntime,
HarnessChatRuntime,
],
},
],
exports: [ChatGateway, ChatRuntimeRouter],
providers: [ChatGateway],
exports: [ChatGateway],
})
export class ChatModule {}
@@ -1,532 +0,0 @@
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { AgentService, type AgentSession } from '../agent/agent.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
import type {
ChatRuntime,
LegacyBrowserMessagePayload,
LegacyEmbeddedChatPort,
LegacyRuntimeEvent,
LegacyRuntimeResult,
LegacySessionPresentation,
LegacySocketTurnLease,
LegacyUsage,
OwnedConversationContext,
VerifiedDiscordIngressContext,
VerifiedDiscordTurnLease,
LegacyRuntimeStream,
} from './chat-runtime.js';
/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */
const REST_TURN_TIMEOUT_MS = 120_000;
/**
* The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}.
*
* It owns the embedded in-process execution path — the `AgentService` stack that the
* `ChatController` and `ChatGateway` drove directly before Task Five. Once the
* {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser
* HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so
* neither the controller nor the gateway retains `AgentService`, `piSession`, session,
* listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by
* `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation
* collapses to `conversation_unavailable` and never throws out of the port.
*/
@Injectable()
export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort {
readonly kind = 'embedded' as const;
private readonly logger = new Logger(EmbeddedChatRuntime.name);
constructor(readonly agentService: AgentService) {}
// -------------------------------------------------------------------------
// Legacy REST completion (op A)
// -------------------------------------------------------------------------
async completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(conversationId, scope, {});
if (!resolved.ok) return resolved;
let responseText = '';
let timer: ReturnType<typeof setTimeout> | undefined;
let detach: (() => void) | undefined;
let disposed = false;
// One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and
// detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt
// rejection fires first. Without this, a prompt() rejection surfaced through the catch below
// would return while leaving the listener attached (free to consume a later turn's events) and
// the 120s timer live (its rejection later going unobserved).
const dispose = (): void => {
if (disposed) return;
disposed = true;
if (timer !== undefined) clearTimeout(timer);
detach?.();
};
const done = new Promise<void>((resolve, reject) => {
timer = setTimeout(() => {
dispose();
reject(new Error('Agent response timed out'));
}, REST_TURN_TIMEOUT_MS);
detach = 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') {
dispose();
resolve();
}
},
scope,
);
});
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
const prompting = this.agentService.prompt(conversationId, input.content, scope);
try {
await Promise.all([prompting, done]);
} catch (err) {
dispose();
const message = err instanceof Error ? err.message : String(err);
if (message.includes('timed out')) {
return { ok: false, code: 'timeout', retryable: true };
}
this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message);
return { ok: false, code: 'operation_failed', retryable: false };
}
const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation;
return { ok: true, value: { text: responseText, presentation } };
}
// -------------------------------------------------------------------------
// Legacy Socket streaming (op B)
// -------------------------------------------------------------------------
async prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(conversationId, scope, {
...(input.provider ? { provider: input.provider } : {}),
...(input.modelId ? { modelId: input.modelId } : {}),
...(input.agentId ? { agentConfigId: input.agentId } : {}),
});
if (!resolved.ok) return resolved;
let detach: () => void;
try {
detach = this.subscribe(conversationId, scope, stream);
} catch (err) {
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
this.logger.error(
`Embedded socket subscription failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
return {
ok: true,
value: this.buildLease(
conversationId,
scope,
input.content,
input.attachments,
detach,
resolved.presentation,
),
};
}
// -------------------------------------------------------------------------
// Thinking level (op C) — synchronous, total
// -------------------------------------------------------------------------
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
const availableThinkingLevels = session.piSession.getAvailableThinkingLevels();
if (!(availableThinkingLevels as readonly string[]).includes(level)) {
return {
ok: false,
code: 'thinking_level_invalid',
retryable: false,
availableThinkingLevels,
};
}
session.piSession.setThinkingLevel(level as never);
return { ok: true, value: this.presentationForSession(session) };
}
// -------------------------------------------------------------------------
// Abort (op D)
// -------------------------------------------------------------------------
async abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
try {
await session.piSession.abort();
} catch (err) {
this.logger.error(
`Legacy abort failed for conversation=${context.conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'operation_failed', retryable: false };
}
return { ok: true, value: undefined };
}
// -------------------------------------------------------------------------
// Model override (synchronous, total)
// -------------------------------------------------------------------------
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
this.agentService.updateSessionModel(context.conversationId, modelId, scope);
const refreshed = this.agentService.getSession(context.conversationId, scope) ?? session;
return { ok: true, value: this.presentationForSession(refreshed) };
}
// -------------------------------------------------------------------------
// Presentation read (synchronous, total)
// -------------------------------------------------------------------------
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
return { ok: true, value: this.presentationForSession(session) };
}
// -------------------------------------------------------------------------
// Verified Discord ingress (embedded-only in both modes)
// -------------------------------------------------------------------------
async dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(
conversationId,
scope,
{ agentConfigId: context.configuredAgent.agentConfigId },
{
agentConfigId: context.configuredAgent.agentConfigId,
instanceId: context.configuredAgent.instanceId,
},
);
if (!resolved.ok) return resolved;
let detach: () => void;
try {
detach = this.subscribe(conversationId, scope, stream);
} catch (err) {
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
this.logger.error(
`Embedded Discord subscription failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
return {
ok: true,
value: this.buildLease(
conversationId,
scope,
context.content,
context.attachments,
detach,
resolved.presentation,
),
};
}
// -------------------------------------------------------------------------
// Shared helpers
// -------------------------------------------------------------------------
/**
* Resolves the owned session, creating it on first use. Ownership/scope rejections
* (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation
* failure surfaces as the retryable `runtime_unavailable`. On success returns the
* session presentation so callers avoid a redundant `getSession`.
*/
private async resolveOrCreate(
conversationId: string,
scope: ActorTenantScope,
extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>,
expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>,
): Promise<
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
> {
// A verified-Discord turn may only run under a session whose configured identity matches the
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
// pre-existing session AND a freshly created one: a session carrying a different configured
// agent — however it arose — is rejected rather than executed under the verified label, so we
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
// no expectedAgent and skips the check.
const identityMatches = (candidate: AgentSession): boolean =>
expectedAgent === undefined ||
(candidate.agentConfigId === expectedAgent.agentConfigId &&
candidate.agentName === expectedAgent.instanceId);
let session = this.agentService.getSession(conversationId, scope);
if (session && !identityMatches(session)) {
// Reused same-scope session minted under a different configured identity — reject with zero
// effects rather than dispatch a verified turn onto a foreign agent's session.
return CONVERSATION_UNAVAILABLE;
}
if (!session) {
try {
session = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
...extraOptions,
});
} catch (err) {
if (err instanceof ForbiddenException || err instanceof NotFoundException) {
return CONVERSATION_UNAVAILABLE;
}
this.logger.error(
`Embedded session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
// The just-created session must ALSO carry the reconciled identity before any effect. A
// createSession that returns a session under a different configured agent (misconfiguration
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
if (!identityMatches(session)) {
return CONVERSATION_UNAVAILABLE;
}
}
return { ok: true, presentation: this.presentationForSession(session) };
}
/** Installs a normalizing event listener that forwards to the server-owned stream. */
private subscribe(
conversationId: string,
scope: ActorTenantScope,
stream: LegacyRuntimeStream,
): () => void {
const unsubscribe = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
const normalized = this.normalizeEvent(conversationId, scope, event);
if (normalized) stream.onEvent(normalized);
},
scope,
);
try {
this.agentService.addChannel(conversationId, stream.channelId, scope);
} catch (err) {
// Partial setup: the listener was acquired but the channel attach failed. Roll back
// exactly what was acquired (the listener) before the failure escapes, so no leaked
// subscription survives; the caller converts the rethrow into a total safe failure.
try {
unsubscribe();
} catch {
/* idempotent teardown */
}
throw err;
}
return () => {
try {
unsubscribe();
} catch {
/* idempotent teardown */
}
try {
this.agentService.removeChannel(conversationId, stream.channelId, scope);
} catch {
/* idempotent teardown */
}
};
}
/** Builds an atomically one-shot, scope-rechecking dispatch lease. */
private buildLease(
conversationId: string,
scope: ActorTenantScope,
content: string,
attachments: VerifiedDiscordIngressContext['attachments'],
detach: () => void,
presentation: LegacySessionPresentation,
): LegacySocketTurnLease & VerifiedDiscordTurnLease {
let dispatched = false;
let disposed = false;
return {
presentation,
dispatch: async (): Promise<LegacyRuntimeResult<void>> => {
if (dispatched) {
return { ok: false, code: 'turn_already_dispatched', retryable: false };
}
dispatched = true;
try {
await this.agentService.prompt(conversationId, content, scope, attachments);
} catch (err) {
this.logger.error(
`Legacy dispatch failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'operation_failed', retryable: false };
}
return { ok: true, value: undefined };
},
dispose: async (): Promise<void> => {
if (disposed) return;
disposed = true;
detach();
},
};
}
/** Normalizes a raw agent event into the redaction-agnostic transport event, or drops it. */
private normalizeEvent(
conversationId: string,
scope: ActorTenantScope,
event: AgentSessionEvent,
): LegacyRuntimeEvent | undefined {
switch (event.type) {
case 'agent_start':
return { type: 'started' };
case 'agent_end':
return { type: 'settled', ...this.usageFor(conversationId, scope) };
case 'message_update': {
const assistant = event.assistantMessageEvent;
if (assistant.type === 'text_delta') return { type: 'text_delta', text: assistant.delta };
if (assistant.type === 'thinking_delta') {
return { type: 'thinking_delta', text: assistant.delta };
}
return undefined;
}
case 'tool_execution_start':
return { type: 'tool_started', toolCallId: event.toolCallId, toolName: event.toolName };
case 'tool_execution_end':
return {
type: 'tool_finished',
toolCallId: event.toolCallId,
toolName: event.toolName,
isError: event.isError,
};
default:
return undefined;
}
}
/**
* Gathers terminal usage from the Pi session and records it into session metrics.
* Embedded owns AgentService metrics; the gateway never touches `piSession` stats.
*/
private usageFor(conversationId: string, scope: ActorTenantScope): { usage?: LegacyUsage } {
const session = this.agentService.getSession(conversationId, scope);
const piSession = session?.piSession;
const stats = piSession?.getSessionStats();
if (!session || !stats) return {};
const contextUsage = piSession?.getContextUsage();
const tokens = {
input: stats.tokens?.input ?? 0,
output: stats.tokens?.output ?? 0,
cacheRead: stats.tokens?.cacheRead ?? 0,
cacheWrite: stats.tokens?.cacheWrite ?? 0,
total: stats.tokens?.total ?? 0,
};
this.agentService.recordTokenUsage(conversationId, { ...tokens });
return {
usage: {
provider: session.provider,
modelId: session.modelId,
thinkingLevel: piSession?.thinkingLevel ?? 'off',
tokens,
cost: stats.cost ?? 0,
context: {
percent: contextUsage?.percent ?? null,
window: contextUsage?.contextWindow ?? 0,
},
},
};
}
/** Presentation from a live session id, or undefined when no owned session exists. */
private presentationFor(
conversationId: string,
scope: ActorTenantScope,
): LegacySessionPresentation | undefined {
const session = this.agentService.getSession(conversationId, scope);
return session ? this.presentationForSession(session) : undefined;
}
/** User-facing projection carrying no session handle, credential, or raw stats. */
private presentationForSession(session: AgentSession): LegacySessionPresentation {
return {
provider: session.provider,
modelId: session.modelId,
thinkingLevel: session.piSession.thinkingLevel,
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
...(session.agentName ? { agentName: session.agentName } : {}),
};
}
}
/** The shared terminal `conversation_unavailable` failure (missing/foreign/lost ownership). */
const CONVERSATION_UNAVAILABLE = {
ok: false as const,
code: 'conversation_unavailable' as const,
retryable: false as const,
};
/** Narrows a branded context scope to the `AgentService` actor/tenant scope (identical shape). */
function toScope(scope: Readonly<{ userId: string; tenantId: string }>): ActorTenantScope {
return { userId: scope.userId, tenantId: scope.tenantId };
}
@@ -1,170 +0,0 @@
import { describe, expect, it } from 'vitest';
import type {
AttachConversation,
ConversationSnapshot,
DetachConversation,
HarnessActorContext,
HarnessConversationService,
HarnessEventEnvelope,
HarnessSelection,
SendHarnessTurn,
TurnReceipt,
} from '@mosaicstack/types';
import { HarnessChatRuntime } from './harness-chat.runtime.js';
/**
* Task Five, Step One (harness runtime). Proves the `pi-rpc` runtime executes
* exclusively through the {@link HarnessConversationService} RPC boundary and
* forwards the caller's exact selection tuple and idempotency key without
* substitution. Red-first: the runtime is an unimplemented stub, so every
* delegation assertion fails until Step Three.
*/
const context: HarnessActorContext = {
actorId: 'actor-1',
tenantId: 'tenant-1',
seatId: 'seat-1',
correlationId: 'corr-1',
};
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude-opus-4-8',
};
const conversationId = '11111111-1111-4111-8111-111111111111';
const idempotencyKey = '22222222-2222-4222-8222-222222222222';
const sendInput: SendHarnessTurn & { idempotencyKey: string } = {
context,
conversationId,
selection,
turnId: 'turn-abc',
correlationId: 'corr-1',
content: 'hello',
idempotencyKey,
};
const attachInput: AttachConversation & { afterSequence?: number } = {
context,
conversationId,
clientId: 'client-1',
selection,
afterSequence: 0,
};
const detachInput: DetachConversation = {
context,
conversationId,
clientId: 'client-1',
};
interface RecordedCalls {
attach: (AttachConversation & { afterSequence?: number })[];
detach: DetachConversation[];
send: (SendHarnessTurn & { idempotencyKey: string })[];
subscribeFrom: { conversationId: string; afterSequence: number }[];
}
const snapshot: ConversationSnapshot = {
session: {
conversationId,
nativeSessionId: 'native-1',
seatId: 'seat-1',
selection,
state: 'idle',
attachedClientIds: ['client-1'],
},
lastSequence: 0,
replay: [],
};
function build(): { runtime: HarnessChatRuntime; calls: RecordedCalls } {
const calls: RecordedCalls = { attach: [], detach: [], send: [], subscribeFrom: [] };
const service: HarnessConversationService = {
attach: (input) => {
calls.attach.push(input);
return Promise.resolve(snapshot);
},
detach: (input) => {
calls.detach.push(input);
return Promise.resolve();
},
send: (input) => {
calls.send.push(input);
// The service echoes only the requested tuple; there is no representable substitute.
const receipt: TurnReceipt = {
conversationId: input.conversationId,
turnId: 'turn-server',
correlationId: input.correlationId,
state: 'accepted',
selection: input.selection,
};
return Promise.resolve(receipt);
},
subscribeFrom: (id, afterSequence) => {
calls.subscribeFrom.push({ conversationId: id, afterSequence });
return (async function* (): AsyncIterable<HarnessEventEnvelope> {
return;
})();
},
};
return { runtime: new HarnessChatRuntime(service), calls };
}
describe('HarnessChatRuntime', () => {
it('is the harness runtime kind and needs only a HarnessConversationService', () => {
const { runtime } = build();
expect(runtime.kind).toBe('harness');
});
it('delegates send to the conversation service with the exact tuple and idempotency key', async () => {
const { runtime, calls } = build();
const receipt = await runtime.send(sendInput);
expect(calls.send).toHaveLength(1);
const firstSend = calls.send[0]!;
expect(firstSend).toEqual(sendInput);
expect(firstSend.idempotencyKey).toBe(idempotencyKey);
expect(firstSend.selection).toEqual(selection);
// The runtime must not substitute an effective tuple onto the receipt.
expect(receipt.selection).toEqual(selection);
});
it('delegates attach to the conversation service and returns its snapshot', async () => {
const { runtime, calls } = build();
const result = await runtime.attach(attachInput);
expect(calls.attach).toHaveLength(1);
expect(calls.attach[0]).toEqual(attachInput);
expect(result).toBe(snapshot);
});
it('delegates detach to the conversation service', async () => {
const { runtime, calls } = build();
await runtime.detach(detachInput);
expect(calls.detach).toHaveLength(1);
expect(calls.detach[0]).toEqual(detachInput);
});
it('delegates subscribeFrom to the conversation service journal replay', async () => {
const { runtime, calls } = build();
const iterable = runtime.subscribeFrom(conversationId, 7);
// Drain to prove it is the service-backed async iterable, not a fabricated one.
const drained: unknown[] = [];
for await (const event of iterable) {
drained.push(event);
}
expect(drained).toHaveLength(0);
expect(calls.subscribeFrom).toHaveLength(1);
expect(calls.subscribeFrom[0]).toEqual({ conversationId, afterSequence: 7 });
});
});
@@ -1,47 +0,0 @@
import type {
AttachConversation,
ConversationSnapshot,
DetachConversation,
HarnessConversationService,
HarnessEventEnvelope,
SendHarnessTurn,
TurnReceipt,
} from '@mosaicstack/types';
import type { ChatRuntime } from './chat-runtime.js';
/**
* The `pi-rpc` chat runtime. It executes browser chat exclusively through the
* harness-neutral {@link HarnessConversationService} RPC boundary — it never
* touches the embedded `AgentService`/`ProviderService`/`RoutingEngineService`
* stack, and it forwards the caller's exact selection tuple and idempotency key
* without substitution.
*
* It owns no state and adds no policy: every method forwards the caller's exact
* argument to the injected {@link HarnessConversationService} and returns its
* result unchanged, so the requested selection tuple and idempotency key can
* never be substituted on the way through.
*/
export class HarnessChatRuntime implements ChatRuntime {
readonly kind = 'harness' as const;
constructor(private readonly conversations: HarnessConversationService) {}
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot> {
return this.conversations.attach(input);
}
detach(input: DetachConversation): Promise<void> {
return this.conversations.detach(input);
}
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt> {
return this.conversations.send(input);
}
subscribeFrom(
conversationId: string,
afterSequence: number,
): AsyncIterable<HarnessEventEnvelope> {
return this.conversations.subscribeFrom(conversationId, afterSequence);
}
}
@@ -1,4 +1,3 @@
import { Logger } from '@nestjs/common';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CommandExecutorService } from './command-executor.service.js';
import type { SlashCommandPayload } from '@mosaicstack/types';
@@ -13,7 +12,6 @@ const mockRegistry = {
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
],
skills: [],
})),
@@ -74,35 +72,17 @@ const mockChatGateway = {
broadcastSessionInfo: vi.fn(),
};
const mockMcpClient = {
reconnectServer: vi.fn().mockResolvedValue(undefined),
getServerStatuses: vi.fn(() => []),
getToolDefinitions: vi.fn(() => []),
};
const allowAuthorization = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
function buildService(
redis: typeof mockRedis | null = mockRedis,
mcpClient: {
reconnectServer: ReturnType<typeof vi.fn>;
getServerStatuses: ReturnType<typeof vi.fn>;
getToolDefinitions: ReturnType<typeof vi.fn>;
} = mockMcpClient,
): CommandExecutorService {
function buildService(): CommandExecutorService {
return new CommandExecutorService(
mockRegistry as never,
mockAgentService as never,
mockSystemOverride as never,
mockSessionGC as never,
redis as never,
mockRedis as never,
mockBrain as never,
null,
mockChatGateway as never,
mcpClient as never,
allowAuthorization as never,
null,
);
}
@@ -151,22 +131,6 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(ttl).toBe(300);
});
it('/provider login remains available without Redis on the local tier', async () => {
const localService = buildService(null);
const payload: SlashCommandPayload = {
command: 'provider',
args: 'login anthropic',
conversationId,
};
const result = await localService.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).not.toContain('token=');
expect(result.data).toEqual({ provider: 'anthropic' });
expect(mockRedis.set).not.toHaveBeenCalled();
});
// /provider with no args — returns usage
it('/provider with no args returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'provider', conversationId };
@@ -278,124 +242,4 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(result.command).toBe('tools');
expect(result.message).toContain('tools');
});
// Top-level catch sanitization (P3-4 re-review finding #1): a rejected
// Redis `set` inside /provider login is the only reachable path into the
// top-level catch in `execute()`. The raw exception must be logged
// server-side but never handed back to the socket client.
it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => {
const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31';
const rawError = new Error(distinctiveRawFailure);
const failingRedis = {
set: vi.fn().mockRejectedValue(rawError),
get: vi.fn(),
del: vi.fn(),
};
const failingService = buildService(failingRedis as unknown as typeof mockRedis);
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const payload: SlashCommandPayload = {
command: 'provider',
args: 'login anthropic',
conversationId,
};
const result = await failingService.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.command).toBe('provider');
expect(result.message).toBe('Command failed due to an internal error.');
expect(result.message).not.toContain(distinctiveRawFailure);
expect(result.message).not.toContain('ECONNREFUSED');
// The real exception is still logged server-side, as the raw Error
// object itself (not stringified/interpolated into the log message).
expect(loggerErrorSpy).toHaveBeenCalled();
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError));
expect(loggedRawError).toBe(true);
loggerErrorSpy.mockRestore();
});
// Inner catch sanitization (P3-5 operator ruling): every catch in
// command-executor.service.ts that returns a SlashCommandResultPayload
// must sanitize the client-facing message the same way the top-level
// catch does, while still logging the raw exception server-side.
it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => {
const marker = new Error('distinctive-agent-create-failure-token-A17f');
mockBrain.agents.create.mockRejectedValueOnce(marker);
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const payload: SlashCommandPayload = {
command: 'agent',
args: 'new my-new-agent',
conversationId,
};
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.command).toBe('agent');
expect(result.message).toBe('Failed to create agent due to an internal error.');
expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f');
expect(loggerErrorSpy).toHaveBeenCalled();
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
expect(loggedRawError).toBe(true);
loggerErrorSpy.mockRestore();
});
it('/agent <name> switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => {
const marker = new Error('distinctive-agent-switch-failure-token-B29c');
mockBrain.agents.findByName.mockRejectedValueOnce(marker);
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const payload: SlashCommandPayload = {
command: 'agent',
args: 'some-other-agent',
conversationId,
};
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.command).toBe('agent');
expect(result.message).toBe('Failed to switch agent due to an internal error.');
expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c');
expect(loggerErrorSpy).toHaveBeenCalled();
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
expect(loggedRawError).toBe(true);
loggerErrorSpy.mockRestore();
});
it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => {
const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e');
const mockMcpClient = {
reconnectServer: vi.fn().mockRejectedValue(marker),
getServerStatuses: vi.fn(() => []),
getToolDefinitions: vi.fn(() => []),
};
const mcpService = buildService(mockRedis, mockMcpClient);
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const payload: SlashCommandPayload = {
command: 'mcp',
args: 'reconnect my-server',
conversationId,
};
const result = await mcpService.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.command).toBe('mcp');
expect(result.message).toBe(
'Failed to reconnect MCP server "my-server" due to an internal error.',
);
expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e');
expect(loggerErrorSpy).toHaveBeenCalled();
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
expect(loggedRawError).toBe(true);
loggerErrorSpy.mockRestore();
});
});
@@ -36,12 +36,6 @@ const authorization = {
),
};
const mockMcpClient = {
getServerStatuses: vi.fn(() => []),
getToolDefinitions: vi.fn(() => []),
reconnectServer: vi.fn().mockResolvedValue(undefined),
};
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
return new CommandExecutorService(
registry as never,
@@ -52,7 +46,7 @@ function buildExecutor(authorizationService: unknown = authorization): CommandEx
{ agents: {} } as never,
null,
null,
mockMcpClient as never,
null,
authorizationService as never,
);
}
@@ -23,10 +23,7 @@ export class CommandExecutorService {
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
@Optional()
@Inject(COMMANDS_REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
@Inject(BRAIN) private readonly brain: Brain,
@Optional()
@Inject(forwardRef(() => ReloadService))
@@ -34,9 +31,12 @@ export class CommandExecutorService {
@Optional()
@Inject(forwardRef(() => ChatGateway))
private readonly chatGateway: ChatGateway | null,
@Inject(McpClientService) private readonly mcpClient: McpClientService,
@Optional()
@Inject(McpClientService)
private readonly mcpClient: McpClientService | null,
@Optional()
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService,
private readonly authorization: CommandAuthorizationService | null = null,
) {}
async execute(
@@ -56,13 +56,13 @@ export class CommandExecutorService {
};
}
const authorization = await this.authorization.authorize(
const authorization = await this.authorization?.authorize(
def,
payload,
userId,
payload.approvalId,
);
if (!authorization.allowed) {
if (authorization && !authorization.allowed) {
return { command, conversationId, success: false, message: authorization.reason };
}
@@ -156,13 +156,8 @@ export class CommandExecutorService {
};
}
} catch (err) {
this.logger.error(`Command /${command} failed`, err);
return {
command,
conversationId,
success: false,
message: 'Command failed due to an internal error.',
};
this.logger.error(`Command /${command} failed: ${err}`);
return { command, conversationId, success: false, message: String(err) };
}
}
@@ -170,7 +165,7 @@ export class CommandExecutorService {
const def = this.registry
.getManifest()
.commands.find((command) => command.name === payload.command);
if (!def) return null;
if (!def || !this.authorization) return null;
return this.authorization.createApproval(def, payload, scope.userId);
}
@@ -338,11 +333,11 @@ export class CommandExecutorService {
data: { agentId: newAgent.id, agentName: newAgent.name },
};
} catch (err) {
this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
this.logger.error(`Failed to create agent: ${err}`);
return {
command: 'agent',
success: false,
message: 'Failed to create agent due to an internal error.',
message: `Failed to create agent: ${String(err)}`,
conversationId,
};
}
@@ -393,11 +388,11 @@ export class CommandExecutorService {
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
};
} catch (err) {
this.logger.error(`Failed to switch agent "${agentName}"`, err);
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
return {
command: 'agent',
success: false,
message: 'Failed to switch agent due to an internal error.',
message: `Failed to switch agent: ${String(err)}`,
conversationId,
};
}
@@ -448,16 +443,14 @@ export class CommandExecutorService {
byte.toString(16).padStart(2, '0'),
).join('');
const key = `mosaic:auth:poll:${tokenHash}`;
if (this.redis) {
// 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,
);
}
// 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,
);
return {
command: 'provider',
success: true,
@@ -545,6 +538,15 @@ export class CommandExecutorService {
args: string | null,
conversationId: string,
): Promise<SlashCommandResultPayload> {
if (!this.mcpClient) {
return {
command: 'mcp',
conversationId,
success: false,
message: 'MCP client service is not available.',
};
}
const action = args?.trim().split(/\s+/)[0] ?? 'status';
switch (action) {
@@ -601,12 +603,11 @@ export class CommandExecutorService {
message: `MCP server "${serverName}" reconnected successfully.`,
};
} catch (err) {
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
return {
command: 'mcp',
conversationId,
success: false,
message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
};
}
}
@@ -11,8 +11,6 @@
* - Unknown command returns descriptive error
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CommandsModule } from './commands.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { CommandRegistryService } from './command-registry.service.js';
import { CommandExecutorService } from './command-executor.service.js';
import type { SlashCommandPayload } from '@mosaicstack/types';
@@ -49,16 +47,6 @@ const mockBrain = {
},
};
const mockMcpClient = {
getServerStatuses: vi.fn(() => []),
getToolDefinitions: vi.fn(() => []),
reconnectServer: vi.fn().mockResolvedValue(undefined),
};
const allowAuthorization = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function buildRegistry(): CommandRegistryService {
@@ -77,8 +65,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
mockBrain as never,
null, // reloadService (optional)
null, // chatGateway (optional)
mockMcpClient as never,
allowAuthorization as never,
null, // mcpClient (optional)
);
}
@@ -166,15 +153,6 @@ describe('CommandRegistryService — integration', () => {
}
});
// ─── Module Wiring Tests ──────────────────────────────────────────────────────
describe('CommandsModule — Nest wiring', () => {
it('CommandsModule imports McpClientModule in its Nest metadata', () => {
const imports = Reflect.getMetadata('imports', CommandsModule) ?? [];
expect(imports).toContain(McpClientModule);
});
});
// ─── Executor Tests ───────────────────────────────────────────────────────────
describe('CommandExecutorService — integration', () => {
@@ -281,14 +259,4 @@ describe('CommandExecutorService — integration', () => {
expect(result.command).toBe(cmd);
});
}
// /mcp status reaches the required McpClientService and never reports it unavailable
it('/mcp status calls the wired McpClientService and reports the no-servers message', async () => {
const payload: SlashCommandPayload = { command: 'mcp', conversationId };
const result = await executor.execute(payload, userScope);
expect(mockMcpClient.getServerStatuses).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(result.message).toContain('No MCP servers configured.');
expect(result.message).not.toBe('MCP client service is not available.');
});
});
+6 -22
View File
@@ -1,10 +1,7 @@
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { ReloadModule } from '../reload/reload.module.js';
import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js';
@@ -15,26 +12,17 @@ import { COMMANDS_REDIS } from './commands.tokens.js';
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
@Module({
imports: [
GCModule,
McpClientModule,
forwardRef(() => ReloadModule),
forwardRef(() => ChatModule),
],
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
providers: [
{
provide: COMMANDS_QUEUE_HANDLE,
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
// On Local tier there is no Redis — skip the ioredis connection.
// CommandExecutorService falls back to no-cache for /provider login on local.
if (config?.queue?.type === 'local') return null;
useFactory: (): QueueHandle => {
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: COMMANDS_REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
useFactory: (handle: QueueHandle) => handle.redis,
inject: [COMMANDS_QUEUE_HANDLE],
},
CommandRegistryService,
@@ -50,13 +38,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
],
})
export class CommandsModule implements OnApplicationShutdown {
constructor(
@Optional()
@Inject(COMMANDS_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {});
await this.handle.close().catch(() => {});
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
import { Global, Module } from '@nestjs/common';
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
import { resolveGatewayConfigPath } from '../env.js';
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
@@ -9,7 +8,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
providers: [
{
provide: MOSAIC_CONFIG,
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
useFactory: (): MosaicConfig => loadConfig(),
},
],
exports: [MOSAIC_CONFIG],
@@ -1,116 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChatRuntimeMode } from '../chat/chat-runtime.js';
import { ConversationsController } from './conversations.controller.js';
/**
* Task 5 harness fence for the conversations REST write path.
*
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
* legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a
* fixed typed `runtime_unsupported` BEFORE the repository is touched never a duplicate write.
* Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`.
*
* Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution,
* injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from
* `process.env` at request time. The two "env is flipped after construction" tests below are the
* load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn
* RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`.
*/
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
const USER = { id: 'user-1' };
function sendMessageDto() {
return {
role: 'user' as const,
content: 'hello from the legacy REST write path',
metadata: undefined,
};
}
function brainWithMessageSpy() {
const addMessage = vi.fn().mockResolvedValue({
id: 'message-1',
conversationId: CONVERSATION_ID,
role: 'user',
content: 'hello from the legacy REST write path',
});
return {
brain: { conversations: { addMessage } } as never,
addMessage,
};
}
/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */
function routerFixedTo(mode: ChatRuntimeMode) {
return { runtimeMode: mode };
}
let priorMode: string | undefined;
describe('conversations REST write path — Task 5 harness fence', () => {
beforeEach(() => {
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
});
afterEach(() => {
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
});
it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => {
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
await expect(
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
).rejects.toMatchObject({ code: 'runtime_unsupported' });
// Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be
// written, so no duplicate message can be produced.
expect(addMessage).not.toHaveBeenCalled();
});
it('writes through the repository when the router resolved legacy (GREEN control)', async () => {
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
expect(addMessage).toHaveBeenCalledWith(
{
conversationId: CONVERSATION_ID,
role: 'user',
content: 'hello from the legacy REST write path',
metadata: undefined,
},
USER.id,
);
expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID });
});
it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => {
// The runtime mode is fixed at module init. A later env mutation must not reopen the fence:
// a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write.
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
await expect(
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
).rejects.toMatchObject({ code: 'runtime_unsupported' });
expect(addMessage).not.toHaveBeenCalled();
});
it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => {
// Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a
// request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write.
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
expect(addMessage).toHaveBeenCalledTimes(1);
});
});
@@ -6,7 +6,6 @@ import {
ForbiddenException,
Get,
HttpCode,
HttpException,
HttpStatus,
Inject,
NotFoundException,
@@ -20,7 +19,6 @@ 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';
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
import {
CreateConversationDto,
UpdateConversationDto,
@@ -28,41 +26,10 @@ import {
SearchMessagesDto,
} from './conversations.dto.js';
/**
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
* legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before
* the repository is touched never a duplicate write. The `code` field is exposed at the top level
* so callers can discriminate the refusal while the 503 status carries the browser-safe surface.
*/
class HarnessRuntimeWriteUnsupportedException extends HttpException {
readonly code = 'runtime_unsupported' as const;
constructor() {
super(
{
code: 'runtime_unsupported',
message:
'Conversation message writes are handled by the harness runtime on this deployment.',
},
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
@Controller('api/conversations')
@UseGuards(AuthGuard)
export class ConversationsController {
/**
* `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3).
* The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at
* request time a single source of truth, so the controller cannot disagree with the router
* about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode`
* so this class depends on nothing else the router exposes.
*/
constructor(
@Inject(BRAIN) private readonly brain: Brain,
@Inject(ChatRuntimeRouter) private readonly router: Pick<ChatRuntimeRouter, 'runtimeMode'>,
) {}
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list(@CurrentUser() user: { id: string }) {
@@ -127,13 +94,6 @@ export class ConversationsController {
@Body() dto: SendMessageDto,
@CurrentUser() user: { id: string },
) {
// Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the
// harness path owns persistence there, so a direct write would duplicate the message. The mode
// comes from the router's init-time resolution, not a request-time env read.
if (this.router.runtimeMode === 'pi-rpc') {
throw new HarnessRuntimeWriteUnsupportedException();
}
const message = await this.brain.conversations.addMessage(
{
conversationId: id,
@@ -1,14 +1,7 @@
import { Module } from '@nestjs/common';
import { ChatModule } from '../chat/chat.module.js';
import { ConversationsController } from './conversations.controller.js';
/**
* Imports {@link ChatModule} solely to inject its exported {@link ChatRuntimeRouter} into
* {@link ConversationsController}, so the REST write fence reads the same init-time runtime mode the
* router resolved one source of truth, no duplicate provider, no global token, no AppModule edit.
*/
@Module({
imports: [ChatModule],
controllers: [ConversationsController],
})
export class ConversationsModule {}
@@ -1,19 +0,0 @@
import 'reflect-metadata';
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { CoordModule } from './coord.module.js';
import { InteractionCoordinationService } from './interaction-coordination.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
describe('CoordModule DI (compiled-metadata boot)', () => {
it('resolves InteractionCoordinationService through Nest DI', async () => {
const moduleRef = await Test.createTestingModule({ imports: [CoordModule] })
.overrideGuard(AuthGuard)
.useValue({ canActivate: (): boolean => true })
.compile();
expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf(
InteractionCoordinationService,
);
await moduleRef.close();
});
});
@@ -1,4 +1,4 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import {
InteractionCoordinationClient,
type CoordinationObservation,
@@ -13,7 +13,6 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY');
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
const MAX_TRACKED_HANDOFFS = 1_000;
@@ -61,8 +60,6 @@ export class InteractionCoordinationService {
constructor(
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
@Optional()
@Inject(HANDOFF_ID_FACTORY)
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {}
-133
View File
@@ -1,133 +0,0 @@
import { config } from 'dotenv';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
type TierSource =
| 'process environment'
| 'daemon .env'
| 'monorepo-root .env'
| 'gateway-local .env'
| 'default';
type BootSource = TierSource | 'mosaic.config.json';
export interface GatewayDotenvPaths {
daemonEnv: string;
monorepoRootEnv: string;
gatewayLocalEnv: string;
}
const here = dirname(fileURLToPath(import.meta.url));
export function resolveGatewayDotenvPaths(
anchor: string = here,
homeBase: string = homedir(),
): GatewayDotenvPaths {
return {
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
gatewayLocalEnv: resolve(anchor, '..', '.env'),
};
}
export function resolveGatewayConfigPath(anchor: string = here): string {
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
const gatewayHome = resolve(
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
);
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
if (existsSync(daemonConfig)) {
return daemonConfig;
}
if (existsSync(gatewayLocalConfig)) {
return gatewayLocalConfig;
}
if (existsSync(monorepoRootConfig)) {
return monorepoRootConfig;
}
return monorepoRootConfig;
}
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
anchor,
homeBase,
);
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
let databaseUrlSource: TierSource =
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
function loadAnchoredDotenv(
path: string,
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
): void {
if (!existsSync(path)) {
return;
}
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
const beforeDatabaseUrl = process.env['DATABASE_URL'];
config({ path, quiet: true });
if (
beforeTier === undefined &&
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
tierSource === 'default'
) {
tierSource = sourceLabel;
}
if (
beforeDatabaseUrl === undefined &&
process.env['DATABASE_URL'] !== undefined &&
databaseUrlSource === 'default'
) {
databaseUrlSource = sourceLabel;
}
}
// Load .env from daemon config dir (global install / daemon mode) first.
// It takes precedence over file-based local-dev configuration.
loadAnchoredDotenv(daemonEnv, 'daemon .env');
// Load .env from the anchored monorepo root, then fill any remaining values
// from apps/gateway/.env when present.
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
const envOnlyTier = detectFromEnv().tier;
const configPath = resolveGatewayConfigPath(anchor);
const anchoredConfigExists = existsSync(configPath);
const resolvedTier = loadConfig(configPath).tier;
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
const recognizedTierDeterminesTier =
(configuredTier === 'federated' ||
configuredTier === 'standalone' ||
configuredTier === 'local') &&
configuredTier === envOnlyTier;
let source: BootSource;
if (anchoredConfigExists) {
source = 'mosaic.config.json';
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
source = databaseUrlSource;
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
source = tierSource;
} else {
source = 'default';
}
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
}
loadGatewayEnv();
@@ -5,8 +5,6 @@ import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GetController } from './server/verbs/get.controller.js';
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
@@ -14,13 +12,7 @@ import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({
controllers: [
EnrollmentController,
FederationController,
CapabilitiesController,
ListController,
GetController,
],
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
providers: [
AdminGuard,
CaService,
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
FederationGetQueryService,
],
exports: [
CaService,
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
FederationGetQueryService,
],
})
export class FederationModule {}
@@ -1,348 +0,0 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
createPgliteDb,
missionTasks,
missions,
projects,
runPgliteMigrations,
teams,
users,
type Db,
type DbHandle,
} from '@mosaicstack/db';
import type { FederationScopeQueryFilter } from '../../scope.service.js';
import { FederationGetQueryService } from '../get-query.service.js';
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
resource: 'credentials',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: [],
limit: 1,
maxRowsPerQuery: 25,
};
const SUBJECT_USER_ID = 'fed-m3-06-subject';
const OTHER_USER_ID = 'fed-m3-06-other';
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
let dbHandle: DbHandle | undefined;
function makeService() {
return new FederationGetQueryService({} as Db);
}
function makeDbService() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
return new FederationGetQueryService(dbHandle.db);
}
async function seedNotesFixture() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
await dbHandle.db.insert(users).values([
{
id: SUBJECT_USER_ID,
name: 'Federation Subject',
email: `${SUBJECT_USER_ID}@example.test`,
emailVerified: false,
},
{
id: OTHER_USER_ID,
name: 'Federation Other',
email: `${OTHER_USER_ID}@example.test`,
emailVerified: false,
},
]);
await dbHandle.db.insert(teams).values([
{
id: TEAM_ID,
name: 'FED-M3-06 Team',
slug: 'fed-m3-06-team',
ownerId: SUBJECT_USER_ID,
managerId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_TEAM_ID,
name: 'FED-M3-06 Unauthorized Team',
slug: 'fed-m3-06-unauthorized-team',
ownerId: OTHER_USER_ID,
managerId: OTHER_USER_ID,
},
]);
await dbHandle.db.insert(projects).values([
{
id: PERSONAL_PROJECT_ID,
name: 'FED-M3-06 Personal Project',
ownerId: SUBJECT_USER_ID,
ownerType: 'user',
},
{
id: TEAM_PROJECT_ID,
name: 'FED-M3-06 Team Project',
teamId: TEAM_ID,
ownerType: 'team',
},
{
id: UNAUTHORIZED_PROJECT_ID,
name: 'FED-M3-06 Unauthorized Project',
teamId: UNAUTHORIZED_TEAM_ID,
ownerType: 'team',
},
]);
await dbHandle.db.insert(missions).values([
{
id: PERSONAL_MISSION_ID,
name: 'FED-M3-06 Personal Mission',
projectId: PERSONAL_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: TEAM_MISSION_ID,
name: 'FED-M3-06 Team Mission',
projectId: TEAM_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_MISSION_ID,
name: 'FED-M3-06 Unauthorized Mission',
projectId: UNAUTHORIZED_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
]);
await dbHandle.db.insert(missionTasks).values([
{
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on team mission',
createdAt: new Date('2026-06-24T03:00:00.000Z'),
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
},
{
id: OTHER_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: OTHER_USER_ID,
notes: 'other user note on team mission',
createdAt: new Date('2026-06-24T02:00:00.000Z'),
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
},
{
id: SUBJECT_PERSONAL_NOTE_ID,
missionId: PERSONAL_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on personal mission',
createdAt: new Date('2026-06-24T01:00:00.000Z'),
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
},
{
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
missionId: UNAUTHORIZED_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note outside grant-visible missions',
createdAt: new Date('2026-06-24T04:00:00.000Z'),
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
},
]);
}
describe('FederationGetQueryService', () => {
beforeAll(async () => {
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
await runPgliteMigrations(dbHandle);
await seedNotesFixture();
});
afterAll(async () => {
await dbHandle?.close();
dbHandle = undefined;
});
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
}),
).resolves.toMatchObject({
allowed: false,
reason: 'credentials federation get access is not implemented in M3',
});
});
it('allows personal memory reads without requiring team lookup', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'memory',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
});
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
const service = makeService();
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
(
service as unknown as {
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
}
).listSubjectTeamIds = listSubjectTeamIds;
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'tasks',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
});
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
});
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
const service = makeService();
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
status: 'denied',
reason: 'credentials federation get is not implemented',
});
});
it('fails closed for unsupported resources instead of returning undefined', async () => {
const service = makeService();
await expect(
service.get({
filter: {
...CREDENTIAL_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
},
id: 'row-1',
}),
).resolves.toEqual({
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
});
});
it('does not leak another user mission task note through team-scoped get reads', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: OTHER_TEAM_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('does not return subject notes from missions outside the grant-visible project set', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_TEAM_NOTE_ID,
}),
).resolves.toMatchObject({
status: 'found',
item: {
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
content: 'subject note on team mission',
},
});
});
it('does not return subject personal notes when includePersonal is false', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_PERSONAL_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
});
@@ -1,207 +0,0 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import type {
FederationScopeEvaluationResult,
FederationScopeQueryFilter,
} from '../../scope.service.js';
import { GetController } from '../get.controller.js';
import type { FederationGetQueryResult } from '../get-query.service.js';
const FEDERATION_CONTEXT = {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope: { resources: ['tasks'], max_rows_per_query: 25 },
};
const TASK_FILTER: FederationScopeQueryFilter = {
resource: 'tasks',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: ['team-1'],
limit: 1,
maxRowsPerQuery: 25,
};
function makeRequest(): FastifyRequest {
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
}
function allowedScope(
filter: FederationScopeQueryFilter = TASK_FILTER,
): FederationScopeEvaluationResult {
return { allowed: true, filter };
}
function makeController(opts?: {
scopeResult?: FederationScopeEvaluationResult;
queryResult?: FederationGetQueryResult;
}) {
const scope = {
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
};
const query = {
evaluateReadAccess: vi.fn(),
get: vi.fn().mockResolvedValue(
opts?.queryResult ?? {
status: 'found',
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
},
},
),
};
return {
controller: new GetController(scope as never, query as never),
scope,
query,
};
}
describe('GetController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
});
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
const { controller, scope, query } = makeController();
const response = await controller.get('tasks', 'task-1', makeRequest());
expect(scope.evaluateAccess).toHaveBeenCalledWith({
context: FEDERATION_CONTEXT,
resource: 'tasks',
requestedLimit: 1,
nativeRbac: query,
});
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
expect(response).toEqual({
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
_source: 'local',
},
});
});
it('returns a federation error envelope when auth guard context is missing', async () => {
const { controller, scope, query } = makeController();
await expect(
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
).rejects.toMatchObject({
response: {
error: {
code: 'unauthorized',
message: 'Federation context missing',
},
},
status: 401,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
it('returns a federation error envelope when scope evaluation denies access', async () => {
const { controller, query } = makeController({
scopeResult: {
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
message: 'Requested federation resource is explicitly excluded by grant scope',
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
},
},
});
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is explicitly excluded by grant scope',
},
},
status: 403,
});
expect(query.get).not.toHaveBeenCalled();
});
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
const { controller } = makeController({ queryResult: { status: 'not_found' } });
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'not_found' } },
status: 404,
});
});
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
const { controller } = makeController({
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
});
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Task is outside the federated scope',
},
},
status: 403,
});
});
it('fails closed when the query layer denies an unsupported resource', async () => {
const unsupportedFilter: FederationScopeQueryFilter = {
...TASK_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
};
const { controller } = makeController({
scopeResult: allowedScope(unsupportedFilter),
queryResult: {
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
},
});
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Unsupported federation get resource: unknown-resource',
},
},
status: 403,
});
});
it('rejects empty ids before evaluating scope', async () => {
const { controller, scope, query } = makeController();
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
});
@@ -1,311 +0,0 @@
/**
* Federation get query layer (FED-M3-06).
*
* Read-only DB adapter used by GetController after FederationAuthGuard and
* FederationScopeService have established the subject user, allowed resource,
* native-RBAC intersection, and row cap. Audit writes are intentionally
* deferred to M4.
*/
import { Inject, Injectable } from '@nestjs/common';
import {
and,
eq,
inArray,
insights,
or,
missionTasks,
missions,
preferences,
projects,
tasks,
teamMembers,
type Db,
} from '@mosaicstack/db';
import { DB } from '../../../database/database.module.js';
import type {
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeQueryFilter,
} from '../scope.service.js';
export interface FederationGetQueryRequest {
readonly filter: FederationScopeQueryFilter;
readonly id: string;
}
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
readonly status: 'found';
readonly item: T;
}
export interface FederationGetQueryNotFoundResult {
readonly status: 'not_found';
}
export interface FederationGetQueryDeniedResult {
readonly status: 'denied';
readonly reason: string;
}
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
| FederationGetQueryFoundResult<T>
| FederationGetQueryNotFoundResult
| FederationGetQueryDeniedResult;
type RowObject = Record<string, unknown>;
function firstRow<T>(rows: T[]): T | undefined {
return rows[0];
}
function rowBelongsToAccessibleProjectOrMission(
row: { projectId?: string | null; missionId?: string | null },
projectIds: readonly string[],
missionIds: readonly string[],
): boolean {
return (
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
);
}
@Injectable()
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
constructor(@Inject(DB) private readonly db: Db) {}
async evaluateReadAccess(
request: FederationNativeRbacRequest,
): Promise<FederationNativeRbacResult> {
if (request.resource === 'credentials' || request.resource === 'api_keys') {
return {
allowed: false,
reason: `${request.resource} federation get access is not implemented in M3`,
details: { resource: request.resource },
};
}
if (request.resource === 'memory') {
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
}
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
return { allowed: true, access: { includePersonal: true, teamIds } };
}
async get<T extends RowObject = RowObject>(
request: FederationGetQueryRequest,
): Promise<FederationGetQueryResult<T>> {
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
}
private async getByResource(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
switch (filter.resource) {
case 'tasks':
return this.getTask(filter, id);
case 'notes':
return this.getNote(filter, id);
case 'memory':
return this.getMemory(filter, id);
case 'credentials':
case 'api_keys':
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
default:
return {
status: 'denied',
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
};
}
}
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
const rows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, subjectUserId));
return rows.map((row) => row.teamId);
}
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
const clauses = [];
if (filter.includePersonal) {
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
}
if (filter.teamIds.length > 0) {
// Project team ownership follows TeamsService.canAccessProject: team-owned
// rows are authorized through projects.teamId, while ownerId remains the
// user who created/bootstrapped the project.
clauses.push(
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
);
}
if (clauses.length === 0) {
return [];
}
const rows = await this.db
.select({ id: projects.id })
.from(projects)
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
return rows.map((row) => row.id);
}
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
if (projectIds.length === 0) {
return [];
}
const rows = await this.db
.select({ id: missions.id })
.from(missions)
.where(inArray(missions.projectId, [...projectIds]));
return rows.map((row) => row.id);
}
private async getTask(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: tasks.id,
title: tasks.title,
description: tasks.description,
status: tasks.status,
priority: tasks.priority,
projectId: tasks.projectId,
missionId: tasks.missionId,
assignee: tasks.assignee,
tags: tasks.tags,
dueDate: tasks.dueDate,
metadata: tasks.metadata,
createdAt: tasks.createdAt,
updatedAt: tasks.updatedAt,
})
.from(tasks)
.where(eq(tasks.id, id))
.limit(1),
);
if (!row) {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
return { status: 'denied', reason: 'Task is outside the federated scope' };
}
return { status: 'found', item: row as RowObject };
}
private async getNote(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: missionTasks.id,
missionId: missionTasks.missionId,
taskId: missionTasks.taskId,
userId: missionTasks.userId,
status: missionTasks.status,
content: missionTasks.notes,
createdAt: missionTasks.createdAt,
updatedAt: missionTasks.updatedAt,
})
.from(missionTasks)
.where(eq(missionTasks.id, id))
.limit(1),
);
if (!row || row.content === null || row.content === '') {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
// mission_tasks rows are user-scoped even when the mission belongs to a team.
// Scope-visible missions must intersect with subject ownership; team scope
// narrows mission IDs but never widens note reads to another user's rows.
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
return { status: 'denied', reason: 'Note is outside the federated scope' };
}
const item = { ...row } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
private async getMemory(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const [insightRow, preferenceRow] = await Promise.all([
this.db
.select({
id: insights.id,
userId: insights.userId,
kind: insights.source,
content: insights.content,
category: insights.category,
relevanceScore: insights.relevanceScore,
metadata: insights.metadata,
createdAt: insights.createdAt,
updatedAt: insights.updatedAt,
})
.from(insights)
.where(eq(insights.id, id))
.limit(1)
.then(firstRow),
this.db
.select({
id: preferences.id,
userId: preferences.userId,
kind: preferences.category,
key: preferences.key,
value: preferences.value,
source: preferences.source,
mutable: preferences.mutable,
createdAt: preferences.createdAt,
updatedAt: preferences.updatedAt,
})
.from(preferences)
.where(eq(preferences.id, id))
.limit(1)
.then(firstRow),
]);
const candidates = [insightRow, preferenceRow].filter(
(row): row is NonNullable<typeof row> => row !== undefined,
);
if (candidates.length === 0) {
return { status: 'not_found' };
}
if (!filter.includePersonal) {
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
}
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
if (!accessible) {
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
}
const item = { ...accessible } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
}
@@ -1,100 +0,0 @@
/**
* Federation get verb (FED-M3-06).
*
* POST /api/federation/v1/get/:resource/:id
*
* Pipeline: FederationAuthGuard attaches the active grant context, then
* FederationScopeService enforces grant scope + native RBAC intersection, then
* the read-only query layer fetches one local row and tags it with `_source`.
* Read audit-log writes are deferred to M4; this controller does not persist
* request or response bodies.
*/
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FederationInvalidRequestError,
FederationNotFoundError,
FederationScopeViolationError,
FederationUnauthorizedError,
SOURCE_LOCAL,
type FederationGetResponse,
type SourceTag,
} from '@mosaicstack/types';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
import { FederationScopeService } from '../scope.service.js';
import { FederationGetQueryService } from './get-query.service.js';
type FederatedRow = Record<string, unknown> & SourceTag;
function scopeDenyToHttpException(deny: {
readonly statusCode: 400 | 403;
readonly message: string;
}): HttpException {
const ErrorClass =
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
}
@Controller('api/federation/v1/get')
@UseGuards(FederationAuthGuard)
export class GetController {
constructor(
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
) {}
@Post(':resource/:id')
async get(
@Param('resource') resource: string,
@Param('id') id: string,
@Req() request: FastifyRequest,
): Promise<FederationGetResponse<FederatedRow>> {
if (!request.federationContext) {
throw new HttpException(
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
401,
);
}
if (id.trim().length === 0) {
throw new HttpException(
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
400,
);
}
const scopeResult = await this.scope.evaluateAccess({
context: request.federationContext,
resource,
requestedLimit: 1,
nativeRbac: this.query,
});
if (!scopeResult.allowed) {
throw scopeDenyToHttpException(scopeResult.deny);
}
const result = await this.query.get({ filter: scopeResult.filter, id });
if (result.status === 'not_found') {
throw new HttpException(
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
404,
);
}
if (result.status === 'denied') {
throw new HttpException(
new FederationScopeViolationError(result.reason, {
resource,
id,
grantId: request.federationContext.grantId,
peerId: request.federationContext.peerId,
subjectUserId: request.federationContext.subjectUserId,
}).toEnvelope(),
403,
);
}
return { item: { ...result.item, _source: SOURCE_LOCAL } };
}
}
+5 -15
View File
@@ -1,7 +1,5 @@
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js';
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
providers: [
{
provide: GC_QUEUE_HANDLE,
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
// On Local tier there is no Redis — skip the ioredis connection entirely.
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
if (config?.queue?.type === 'local') return null;
useFactory: (): QueueHandle => {
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
useFactory: (handle: QueueHandle) => handle.redis,
inject: [GC_QUEUE_HANDLE],
},
SessionGCService,
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
exports: [SessionGCService],
})
export class GCModule implements OnApplicationShutdown {
constructor(
@Optional()
@Inject(GC_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {});
await this.handle.close().catch(() => {});
}
}
@@ -119,19 +119,6 @@ describe('SessionGCService', () => {
).resolves.toEqual({ allowed: true });
});
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
const result = await localService.collect('local-session');
expect(result.sessionId).toBe('local-session');
expect(result.cleaned.valkeyKeys).toBeUndefined();
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
'local-session',
expect.any(Date),
);
});
it('collect() returns sessionId in result', async () => {
const result = await service.collect('test-session-id');
expect(result.sessionId).toBe('test-session-id');
+8 -15
View File
@@ -1,4 +1,4 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -21,10 +21,7 @@ function escapeRedisGlobLiteral(value: string): string {
@Injectable()
export class SessionGCService {
constructor(
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
@Optional()
@Inject(REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
@Inject(LOG_SERVICE) private readonly logService: LogService,
) {}
@@ -32,10 +29,8 @@ export class SessionGCService {
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
* KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes.
* Returns an empty population on the Local tier where Redis is disabled.
*/
private async scanKeys(pattern: string): Promise<string[]> {
if (!this.redis) return [];
const collected: string[] = [];
let cursor = '0';
do {
@@ -52,14 +47,12 @@ export class SessionGCService {
async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
if (this.redis) {
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
// 1. Valkey: delete all session-scoped keys
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
// 2. PG: demote hot-tier agent logs for this session only.
@@ -1,164 +0,0 @@
import 'reflect-metadata';
import {
type CanActivate,
type ExecutionContext,
type INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { HarnessRegistry } from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import { HarnessSelectionRepository } from './harness-selection.repository.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
// Import the REAL module (not a hand-listed controllers+mocks list) so an
// unresolved provider fails at app.init() — the #1145-class DI-boot guard.
import { HarnessModule } from './harness.module.js';
// A known-available tuple from the fake adapter's default catalog.
const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' };
// A tuple whose provider/model are not in any catalog.
const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' };
// A tuple that is known in the catalog but flagged unavailable.
const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' };
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
requestContext.user = { id: 'user-1' };
return true;
},
};
function registryWithFake(): HarnessRegistry {
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
return registry;
}
describe('Harness selection HTTP surface', () => {
let app: INestApplication;
let repository: HarnessSelectionRepository;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [HarnessModule],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.overrideProvider(HARNESS_REGISTRY)
.useValue(registryWithFake())
.compile();
// Real in-memory repository from the module graph — proves the module wired it.
repository = moduleRef.get(HarnessSelectionRepository);
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
beforeEach(() => {
// Reset owner-scoped state between tests via the public API surface.
repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID);
});
afterAll(async () => {
await app.close();
});
it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => {
const response = await request(app.getHttpServer())
.get('/api/chat/preferences/selection')
.query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' });
expect(response.status).toBe(200);
// The returned selection is user-1's (guard-derived scope), not the query's.
expect(response.body.selection).toEqual(VALID);
});
it('PUT with a valid structured tuple persists and round-trips via GET', async () => {
const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' };
const put = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(next)
.set('Content-Type', 'application/json');
expect(put.status).toBe(200);
expect(put.body.selection).toEqual(next);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.status).toBe(200);
expect(get.body.selection).toEqual(next);
});
it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send({ selection: 'gpt-4o' })
.set('Content-Type', 'application/json');
expect(response.status).toBe(400);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
});
it.each([
['seatId', { ...VALID, seatId: 'attacker-seat' }],
['tenantId', { ...VALID, tenantId: 'attacker-tenant' }],
['userId', { ...VALID, userId: 'attacker' }],
['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }],
['executable', { ...VALID, executable: '/usr/bin/evil' }],
['home', { ...VALID, home: '/home/attacker' }],
['cwd', { ...VALID, cwd: '/tmp/attacker' }],
])(
'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection',
async (_name, body) => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(body)
.set('Content-Type', 'application/json');
expect(response.status).toBe(400);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
},
);
it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(UNKNOWN)
.set('Content-Type', 'application/json');
expect(response.status).toBe(422);
expect(response.body.code).toBe('selection_invalid');
// Echoed back unchanged: no first-row / first-provider substitution.
expect(response.body.selection).toEqual(UNKNOWN);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
});
it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(UNAVAILABLE)
.set('Content-Type', 'application/json');
expect(response.status).toBe(422);
expect(response.body.code).toBe('model_unavailable');
expect(response.body.selection).toEqual(UNAVAILABLE);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
});
});
@@ -1,46 +0,0 @@
import { Body, Controller, Get, HttpException, HttpStatus, Put, 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 { HarnessOperationError } from './harness.registry.js';
import { HarnessSelectionService } from './harness-selection.service.js';
import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js';
/**
* Chat-preferences selection surface. The scope is ALWAYS derived on the server
* from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and
* query string can never name another user, tenant, or seat. A typed selection
* failure (unknown tuple `selection_invalid`, known-but-unavailable
* `model_unavailable`) is returned as 422 with the requested tuple echoed back
* unchanged, and never mutates the stored selection.
*/
@Controller('api/chat/preferences/selection')
@UseGuards(AuthGuard)
export class HarnessSelectionController {
constructor(private readonly selection: HarnessSelectionService) {}
@Get()
get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto {
return { selection: this.selection.getSelection(scopeFromUser(user)) };
}
@Put()
async put(
@CurrentUser() user: AuthenticatedUserLike,
@Body() dto: HarnessSelectionInputDto,
): Promise<SelectionResponseDto> {
try {
const stored = await this.selection.setSelection(scopeFromUser(user), {
harnessId: dto.harnessId,
providerId: dto.providerId,
modelId: dto.modelId,
});
return { selection: stored };
} catch (error) {
if (error instanceof HarnessOperationError) {
throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY);
}
throw error;
}
}
}
@@ -1,90 +0,0 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { HarnessSelection } from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
import {
HarnessAdapterUnavailableError,
HarnessRegistry,
operationError,
} from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import { readContextFromScope } from './harness.dto.js';
import { HarnessSelectionRepository } from './harness-selection.repository.js';
/**
* Selection logic for the Slice-Zero chat-preferences surface. It validates the
* requested harness/provider/model tuple against the live catalog with NO
* fallback substitution, then persists it owner-scoped. The stored selection is
* only ever mutated when the tuple is valid AND available.
*/
@Injectable()
export class HarnessSelectionService {
constructor(
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
private readonly repository: HarnessSelectionRepository,
) {}
getSelection(scope: ActorTenantScope): HarnessSelection | null {
return this.repository.get(scope);
}
async setSelection(
scope: ActorTenantScope,
selection: HarnessSelection,
): Promise<HarnessSelection> {
// Throws HarnessOperationError (selection_invalid / model_unavailable) with the
// requested tuple echoed back unchanged. The store is untouched on any throw.
await this.assertSelectionAvailable(scope, selection);
return this.repository.set(scope, selection);
}
private async assertSelectionAvailable(
scope: ActorTenantScope,
selection: HarnessSelection,
): Promise<void> {
const correlationId = randomUUID();
let adapter;
try {
adapter = this.registry.get(selection.harnessId);
} catch (error) {
if (error instanceof HarnessAdapterUnavailableError) {
// An unknown harness makes the whole tuple invalid — no fallback adapter.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
correlationId,
);
}
throw error;
}
const catalog = await adapter.catalog(readContextFromScope(scope));
const entry = catalog.models.find(
(candidate) =>
candidate.harnessId === selection.harnessId &&
candidate.providerId === selection.providerId &&
candidate.modelId === selection.modelId,
);
if (!entry) {
// No first-row / first-provider fallback: reject the requested tuple unchanged.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
correlationId,
);
}
if (entry.availability === 'unavailable') {
throw operationError(
'model_unavailable',
'The requested model is currently unavailable.',
selection,
correlationId,
true,
);
}
}
}
@@ -1,138 +0,0 @@
import 'reflect-metadata';
import {
type CanActivate,
type ExecutionContext,
type INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { HarnessRegistry } from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
// The real module under test — importing it (not a hand-listed controllers/mocks
// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard).
import { HarnessModule } from './harness.module.js';
// Fields that must NEVER surface on a browser-facing catalog/list response.
const FORBIDDEN_KEYS = [
'executable',
'executablePath',
'home',
'homeDir',
'cwd',
'workingDir',
'workingDirectory',
'nativeSessionPath',
'sessionPath',
'env',
'secret',
'secrets',
'token',
'apiKey',
];
function assertNoForbiddenLeak(payload: unknown): void {
const serialized = JSON.stringify(payload).toLowerCase();
for (const key of FORBIDDEN_KEYS) {
expect(serialized).not.toContain(key.toLowerCase());
}
}
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
requestContext.user = { id: 'user-1' };
return true;
},
};
function registryWithFake(): HarnessRegistry {
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
return registry;
}
describe('Harness catalog HTTP surface', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [HarnessModule],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.overrideProvider(HARNESS_REGISTRY)
.useValue(registryWithFake())
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app.close();
});
it('boots the real HarnessModule so all providers resolve at app.init()', () => {
// If HarnessModule failed to resolve a provider, beforeAll's app.init() would
// have thrown and this suite would never reach here.
expect(app).toBeDefined();
});
it('GET /api/harnesses returns 200 with safe fields only', async () => {
const response = await request(app.getHttpServer()).get('/api/harnesses');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
const summary = response.body[0];
expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']);
expect(summary.id).toBe('fake');
expect(typeof summary.displayName).toBe('string');
expect(Array.isArray(summary.capabilities)).toBe(true);
assertNoForbiddenLeak(response.body);
});
it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => {
const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog');
expect(response.status).toBe(200);
expect(response.body.harnessId).toBe('fake');
expect(typeof response.body.version).toBe('string');
expect(typeof response.body.fingerprint).toBe('string');
expect(Array.isArray(response.body.models)).toBe(true);
expect(response.body.models.length).toBeGreaterThan(0);
const entry = response.body.models[0];
// Whitelisted catalog-entry fields only (no executables/paths/secrets).
expect(Object.keys(entry).sort()).toEqual(
[
'authState',
'availability',
'displayName',
'harnessId',
'inputTypes',
'modelId',
'providerId',
'reasoningCapability',
].sort(),
);
assertNoForbiddenLeak(response.body);
});
it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => {
const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog');
expect(response.status).toBe(404);
expect(response.body.code).toBe('adapter_unavailable');
// A fallback catalog would carry a models array; a typed error must not.
expect(response.body.models).toBeUndefined();
});
});
@@ -1,65 +0,0 @@
import {
Controller,
Get,
HttpException,
HttpStatus,
Inject,
Param,
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 { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import {
readContextFromScope,
toHarnessSummary,
toSafeCatalog,
type HarnessCatalogDto,
type HarnessSummaryDto,
} from './harness.dto.js';
/**
* Generic harness catalog surface. It exposes only harness-neutral, browser-safe
* fields (identity, capabilities, provider/model catalog) never executables,
* native paths, home/cwd, env, or secrets. There is NO provider-probe route here;
* `/api/providers` and `POST /api/providers/test` are intentionally out of scope.
*/
@Controller('api/harnesses')
@UseGuards(AuthGuard)
export class HarnessController {
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
@Get()
async list(@CurrentUser() user: AuthenticatedUserLike): Promise<HarnessSummaryDto[]> {
const context = readContextFromScope(scopeFromUser(user));
const summaries: HarnessSummaryDto[] = [];
for (const adapter of this.registry.list()) {
summaries.push(toHarnessSummary(await adapter.describe(context)));
}
return summaries;
}
@Get(':harnessId/catalog')
async catalog(
@CurrentUser() user: AuthenticatedUserLike,
@Param('harnessId') harnessId: string,
): Promise<HarnessCatalogDto> {
const context = readContextFromScope(scopeFromUser(user));
let adapter;
try {
adapter = this.registry.get(harnessId);
} catch (error) {
if (error instanceof HarnessAdapterUnavailableError) {
// Typed failure — NEVER a fallback catalog for an unknown harness id.
throw new HttpException(
{ code: error.code, message: error.message, harnessId },
HttpStatus.NOT_FOUND,
);
}
throw error;
}
return toSafeCatalog(await adapter.catalog(context));
}
}
-116
View File
@@ -1,116 +0,0 @@
import { randomUUID } from 'node:crypto';
import { IsNotEmpty, IsString } from 'class-validator';
import type {
HarnessActorContext,
HarnessAuthState,
HarnessCapability,
HarnessCatalog,
HarnessCatalogEntry,
HarnessDescriptor,
HarnessInputType,
HarnessModelAvailability,
HarnessSelection,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
/**
* Structured selection tuple accepted on `PUT /api/chat/preferences/selection`.
*
* The body is a STRUCTURED tuple (harness + provider + model), never a free-text
* model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`
* any extra property including smuggled server-authority fields such as
* `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd`
* is rejected with 400. There is deliberately no field through which a caller can
* name a scope; scope is derived on the server from the authenticated session.
*/
export class HarnessSelectionInputDto {
@IsString()
@IsNotEmpty()
harnessId!: string;
@IsString()
@IsNotEmpty()
providerId!: string;
@IsString()
@IsNotEmpty()
modelId!: string;
}
/** Browser-safe harness summary — identity and capabilities only. */
export interface HarnessSummaryDto {
readonly id: string;
readonly displayName: string;
readonly capabilities: readonly HarnessCapability[];
}
/** Browser-safe catalog entry — no executables, paths, secrets, or env. */
export interface HarnessCatalogEntryDto {
readonly harnessId: string;
readonly providerId: string;
readonly modelId: string;
readonly displayName: string;
readonly reasoningCapability: boolean;
readonly inputTypes: readonly HarnessInputType[];
readonly authState: HarnessAuthState;
readonly availability: HarnessModelAvailability;
}
/** Browser-safe catalog envelope. */
export interface HarnessCatalogDto {
readonly harnessId: string;
readonly version: string;
readonly fingerprint: string;
readonly models: readonly HarnessCatalogEntryDto[];
}
/** Response envelope for the caller's current selection (null when unset). */
export interface SelectionResponseDto {
readonly selection: HarnessSelection | null;
}
/**
* Derive a server-trusted {@link HarnessActorContext} for read operations from the
* session-derived {@link ActorTenantScope}. All authority originates on the server;
* nothing here is caller-supplied. A fresh correlation id is minted per call.
*/
export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext {
return {
actorId: scope.userId,
tenantId: scope.tenantId,
seatId: scope.userId,
correlationId: randomUUID(),
};
}
/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */
export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto {
return {
id: descriptor.id,
displayName: descriptor.displayName,
capabilities: [...descriptor.capabilities],
};
}
/** Project a catalog onto the browser-safe shape (whitelist by construction). */
export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto {
return {
harnessId: catalog.harnessId,
version: catalog.version,
fingerprint: catalog.fingerprint,
models: catalog.models.map(toSafeCatalogEntry),
};
}
function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto {
return {
harnessId: entry.harnessId,
providerId: entry.providerId,
modelId: entry.modelId,
displayName: entry.displayName,
reasoningCapability: entry.reasoningCapability,
inputTypes: [...entry.inputTypes],
authState: entry.authState,
availability: entry.availability,
};
}
@@ -1,37 +0,0 @@
import { Module } from '@nestjs/common';
import { HarnessRegistry } from './harness.registry.js';
import { HarnessService } from './harness.service.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
HARNESS_REGISTRY,
HARNESS_SERVICE,
} from './harness.tokens.js';
import { HarnessController } from './harness.controller.js';
import { HarnessSelectionController } from './harness-selection.controller.js';
import { HarnessSelectionService } from './harness-selection.service.js';
import { HarnessSelectionRepository } from './harness-selection.repository.js';
/**
* Wires the harness-neutral registry/service (Task Two) together with the
* Slice-Zero catalog and selection HTTP surfaces (Task Three).
*
* The registry is provided empty here; real harness adapters are registered in a
* later task. Because the controllers/services resolve their collaborators through
* this real module graph, an unresolved provider fails loudly at `app.init()`.
*/
@Module({
controllers: [HarnessController, HarnessSelectionController],
providers: [
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
{ provide: HARNESS_SERVICE, useClass: HarnessService },
// Task Five: bind the conversation-service token to its explicit "not yet bound"
// sentinel. The pi-rpc router treats this as a hard, typed startup failure; Task 14
// replaces it with a real service. Exported so ChatModule's router can inject it.
{ provide: HARNESS_CONVERSATION_SERVICE, useValue: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE },
HarnessSelectionRepository,
HarnessSelectionService,
],
exports: [HARNESS_REGISTRY, HARNESS_SERVICE, HARNESS_CONVERSATION_SERVICE],
})
export class HarnessModule {}
@@ -1,69 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
HarnessAdapterUnavailableError,
HarnessRegistrationError,
HarnessRegistry,
} from './harness.registry.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
describe('HarnessRegistry', () => {
it('registers and looks up an adapter by harness id', () => {
const registry = new HarnessRegistry();
const adapter = new FakeHarnessAdapter({ id: 'fake' });
registry.register(adapter);
expect(registry.get('fake')).toBe(adapter);
expect(registry.has('fake')).toBe(true);
expect(registry.list().map((entry) => entry.id)).toEqual(['fake']);
});
it('rejects a blank adapter id', () => {
const registry = new HarnessRegistry();
let error: unknown;
try {
registry.register(new FakeHarnessAdapter({ id: ' ' }));
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessRegistrationError);
expect((error as HarnessRegistrationError).reason).toBe('blank_id');
expect(registry.list()).toEqual([]);
});
it('rejects a duplicate adapter id', () => {
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
let error: unknown;
try {
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessRegistrationError);
expect((error as HarnessRegistrationError).reason).toBe('duplicate_id');
expect((error as HarnessRegistrationError).harnessId).toBe('fake');
// The original registration is untouched.
expect(registry.list()).toHaveLength(1);
});
it('returns adapter_unavailable for an unknown harness id', () => {
const registry = new HarnessRegistry();
let error: unknown;
try {
registry.get('missing');
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessAdapterUnavailableError);
expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable');
expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing');
expect(registry.has('missing')).toBe(false);
});
});
@@ -1,100 +0,0 @@
import { Injectable } from '@nestjs/common';
import type {
HarnessAdapter,
HarnessErrorCode,
HarnessErrorDto,
HarnessSelection,
} from '@mosaicstack/types';
/**
* A typed harness operation failure that carries a fully-formed, browser-safe
* {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested
* tuple there is no field through which a substituted "effective" selection
* could ever be reported.
*/
export class HarnessOperationError extends Error {
readonly code: HarnessErrorCode;
readonly dto: HarnessErrorDto;
constructor(dto: HarnessErrorDto) {
super(dto.message);
this.name = 'HarnessOperationError';
this.code = dto.code;
this.dto = dto;
}
}
/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */
export function operationError(
code: HarnessErrorCode,
message: string,
selection: HarnessSelection,
correlationId: string,
retryable = false,
): HarnessOperationError {
return new HarnessOperationError({ code, message, retryable, correlationId, selection });
}
/** Raised when an unknown harness id is looked up. Discriminated by `code`. */
export class HarnessAdapterUnavailableError extends Error {
readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode;
constructor(readonly harnessId: string) {
super(`No harness adapter is registered for id "${harnessId}".`);
this.name = 'HarnessAdapterUnavailableError';
}
}
export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id';
/** Raised when an adapter cannot be registered (blank or duplicate id). */
export class HarnessRegistrationError extends Error {
constructor(
readonly reason: HarnessRegistrationFailure,
readonly harnessId: string,
) {
super(
reason === 'blank_id'
? 'A harness adapter id must be a non-empty string.'
: `A harness adapter is already registered for id "${harnessId}".`,
);
this.name = 'HarnessRegistrationError';
}
}
/**
* Harness-neutral adapter registry. Adapters are keyed by their harness id.
* Registration rejects blank and duplicate ids; lookup of an unknown id fails
* with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`).
*/
@Injectable()
export class HarnessRegistry {
private readonly adapters = new Map<string, HarnessAdapter>();
register(adapter: HarnessAdapter): void {
const id = adapter.id;
if (typeof id !== 'string' || id.trim().length === 0) {
throw new HarnessRegistrationError('blank_id', id ?? '');
}
if (this.adapters.has(id)) {
throw new HarnessRegistrationError('duplicate_id', id);
}
this.adapters.set(id, adapter);
}
get(harnessId: string): HarnessAdapter {
const adapter = this.adapters.get(harnessId);
if (!adapter) {
throw new HarnessAdapterUnavailableError(harnessId);
}
return adapter;
}
has(harnessId: string): boolean {
return this.adapters.has(harnessId);
}
list(): readonly HarnessAdapter[] {
return [...this.adapters.values()];
}
}
@@ -1,227 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
import {
HarnessScopeViolationError,
HarnessService,
type TrustedGatewayScope,
} from './harness.service.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
const SCOPE: TrustedGatewayScope = {
actorId: 'actor-trusted',
tenantId: 'tenant-trusted',
seatId: 'seat-trusted',
correlationId: 'correlation-trusted',
};
const READ_CONTEXT: HarnessActorContext = {
actorId: SCOPE.actorId,
tenantId: SCOPE.tenantId,
seatId: SCOPE.seatId,
correlationId: SCOPE.correlationId,
};
function setup(capabilities?: readonly HarnessCapability[]) {
const registry = new HarnessRegistry();
const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
registry.register(adapter);
const service = new HarnessService(registry);
return { registry, adapter, service };
}
async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
const catalog = await adapter.catalog(READ_CONTEXT);
const entry = catalog.models.find((model) => model.availability === 'available');
if (!entry) {
throw new Error('fixture requires an available model');
}
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
}
describe('HarnessService', () => {
it('derives the actor context from trusted scope on create', async () => {
const { service, adapter } = setup();
const selection = await availableSelection(adapter);
const snapshot = await service.createSession(SCOPE, {
conversationId: 'conversation-1',
selection,
});
expect(snapshot.seatId).toBe(SCOPE.seatId);
expect(snapshot.state).toBe('idle');
expect(snapshot.selection).toEqual(selection);
expect(snapshot.nativeSessionId).toBeTruthy();
});
it('rejects server-authority fields supplied by an external caller', async () => {
const { service, adapter } = setup();
const selection = await availableSelection(adapter);
const hostile = {
conversationId: 'conversation-1',
selection,
seatId: 'attacker-seat',
executablePath: '/usr/bin/evil',
home: '/home/attacker',
cwd: '/tmp/attacker',
nativeSessionPath: '/var/native/attacker.jsonl',
} as unknown as Parameters<HarnessService['createSession']>[1];
let error: unknown;
try {
await service.createSession(SCOPE, hostile);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessScopeViolationError);
expect((error as HarnessScopeViolationError).field).toBe('seatId');
});
it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
const { service } = setup();
const selection: HarnessSelection = {
harnessId: 'ghost-harness',
providerId: 'p',
modelId: 'm',
};
let error: unknown;
try {
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('adapter_unavailable');
expect(dto.selection).toEqual(selection);
expect(dto.correlationId).toBe(SCOPE.correlationId);
});
it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
const { service } = setup();
const selection: HarnessSelection = {
harnessId: 'fake',
providerId: 'ghost-provider',
modelId: 'ghost-model',
};
let error: unknown;
try {
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('selection_invalid');
expect(dto.selection).toEqual(selection);
});
it('returns model_unavailable without falling back for a known unavailable model', async () => {
const { service, adapter } = setup();
const catalog = await adapter.catalog(READ_CONTEXT);
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
expect(unavailable).toBeDefined();
const selection: HarnessSelection = {
harnessId: unavailable!.harnessId,
providerId: unavailable!.providerId,
modelId: unavailable!.modelId,
};
let error: unknown;
try {
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('model_unavailable');
// No substitution: the DTO tuple is exactly what was requested.
expect(dto.selection).toEqual(selection);
});
it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
const { service, adapter } = setup();
const selection = await availableSelection(adapter);
const created = await service.createSession(SCOPE, {
conversationId: 'conversation-create',
selection,
});
expect(created.state).toBe('idle');
expect(created.processId).toBeTruthy();
expect(created.attachedClientIds).toEqual([]);
const resumed = await service.resumeSession(SCOPE, {
conversationId: 'conversation-resume',
nativeSessionId: 'native-preexisting-123',
selection,
});
// Resume binds the supplied native session; create mints a fresh one.
expect(resumed.nativeSessionId).toBe('native-preexisting-123');
expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);
await service.attach(SCOPE, {
conversationId: 'conversation-create',
clientId: 'browser-1',
});
const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
expect(afterAttach.attachedClientIds).toEqual(['browser-1']);
const afterDetach = await service.detach(SCOPE, {
conversationId: 'conversation-create',
clientId: 'browser-1',
});
// Detach removes the browser attachment only; the process stays alive.
expect(afterDetach.attachedClientIds).toEqual([]);
expect(afterDetach.state).toBe('idle');
expect(afterDetach.processId).toBeTruthy();
const afterEvict = await service.evict(SCOPE, {
conversationId: 'conversation-create',
reason: 'idle_timeout',
});
// Evict stops the process but retains the resumable native session.
expect(afterEvict.state).toBe('evicted');
expect(afterEvict.processId).toBeUndefined();
expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);
const afterEnd = await service.end(SCOPE, {
conversationId: 'conversation-create',
reason: 'session_ended',
});
// End destructively terminates the native session.
expect(afterEnd.state).toBe('ended');
});
it('fails typed when an unsupported capability is exercised', async () => {
const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
(capability) => capability !== 'extensionUi',
);
const { service, adapter } = setup(withoutExtensionUi);
const selection = await availableSelection(adapter);
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
let error: unknown;
try {
await service.respondInteraction(SCOPE, {
conversationId: 'conversation-1',
response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
});
});
-285
View File
@@ -1,285 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
HarnessActorContext,
HarnessAdapter,
HarnessCatalog,
HarnessCloseReason,
HarnessInteractionResponse,
HarnessSelection,
HarnessSessionHandle,
HarnessSessionSnapshot,
} from '@mosaicstack/types';
import {
HarnessAdapterUnavailableError,
HarnessRegistry,
operationError,
} from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
/**
* Trusted, server-derived authority. In production this is produced by the
* Gateway from the authenticated session never from a browser/caller DTO.
*/
export interface TrustedGatewayScope {
readonly actorId: string;
readonly tenantId: string;
readonly seatId: string;
readonly correlationId: string;
}
/** Server-authority fields that must never arrive from an external request DTO. */
const FORBIDDEN_REQUEST_FIELDS = [
'actorId',
'tenantId',
'correlationId',
'seatId',
'seat',
'executable',
'executablePath',
'home',
'homeDir',
'cwd',
'workingDir',
'workingDirectory',
'nativeSessionPath',
'sessionPath',
] as const;
/** Raised when an external request DTO smuggles a server-authority field. */
export class HarnessScopeViolationError extends Error {
constructor(readonly field: string) {
super(`External request supplied server-authority field "${field}".`);
this.name = 'HarnessScopeViolationError';
}
}
export interface CreateHarnessSessionRequest {
readonly conversationId: string;
readonly selection: HarnessSelection;
}
export interface ResumeHarnessSessionRequest {
readonly conversationId: string;
readonly nativeSessionId: string;
readonly selection: HarnessSelection;
}
export interface AttachClientRequest {
readonly conversationId: string;
readonly clientId: string;
}
export interface DetachClientRequest {
readonly conversationId: string;
readonly clientId: string;
}
export interface EvictSessionRequest {
readonly conversationId: string;
readonly reason: HarnessCloseReason;
}
export interface EndSessionRequest {
readonly conversationId: string;
readonly reason: HarnessCloseReason;
}
export interface RespondInteractionRequest {
readonly conversationId: string;
readonly response: HarnessInteractionResponse;
}
interface ActiveSession {
readonly harnessId: string;
readonly handle: HarnessSessionHandle;
readonly correlationId: string;
}
/**
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
* from trusted Gateway scope, validates the selected provider/model tuple with
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
* lifecycle operations.
*/
@Injectable()
export class HarnessService {
private readonly sessions = new Map<string, ActiveSession>();
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
async createSession(
scope: TrustedGatewayScope,
request: CreateHarnessSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const { conversationId, selection } = request;
const adapter = this.resolveAdapter(scope, selection);
const context = deriveActorContext(scope);
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
const handle = await adapter.create({ context, conversationId, selection });
this.sessions.set(conversationId, {
harnessId: selection.harnessId,
handle,
correlationId: scope.correlationId,
});
return handle.snapshot();
}
async resumeSession(
scope: TrustedGatewayScope,
request: ResumeHarnessSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const { conversationId, nativeSessionId, selection } = request;
const adapter = this.resolveAdapter(scope, selection);
const context = deriveActorContext(scope);
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
this.sessions.set(conversationId, {
harnessId: selection.harnessId,
handle,
correlationId: scope.correlationId,
});
return handle.snapshot();
}
async attach(
scope: TrustedGatewayScope,
request: AttachClientRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.attach({ clientId: request.clientId });
return handle.snapshot();
}
async detach(
scope: TrustedGatewayScope,
request: DetachClientRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.detach(request.clientId);
return handle.snapshot();
}
async evict(
scope: TrustedGatewayScope,
request: EvictSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.evictProcess(request.reason);
return handle.snapshot();
}
async end(
scope: TrustedGatewayScope,
request: EndSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.endSession(request.reason);
const snapshot = await handle.snapshot();
this.sessions.delete(request.conversationId);
return snapshot;
}
async respondInteraction(
scope: TrustedGatewayScope,
request: RespondInteractionRequest,
): Promise<void> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.respondInteraction(request.response);
}
async snapshot(
scope: TrustedGatewayScope,
conversationId: string,
): Promise<HarnessSessionSnapshot> {
const handle = this.requireHandle(scope, conversationId);
return handle.snapshot();
}
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
try {
return this.registry.get(selection.harnessId);
} catch (error) {
if (error instanceof HarnessAdapterUnavailableError) {
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
}
throw error;
}
}
private async assertSelectionAvailable(
scope: TrustedGatewayScope,
catalogPromise: Promise<HarnessCatalog>,
selection: HarnessSelection,
): Promise<void> {
const catalog = await catalogPromise;
const entry = catalog.models.find(
(candidate) =>
candidate.harnessId === selection.harnessId &&
candidate.providerId === selection.providerId &&
candidate.modelId === selection.modelId,
);
if (!entry) {
// No first-row fallback: reject the requested tuple unchanged.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
scope.correlationId,
);
}
if (entry.availability === 'unavailable') {
throw operationError(
'model_unavailable',
'The requested model is currently unavailable.',
selection,
scope.correlationId,
true,
);
}
}
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
const active = this.sessions.get(conversationId);
if (!active) {
throw operationError(
'session_not_found',
`No active harness session for conversation "${conversationId}".`,
{ harnessId: '', providerId: '', modelId: '' },
scope.correlationId,
);
}
return active.handle;
}
}
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
return {
actorId: scope.actorId,
tenantId: scope.tenantId,
seatId: scope.seatId,
correlationId: scope.correlationId,
};
}
/** Reject any request object that carries a server-authority field. */
function assertTrustedRequest(request: object): void {
for (const field of FORBIDDEN_REQUEST_FIELDS) {
if (Object.prototype.hasOwnProperty.call(request, field)) {
throw new HarnessScopeViolationError(field);
}
}
}
// Re-export the typed operation error so callers importing from the service
// have the discriminated failure type without reaching into the registry.
export { HarnessOperationError } from './harness.registry.js';
@@ -1,45 +0,0 @@
/**
* Nest dependency-injection tokens for the harness-neutral registry and service.
*
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
*/
import type { HarnessConversationService } from '@mosaicstack/types';
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
export type HarnessServiceToken = typeof HARNESS_SERVICE;
/**
* Token for the {@link HarnessConversationService} that {@link HarnessChatRuntime}
* depends on. Until Task 14 provides a real implementation, `HarnessModule` binds
* the {@link HARNESS_CONVERSATION_SERVICE_UNAVAILABLE} sentinel here, and the
* `pi-rpc` router treats that sentinel as a hard, typed startup failure.
*/
export const HARNESS_CONVERSATION_SERVICE = 'HARNESS_CONVERSATION_SERVICE' as const;
export type HarnessConversationServiceToken = typeof HARNESS_CONVERSATION_SERVICE;
/**
* Explicit "not yet bound" value for {@link HARNESS_CONVERSATION_SERVICE}. It is a
* distinct sentinel never `null`/`undefined` so an unbound service is an
* intentional, checkable state rather than an accidental nil that could read as
* "present". Replaced by a real service in Task 14.
*/
export const HARNESS_CONVERSATION_SERVICE_UNAVAILABLE: unique symbol = Symbol(
'HARNESS_CONVERSATION_SERVICE_UNAVAILABLE',
);
/** A binding for {@link HARNESS_CONVERSATION_SERVICE}: a real service or the sentinel. */
export type HarnessConversationServiceBinding =
| HarnessConversationService
| typeof HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
/** Narrows a binding to a usable service, excluding the unavailable sentinel. */
export function isHarnessConversationServiceAvailable(
binding: HarnessConversationServiceBinding,
): binding is HarnessConversationService {
return binding !== HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
}
@@ -1,107 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types';
import { HarnessOperationError } from '../harness.registry.js';
import { FakeHarnessAdapter } from './fake-harness.adapter.js';
import { runHarnessAdapterContract } from './harness-adapter.contract.js';
const CONTEXT: HarnessActorContext = {
actorId: 'actor-1',
tenantId: 'tenant-1',
seatId: 'seat-1',
correlationId: 'correlation-1',
};
// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter.
runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' }));
describe('FakeHarnessAdapter no-substitution', () => {
it('never substitutes the first catalog row when a bogus selection is requested', async () => {
const adapter = new FakeHarnessAdapter({ id: 'fake' });
const catalog = await adapter.catalog(CONTEXT);
const firstRow = catalog.models[0];
if (!firstRow) {
throw new Error('fixture requires a catalog model');
}
const available = catalog.models.find(
(entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId,
);
expect(available).toBeDefined();
const selected: HarnessSelection = {
harnessId: available!.harnessId,
providerId: available!.providerId,
modelId: available!.modelId,
};
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conversation-1',
selection: selected,
});
const bogus: HarnessSelection = {
harnessId: 'fake',
providerId: 'ghost-provider',
modelId: 'ghost-model',
};
let error: unknown;
try {
await handle.setModel(bogus);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('selection_invalid');
// The DTO echoes the exact requested tuple, unchanged.
expect(dto.selection).toEqual(bogus);
// No substitution to the first catalog row.
expect(dto.selection).not.toEqual({
harnessId: firstRow.harnessId,
providerId: firstRow.providerId,
modelId: firstRow.modelId,
});
// The active selection is untouched by the rejected request.
expect((await handle.snapshot()).selection).toEqual(selected);
});
it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => {
const adapter = new FakeHarnessAdapter({ id: 'fake' });
const catalog = await adapter.catalog(CONTEXT);
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
const available = catalog.models.find((entry) => entry.availability === 'available');
expect(unavailable).toBeDefined();
expect(available).toBeDefined();
const startingSelection: HarnessSelection = {
harnessId: available!.harnessId,
providerId: available!.providerId,
modelId: available!.modelId,
};
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conversation-2',
selection: startingSelection,
});
const requested: HarnessSelection = {
harnessId: unavailable!.harnessId,
providerId: unavailable!.providerId,
modelId: unavailable!.modelId,
};
let error: unknown;
try {
await handle.setModel(requested);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('model_unavailable');
expect(dto.selection).toEqual(requested);
expect((await handle.snapshot()).selection).toEqual(startingSelection);
});
});
@@ -1,248 +0,0 @@
import type {
AttachClient,
CreateHarnessSession,
HarnessAdapter,
HarnessActorContext,
HarnessCapability,
HarnessCatalog,
HarnessCatalogEntry,
HarnessCloseReason,
HarnessDescriptor,
HarnessEvent,
HarnessInteractionResponse,
HarnessPrompt,
HarnessPromptReceipt,
HarnessSelection,
HarnessSessionHandle,
HarnessSessionSnapshot,
HarnessSessionState,
ResumeHarnessSession,
} from '@mosaicstack/types';
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
import { operationError } from '../harness.registry.js';
export interface FakeHarnessAdapterOptions {
readonly id: string;
readonly capabilities?: readonly HarnessCapability[];
readonly catalog?: readonly HarnessCatalogEntry[];
}
const FAKE_PROVIDER = 'fake-openai';
function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] {
return [
{
harnessId,
providerId: FAKE_PROVIDER,
modelId: 'fake-mini',
displayName: 'Fake Mini',
reasoningCapability: false,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
{
harnessId,
providerId: FAKE_PROVIDER,
modelId: 'fake-pro',
displayName: 'Fake Pro',
reasoningCapability: true,
inputTypes: ['text', 'image'],
authState: 'ready',
availability: 'available',
},
{
harnessId,
providerId: FAKE_PROVIDER,
modelId: 'fake-legacy',
displayName: 'Fake Legacy',
reasoningCapability: false,
inputTypes: ['text'],
authState: 'unavailable',
availability: 'unavailable',
},
];
}
function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean {
return (
entry.harnessId === selection.harnessId &&
entry.providerId === selection.providerId &&
entry.modelId === selection.modelId
);
}
/**
* In-memory harness session handle used by the fake adapter and by the shared
* conformance suite. It enforces the two invariants the real adapters must also
* honor: model selection is validated against the catalog and is NEVER
* substituted, and unsupported capabilities fail with a typed error.
*/
export class FakeHarnessSessionHandle implements HarnessSessionHandle {
private state: HarnessSessionState = 'idle';
private processId: string | undefined;
private readonly attachedClientIds = new Set<string>();
private readonly listeners = new Set<(event: HarnessEvent) => void>();
constructor(
private readonly conversationId: string,
private readonly nativeSessionId: string,
private readonly seatId: string,
private selection: HarnessSelection,
private readonly correlationId: string,
private readonly capabilities: readonly HarnessCapability[],
private readonly catalog: readonly HarnessCatalogEntry[],
) {
this.processId = `process-${nativeSessionId}`;
}
async snapshot(): Promise<HarnessSessionSnapshot> {
return {
conversationId: this.conversationId,
nativeSessionId: this.nativeSessionId,
processId: this.processId,
seatId: this.seatId,
selection: this.selection,
state: this.state,
attachedClientIds: [...this.attachedClientIds],
};
}
async attach(input: AttachClient): Promise<void> {
this.attachedClientIds.add(input.clientId);
}
async detach(clientId: string): Promise<void> {
// Removes the browser attachment only; the process and native session persist.
this.attachedClientIds.delete(clientId);
}
async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt> {
return {
conversationId: this.conversationId,
turnId: input.turnId,
correlationId: input.correlationId,
state: 'accepted',
selection: this.selection,
};
}
async setModel(selection: HarnessSelection): Promise<HarnessSelection> {
const entry = this.catalog.find((candidate) => matches(candidate, selection));
if (!entry) {
// No fallback to the first catalog row: reject with the requested tuple, unchanged.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
this.correlationId,
);
}
if (entry.availability === 'unavailable') {
throw operationError(
'model_unavailable',
'The requested model is currently unavailable.',
selection,
this.correlationId,
true,
);
}
this.selection = selection;
return this.selection;
}
async abort(_turnId: string): Promise<void> {
// No active turn machinery in the fake; abort is a no-op acknowledgement.
}
async respondInteraction(_input: HarnessInteractionResponse): Promise<void> {
if (!this.capabilities.includes('extensionUi')) {
throw operationError(
'interaction_unsupported',
'This harness does not support interactive responses.',
this.selection,
this.correlationId,
);
}
}
events(listener: (event: HarnessEvent) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async evictProcess(_reason: HarnessCloseReason): Promise<void> {
// Stop the process but keep the resumable native session.
this.processId = undefined;
this.state = 'evicted';
}
async endSession(_reason: HarnessCloseReason): Promise<void> {
// Destructively end the native session.
this.processId = undefined;
this.state = 'ended';
}
}
/**
* Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh
* native session id on `create` and binds the supplied one on `resume`, so the
* two paths are observably distinct.
*/
export class FakeHarnessAdapter implements HarnessAdapter {
readonly id: string;
private readonly capabilities: readonly HarnessCapability[];
private readonly catalogEntries: readonly HarnessCatalogEntry[];
private createdCount = 0;
constructor(options: FakeHarnessAdapterOptions) {
this.id = options.id;
this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES];
this.catalogEntries = options.catalog ?? defaultCatalog(options.id);
}
async describe(_context: HarnessActorContext): Promise<HarnessDescriptor> {
return {
id: this.id,
displayName: `Fake harness (${this.id})`,
capabilities: this.capabilities,
};
}
async catalog(_context: HarnessActorContext): Promise<HarnessCatalog> {
return {
harnessId: this.id,
version: '1.0.0',
fingerprint: `fake-${this.id}-${this.catalogEntries.length}`,
models: this.catalogEntries,
};
}
async create(input: CreateHarnessSession): Promise<HarnessSessionHandle> {
this.createdCount += 1;
const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`;
return new FakeHarnessSessionHandle(
input.conversationId,
nativeSessionId,
input.context.seatId,
input.selection,
input.context.correlationId,
this.capabilities,
this.catalogEntries,
);
}
async resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle> {
return new FakeHarnessSessionHandle(
input.conversationId,
input.nativeSessionId,
input.context.seatId,
input.selection,
input.context.correlationId,
this.capabilities,
this.catalogEntries,
);
}
}
@@ -1,157 +0,0 @@
import { describe, expect, it } from 'vitest';
import type {
HarnessActorContext,
HarnessAdapter,
HarnessCatalogEntry,
HarnessSelection,
} from '@mosaicstack/types';
import { HarnessOperationError } from '../harness.registry.js';
const CONTEXT: HarnessActorContext = {
actorId: 'contract-actor',
tenantId: 'contract-tenant',
seatId: 'contract-seat',
correlationId: 'contract-correlation',
};
function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
}
function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
if (!entry) {
throw new Error('contract fixture requires at least one catalog model');
}
return entry;
}
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
try {
await run();
return undefined;
} catch (caught) {
return caught;
}
}
/**
* Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
* runs it against the fake adapter; Task 13 re-runs the identical suite against
* the native Pi adapter so both share one behavioral contract.
*/
export function runHarnessAdapterContract(
label: string,
createAdapter: () => HarnessAdapter,
): void {
describe(`harness adapter contract: ${label}`, () => {
it('mints a fresh native session on create and binds the supplied one on resume', async () => {
const adapter = createAdapter();
const catalog = await adapter.catalog(CONTEXT);
const selection = toSelection(pickAvailable(catalog.models));
const created = await (
await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
).snapshot();
const resumed = await (
await adapter.resume({
context: CONTEXT,
conversationId: 'conv-resume',
nativeSessionId: 'native-supplied-1',
selection,
})
).snapshot();
expect(created.nativeSessionId).toBeTruthy();
expect(resumed.nativeSessionId).toBe('native-supplied-1');
expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
expect(created.seatId).toBe(CONTEXT.seatId);
});
it('gives detach, evict, and end distinct effects (not aliases)', async () => {
const adapter = createAdapter();
const catalog = await adapter.catalog(CONTEXT);
const selection = toSelection(pickAvailable(catalog.models));
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conv-lifecycle',
selection,
});
await handle.attach({ clientId: 'browser-1' });
await handle.detach('browser-1');
const afterDetach = await handle.snapshot();
expect(afterDetach.attachedClientIds).toEqual([]);
expect(afterDetach.state).not.toBe('evicted');
expect(afterDetach.state).not.toBe('ended');
await handle.evictProcess('idle_timeout');
const afterEvict = await handle.snapshot();
expect(afterEvict.state).toBe('evicted');
// The native session survives eviction (resumable); the process does not.
expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
expect(afterEvict.processId).toBeUndefined();
await handle.endSession('session_ended');
const afterEnd = await handle.snapshot();
expect(afterEnd.state).toBe('ended');
// End is not an alias of evict.
expect(afterEnd.state).not.toBe(afterEvict.state);
});
it('never substitutes the first catalog row for an unknown selection', async () => {
const adapter = createAdapter();
const catalog = await adapter.catalog(CONTEXT);
const firstRow = catalog.models[0];
if (!firstRow) {
throw new Error('contract fixture requires a catalog model');
}
const start = toSelection(pickAvailable(catalog.models));
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conv-nosub',
selection: start,
});
const bogus: HarnessSelection = {
harnessId: adapter.id,
providerId: 'contract-ghost-provider',
modelId: 'contract-ghost-model',
};
const error = await captureError(() => handle.setModel(bogus));
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('selection_invalid');
expect(dto.selection).toEqual(bogus);
expect(dto.selection).not.toEqual(toSelection(firstRow));
expect((await handle.snapshot()).selection).toEqual(start);
});
it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
const adapter = createAdapter();
const descriptor = await adapter.describe(CONTEXT);
const catalog = await adapter.catalog(CONTEXT);
const selection = toSelection(pickAvailable(catalog.models));
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conv-interaction',
selection,
});
const response = {
requestId: 'interaction-1',
type: 'confirm',
accepted: true,
} as const;
if (descriptor.capabilities.includes('extensionUi')) {
await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
} else {
const error = await captureError(() => handle.respondInteraction(response));
expect(error).toBeInstanceOf(HarnessOperationError);
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
}
});
});
}
+3 -9
View File
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
@Injectable()
export class CronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CronService.name);
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService,
@@ -26,12 +26,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
) {}
async onModuleInit(): Promise<void> {
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
if (!this.queueService.isEnabled()) {
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
return;
}
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
@@ -45,7 +39,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
await this.summarization.runSummarization();
});
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
this.registeredWorkers.push(summarizationWorker);
// M6-005: Tier management repeatable job
await this.queueService.addRepeatableJob(
@@ -57,7 +51,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
await this.summarization.runTierManagement();
});
if (tierWorker) this.registeredWorkers.push(tierWorker);
this.registeredWorkers.push(tierWorker);
// Retire any repeatable global GC schedule created by older deployments.
// Session cleanup is now triggered only by an authorized session lifecycle operation.
-164
View File
@@ -1,164 +0,0 @@
import 'reflect-metadata';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import * as nodeOs from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import * as nodeUrl from 'node:url';
import type { MosaicConfig } from '@mosaicstack/config';
import type * as MosaicStorage from '@mosaicstack/storage';
import { describe, expect, it, vi, type MockInstance } from 'vitest';
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
function snapshotProcessEnv(): Record<string, string | undefined> {
return { ...process.env };
}
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
for (const key of Object.keys(process.env)) {
if (!(key in snapshot)) {
delete process.env[key];
}
}
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
continue;
}
process.env[key] = value;
}
}
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
const relativePath = relative(tempRoot, path);
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
true,
);
}
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
expectPathUnderTempRoot(path, tempRoot);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf8');
}
interface BootstrapPreflightResult {
capturedConfig: MosaicConfig | undefined;
}
async function runBootstrapPreflight(
anchoredConfigContents: string,
ambientConfigContents: string,
): Promise<BootstrapPreflightResult> {
const originalEnv = snapshotProcessEnv();
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
let exitSpy: MockInstance<typeof process.exit> | undefined;
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
let capturedConfig: MosaicConfig | undefined;
try {
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
const homePath = join(tempRoot, 'home');
const cwdPath = join(tempRoot, 'ambient', 'cwd');
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
await mkdir(anchor, { recursive: true });
await mkdir(cwdPath, { recursive: true });
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
process.env['HOME'] = homePath;
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['DATABASE_URL'];
delete process.env['VALKEY_URL'];
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
const exitMock = vi.fn<typeof process.exit>();
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
vi.resetModules();
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
vi.doMock('node:url', () => ({
...nodeUrl,
fileURLToPath: (url: string | URL): string => {
const actualPath = nodeUrl.fileURLToPath(url);
if (
actualPath.endsWith('/apps/gateway/src/env.ts') ||
actualPath.endsWith('/apps/gateway/src/env.js')
) {
return join(anchor, 'env.ts');
}
return actualPath;
},
}));
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
vi.doMock('./tracing.js', () => ({}));
const preflightSentinel = new Error('preflight-capture-sentinel');
vi.doMock('@mosaicstack/storage', async () => {
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
return {
...actual,
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
capturedConfig = config;
throw preflightSentinel;
}),
};
});
await import('./main.js');
await vi.waitFor((): void => {
expect(exitSpy).toHaveBeenCalled();
});
return { capturedConfig };
} finally {
cwdSpy?.mockRestore();
exitSpy?.mockRestore();
consoleInfoSpy?.mockRestore();
vi.doUnmock('@mosaicstack/storage');
vi.doUnmock('./tracing.js');
vi.doUnmock('node:url');
vi.doUnmock('node:os');
vi.resetModules();
restoreProcessEnv(originalEnv);
await rm(tempRoot, { recursive: true, force: true });
}
}
describe('main bootstrap preflight config anchoring', (): void => {
it(
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
async (): Promise<void> => {
const anchoredConfig = JSON.stringify({
tier: 'local',
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' },
});
const ambientConfig = JSON.stringify({
tier: 'federated',
storage: {
type: 'postgres',
url: 'postgresql://ambient-attacker.invalid/mosaic',
enableVector: true,
},
queue: { type: 'bullmq' },
memory: { type: 'pgvector' },
});
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
expect(capturedConfig?.tier).toBe('local');
expect(capturedConfig?.storage).not.toEqual(
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
);
},
MODULE_IMPORT_TIMEOUT_MS,
);
});
+15 -3
View File
@@ -1,5 +1,18 @@
#!/usr/bin/env node
import './env.js';
import { config } from 'dotenv';
import { existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { homedir } from 'node:os';
// Load .env from daemon config dir (global install / daemon mode).
// Loaded first so monorepo .env can override for local dev.
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
if (existsSync(daemonEnv)) config({ path: daemonEnv });
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
config({ path: resolve(process.cwd(), '../../.env') });
config(); // Also load apps/gateway/.env if present (overrides)
import './tracing.js';
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
@@ -13,7 +26,6 @@ import { mountAuthHandler } from './auth/auth.controller.js';
import { mountMcpHandler } from './mcp/mcp.controller.js';
import { McpService } from './mcp/mcp.service.js';
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
import { resolveGatewayConfigPath } from './env.js';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
@@ -25,7 +37,7 @@ async function bootstrap(): Promise<void> {
// Pre-flight: assert all external services required by the configured tier
// are reachable. Runs before NestFactory.create() so failures are visible
// immediately with actionable remediation hints.
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
const mosaicConfig = loadConfig();
try {
await detectAndAssertTier(mosaicConfig);
} catch (err) {
@@ -1,44 +0,0 @@
import { Logger } from '@nestjs/common';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { McpClientService } from './mcp-client.service.js';
const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret';
describe('McpClientService — failed connect error sanitization', () => {
const originalMcpServers = process.env['MCP_SERVERS'];
beforeEach(() => {
process.env['MCP_SERVERS'] = JSON.stringify([
{ name: 'leaky-server', url: 'http://localhost:9999/mcp' },
]);
});
afterEach(() => {
vi.restoreAllMocks();
if (originalMcpServers === undefined) {
delete process.env['MCP_SERVERS'];
} else {
process.env['MCP_SERVERS'] = originalMcpServers;
}
});
it('stores a generic serverEntry.error while logging the raw exception server-side', async () => {
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER));
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const service = new McpClientService();
await service.onModuleInit();
const statuses = service.getServerStatuses();
expect(statuses).toHaveLength(1);
expect(statuses[0]?.connected).toBe(false);
expect(statuses[0]?.error).toBe('Connection failed (see server logs).');
expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER);
const loggedRawMarker = errorSpy.mock.calls.some((call) =>
call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)),
);
expect(loggedRawMarker).toBe(true);
});
});
@@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy {
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
serverEntry.error = 'Connection failed (see server logs).';
serverEntry.error = message;
serverEntry.connected = false;
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
}
File diff suppressed because it is too large Load Diff
-14
View File
@@ -61,16 +61,6 @@ function requiredDiscordAllowlist(name: string): string[] {
return value;
}
function optionalPositiveInteger(name: string): number | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer when configured`);
}
return value;
}
function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN'];
@@ -92,10 +82,6 @@ function createPluginRegistry(): IChannelPlugin[] {
guildId: discordGuildId,
gatewayUrl: discordGatewayUrl,
serviceToken: discordServiceToken,
messageRateLimitPerMinute: optionalPositiveInteger(
'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE',
),
threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'),
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
import { SystemOverrideService } from './system-override.service.js';
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
describe('SystemOverrideService local tier', () => {
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
const service = new SystemOverrideService(localConfig);
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
await service.set('shared-session', 'first override', firstScope);
await service.set('shared-session', 'second override', secondScope);
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
await service.clear('shared-session', firstScope);
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
});
});
@@ -1,8 +1,6 @@
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
`${scope.tenantId}:${scope.userId}:${sessionId}`;
@@ -17,45 +15,16 @@ interface OverrideFragment {
addedAt: number;
}
interface LocalOverrideEntry {
condensed: string;
fragments: OverrideFragment[];
}
@Injectable()
export class SystemOverrideService implements OnApplicationShutdown {
export class SystemOverrideService {
private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle | null;
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
private readonly localStore = new Map<string, LocalOverrideEntry>();
private readonly handle: QueueHandle;
constructor(
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
}
async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {});
constructor() {
this.handle = createQueue();
}
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) {
const key = scopedSessionId(sessionId, scope);
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
entry.fragments.push({ text: override, addedAt: Date.now() });
entry.condensed = await this.condenseOverrides(
entry.fragments.map((fragment) => fragment.text),
);
this.localStore.set(key, entry);
this.logger.debug(
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
);
return;
}
// Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
const fragments: OverrideFragment[] = existing
@@ -85,14 +54,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
}
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
if (!this.handle) {
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
}
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
}
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) return;
const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
@@ -100,11 +65,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
}
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) {
this.localStore.delete(scopedSessionId(sessionId, scope));
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
return;
}
await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId, scope),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
@@ -1,36 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
import { QueueService } from './queue.service.js';
const localConfig = {
queue: { type: 'local' },
} as MosaicConfig;
describe('QueueService local tier', () => {
it('disables BullMQ and treats queue operations as local no-ops', async () => {
const service = new QueueService(null, localConfig);
expect(service.isEnabled()).toBe(false);
expect(service.getQueue('mosaic-test')).toBeNull();
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
await expect(
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
).resolves.toBeUndefined();
await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
await expect(service.listJobs()).resolves.toEqual([]);
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
});
});
+6 -65
View File
@@ -8,9 +8,7 @@ import {
} from '@nestjs/common';
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
import type { LogService } from '@mosaicstack/log';
import type { MosaicConfig } from '@mosaicstack/config';
import { LOG_SERVICE } from '../log/log.tokens.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import type { JobDto, JobStatus } from './queue-admin.dto.js';
// ---------------------------------------------------------------------------
@@ -110,42 +108,21 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly connection: ConnectionOptions;
private readonly queues = new Map<string, Queue<MosaicJobData>>();
private readonly workers = new Map<string, Worker<MosaicJobData>>();
/** False on Local tier — BullMQ/Redis operations become no-ops. */
private readonly enabled: boolean;
constructor(
@Optional()
@Inject(LOG_SERVICE)
private readonly logService: LogService | null,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
this.connection = this.enabled
? getConnection()
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
}
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
isEnabled(): boolean {
return this.enabled;
this.connection = getConnection();
}
onModuleInit(): void {
if (this.enabled) {
this.logger.log('QueueService initialised (BullMQ)');
} else {
this.logger.log(
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
);
}
this.logger.log('QueueService initialised (BullMQ)');
}
async onModuleDestroy(): Promise<void> {
if (this.enabled) {
await this.closeAll();
}
await this.closeAll();
}
// -------------------------------------------------------------------------
@@ -154,10 +131,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Get or create a BullMQ Queue for the given queue name.
* Returns null on Local tier where BullMQ is disabled.
*/
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
if (!this.enabled) return null;
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
let queue = this.queues.get(name) as Queue<T> | undefined;
if (!queue) {
queue = new Queue<T>(name, { connection: this.connection });
@@ -169,7 +144,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Add a BullMQ repeatable job (cron-style).
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
* No-op on Local tier.
*/
async addRepeatableJob<T extends MosaicJobData>(
queueName: string,
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
data: T,
cronExpression: string,
): Promise<void> {
if (!this.enabled) {
this.logger.debug(
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return;
}
const queue = this.getQueue<T>(queueName)!;
const queue = this.getQueue<T>(queueName);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (queue as Queue<any>).add(jobName, data, {
repeat: { pattern: cronExpression },
@@ -199,14 +167,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* safe retirement of previously registered system-wide jobs.
*/
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
if (!this.enabled) {
this.logger.debug(
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return 0;
}
const queue = this.getQueue(queueName);
if (!queue) return 0;
const jobs = await queue.getRepeatableJobs();
const matchingJobs = jobs.filter((job) => job.name === jobName);
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
@@ -221,18 +182,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Register a Worker for the given queue name with error handling and
* exponential backoff.
* Returns null on Local tier where BullMQ is disabled.
*/
registerWorker<T extends MosaicJobData>(
queueName: string,
handler: JobHandler<T>,
): Worker<T> | null {
if (!this.enabled) {
this.logger.debug(
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
);
return null;
}
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
const worker = new Worker<T>(
queueName,
async (job) => {
@@ -289,12 +240,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Return queue health statistics for all managed queues.
* Returns an empty healthy result on Local tier.
*/
async getHealthStatus(): Promise<QueueHealthStatus> {
if (!this.enabled) {
return { queues: {}, healthy: true };
}
const queues: QueueHealthStatus['queues'] = {};
let healthy = true;
@@ -325,10 +272,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* List jobs across all managed queues, optionally filtered by status.
* BullMQ jobs are fetched by state type from each queue.
* Returns empty array on Local tier.
*/
async listJobs(status?: JobStatus): Promise<JobDto[]> {
if (!this.enabled) return [];
const jobs: JobDto[] = [];
const states: JobStatus[] = status
? [status]
@@ -355,10 +300,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
* job IDs are not globally unique they are scoped to their queue.
* Returns an error on Local tier.
*/
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const sep = compositeId.lastIndexOf('__');
if (sep === -1) {
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
@@ -390,7 +333,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Pause a queue by name.
*/
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.pause();
@@ -402,7 +344,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Resume a paused queue by name.
*/
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.resume();
@@ -1,8 +1,5 @@
import { Logger } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
import { ReloadService } from './reload.service.js';
import { CommandExecutorService } from '../commands/command-executor.service.js';
function createMockCommandRegistry() {
return {
@@ -107,86 +104,3 @@ describe('ReloadService', () => {
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
});
});
describe('ReloadService — /reload command sanitizes plugin errors', () => {
it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => {
const registry = {
getManifest: vi.fn().mockReturnValue({
version: 1,
commands: [
{ name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true },
],
skills: [],
}),
};
const reloadService = new ReloadService(registry as never);
const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret';
const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret';
reloadService.registerPlugin('unload-fails', {
pluginName: 'unload-fails',
onLoad: vi.fn().mockResolvedValue(undefined),
onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)),
});
reloadService.registerPlugin('load-fails', {
pluginName: 'load-fails',
onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)),
onUnload: vi.fn().mockResolvedValue(undefined),
});
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const broadcastReload = vi.fn();
const mockChatGateway = { broadcastReload };
const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() };
const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() };
const mockSessionGC = { sweepOrphans: vi.fn() };
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
const mockMcpClient = {
getServerStatuses: vi.fn(() => []),
getToolDefinitions: vi.fn(() => []),
reconnectServer: vi.fn().mockResolvedValue(undefined),
};
const executor = new CommandExecutorService(
registry as never,
mockAgentService as never,
mockSystemOverride as never,
mockSessionGC as never,
null,
mockBrain as never,
reloadService,
mockChatGateway as never,
mockMcpClient as never,
{ authorize: vi.fn().mockResolvedValue({ allowed: true }) } as never,
);
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' });
expect(result.success).toBe(true);
expect(result.message).toContain('unload-fails: unload failed (internal error)');
expect(result.message).toContain('load-fails: load failed (internal error)');
expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
expect(broadcastReload).toHaveBeenCalledOnce();
const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload;
expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)');
expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)');
expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
const loggedUnloadMarker = errorSpy.mock.calls.some((call) =>
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)),
);
const loggedLoadMarker = errorSpy.mock.calls.some((call) =>
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)),
);
expect(loggedUnloadMarker).toBe(true);
expect(loggedLoadMarker).toBe(true);
errorSpy.mockRestore();
});
});
+2 -4
View File
@@ -58,8 +58,7 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
await plugin.onUnload();
reloaded.push(name);
} catch (err) {
this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
errors.push(`${name}: unload failed (internal error)`);
errors.push(`${name}: unload failed — ${err}`);
}
}
}
@@ -70,8 +69,7 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
try {
await plugin.onLoad();
} catch (err) {
this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
errors.push(`${name}: load failed (internal error)`);
errors.push(`${name}: load failed ${err}`);
}
}
}
@@ -1,104 +0,0 @@
import 'reflect-metadata';
import {
type CanActivate,
type ExecutionContext,
type INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { ProjectBootstrapService } from './project-bootstrap.service.js';
import { WorkspaceController } from './workspace.controller.js';
const bootstrapMock = vi.fn(() =>
Promise.resolve({
projectId: 'project-1',
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
}),
);
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
requestContext.user = { id: 'user-1' };
return true;
},
};
describe('POST /api/workspaces repoUrl validation', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [WorkspaceController],
providers: [
{
provide: ProjectBootstrapService,
useValue: { bootstrap: bootstrapMock },
},
],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
beforeEach(() => {
bootstrapMock.mockClear();
});
afterAll(async () => {
await app.close();
});
it.each([
['a leading-dash value', '--upload-pack=sh -c id'],
['an ext remote helper', 'ext::sh -c id'],
['a file URL', 'file:///tmp/repository'],
['an unparseable value', 'not a url'],
['an SSH shorthand', '[email protected]:acme/repository.git'],
['a scheme without //', 'https:example.com/acme/repository.git'],
['a hostless git URL', 'git:///tmp/repository'],
])('returns 400 for %s', async (_description, repoUrl) => {
const response = await request(app.getHttpServer())
.post('/api/workspaces')
.send({ name: 'Example', repoUrl })
.set('Content-Type', 'application/json');
expect(response.status).toBe(400);
expect(bootstrapMock).not.toHaveBeenCalled();
});
it.each([
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
])('accepts %s', async (_description, repoUrl) => {
const response = await request(app.getHttpServer())
.post('/api/workspaces')
.send({ name: 'Example', repoUrl })
.set('Content-Type', 'application/json');
expect(response.status).toBe(201);
expect(bootstrapMock).toHaveBeenCalledWith({
name: 'Example',
description: undefined,
userId: 'user-1',
teamId: undefined,
repoUrl,
});
});
});
@@ -1,11 +1,7 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import {
ProjectBootstrapService,
type BootstrapProjectResult,
} from './project-bootstrap.service.js';
import { CreateWorkspaceDto } from './workspace.dto.js';
import { ProjectBootstrapService } from './project-bootstrap.service.js';
@Controller('api/workspaces')
@UseGuards(AuthGuard)
@@ -15,14 +11,20 @@ export class WorkspaceController {
@Post()
async create(
@CurrentUser() user: { id: string },
@Body() dto: CreateWorkspaceDto,
): Promise<BootstrapProjectResult> {
@Body()
body: {
name: string;
description?: string;
teamId?: string;
repoUrl?: string;
},
) {
return this.bootstrap.bootstrap({
name: dto.name,
description: dto.description,
name: body.name,
description: body.description,
userId: user.id,
teamId: dto.teamId,
repoUrl: dto.repoUrl,
teamId: body.teamId,
repoUrl: body.repoUrl,
});
}
}
@@ -1,33 +0,0 @@
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
export class CreateWorkspaceDto {
@IsString()
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string;
@IsOptional()
@IsString()
teamId?: string;
@IsOptional()
@IsString()
@Matches(/^(?:https|git):\/\//i, {
message: 'repoUrl must be a valid https:// or git:// URL',
})
@IsUrl(
{
protocols: ['https', 'git'],
require_host: true,
require_protocol: true,
require_tld: false,
require_valid_protocol: true,
},
{ message: 'repoUrl must be a valid https:// or git:// URL' },
)
repoUrl?: string;
}
@@ -1,33 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { WorkspaceService } from './workspace.service.js';
type ExecFileMock = (
command: string,
args: readonly string[],
options: { cwd: string },
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => void;
const { execFileMock } = vi.hoisted(() => ({
execFileMock: vi.fn<ExecFileMock>(),
}));
vi.mock('node:child_process', () => ({
execFile: execFileMock,
}));
import path from 'node:path';
describe('WorkspaceService', () => {
let service: WorkspaceService;
beforeEach(() => {
execFileMock.mockReset();
execFileMock.mockImplementation((_command, _args, _options, callback) => {
callback(null, '', '');
});
service = new WorkspaceService();
});
@@ -98,69 +76,4 @@ describe('WorkspaceService', () => {
}
});
});
describe('create', () => {
const project = {
id: 'project-1',
ownerType: 'user',
userId: 'user-1',
teamId: null,
} as const;
let originalRoot: string | undefined;
let temporaryRoot: string;
beforeEach(async () => {
originalRoot = process.env['MOSAIC_ROOT'];
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
process.env['MOSAIC_ROOT'] = temporaryRoot;
service = new WorkspaceService();
});
afterEach(async () => {
if (originalRoot === undefined) {
delete process.env['MOSAIC_ROOT'];
} else {
process.env['MOSAIC_ROOT'] = originalRoot;
}
await fs.rm(temporaryRoot, { recursive: true, force: true });
});
it.each([
['a leading-dash URL', '--upload-pack=sh -c id'],
['an ext remote helper', 'ext::sh -c id'],
['a file URL', 'file:///tmp/repository'],
['an unparseable value', 'not a url'],
['an SSH shorthand', '[email protected]:acme/repository.git'],
['a scheme without //', 'https:example.com/acme/repository.git'],
['a hostless git URL', 'git:///tmp/repository'],
])('rejects %s before invoking git', async (_description, repoUrl) => {
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
expect(execFileMock).not.toHaveBeenCalled();
});
it.each([
['an HTTPS URL', 'https://example.com/acme/repository.git'],
['a git protocol URL', 'git://example.com/acme/repository.git'],
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
const workspacePath = await service.create(project, repoUrl);
expect(execFileMock).toHaveBeenCalledOnce();
expect(execFileMock).toHaveBeenCalledWith(
'git',
[
'-c',
'protocol.ext.allow=never',
'-c',
'protocol.file.allow=never',
'clone',
'--',
repoUrl,
'.',
],
{ cwd: workspacePath },
expect.any(Function),
);
});
});
});
@@ -1,30 +1,10 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import fs from 'node:fs/promises';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
function assertAllowedRepositoryUrl(repoUrl: string): void {
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
throw new BadRequestException(repositoryUrlError);
}
let parsedUrl: URL;
try {
parsedUrl = new URL(repoUrl);
} catch {
throw new BadRequestException(repositoryUrlError);
}
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
throw new BadRequestException(repositoryUrlError);
}
}
export interface WorkspaceProject {
id: string;
@@ -59,32 +39,14 @@ export class WorkspaceService {
* If repoUrl is provided, clone instead of init.
*/
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
if (repoUrl !== undefined) {
assertAllowedRepositoryUrl(repoUrl);
}
const workspacePath = this.resolvePath(project);
// Create directory
await fs.mkdir(workspacePath, { recursive: true });
if (repoUrl !== undefined) {
// Clone existing repo. Defense in depth keeps dangerous local helpers
// disabled and terminates option parsing before positional arguments.
await execFileAsync(
'git',
[
'-c',
'protocol.ext.allow=never',
'-c',
'protocol.file.allow=never',
'clone',
'--',
repoUrl,
'.',
],
{ cwd: workspacePath },
);
if (repoUrl) {
// Clone existing repo
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
} else {
// Init new git repo
-30
View File
@@ -1,30 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mosaic</title>
<meta name="description" content="Mosaic Stack Dashboard" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
/>
<script>
// set data-theme before first paint so the stored theme never flashes
(function () {
try {
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
} catch (error) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4 -10
View File
@@ -3,26 +3,22 @@
"version": "0.0.2",
"private": true,
"scripts": {
"build": "node ../../scripts/build-web.mjs",
"build:vite": "vite build",
"dev": "next dev -p 3101",
"dev:vite": "vite",
"build": "next build",
"dev": "next dev",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:e2e": "playwright test",
"start": "next start -p 3101"
"start": "next start"
},
"dependencies": {
"@mosaicstack/design-tokens": "workspace:^",
"@mosaicstack/types": "workspace:^",
"better-auth": "^1.5.5",
"clsx": "^2.1.0",
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2",
"socket.io-client": "^4.8.0",
"tailwind-merge": "^3.5.0"
},
@@ -32,11 +28,9 @@
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.5",
"jsdom": "^29.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.8.0",
"vite": "^8.2.1",
"vitest": "^3.2.7"
"vitest": "^2.0.0"
}
}
@@ -3,56 +3,41 @@
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { api } from '@/lib/api';
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
import { signIn } from '@/lib/auth-client';
import type { SsoProviderDiscovery } from '@/lib/sso';
import { getSsoProvider } from '@/lib/sso-providers';
export default function AuthProviderRedirectPage(): React.ReactElement {
const params = useParams<{ provider: string }>();
const searchParams = useSearchParams();
const providerId = typeof params.provider === 'string' ? params.provider : '';
const requestedCallbackURL = searchParams.get('callbackURL');
const [providerName, setProviderName] = useState<string | null>(null);
const provider = getSsoProvider(providerId);
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const currentProvider = provider;
if (!currentProvider) {
setError('Unknown SSO provider.');
return;
}
if (!currentProvider.enabled) {
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
return;
}
const activeProvider = currentProvider;
let cancelled = false;
async function redirectToProvider(): Promise<void> {
try {
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
if (cancelled) return;
const result = await signIn.oauth2({
providerId: activeProvider.id,
callbackURL,
});
const provider = providers.find((candidate) => candidate.id === providerId);
if (!provider) {
setError('Unknown SSO provider.');
return;
}
setProviderName(provider.name);
if (!provider.configured) {
setError(`${provider.name} is not enabled in this deployment.`);
return;
}
if (provider.loginMode !== 'oidc') {
setError(`${provider.name} is not available for OIDC sign in.`);
return;
}
const result = await signIn.oauth2({
providerId: provider.id,
callbackURL,
});
if (!cancelled && result?.error) {
setError(result.error.message ?? `${provider.name} sign in failed.`);
}
} catch (caught: unknown) {
if (!cancelled) {
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
}
if (!cancelled && result?.error) {
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
}
}
@@ -61,22 +46,19 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
return () => {
cancelled = true;
};
}, [providerId, requestedCallbackURL]);
}, [callbackURL, provider]);
return (
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
<p className="mt-2 text-sm text-text-secondary">
{providerName
? `Redirecting you to ${providerName}...`
{provider
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
: 'Preparing your sign-in request...'}
</p>
{error ? (
<div
role="alert"
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
<p>{error}</p>
<Link
href="/login"

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