Compare commits

..
Author SHA1 Message Date
jason.woltjeandClaude Opus 4.8 f16f206a0a feat(gateway): expose generic harness catalog and selection
ci/woodpecker/pr/ci Pipeline failed
Add the Slice-Zero catalog and selection HTTP surfaces for P3 Task 3:
GET /api/harnesses, GET /api/harnesses/:harnessId/catalog,
GET+PUT /api/chat/preferences/selection. Scope is always server-derived
via scopeFromUser(CurrentUser); selection tuples are validated against the
live catalog with no fallback substitution and persisted in a transitional
owner-scoped in-memory store. HarnessModule is wired into AppModule.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-11 19:56:11 -05:00
jason.woltje a186922e3a Merge pull request 'feat(gateway): harness registry, fake adapter, no-substitution suite (P3 Slice Zero, Task 2)' (#1168) from feat/p3-slice0-task2-harness-registry into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 00:40:03 +00:00
jason.woltje 43513c28f7 feat(gateway): add harness registry and fake adapter
ci/woodpecker/pr/ci Pipeline failed
2026-08-11 19:22:27 -05:00
jason.woltje fb9f9cda5a Merge pull request 'fix(gateway): resolve InteractionCoordinationService handoff factory via optional DI token (#1145)' (#1167) from fix/1145-coord-di-compiled-boot into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 23:57:43 +00:00
jason.woltje 400a21ca18 Merge pull request 'feat(types): generic harness contracts (P3 Slice Zero, Task 1)' (#1166) from feat/p3-slice0-task1-harness-contracts into next
ci/woodpecker/push/publish Pipeline is pending
2026-08-11 23:51:50 +00:00
shaggy (mosaic-dev box) 4cefa5cd88 fix(gateway): resolve InteractionCoordinationService handoff factory via optional DI token (#1145)
ci/woodpecker/pr/ci Pipeline failed
root cause: emitDecoratorMetadata reflected the third constructor parameter as Function and Nest attempted to resolve it

fix: optional HANDOFF_ID_FACTORY injection token, no production provider, preserving undefined -> crypto.randomUUID() default and unchanged positional construction

TDD: real CoordModule red at Function index [2], then green; test overrides only unrelated AuthGuard because its AUTH provider comes from AppModule's global AuthModule context

Closes #1145
2026-08-11 18:37:54 -05:00
shaggy (mosaic-dev box) ddf8616716 feat(types): add generic harness contracts
ci/woodpecker/pr/ci Pipeline was canceled
2026-08-11 18:32:39 -05:00
mos-dt-0 9185b0cce4 Merge pull request 'docs(webui): Phase P structure & migration map' (#1147) from docs/webui-phase-p-structure into next 2026-08-11 22:29:10 +00:00
mos-dt-0andClaude Fable 5 8925a502ae style(webui): prettier-format PHASE-P-STRUCTURE.md
ci/woodpecker/pr/ci Pipeline was successful
format:check was the only red CI step on #1147.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-11 17:14:38 -05:00
mos-dt-0 0e4eb1445c Merge pull request 'fix(installer): propagate wizard gateway failures' (#1134) from fix/wizard-gateway-failure into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 22:01:43 +00:00
mos-dt-0 592d60425f Merge pull request 'fix(installer): require Node 22 for the --next lane' (#1130) from fix/next-node-gate into next
ci/woodpecker/push/publish Pipeline was canceled
2026-08-11 22:01:39 +00:00
mos-dt-0 a43f343efd Merge pull request 'fix(security): mosaic-init RCE — eval on prompt answers → printf -v (#1115)' (#1127) from fix/mosaic-init-rce into next
ci/woodpecker/push/publish Pipeline was canceled
2026-08-11 22:01:35 +00:00
jason.woltje 88ef9d4fa5 Merge pull request 'P3-R1: repair routing health-enum (#1) + wire /mcp command (#5)' (#1154) from feat/webui-p3r1-routing-mcp into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 20:51:52 +00:00
shaggy (mosaic-dev box) bda308efd9 fix(gateway): repair routing health and MCP command wiring
ci/woodpecker/pr/ci Pipeline was successful
2026-08-11 15:28:57 -05:00
jason.woltje 20718b5a27 Merge pull request 'P4-1: read-only Projects + Tasks SPA pages + dev-port pins' (#1153) from feat/webui-p4-1 into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 04:00:36 +00:00
shaggy (mosaic-dev box) 29db24210c fix(web): P4-1 restore graceful degradation for missions/tasks fetch on project detail
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 22:43:47 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 a6085eea37 feat(web): add read-only project and task SPA pages
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-10 22:23:59 -05:00
Mos e00cc475a2 Merge pull request 'P3 — Typed SPA chat (Phase P webUI)' (#1151) from feat/webui-p3-chat into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 22:57:07 +00:00
shaggy (mosaic-dev box) 7d84e4ee03 fix(gateway): sanitize raw exceptions in /mcp status + /reload sources (P3 re-review#5 blocker)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 16:58:07 -05:00
shaggy (mosaic-dev box) 4aaf41dd1a fix(web,gateway): close P3 re-review#4 findings — sanitize all executor catches; lock pre-start turn boundary (wire turnId deferred) 2026-08-10 16:07:12 -05:00
mos-dt-0 bf32f29acd fix(ci): make queue guard purpose-sensitive (#1148)
ci/woodpecker/push/publish Pipeline failed
2026-08-10 20:54:09 +00:00
Jason Woltje 1655b1579a Move deploy and briefs into docs folder. Remove old files.
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:44:19 -05:00
Jason Woltje e478a359eb Move briefs directory inside the docs
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:42:04 -05:00
Jason Woltje 76e4242cb1 Reformat Requirements block
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 14:40:18 -05:00
shaggy (mosaic-dev box)andClaude Opus 4.8 a45f53071a docs(webui): add Phase P structure & migration map
ci/woodpecker/pr/ci Pipeline failed
First-pass structural reference for the Vite SPA migration (apps/web): dual-app
tree during migration, shared lib/ networking layer, origin-relative/same-origin
serving model, build scripts, the P1-P6 increment map, and the #1145 P5 blocker.
Living doc — details to be fleshed out by follow-up.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-10 14:34:57 -05:00
Jason Woltje 00eb216480 docs(agents): consolidate project guidance 2026-08-10 14:27:59 -05:00
shaggy (mosaic-dev box) d46a2d675a fix(web,gateway): close P3 re-review#3 findings — sanitize command errors, harden turn-lock & caps 2026-08-10 14:21:52 -05:00
shaggy 8c27024d0e Merge pull request 'fix(gateway): gate FederationModule on tier === 'federated' (#1138)' (#1140) from fix/1138-conditional-federation into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 07:09:51 +00:00
shaggy (mosaic-dev box) 48bb19310d fix(web): close P3 chat re-review findings 2026-08-10 01:58:27 -05:00
shaggy (mosaic-dev box) 406e40584d test(gateway): raise module-graph import timeout for CI load robustness (#1138)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-10 01:34:24 -05:00
shaggy (mosaic-dev box) 677aeb0c93 fix(gateway): restore daemon config discovery (#1138)
ci/woodpecker/pr/ci Pipeline failed
2026-08-10 00:31:58 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 caebf9ef70 fix(web): harden typed SPA chat lifecycle
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-10 00:24:20 -05:00
shaggy (mosaic-dev box)andClaude Haiku 4.5 bd0ef2ab25 fix(gateway): anchor remaining config loads (#1138)
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-08-09 23:54:53 -05:00
shaggy (mosaic-dev box) 2d5a8c81ec fix(gateway): anchor config discovery and isolate env tests (#1138) 2026-08-09 23:11:09 -05:00
shaggy (mosaic-dev box) 884d527cc8 fix(gateway): anchor dotenv discovery to module (#1138) 2026-08-09 22:21:17 -05:00
shaggy (mosaic-dev box) b2e005f2b4 feat(web): add typed SPA chat
Bring the chat experience into the Vite/React-Router SPA on the exact typed
Socket.IO /chat contract from @mosaicstack/types, replacing the /chat
placeholder behind AuthGuard. Surfaces message:ack (with an accessible
status), agent:start, streamed agent:text/agent:thinking, tool start/end
status, agent:end with usage, session:info (thinking controls + routing
decision), commands:manifest, command:result, command:approval (with a
one-time approved-run affordance), system:reload (refreshing the rendered
manifest), and error, and emits message/abort/set:thinking/command:execute/
command:approve with exact payloads.

The gateway does not guarantee message:ack is the first event for a new
conversation (session:info, and error on auth/session-creation failure, can
both arrive first) — conversation-scoped events now adopt the conversation
from whichever scoped event names it first while a send is pending, then
filter everything else against that established conversation. A typed error
stops streaming instead of leaving Stop stuck active; agent:end no longer
appends an empty assistant turn when there is no text or thinking; and a
second message can no longer be sent while a turn is streaming.

Command approval is now integrity-checked end to end: only one
command:approve request may be outstanding at a time (a concurrent request
is ignored rather than overwriting the pending command/args), a stale or
mismatched command:approval response cannot replace active approval state,
and running an approved command clears its approval state immediately (via
a ref, before React re-renders) so a double-click cannot replay
command:execute.

The `/chat` socket is now typed at a single boundary: apps/web/src/lib/
socket.ts narrows socket.io-client's untyped `io()` return value to
`ChatSocket` (Socket<ServerToClientEvents, ClientToServerEvents>) once, at
creation, via the one assertion the library's types force; every consumer
(use-chat-connection.ts) then gets fully checked `on`/`emit` calls with no
further casts. The shared contract types live in the new
apps/web/src/lib/chat-contract.ts (replacing the old spa/chat/types.ts
shim), which re-exports them via type-only imports resolved directly
against packages/types/src (apps/web has no @mosaicstack/types package
dependency, so this stays source-only and is erased at compile time —
no package manifest or lockfile is touched). The two recorded-event test
suites now drive a shared, typed fake socket
(spa/chat/test-support/fake-chat-socket.ts) instead of an untyped
`(event: string, payload: unknown)` harness, so a wrong event name or
malformed payload fails to compile.
2026-08-09 21:39:16 -05:00
shaggy 87daa12976 Merge pull request 'P2 — web SPA data layer + same-origin auth' (#1144) from feat/webui-p2-data-auth into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 01:52:01 +00:00
shaggy (mosaic-dev box) b82a51da80 fix(gateway): load dotenv before federation tier gate (#1138) 2026-08-09 20:50:37 -05:00
shaggy (mosaic-dev box) 90cf286a09 fix(web): reject protocol-relative auth callbacks
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 20:43:27 -05:00
shaggy (mosaic-dev box) 0aef432052 docs(scratchpad): record P2 remediation evidence 2026-08-09 20:21:53 -05:00
shaggy 41a16cc916 Merge pull request 'fix(docker): gateway image — git in runner, MOSAIC_ROOT workspace dir, scripts/ in builder, EXPOSE 14242' (#1142) from fix/gateway-runner-image into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 01:21:30 +00:00
shaggy (mosaic-dev box) e16c08aa9f test(web): align jsdom abort signals with Node 2026-08-09 20:20:30 -05:00
shaggy (mosaic-dev box) a34e92cf39 fix(gateway): harden workspace repository cloning
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 20:12:39 -05:00
shaggy (mosaic-dev box) a4861c221f docs(scratchpad): record WebUI P2 verification 2026-08-09 20:02:22 -05:00
shaggy (mosaic-dev box) 46d68e1ff4 feat(web): add same-origin SPA authentication 2026-08-09 20:00:22 -05:00
shaggy c3496334a5 Merge pull request 'feat(web): P1 — Vite + React Router skeleton beside Next (Phase P RFC, increment 1/6)' (#1143) from feat/webui-p1-vite-skeleton into next
ci/woodpecker/push/publish Pipeline failed
2026-08-10 00:51:02 +00:00
shaggy 6f29d00149 Merge pull request 'fix: break-C — install-hooks no-ops without git; web image builds @mosaicstack/web' (#1141) from fix/break-c-hooks-and-web-image into next
ci/woodpecker/push/publish Pipeline was canceled
2026-08-10 00:50:19 +00:00
shaggy (mosaic-dev box)andClaude Fable 5 068d0f9b1c feat(web): P1 Vite skeleton beside Next — entry, router, guards, vitest 3
ci/woodpecker/pr/ci Pipeline was successful
First increment of the approved Phase P RFC (webui-mission). Adds a Vite + React
Router SPA scaffold coexisting with the Next app: index.html with the theme
anti-flash script, src/main.tsx entry, the v1 parity route table under Guest/Auth
guard shells, and a dev proxy (/api, /socket.io ws) to the gateway on 14242 so the
SPA is same-origin in dev. vitest bumped to v3 (vite 8 pairing); existing specs
pass unchanged. Next remains the served app until the P5 cutover.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 19:02:55 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 13cd673d50 fix(docker): gateway runner needs git + MOSAIC_ROOT workspace dir; EXPOSE actual port 14242
ci/woodpecker/pr/ci Pipeline was successful
WorkspaceService shells out to git at runtime and roots workspaces at
$MOSAIC_ROOT/.workspaces — the runner image had no git binary and no
workspace directory. EXPOSE said 4000 but main.ts defaults to 14242.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 18:03:42 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 620cc608e0 fix(docker): copy scripts/ into web builder — prepare runs install-hooks.mjs on install
ci/woodpecker/pr/ci Pipeline was successful
The layer-cached install copies only manifests and packages/, so the
root prepare script could not be found and pnpm install exited 1
before the git-absent guard could even run.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 18:03:18 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 91e692e3e7 fix: break-C — install-hooks no-ops without git; web image builds @mosaicstack/web
ci/woodpecker/pr/ci Pipeline was canceled
install-hooks.mjs hard-failed (exit 1) in environments without a git
binary — e.g. the docker image builds, which have no git and no repo.
Hook installation is meaningless there; skip with a warning instead.

docker/web.Dockerfile filtered @mosaic/web, but the package is named
@mosaicstack/web, so the image build compiled nothing.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 17:58:13 -05:00
shaggy (mosaic-dev box) b4753a75cd fix(gateway): gate FederationModule on tier federated (#1138)
ci/woodpecker/pr/ci Pipeline was successful
CaService hard-requires STEP_CA_URL/provisioner config at construction, so an
unconditional FederationModule import makes every standalone/local boot die at
DI time. Gate the module on loadConfig().tier === federated, matching the
documented intent of the federation compose profile (must not start in
non-federated dev).

Verified in mosaic-dev box: standalone tier boots to "Gateway listening on
port 14242" with bootstrap/socket.io/auth surfaces responding; federated tier
path unchanged.
2026-08-09 17:26:28 -05:00
velmaandmos-dt-0 24bbd40dc7 docs: WebUI fleet Claude bridge — Task 0 decision plan (#1131)
ci/woodpecker/push/publish Pipeline failed
Docs-only plan PR. FRED_APPROVED_REF=0629361ca39a4dd7fb3e575d11c64bea9e545dae (review 147). Merged by fred (orchestrator) via API: pr-merge.sh policy predates the next lane (main-only hardcode) — wrapper fix tracked separately.

Co-authored-by: Velma <[email protected]>
2026-08-09 10:28:41 +00:00
Jason Woltje dc67590a96 fix(installer): propagate wizard gateway failures (#1120)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 05:01:09 -05:00
Jason Woltje baf4306f51 fix(installer): require Node 22 for next lane
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 02:37:03 -05:00
Jason Woltje 12677a928d fix(mosaic): prevent init prompt code execution
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 00:28:57 -05:00
mos-dt-0 4df478cdd1 Merge pull request 'chore(sync): merge main → next (B1) — resolve 8 conflicts, restore next current' (#1041) from sync/b1-main-into-next into next
ci/woodpecker/push/publish Pipeline failed
chore(sync): merge main → next (B1) — restore next current, resolve 8 conflicts (#1041)

Brings next current with main incl the RM-03 guard fix (482/5); preserves next's 10 in-flight commits. Closes #1040.
2026-08-03 10:17:06 +00:00
coder-mos2 b8844e1ff0 fix(sync): close local queue semantic merge gap
ci/woodpecker/pr/ci Pipeline was successful
2026-08-02 23:16:09 -05:00
coder-mos2 906ad8dc30 wip(sync): merge main into next with combined resolutions 2026-08-02 23:11:22 -05:00
coder-mos1andmos-dt-0 5916aeefd6 chore(release): @mosaicstack/mosaic 0.0.49 — ship RM-03 guard fix to release channel (#1036)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
Co-authored-by: coder-mos1 <[email protected]>
2026-08-02 20:09:11 +00:00
coder-mos1andmos-dt-0 58b971aba3 fix(rm-03): make CI queue guard fail on asserted non-readiness (#1032)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: coder-mos1 <[email protected]>
2026-08-01 18:55:26 +00:00
coder-mos1andmos-dt-0 f4fd5967fc RM-61: prove ci-postgres teardown discrimination (#1033)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: coder-mos1 <[email protected]>
2026-08-01 14:54:04 +00:00
mos-dt-0andMos f65e9ea656 docs(remediation): mission-state snapshot at the RM-01 seam (#1028)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-01 01:08:11 +00:00
mos-dt-0andMos f58b3699a6 RM-01: reproducible checkout — the pre-push gate fails on code, not environment (#1027)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-01 00:50:12 +00:00
mos-dt-0andMos 01e966f36d docs(remediation): mission charter + reconciled execution backlog (#1026)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 22:47:03 +00:00
mos-dt-0andMos 524146055d fix(hygiene): .prettierignore must exclude Python build/test artifacts (#1025)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 22:24:49 +00:00
mos-dt-0andMos 06e0d40352 feat(quality): CI test-membership guard — enumeration can no longer silently under-run the disk (#1017) (#1018)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 14:08:41 +00:00
Mos 166ee8c90f fix(wake): close fd 9 in the detector's sleep child so a dead detector's lock dies with it (#993)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-31 13:48:33 +00:00
mos-dt-0andMos 826a8b3b26 fix(git): pr-review.sh — surface the provider's stated reason, drop the hardcoded #865 attribution (#1006)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 10:52:58 +00:00
mos-dt-0andMos a4280b9c98 fix(wake): #984 fatal source guard + #985 absorb re-scan — #973 follow-up batch (#1001)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 10:16:22 +00:00
Mos 4fb44f6345 fix(wake): three-valued grep verdicts — has_match/count_lines across all ten suites (closes #973) (#983)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-31 08:39:04 +00:00
Mos 089615f63b feat(git): push-guard — refuse verifications satisfied by the null case (closes #975) (#974)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-31 03:35:24 +00:00
mos-dt-0andMos 76eef39a29 docs(wake): #953 dead-letter retention recorded as load-bearing at the write site (#968)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #953.

GATE RECORD: review CLEAR at this exact head (author != reviewer, pre-registered diff-blind checks) + terminal-green CI at this exact head + queue guard clear.
CI CAVEAT (#973): green on the wake suites is currently WEAKER THAN IT LOOKS, IN BOTH DIRECTIONS. grep error/spawn exit codes are read as absence across 257 assertion sites in six idiom forms; 36 inverted (&&-fail) sites — including 19 credential-security canaries — fail toward GREEN under load. These greens were obtained on solo reruns after load-correlated FALSE reds (2115/2116/2118; main itself was red). This merge's safety therefore rests on the CONTENT review, not on the green. Remediation charter fa551c2d0 is authored and in flight.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 23:25:38 +00:00
mos-dt-0andMos 47f8689231 fix(wake): #952 quarantine-audit clean sweep names BOTH unprovable residual classes (#967)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Closes #952.

GATE RECORD: review CLEAR at this exact head (author != reviewer, pre-registered diff-blind checks) + terminal-green CI at this exact head + queue guard clear.
CI CAVEAT (#973): green on the wake suites is currently WEAKER THAN IT LOOKS, IN BOTH DIRECTIONS. grep error/spawn exit codes are read as absence across 257 assertion sites in six idiom forms; 36 inverted (&&-fail) sites — including 19 credential-security canaries — fail toward GREEN under load. These greens were obtained on solo reruns after load-correlated FALSE reds (2115/2116/2118; main itself was red). This merge's safety therefore rests on the CONTENT review, not on the green. Remediation charter fa551c2d0 is authored and in flight.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 23:25:34 +00:00
mos-dt-0andMos 8d1d6e5e76 feat(wake): #958 A11 preimage.sh — durable provenance for the operator-side preimage definition (#964)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #958.

The preimage definition (source-adapter.sh) is the single most consequential file in the wake pipeline — every observed_hash is a sha256 of what it emits — and it was UNVERSIONED: no git history, no backup. When it was edited at 07:27 on 2026-07-30, attribution was recoverable only because an agent transcript happened to still be on disk. A11 gives that file durable provenance (option (2) of #958: recorded content-addressed, not in-band).

DESIGN, per the pre-registration:
- Provenance is OUT-OF-BAND (never on the adapter's stdout) — an in-band record would advance observed_hash for every source at once and manufacture the re-baseline it exists to explain (B3).
- The obligation never depends on the provenance path: a missing/corrupt store cannot halt the detector or swallow a wake (#940 advisory-fields precedent; B4).
- DESC_FMT=d1 is NOT the provenance record — the tag versions the descriptor FORMAT; a behaviour change that keeps descriptor shape re-baselines every hash and leaves the tag unchanged (B6).

CREDENTIAL HARD GATE (rebuilt after the first verdict FAILED it): byte capture is now RECORD-ONLY BY DEFAULT (extras opt in via WAKE_PREIMAGE_CAPTURE), not allow-by-default-refuse-on-shape — because a shape list can only refuse the secrets someone already enumerated, and the tool's own usage text recommended adding detector.env (where HMAC material lives). Deny is evaluated on BOTH raw and resolved path forms with resolved anchors, ordered before allow — closing the realpath-before-deny ordering defect that let a renamed symlink target through.

VERIFICATION (reviewer, mos-dt, independent of the author's claims):
- Seven decoy cases by planted-marker-then-grep-whole-state-dir: known cred path / same-name symlink / RENAMED-target symlink / prefixed secret / prefixless secret / opted-in-symlink-to-DENIED-target all REFUSED; opted-in-symlink-to-ALLOWED-target CAPTURED (positive control that the harness can capture at all, and that C was not closed by breaking every symlink).
- Polarity-completeness self-test RE-RUN with the shape list stubbed always-allow AND both deny lists stubbed — case D still safe: the flip is complete, the shape list is not load-bearing. Each stub proven live first (a stub that silently fails to apply reports the dangerous state as safe).
- B11: rm-then-change fails LOUD (rc=1), refuses to re-baseline, leaves the ledger absent; absent-with-emptied-objects still first-installs cleanly (absent-is-not-corrupt not paid for by breaking first install).
- RED-first reproduced exactly P13-P16 pre-fix; each refusal corroborated three ways (loud stderr, ledger row captured:false WITH a hash so attribution survives refusal, objects/ holding only the adapter).

KNOWN RESIDUAL (filed #969, non-gating): the deny check is both-forms but the suite needles only the resolved form — a deny reduced to resolved-only survives 17/17 green and would leak a renamed-symlink case. No reachable leak at this head (shipped code correct on all seven decoys); it constrains a FUTURE edit. Doctrine: a both-forms fix needs a needle per form; a fixture that satisfies its assertion through a DIFFERENT rule is testing the rule it did not mean to test.

Authored by pepper (sb-it-1-dt); independently reviewed by mos-dt (sb-it-1-dt) under diff-blind pre-registration (7242688b1, predating first read) — NOT CLEAR on the first verdict (B2/B11 failed by decoy), CLEAR at 8aff7d8 after the polarity rebuild. Manifest version 0.7.0.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 21:48:06 +00:00
mos-dt-0andMos 6a7fce34bb fix(wake): #946 digest ack watermark clamped at quarantined seqs — disclose AND clamp (#951)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #946.

The digest omitted a quarantined entry's claim from disclosure while still advancing the ack watermark it instructed the consumer to run — converting a fail-safe HOLD into a silent DISCARD, through the documented normal path. Measured: the burial instruction was re-issued FIVE times, four fresh digests plus one system-initiated redelivery fired purely because the entry had gone unconsumed for 1826s. That redelivery is the proof of the 'indefinitely' half: the mechanism re-asserted itself with no new information.

SCOPE — this was NOT a missing check in the consume path. Measured before the fix: dead-letter occurrences were digest.sh 27, store.sh 0, ack.sh 0, detector.sh 0, reconcile.sh 0. Quarantine was owned ENTIRELY by the renderer; the store that advances the watermark had zero knowledge the ledger existed, so an entry could be quarantined by one subsystem and consumed by another with no possible interaction. The fix is therefore a deliberate cross-module decision — option (b), quarantine recorded into a store-owned file, preserving the existing direction of dependency — pre-registered by the consumer before any diff existed.

VERIFICATION
- Pipeline 2107 terminal SUCCESS at e11bc6622, read clone-inclusive from the provider API rather than through `pipeline-status.sh` (which filters `.type != "clone"` per workflow and would hide a clone failure behind an all-green table): 9/9 children success, exit 0 each, no non-success member. Its `test` step runs all nine wake harnesses via turbo -> packages/mosaic `test` -> `test:framework-shell`.
- Independent review by the consumer on the affected lane: eleven pre-registered acceptance checks, authored and delivered BEFORE the diff was read — the file list deliberately unlooked-at, because a filename alone would have disclosed which option was chosen. All eleven resolved, no blocker.
- The check that decides it: a RAW `ack.sh consumed --upto N` with no digest involved must refuse to advance past a quarantined seq — the case an agent hits when a digest is MISSED, and the one that would have sunk a disclosure-only fix. Covered at the head as a named assertion (T13 ordinary-path bypass), written independently of the reviewer's list.
- Mutation: one asserted site disabled -> TWELVE assertions die, every one BEHAVIOURAL, ZERO count assertions, including one killing across the module boundary the fix spans.
- RED control at base a6b5f6a: 34 and 10 assertions fail, matching the body exactly.
- Coordinator re-verify by a different instrument than the reviewer used: static reference counts across the base/head boundary — store.sh 0 -> 49, ack.sh 0 -> 6, `--agent` unchanged at 4 (so #949 correctly stayed out). A fix present-but-inert passes the count and fails the mutation; a fix behaviourally correct but smuggling #949 passes the mutation and fails the count. Neither result is reachable by repeating the other.

KNOWN RESIDUALS
- The `consumed-hashes` repair criterion is met only for keys that RE-EMIT. A corrupted row whose key never recurs stays false indefinitely; the sweep covers those, and the known-false row named in the acceptance criteria had already self-healed by re-emission rather than by design — safe by population, not by design.
- The audit's clean-sweep message names one unprovable class; a second exists (a surviving dead-letter row with an empty `observed_hash` cannot be convicted either). Wording, not logic. Filed separately.
- The audit's provability bound makes dead-letter RETENTION load-bearing for auditability. Nothing prunes it today, so this is latent — but any future rotation or size cap silently converts provable rows into unprovable ones with no signal at either end. This is not a defect; it is a property that BECAME load-bearing and is recorded nowhere. Filed separately.
- `test-wake-detector.sh` D4 fails at this head AND identically at base, with an empty diff over detector files — pre-existing, tracked, not introduced here.

Authored by pepper (sb-it-1-dt); reviewed independently by mos-dt (sb-it-1-dt). The mos-dt-0 commit and fork identity does not identify the author — attribution collapse tracked separately.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 15:41:03 +00:00
mos-dt-0andMos a6b5f6a01a fix(wake): #944 path becomes a hard-locator arm — detector-shape actionable entries pass the §2.1 gate (#945)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #944.

_has_hard_locator accepted only repo+issue / 40-hex sha / file — the forge vocabulary. The detector emits path (+snapshot_sha when attested) and NEVER emits file/issue/sha, so predicate and sole producer shared ZERO keys and every class=actionable board_file entry dead-lettered. Latent since #920, whose harness pinned the detector's own emission shape as its malformed example — the suite certified the gap it was written to guard.

Fix: `path` becomes a hard-locator arm, and ONLY path. Bare path-less snapshot_sha is a deliberate NON-arm (would widen past the board_file vocabulary); Q11(d) asserts it still quarantines at 7/40/64 chars, spanning the detector's ^[0-9a-f]{7,64}$ attestation range.

VERIFICATION
- Pipeline 2105 terminal-green at fa36da8. Its `test` step reaches all nine wake harnesses via turbo -> packages/mosaic `test` -> `test:framework-shell`, which names each suite explicitly. The two-levels-down indirection matters: no search of .woodpecker/* can see it, and that is exactly why this question was got wrong earlier today and then corrected. CI therefore DOES attest the quarantine suite and the detector suite at this head.
- Independent review (mos-dt, consumer on the affected lane) PASS at fa36da8, from a detached worktree: predicate provably unmoved from fb3c3c3 (comment-stripped sha256 identical, _has_hard_locator body byte-identical), RED control at base reproduced exactly 8 failures all Q11 including "got 6".
- Third reviewer (wake-judge) ACCEPT on both judgment calls: the Q1 assertion reversal is a legitimate correction (the flip was forced, not elective — base fixtures red 19 assertions against the head predicate) and path-alone satisfies §2.1, whose operative test is "one targeted call, never a search" — two of its four named exemplars already resolve to current state. Requiring path+snapshot_sha jointly would permanently dead-letter a declared source class and conflict with #940's advisory-fields ruling.
- Judge's mutation criterion met: with the reconciled exemption disabled, the gate-level assertion ("an ORIENTATION-tier enumeration must NOT be quarantined") dies at this head and did not exist as a casualty before F1.
- D4 (detector lock re-acquisition) fails intermittently at base AND head; git diff base..head over the detector files is EMPTY, so it is out of this PR's surface on structural grounds rather than on a re-roll. Known defect, fix identified (detector.sh:516, fd 9 leaked into sleep), tracked separately.

KNOWN RESIDUALS
- ENUM-B is now the sole address-free reconciled fixture, so the exemption's gate-level guard is a population of one. Safe by population, not by design. Author follow-up: assert ENUM-B carries no hard-locator arm so the harness guards its own premise.
- The binding spec (CONVERGED-DESIGN.md §2.1, separate repo) still enumerates four forge tokens and reads narrower than the shipped gate. Tracked as #948, sequenced after the dragon-lin reseed.
- Hard-locator arms are type-loose: repo/file/path accept any non-null JSON value. Pre-existing; `sha` fails closed only by accident of test(). Tracked separately.

Authored by pepper (sb-it-1-dt); reviewed independently by mos-dt (sb-it-1-dt) and wake-judge. The mos-dt-0 commit/fork identity does not identify the author — attribution collapse tracked in #3092.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 14:01:48 +00:00
mos-dt-0andMos 539b475a92 fix(wake): #943 whole-string validation for WAKE_SNAPSHOT_TS_FUTURE_SLACK + version 0.6.13
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
grep is line-oriented, so a multi-line knob value passed the per-line anchors and was still fatal in arithmetic. Replaced with a case pattern matching the whole string, so an embedded or leading newline rejects. Manifest bumped 0.6.12 -> 0.6.13: three materially different detectors had shipped under one version string, and version= is the component sole self-identity claim.

Authored-by: pepper
Reviewed-by: mos-dt (independent, at this head; transfer proven by blob-hash equality)
Merged-by: Mos
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 11:56:18 +00:00
mos-dt-0andMos 3e47fc076f fix(wake): #942 harden WAKE_SNAPSHOT_TS_FUTURE_SLACK — validate shape AND force base-10
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
The slack knob was interpolated raw into $((...)) under set -u: a malformed value was FATAL to the poll, falsifying the poll-never-fails invariant, and a negative value inverted the guard to deny-all. Shape validation alone was insufficient — bash reads a leading zero as octal, so 08/09 passed the regex yet were fatal and 0300 silently meant 192. Now validated ^[0-9]{1,9}$ with a loud fallback to 300, then forced to base-10 via 10# so the knob means what the operator wrote.

Authored-by: pepper
Reviewed-by: mos-dt (independent, found both the original defect and the radix residual)
Merged-by: Mos
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 11:17:51 +00:00
mos-dt-0andMos 8710d0f6d7 feat(wake): #940 snapshot-datable digests — fd-3 snapshot-metadata channel (#941)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Adapter emits snapshot sha/ts out-of-band on fd 3 so a changing value never enters the delta-gate hash. Detector validates advisorily (sha regex, epoch sanity before arithmetic, future-skew slack); malformed metadata is dropped loudly and never gates the wake. Digest renders snapshot_sha/snapshot_ts plus a git-show re-verify hint. Adapters that never write fd 3 are byte-identical.

Reviewed-by: Mos (design, independent)
Reviewed-by: mos-dt (artifact, hardening §2)
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 10:55:18 +00:00
jason.woltjeandMos b981b4ec10 fix(wake): #934 mount-free, privilege-invariant seq-integrity fault injection (T9/T11 run in non-priv CI) (#936)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 16:34:58 +00:00
jason.woltjeandMos 9e81ffd7fc fix(wake): #932 stop reconciler re-enumerating already-CONSUMED detector state (#935)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 15:48:40 +00:00
jason.woltjeandMos 9becaf877f fix(wake): #917 gate the final observed_seq cursor write + observed.set/cursor consistency (defense-in-depth) (#933)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 15:14:38 +00:00
jason.woltjeandMos 17087efe15 fix(wake): #925 framework-ship canon fallback-wake (systemd timer + schema bound + A10 install/validate) — F7 out of the box (#931)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 13:44:51 +00:00
jason.woltjeandMos 0ea41e848b fix(wake): #913 installer adoption gaps — _lib dep-check fail-loud + mosaic-wake.service systemd-search-path link+validate (#930)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 12:37:29 +00:00
jason.woltjeandMos 347c1d57c1 fix(wake): #924 route dead-letter quarantine alarm via WAKE_ALARM_SINK_CMD with per-observed_seq dedup (G2a) (#929)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 11:45:09 +00:00
jason.woltjeandMos 13e6ce5e5c fix(wake): #927 enqueue TOCTOU — move stale-tmp cleanup off the hot enqueue path (no concurrent in-flight-write clobber) (#928)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 10:56:40 +00:00
jason.woltjeandMos 90265ef550 test(wake): #923 de-flake T10 concurrent-enqueue race (deterministic barrier) (#926)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 10:23:07 +00:00
jason.woltjeandMos 937a276208 fix(wake): #920 quarantine render-refused drain entry (no head-of-line block) + reconciler enumerations render orientation-tier (reconciled:true, render-tier not class=digest) (#922)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 08:45:51 +00:00
jason.woltjeandMos 712c770b7a fix(wake): #912 exercise the digest/HMAC trust suite in real CI (fix runner divergence + openssl + hard-require) (#921)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 08:11:28 +00:00
jason.woltjeandMos d967a4a926 fix(wake): #914 digest renderer — WAKE_AGENT-prefixed ack line + digest-class locator threading (#916)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 07:13:07 +00:00
jason.woltjeandMos c585ac3326 test(wake): #918 de-flake T7 ack-no-network-block (sub-second timing) (#919)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 06:46:28 +00:00
jason.woltjeandMos e2ec927b1c fix(wake): #908 unify observed_seq on a single store-side allocator (dissolve detector-private-counter seam) (#915)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 06:02:02 +00:00
jason.woltjeandMos 2378665eaf feat(wake): W7 A10 idempotent installer + mosaic-wake.service (component-manifest, Gate-A, blank-reset retire, snapshot-guard, fail-closed install-validate) (#911)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 04:19:09 +00:00
jason.woltjeandMos 003cdaa1a6 feat(wake): W6 — off-host dead-man beacon + pluggable alarm-sink adapter (#910)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 02:30:28 +00:00
jason.woltjeandMos 320f5bfb6f feat(wake): W5 — synthetic-canary FN-oracle + source-parity reconciler (#909)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 02:04:19 +00:00
jason.woltjeandMos 5df47e735e feat(wake): W4 — per-host delta-gated detector daemon (fail-loud source semantics, enqueues to W2 store) (#907)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 01:04:55 +00:00
jason.woltjeandMos dd1391fd76 fix(wake): digest hard-locator gate covers top-level .claim entries (Closes #905) (#906)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 00:58:40 +00:00
jason.woltjeandMos 10d957d095 feat(wake): W3 — cumulative-state digest renderer + non-circular HMAC signer (#904)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 00:32:12 +00:00
jason.woltjeandMos dc45eb7c30 feat(kbn): land KBN-101 Envelope A v6 (rc.20) — declarative sink-RBAC + RLS write-source (Form A) (#902)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 23:58:18 +00:00
jason.woltjeandMos 28f022d9c0 feat(wake): W2 — three-cursor durable store + RECEIVED/CONSUMED ack-wrapper + watch-list schema (#903)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 23:58:08 +00:00
jason.woltjeandMos 2726fab5e0 chore(framework): wire agent-send.test.sh into CI test:framework-shell (W1 follow-up) (#901)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 23:23:24 +00:00
jason.woltjeandMos ab6e8e80dc fix(framework): send-message.sh fail-loud submission verdict + regression tests (Patch 6) (#895)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:57:11 +00:00
jason.woltjeandMos 1933c6cb1d feat(framework): accept digest message class in agent-send.sh (W1) (#894)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:56:46 +00:00
jason.woltjeandMos 48a0c86093 fix(lease-broker): recovery_runtime_unittest wait_ready() connect-probe (co-equal CI flake, cherry-pick #898) (#900)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:26:38 +00:00
jason.woltjeandMos 79c8647fd9 fix(lease-broker): wait_ready() polls real connect-readiness not socket-file existence (flaky CI race) (#898)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:10:15 +00:00
jason.woltjeandMos 2483dada33 fix(framework): pr-review.sh -r/--repo + -H/--host overrides + UA + repo preflight (Patches 5/5c) (#896)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:08:08 +00:00
jason.woltjeandMos 4c117afe03 docs(framework): add WAKE-DOCTRINE.md guide (W0) (#893)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 21:57:51 +00:00
jason.woltjeandMos 2698ddb7b5 feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness (#888)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 21:03:18 +00:00
jason.woltjeandMos fabde1c834 docs(rfc): add RFC-001 (MACP/Matrix-native comms) + RFC-002 (install/config/topology) (#886)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 21:02:54 +00:00
jason.woltjeandMos 3c7890f17f fix(framework): detect-platform get_gitea_token fail-loud on absent per-slot token (Patch 2b) (#890)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 20:51:19 +00:00
jason.woltjeandMos 529c177830 fix(update): mosaic update runs the install-ordering guard post-reseed (#882 --sync-only bypass) (#883)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 22:18:34 +00:00
jason.woltjeandMos a32ce4c8f9 feat(869-c4): activation version-coupling assertion (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @90eb48fa (fail-closed identity locks byte-unchanged verified), author id2 != approver id11, clean mosaic-coder author, CI green wp1992. #869 Point-1 CODE COMPLETE (C1/C3/C5/C2/C4).

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 19:07:27 +00:00
jason.woltjeandMos d351caad36 feat(869-c2): install-ordering enforcement-hook guard (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @b6f36564 (8/8, verified vs real production settings template), author id2 != approver id11, clean mosaic-coder author, CI green wp1988.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 18:48:33 +00:00
jason.woltjeandMos 76b86a246e feat(869-c5): mosaic doctor activation-check (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @e75e3238 (8/8), author id2 != approver id11, clean mosaic-coder author, CI green wp1987.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 18:38:21 +00:00
jason.woltjeandMos 4422231bdb feat: per-agent Gitea identity resolution (#873)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #873

Mos (id-11) Gate-16 merge: independent APPROVE @4b472a22 (author-blocker dissolved via (a) re-author, identical tree hash to tech-approved head), author id2 != approver id11, clean mosaic-coder commit-author, CI green wp1985. Framework train COMPLETE 6/6.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 18:09:34 +00:00
jason.woltjeandMos 8504216964 fix(pr-review): case-insensitive _belongs slug compare (#875)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #875

Mos (id-11) Gate-16 merge: independent APPROVE @9d8d58ae, author id2 != approver id11, clean mosaic-coder commit-author, CI green wp1982.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:53:26 +00:00
jason.woltjeandMos 7edc9b3121 fix(gitea): direct REST comment/review with fail-closed read-back (#865)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Closes #865

Mos (id-11) Gate-16 merge: fresh confirmatory independent APPROVE @8ac7e70f (1241-case fuzz 0 fail-open), author id2 != approver id11, clean commit-author, CI green wp1966.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:25:32 +00:00
jason.woltjeandMos 2f50c0876b feat(869-c3): lease-broker supervisor unit (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @75235ef8 (9/9, no live host mutation), author id2 != approver id11, CI green wp1971.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:19:48 +00:00
jason.woltjeandMos db90da347e feat(869-c1): activation-capability probe (Part of #869)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Part of #869

Mos (id-11) Gate-16 merge: independent 3-round APPROVE @c5a2bcc5, author id2 != approver id11, CI green wp1973.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:14:57 +00:00
jason.woltjeandMos 48fd1df28a fix(ci-queue-wait): treat absent branch (404) as queue-clear (#872)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was canceled
Closes #872

Mos (id-11) Gate-16 merge: independent review APPROVE @23cbdaf8, author jason.woltje(id2) != approver Mos(id11), CI green wp1974.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:08:57 +00:00
jason.woltje b79336a8c1 feat(orchestrator): board-roll.sh — auto-roll LIVE board to LEDGER under byte cap (#868)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
feat(orchestrator): board-roll.sh - auto-roll LIVE board to LEDGER under byte cap

Closes #868
2026-07-22 09:20:03 +00:00
jason.woltje 4e5af23214 Merge pull request 'skills: add glpi-* family (solve, followup, sweep, list, create)' (#863) from feat/glpi-skills into main
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-21 01:09:50 +00:00
Hermes Agent 880c28b191 docs(glpi-skills): genericize operator-specific content per review
ci/woodpecker/pr/ci Pipeline was successful
2026-07-20 19:45:50 -05:00
Jason WoltjeandClaude Opus 4.8 7bc2dfb6c8 skills: add glpi-* family (solve, followup, sweep, list, create)
ci/woodpecker/pr/ci Pipeline was successful
GLPI helpdesk workflow skills written against the portable
tools/glpi/ tooling (session-init.sh, ticket-list.sh, ticket-create.sh),
cross-linked via [[glpi-*]]:

- glpi-solve    — close a ticket by setting status Solved (5); GLPI auto-closes
- glpi-followup — add a followup via the top-level /ITILFollowup endpoint
- glpi-sweep    — read-only hunt for done-but-open tickets needing Solve
- glpi-list     — query tickets by status/recency
- glpi-create   — open a new ticket

Core rule encoded: completing work means setting status Solved, not just
posting a resolution followup (a followup documents; only Solved auto-closes).

Note: illustrative examples in the bodies are USC-flavored (M2M / helpdesk
ticket numbers) and can be genericized in review if preferred.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_019GjBgrb9tHgvq414Fqj37c
2026-07-20 18:04:53 -05:00
jason.woltje b0d78d8632 fix(mosaic): de-flake mutator-class lease gate TTL-expiry test (#861)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 10:32:45 +00:00
jason.woltje 344d86a635 fix(#812 follow-up): normalize detect-platform.sh host-match port comparison by scheme (#859)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 10:13:29 +00:00
jason.woltje acd7d380f6 fix(framework): install deps on worktree bootstrap + legible deps-preflight at gate seam (#856) (#858)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-07-20 09:38:12 +00:00
jason.woltje 3b70c66c07 fix(framework): drop unsupported --comment from tea pr approve/reject; route review body via durable comment (#835) (#857)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 09:19:48 +00:00
jason.woltje 11d2818453 docs(tasks): FCM-M5-001 done — verified completion evidence (supersedes #848) (#853)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 07:45:08 +00:00
jason.woltje aa999daf1b fix(framework): durable Gitea comment posting in pr-review.sh via REST + read-back verify (#812) (#852)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 06:45:48 +00:00
jason.woltje 77c9a82614 fix(lease-broker): de-flake recovery_runtime b2 broker-socket ConnectionRefused race (#849) (#851)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-20 06:44:37 +00:00
jason.woltje 627cf2bb38 docs(fleet): add operator configuration guide (#789)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-07-20 05:22:25 +00:00
jason.woltje 0582a8912b WI-7 #834: T-C server-side branch-protection posture + R1 honesty amendment (#847)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 04:20:02 +00:00
jason.woltje 2509eb7646 WI-6 (#833): constrained recovery command + mosaic-context-refresh skill wrapper (#846)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-20 03:33:00 +00:00
jason.woltje 07553ead33 WI-5 #832: Receipt-challenge protocol (compaction-refresh, milestone 188) (#845)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-19 23:18:56 +00:00
jason.woltje e522b22fa4 WI-4 (#831): verbatim-hashed normative fragments (B_payload/H_payload) (#844)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-19 20:34:20 +00:00
jason.woltje e4d7d4502d WI-3 (#830): compaction observers → revoke + D4 same-PID generation auto-revoke (#842)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-19 19:46:28 +00:00
jason.woltje 8dfcf1903e fix(#838): bound broker reply deadlines + fail-close empty-read; de-flake acceptance harness (#839)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Bound broker reply deadlines (separate read/lock/send budgets, BROKER_BUSY-before-mutation, fresh post-handle send budget → closes drop-after-commit window); fail-close empty/truncated read → GATE_UNAVAILABLE deny. De-flakes the 1917 acceptance surface. terra CODE APPROVE (pi) + Opus SECREV APPROVED (claude) — RoR comment 18143. Promote-lease-lost-ACK residual = fail-safe two-generals observability-gap, routed to WI-3 D2-v5 as named-disclosed-bounded-residual (route i). Gate-16 3-principal author=gpt-sol.

closes #838
2026-07-18 07:15:50 +00:00
jason.woltje abd2791f59 feat(mosaic): WI-2 mutator-class guard for directive-freshness (#837)
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
WI-2: mutator-class guard for the compaction directive-freshness mechanism — command-position parser (prefix x var-indirection unified) + primitive-anchored invariant backstop + all-tools-hook fail-close. Parser-complete on principle: realistic evasion matrix RED-regression-covered, residual exotic evasions proven backstop-caught (B-tests), lens-convergence reached.

closes #829
2026-07-18 05:51:58 +00:00
jason.woltje 8ec67a1126 feat(mosaic): add authenticated external lease broker (#836)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-18 03:12:23 +00:00
jason.woltje d801d6c4c8 feat(mosaic): add secure skill registration CLI (#826)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-17 23:45:35 +00:00
jason.woltje d3bf52898b fix: reject unknown installer arguments (#825)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-17 22:16:51 +00:00
jason.woltje 3f77229e88 fix(#792): fleet roster ENOENT actionable exit + installer heading printf (#818)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-17 21:36:36 +00:00
jason.woltje 686c881fe4 fix: fetch actual Gitea PR head for Codex reviews (#815)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-17 19:44:56 +00:00
jason.woltje fe7a468c9d ci: bake jq into the prebuilt test image (#821)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-17 19:12:43 +00:00
jason.woltje cabf02e7b9 ci: bake git into the prebuilt test image (#819)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-17 18:11:16 +00:00
jason.woltje 9ddc6fbda8 feat(fleet): mosaic fleet regen — regenerate roster-derived projections (PR3 of #791) (#813)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-17 03:17:55 +00:00
jason.woltje 31607a4af6 feat(mosaic): durable pre-update snapshot + verify net + restore CLI (#791 PR2) (#811)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-17 00:43:18 +00:00
jason.woltje 32a0ffba13 feat(mosaic): manifest-owned upgrade guard so updates never wipe operator config (#791) (#802)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 23:01:26 +00:00
jason.woltje 8536454257 fix(glpi): accept HTTP 206 in list wrappers (#807) (#810)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 22:47:58 +00:00
jason.woltje 4f29cc604d fix(tmux): correct cross-socket sender identity (#808) (#809)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-16 22:47:25 +00:00
jason.woltje 3be443c96d feat(mosaic): claudex isolated config + env-inject + yolo wiring (P2–P4 of #790) (#806)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 22:10:33 +00:00
jason.woltje 59f5f51ffd feat(mosaic): claudex proxy preflight + lifecycle (P1 of #790) (#793)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 21:15:33 +00:00
jason.woltje 9745bc3f29 feat(fleet): add reviewed v1-to-v2 migration preview (#788)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 13:11:16 +00:00
jason.woltje adad486b6f fix(fleet): enforce exact comms authority (#787)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 00:32:23 +00:00
jason.woltje c1aecfabe9 test(fleet): cover reconciler lifecycle gates (#786)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 16:46:13 +00:00
jason.woltje 499090508e feat(fleet): reconcile local roster state (#785)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 15:03:31 +00:00
jason.woltje c593a15ef8 docs(kbn): freeze KBN-101 database role split contract (#774)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 13:34:29 +00:00
jason.woltje bc5e73629e feat(fleet): add generation-guarded agent CRUD (#773)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 12:03:05 +00:00
jason.woltje 191efaefeb feat(fleet): enforce generated environment boundary (#772)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 08:40:32 +00:00
jason.woltje e9c4aa3e8b test(fleet): validate shipped artifact dispositions (#770)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 01:37:12 +00:00
jason.woltje a5e8e55401 feat(fleet): add shared role semantics (#768)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 00:53:47 +00:00
jason.woltje eb4e14ae5c feat(mos): add logical identity connector fencing (#757)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-14 23:33:06 +00:00
jason.woltje 2e2280070a docs(#753): clear KBN-010 threat and schema gate (#765)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 21:46:44 +00:00
jason.woltje aa5b43bba2 feat(fleet): add roster v2 structural compiler (#764)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 20:37:52 +00:00
jason.woltje ba13c08890 Fixes #756 (#763)
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-14 20:26:15 +00:00
jason.woltje c32d85a337 docs(fleet): define declarative configuration M0 (#760)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 19:53:12 +00:00
jason.woltje 48b2bc42c9 docs(mos): format Option 2 qualification report (#762)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 19:33:09 +00:00
jason.woltje 5e832049bb docs(mos): preserve Option 2 qualification evidence (#759)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-14 19:16:11 +00:00
jason.woltje 49e8a54105 docs(#751): Publish native Kanban/SOT canon (#752)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 17:08:09 +00:00
jason.woltje 193331544d fix(wizard): honor MOSAIC_GATEWAY_SKIP_NPM_INSTALL — unblock install.sh --dev gateway testing (#698)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-10 01:30:10 +00:00
jason.woltje 495f73bfdb fix(wizard): avoid rerunning completed setup steps (#692)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 18:44:35 +00:00
jason.woltje b96cc7982a fix(wizard): report gateway failures before success summary (#691)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 18:14:40 +00:00
jason.woltje 0883fb91ec fix(wizard): resolve skills sync script path (#690)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 17:35:19 +00:00
jason.woltje 56787fabf1 fix(gateway): disable Redis consumers on local tier (#689)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-25 17:17:24 +00:00
jason.woltjeandClaude Opus 4.8 940ae3cc41 feat(installer): prefer npm next lane (#688)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
--next now prefers a fast npm @next install (CLI + gateway from the Gitea registry) and falls back to source build at next if the dist-tag is unavailable. Registry lane gated to non-dev, non-explicit-ref next installs; CLI/gateway prerelease versions must share a pipeline suffix. Adds tools/install-next-lane.test.sh (wired into CI). PR-event CI 1635 fully green + review-of-record APPROVE (functional install test, head 2fd7cfc3).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 07:14:24 +00:00
jason.woltjeandClaude Opus 4.8 c25a551c28 ci(#462): add durable next publish pipeline (#687)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Durable @next integration-line publish: on next pushes, compute <patch+1>-next.<pipeline#> prerelease versions (in-CI, uncommitted) and publish @mosaicstack/* under the next dist-tag; gateway image sha-only on next. Strict guardrails: next-only, never writes latest, never tags from next; main path unchanged. PR-event CI 1631 fully green + review-of-record APPROVE (head b1a887a2). Guardrails independently verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 05:45:09 +00:00
jason.woltjeandClaude Opus 4.8 94d6538061 feat(installer): add next integration lane (#686)
ci/woodpecker/push/ci Pipeline was successful
Add --next installer flag (build-from-source at the next integration branch; MOSAIC_NEXT=1 env equiv; explicit --ref wins). Three-lane install docs (stable @latest / --next prerelease / --dev source) + @next dist-tag pipeline design doc. Green PR-event CI 1626 + review-of-record APPROVE (head 3a5c12a5).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 05:14:32 +00:00
jason.woltjeandClaude Opus 4.8 a3c1ab923c test(#462): add federation M3 integration coverage (#685)
ci/woodpecker/push/ci Pipeline was successful
FED-M3-10 integration tests for the federation M3 verbs (list/get/scope). Test-infra + docs only; green PR-event CI 1623 (all steps incl ci-postgres).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 04:14:56 +00:00
jason.woltjeandClaude Opus 4.8 838701bde2 feat(#462): add federation get verb (#683)
ci/woodpecker/push/ci Pipeline was successful
FED-M3-06 get verb. Trust boundary mirrors M3-05 AND-intersect (note returned only when owned by subject AND on an authorized mission). Reviewed (review-of-record APPROVE, head 80a259b2) + green PR-event CI 1620.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-25 03:44:54 +00:00
630 changed files with 111980 additions and 4851 deletions
+1
View File
@@ -8,6 +8,7 @@ coverage
.env.local .env.local
*.tsbuildinfo *.tsbuildinfo
.pnpm-store .pnpm-store
__pycache__/
docs/reports/ docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example # Step-CA dev password — real file is gitignored; commit only the .example
+1 -1
View File
@@ -1 +1 @@
pnpm typecheck && pnpm lint && pnpm format:check pnpm preflight && 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/ @mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci), # HOME resolves to /root in the ci-base image, preserving its warmed-store path.
# so the pipeline `pnpm install --prefer-offline` consumes the baked store # Non-root checkouts use their own HOME. Override without editing this file via
# instead of repopulating a fresh one. # NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
store-dir=/root/.local/share/pnpm/store store-dir=${HOME}/.local/share/pnpm/store
+9
View File
@@ -4,6 +4,15 @@ pnpm-lock.yaml
**/node_modules **/node_modules
**/drizzle **/drizzle
**/.next **/.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/ .claude/
docs/tess/TASKS.md docs/tess/TASKS.md
docs/scratchpads/ docs/scratchpads/
packages/mosaic/src/fleet/testdata/documentation-publication-v1/inline-migration-v1.json
+33
View File
@@ -41,6 +41,32 @@ steps:
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9. # (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 --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh - 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: typecheck:
image: *node_image image: *node_image
@@ -50,6 +76,7 @@ steps:
depends_on: depends_on:
- install - install
- sanitization - sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck # lint, format, and test are independent — run in parallel after typecheck
lint: lint:
@@ -76,6 +103,12 @@ steps:
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
commands: commands:
- *enable_pnpm - *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
# postgresql-client (pg_isready) is baked into ci-base. # 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. # Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- | - |
+104 -5
View File
@@ -1,5 +1,5 @@
# Build, publish npm packages, and push Docker images # Build, publish npm packages, and push Docker images
# Runs only on main branch push/tag # Runs on main for stable publishes and on next for integration-line prereleases/images
variables: variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine + # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
@@ -23,9 +23,21 @@ variables:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
- '.woodpecker/**' - '.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: when:
- branch: [main] - branch: [main, next]
event: [push, manual, tag] event: [push, manual, tag]
steps: steps:
@@ -103,6 +115,84 @@ steps:
depends_on: depends_on:
- build - 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 # TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs: # publish-npmjs:
# image: *node_image # image: *node_image
@@ -134,8 +224,17 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json - 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}" DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then 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
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest" 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 fi
if [ -n "$CI_COMMIT_TAG" ]; then if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
@@ -146,7 +245,7 @@ steps:
build-appservice: build-appservice:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *image_build_when when: *main_image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username
@@ -172,7 +271,7 @@ steps:
build-web: build-web:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *image_build_when when: *main_image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username
+70 -31
View File
@@ -11,48 +11,87 @@
## Project Context ## Project Context
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. 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.
## Package Map ### Stack
| Package | Purpose | Key Dependencies | - **API:** NestJS with Fastify (`apps/gateway`)
| ------------------ | ------------------------------- | -------------------------------- | - **Web:** Next.js 16 with React 19 (`apps/web`)
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL | - **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
| `apps/web` | Next.js dashboard | React 19, Tailwind | - **Authentication:** BetterAuth (`packages/auth`)
| `packages/types` | Shared TypeScript contracts | class-validator | - **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres | - **Queue:** Valkey 8 (`packages/queue`)
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db | - **Build:** pnpm workspaces and Turborepo
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db | - **CI:** Woodpecker CI
| `packages/queue` | Valkey task queue + MCP | ioredis | - **Observability:** OpenTelemetry and Jaeger
| `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 |
## Architecture Rules ### Package Map
1. Gateway is the single API surface — all clients connect through it | Package | Purpose | Key Dependencies |
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 | `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
4. OTEL auto-instrumentation loads before NestJS bootstrap | `apps/web` | Next.js dashboard | React 19, Tailwind |
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db` | `packages/types` | Shared TypeScript contracts | class-validator |
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686) | `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata) | `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.
## Development Workflow ## Development Workflow
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
```bash ```bash
docker compose up -d # Infrastructure pnpm install --frozen-lockfile
pnpm install # Dependencies pnpm preflight
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
# Optional local queue service only; do not start the full Compose stack.
docker compose up -d valkey
``` ```
## Repo-Specific Notes The pre-push hook requires:
- DTOs in `*.dto.ts` files at module boundaries ```bash
- ESM everywhere (`"type": "module"`, `.js` extensions in imports) pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
- NodeNext module resolution in all tsconfigs ```
- Scratchpads are mandatory for non-trivial tasks
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
```
## docs/TASKS.md — Schema (CANONICAL) ## docs/TASKS.md — Schema (CANONICAL)
+3 -43
View File
@@ -1,45 +1,5 @@
# CLAUDE.md — Mosaic Stack # Claude Compatibility Pointer
## Project @AGENTS.md
Self-hosted, multi-user AI agent platform. TypeScript monorepo. Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
## 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
+7 -4
View File
@@ -22,10 +22,13 @@
FROM node:24-alpine FROM node:24-alpine
# Native toolchain required to compile node-gyp deps on musl, plus the # 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` # 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 # `git`, and `jq` are baked here too — framework shell tests and the shipped
# `apk add bash`. # Codex review wrappers require them without per-run installation in ci.yml.
RUN apk add --no-cache python3 make g++ postgresql-client bash # `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
# Pin pnpm to the repo's packageManager version via corepack. # Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare [email protected] --activate RUN corepack enable && corepack prepare [email protected] --activate
+66 -23
View File
@@ -30,6 +30,16 @@ This installs both components:
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` | | **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` | | **@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: After install, the wizard runs automatically or you can invoke it manually:
```bash ```bash
@@ -38,9 +48,13 @@ mosaic wizard # Full guided setup (gateway install → verify)
### Requirements ### Requirements
- Node.js ≥ 20 - Node.js ≥ 22
- npm (for global @mosaicstack/mosaic install) - npm (for global @mosaicstack/mosaic install)
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent) - 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)
## Usage ## Usage
@@ -97,7 +111,10 @@ mosaic config path # Print config file path
```bash ```bash
mosaic doctor # Health audit — detect drift and missing files mosaic doctor # Health audit — detect drift and missing files
mosaic sync # Sync skills from canonical source mosaic sync # Sync skills from canonical source
mosaic update # Check for and install CLI updates 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 wizard # Full guided setup wizard mosaic wizard # Full guided setup wizard
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
mosaic coord init # Initialize a new orchestration mission mosaic coord init # Initialize a new orchestration mission
@@ -157,7 +174,12 @@ mosaic storage status
mosaic storage tier mosaic storage tier
mosaic storage export mosaic storage export
mosaic storage import mosaic storage import
mosaic storage migrate # 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.
``` ```
### Telemetry ### Telemetry
@@ -182,7 +204,7 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
### Prerequisites ### Prerequisites
- Node.js ≥ 20 - Node.js ≥ 22
- pnpm 10.6+ - pnpm 10.6+
- Docker & Docker Compose - Docker & Docker Compose
@@ -192,33 +214,50 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
git clone [email protected]:mosaicstack/stack.git git clone [email protected]:mosaicstack/stack.git
cd stack cd stack
# Start infrastructure (Postgres, Valkey, Jaeger) # Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
docker compose up -d # 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.
# Install dependencies
pnpm install pnpm install
# Run migrations # Verify dependencies and generated state before running source-quality gates.
pnpm --filter @mosaicstack/db run db:migrate # 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
# Start all services in dev mode # Optional local queue service only. This does not start PostgreSQL.
pnpm dev 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.
``` ```
### Infrastructure ### Held future procedure
Docker Compose provides: 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.
| Service | Port | Purpose | **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`
| PostgreSQL (pgvector) | 5433 | Primary database | `mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
| Valkey | 6380 | Task queue + caching | this README—will provide the reviewed commands and secret-consumer interface.
| Jaeger | 16686 | Distributed tracing UI |
| OTEL Collector | 4317/4318 | Telemetry ingestion | 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.
### Quality Gates ### Quality Gates
```bash ```bash
pnpm preflight # Checkout/dependency/generated-state validation
pnpm typecheck # TypeScript type checking (all packages) pnpm typecheck # TypeScript type checking (all packages)
pnpm lint # ESLint (all packages) pnpm lint # ESLint (all packages)
pnpm test # Vitest (all packages) pnpm test # Vitest (all packages)
@@ -231,7 +270,7 @@ pnpm format # Prettier auto-fix
Woodpecker CI runs on every push: Woodpecker CI runs on every push:
- `pnpm install --frozen-lockfile` - `pnpm install --frozen-lockfile`
- Database migration against a fresh Postgres - **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.
- `pnpm test` (Turbo-orchestrated across all packages) - `pnpm test` (Turbo-orchestrated across all packages)
npm packages are published to the Gitea package registry on main merges. npm packages are published to the Gitea package registry on main merges.
@@ -336,11 +375,15 @@ 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 --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework) bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref bash tools/install.sh --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 --yes # Non-interactive, accept all defaults bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard 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 ## Contributing
```bash ```bash
@@ -0,0 +1,519 @@
/**
* 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,
});
});
});
@@ -72,6 +72,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1', guildId: 'guild-1',
channelId: 'channel-1', channelId: 'channel-1',
pairedUsers: { pairedUsers: {
@@ -133,6 +134,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
interactionBindings: [ interactionBindings: [
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1', guildId: 'guild-1',
channelId: 'channel-1', channelId: 'channel-1',
pairedUsers: { pairedUsers: {
@@ -1,9 +1,11 @@
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaicstack/db'; import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue'; import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js'; import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js'; import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js'; import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js'; import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@@ -14,6 +16,9 @@ export class AdminHealthController {
@Inject(DB) private readonly db: Db, @Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService, @Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService, @Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {} ) {}
@Get() @Get()
@@ -55,6 +60,14 @@ export class AdminHealthController {
} }
private async checkCache(): Promise<ServiceStatusDto> { 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 start = Date.now();
const handle = createQueue(); const handle = createQueue();
try { try {
@@ -115,6 +115,18 @@ describe('AgentService owner/tenant scope enforcement', () => {
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE); await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt'); 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( await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException, ForbiddenException,
+14
View File
@@ -21,6 +21,12 @@ import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js'; import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js'; import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.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 { import {
AGENT_RUNTIME_PROVIDER_REGISTRY, AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER, RUNTIME_APPROVAL_VERIFIER,
@@ -46,6 +52,13 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
SkillLoaderService, SkillLoaderService,
DurableSessionRepository, DurableSessionRepository,
DurableSessionService, DurableSessionService,
ConnectorLeaseRepository,
DenyConnectorLeasePolicy,
{
provide: CONNECTOR_LEASE_POLICY,
useExisting: DenyConnectorLeasePolicy,
},
ConnectorLeaseService,
{ {
provide: AGENT_RUNTIME_PROVIDER_REGISTRY, provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry, useFactory: createGatewayRuntimeProviderRegistry,
@@ -78,6 +91,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
SkillLoaderService, SkillLoaderService,
DurableSessionService, DurableSessionService,
RuntimeProviderService, RuntimeProviderService,
ConnectorLeaseService,
AGENT_RUNTIME_PROVIDER_REGISTRY, AGENT_RUNTIME_PROVIDER_REGISTRY,
], ],
}) })
+38 -4
View File
@@ -15,6 +15,7 @@ import {
type ToolDefinition, type ToolDefinition,
} from '@mariozechner/pi-coding-agent'; } from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaicstack/brain'; import type { Brain } from '@mosaicstack/brain';
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory'; import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js'; import { MEMORY } from '../memory/memory.tokens.js';
@@ -43,6 +44,8 @@ export interface ConversationHistoryMessage {
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
createdAt: Date; createdAt: Date;
/** Validated, URI-referenced channel attachments preserved on session resume. */
attachments?: readonly ChannelAttachmentDto[];
} }
export interface AgentSessionOptions { export interface AgentSessionOptions {
@@ -428,7 +431,7 @@ export class AgentService implements OnModuleDestroy {
const formatMessage = (msg: ConversationHistoryMessage): string => { const formatMessage = (msg: ConversationHistoryMessage): string => {
const roleLabel = const roleLabel =
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System'; msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
return `**${roleLabel}:** ${msg.content}`; return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`;
}; };
const formatted = history.map((msg) => formatMessage(msg)); const formatted = history.map((msg) => formatMessage(msg));
@@ -487,6 +490,21 @@ export class AgentService implements OnModuleDestroy {
return result; 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) { private resolveModel(options?: AgentSessionOptions) {
if (!options?.provider && !options?.modelId) { if (!options?.provider && !options?.modelId) {
return this.providerService.getDefaultModel() ?? null; return this.providerService.getDefaultModel() ?? null;
@@ -673,7 +691,19 @@ export class AgentService implements OnModuleDestroy {
session.channels.delete(channel); session.channels.delete(channel);
} }
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> { async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] | undefined,
): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] = [],
): Promise<void> {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
throw new Error(`No agent session found: ${sessionId}`); throw new Error(`No agent session found: ${sessionId}`);
@@ -681,12 +711,16 @@ export class AgentService implements OnModuleDestroy {
this.assertSessionScope(session, scope); this.assertSessionScope(session, scope);
session.promptCount += 1; 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) // Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = message; let effectiveMessage = `${message}${attachmentContext}`;
if (this.systemOverride) { if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId, scope); const override = await this.systemOverride.get(sessionId, scope);
if (override) { if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${message}`; effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope); await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`); this.logger.debug(`Applied system override for session ${sessionId}`);
} }
@@ -0,0 +1,341 @@
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();
});
});
@@ -0,0 +1,76 @@
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' });
});
});
@@ -0,0 +1,149 @@
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);
});
});
@@ -0,0 +1,354 @@
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),
});
}
@@ -0,0 +1,285 @@
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,6 +8,7 @@
* to avoid real I/O — they verify the complete classify → match → decide path. * to avoid real I/O — they verify the complete classify → match → decide path.
*/ */
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import type { ProviderHealthStatus } from '@mosaicstack/types';
import { RoutingEngineService } from './routing-engine.service.js'; import { RoutingEngineService } from './routing-engine.service.js';
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js'; import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
import type { RoutingRule } from './routing.types.js'; import type { RoutingRule } from './routing.types.js';
@@ -17,7 +18,7 @@ import type { RoutingRule } from './routing.types.js';
/** Build a RoutingEngineService backed by the given rule set and health map. */ /** Build a RoutingEngineService backed by the given rule set and health map. */
function makeService( function makeService(
rules: RoutingRule[], rules: RoutingRule[],
healthMap: Record<string, { status: string }>, healthMap: Record<string, { status: ProviderHealthStatus }>,
): RoutingEngineService { ): RoutingEngineService {
const mockDb = { const mockDb = {
select: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({
@@ -67,11 +68,11 @@ function defaultRules(): RoutingRule[] {
} }
/** A health map where anthropic, openai, and zai are all healthy. */ /** A health map where anthropic, openai, and zai are all healthy. */
const allHealthy: Record<string, { status: string }> = { const allHealthy: Record<string, { status: ProviderHealthStatus }> = {
anthropic: { status: 'up' }, anthropic: { status: 'healthy' },
openai: { status: 'up' }, openai: { status: 'healthy' },
zai: { status: 'up' }, zai: { status: 'healthy' },
ollama: { status: 'up' }, ollama: { status: 'healthy' },
}; };
// ─── M4-013 E2E tests ───────────────────────────────────────────────────────── // ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
@@ -212,10 +213,10 @@ describe('M4-013: routing end-to-end pipeline', () => {
// Let's use a simple coding message to target Simple coding → Codex (openai) // Let's use a simple coding message to target Simple coding → Codex (openai)
const message = 'implement a sort function'; const message = 'implement a sort function';
const unhealthyHealth = { const unhealthyHealth: Record<string, { status: ProviderHealthStatus }> = {
anthropic: { status: 'down' }, anthropic: { status: 'down' },
openai: { status: 'up' }, openai: { status: 'healthy' },
zai: { status: 'up' }, zai: { status: 'healthy' },
ollama: { status: 'down' }, ollama: { status: 'down' },
}; };
@@ -1,5 +1,6 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db'; 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 { DB } from '../../database/database.module.js';
import { ProviderService } from '../provider.service.js'; import { ProviderService } from '../provider.service.js';
import { classifyTask } from './task-classifier.js'; import { classifyTask } from './task-classifier.js';
@@ -49,7 +50,7 @@ export class RoutingEngineService {
async resolve( async resolve(
message: string, message: string,
userId?: string, userId?: string,
availableProviders?: Record<string, { status: string }>, availableProviders?: Record<string, { status: ProviderHealthStatus }>,
): Promise<RoutingDecision> { ): Promise<RoutingDecision> {
const classification = classifyTask(message); const classification = classifyTask(message);
this.logger.debug( this.logger.debug(
@@ -69,9 +70,8 @@ export class RoutingEngineService {
if (!this.matchConditions(rule, classification)) continue; if (!this.matchConditions(rule, classification)) continue;
const providerStatus = health[rule.action.provider]?.status; const providerStatus = health[rule.action.provider]?.status;
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
if (!isHealthy) { if (!this.isRoutable(providerStatus)) {
this.logger.debug( this.logger.debug(
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`, `Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
); );
@@ -111,6 +111,10 @@ export class RoutingEngineService {
// ─── Private helpers ─────────────────────────────────────────────────────── // ─── Private helpers ───────────────────────────────────────────────────────
private isRoutable(status: ProviderHealthStatus | undefined): boolean {
return status === 'healthy' || status === 'degraded';
}
private evaluateCondition( private evaluateCondition(
condition: RoutingCondition, condition: RoutingCondition,
classification: TaskClassification, classification: TaskClassification,
@@ -186,11 +190,12 @@ export class RoutingEngineService {
* Walk the fallback chain and return the first healthy provider/model pair. * Walk the fallback chain and return the first healthy provider/model pair.
* If none are healthy, return the first entry unconditionally (last resort). * If none are healthy, return the first entry unconditionally (last resort).
*/ */
private applyFallbackChain(health: Record<string, { status: string }>): RoutingDecision { private applyFallbackChain(
health: Record<string, { status: ProviderHealthStatus }>,
): RoutingDecision {
for (const candidate of FALLBACK_CHAIN) { for (const candidate of FALLBACK_CHAIN) {
const providerStatus = health[candidate.provider]?.status; const providerStatus = health[candidate.provider]?.status;
const isHealthy = providerStatus === 'up' || providerStatus === 'ok'; if (this.isRoutable(providerStatus)) {
if (isHealthy) {
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`); this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
return { return {
provider: candidate.provider, provider: candidate.provider,
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ProviderHealthStatus } from '@mosaicstack/types';
import { RoutingEngineService } from './routing-engine.service.js'; import { RoutingEngineService } from './routing-engine.service.js';
import type { RoutingRule, TaskClassification } from './routing.types.js'; import type { RoutingRule, TaskClassification } from './routing.types.js';
@@ -29,7 +30,7 @@ function makeClassification(overrides: Partial<TaskClassification> = {}): TaskCl
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */ /** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
function makeService( function makeService(
rules: RoutingRule[] = [], rules: RoutingRule[] = [],
healthMap: Record<string, { status: string }> = {}, healthMap: Record<string, { status: ProviderHealthStatus }> = {},
): RoutingEngineService { ): RoutingEngineService {
const mockDb = { const mockDb = {
select: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({
@@ -217,7 +218,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
}), }),
]; ];
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } }); const service = makeService(rules, {
anthropic: { status: 'healthy' },
openai: { status: 'healthy' },
});
const decision = await service.resolve('implement a function'); const decision = await service.resolve('implement a function');
expect(decision.ruleName).toBe('high priority'); expect(decision.ruleName).toBe('high priority');
@@ -241,7 +245,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
}), }),
]; ];
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } }); const service = makeService(rules, {
anthropic: { status: 'healthy' },
openai: { status: 'healthy' },
});
const decision = await service.resolve('implement a function'); const decision = await service.resolve('implement a function');
expect(decision.ruleName).toBe('coding rule'); expect(decision.ruleName).toBe('coding rule');
@@ -270,7 +277,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
const service = makeService(rules, { const service = makeService(rules, {
anthropic: { status: 'down' }, // primary is unhealthy anthropic: { status: 'down' }, // primary is unhealthy
openai: { status: 'up' }, openai: { status: 'healthy' },
}); });
const decision = await service.resolve('implement a function'); const decision = await service.resolve('implement a function');
@@ -290,7 +297,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
]; ];
const service2 = makeService(unhealthyRules, { const service2 = makeService(unhealthyRules, {
anthropic: { status: 'up' }, anthropic: { status: 'healthy' },
openai: { status: 'down' }, openai: { status: 'down' },
}); });
@@ -306,7 +313,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
const service = makeService(rules, { const service = makeService(rules, {
anthropic: { status: 'down' }, // Sonnet is on anthropic — down anthropic: { status: 'down' }, // Sonnet is on anthropic — down
ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next ollama: { status: 'healthy' }, // Haiku is also on anthropic — use Ollama as next
}); });
const decision = await service.resolve('hello there'); const decision = await service.resolve('hello there');
@@ -345,7 +352,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
}), }),
]; ];
const service = makeService(rules, { anthropic: { status: 'up' } }); const service = makeService(rules, { anthropic: { status: 'healthy' } });
const decision = await service.resolve('completely unrelated message xyz'); const decision = await service.resolve('completely unrelated message xyz');
expect(decision.ruleName).toBe('catch-all'); expect(decision.ruleName).toBe('catch-all');
@@ -369,7 +376,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
}), }),
]; ];
const service = makeService(rules, { anthropic: { status: 'up' } }); const service = makeService(rules, { anthropic: { status: 'healthy' } });
const codingDecision = await service.resolve('implement a function'); const codingDecision = await service.resolve('implement a function');
expect(codingDecision.ruleName).toBe('specific coding rule'); expect(codingDecision.ruleName).toBe('specific coding rule');
@@ -401,7 +408,7 @@ describe('RoutingEngineService.resolve — disabled rules', () => {
}), }),
]; ];
const service = makeService(rules, { anthropic: { status: 'up' } }); const service = makeService(rules, { anthropic: { status: 'healthy' } });
const decision = await service.resolve('implement a function'); const decision = await service.resolve('implement a function');
expect(decision.ruleName).toBe('enabled fallback'); expect(decision.ruleName).toBe('enabled fallback');
@@ -452,9 +459,45 @@ describe('RoutingEngineService.resolve — availableProviders override', () => {
ps: unknown, ps: unknown,
) => RoutingEngineService)(mockDb, mockProviderService); ) => RoutingEngineService)(mockDb, mockProviderService);
const preSupplied = { anthropic: { status: 'up' } }; const preSupplied: Record<string, { status: ProviderHealthStatus }> = {
anthropic: { status: 'healthy' },
};
await service.resolve('implement a function', undefined, preSupplied); await service.resolve('implement a function', undefined, preSupplied);
expect(mockHealthCheckAll).not.toHaveBeenCalled(); 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
@@ -0,0 +1,624 @@
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,
);
});
+13 -1
View File
@@ -21,11 +21,22 @@ import { AdminModule } from './admin/admin.module.js';
import { CommandsModule } from './commands/commands.module.js'; import { CommandsModule } from './commands/commands.module.js';
import { PreferencesModule } from './preferences/preferences.module.js'; import { PreferencesModule } from './preferences/preferences.module.js';
import { GCModule } from './gc/gc.module.js'; import { GCModule } from './gc/gc.module.js';
import { HarnessModule } from './harness/harness.module.js';
import { ReloadModule } from './reload/reload.module.js'; import { ReloadModule } from './reload/reload.module.js';
import { WorkspaceModule } from './workspace/workspace.module.js'; import { WorkspaceModule } from './workspace/workspace.module.js';
import { QueueModule } from './queue/queue.module.js'; import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js'; import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; 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({ @Module({
imports: [ imports: [
@@ -50,10 +61,11 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
PreferencesModule, PreferencesModule,
CommandsModule, CommandsModule,
GCModule, GCModule,
HarnessModule,
QueueModule, QueueModule,
ReloadModule, ReloadModule,
WorkspaceModule, WorkspaceModule,
FederationModule, ...(federationEnabled ? [FederationModule] : []),
], ],
controllers: [HealthController], controllers: [HealthController],
providers: [ providers: [
+4
View File
@@ -1,3 +1,4 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto { export class ChatRequestDto {
@@ -32,4 +33,7 @@ export class ChatSocketMessageDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
agentId?: string; agentId?: string;
/** Validated channel attachment references; binary content is not embedded. */
attachments?: readonly ChannelAttachmentDto[];
} }
@@ -4,6 +4,10 @@ import { ChatGateway } from './chat.gateway.js';
const CONVERSATION_ID = 'conversation-1'; const CONVERSATION_ID = 'conversation-1';
const CANARY = 'sk_canary12345678'; const CANARY = 'sk_canary12345678';
function clientConversationKey(clientId: string, conversationId: string): string {
return `${clientId}\u0000${conversationId}`;
}
type GatewayInternals = { type GatewayInternals = {
clientSessions: Map<string, unknown>; clientSessions: Map<string, unknown>;
relayEvent(client: unknown, conversationId: string, event: unknown): void; relayEvent(client: unknown, conversationId: string, event: unknown): void;
@@ -40,6 +44,7 @@ describe('ChatGateway redaction boundary', (): void => {
emit: vi.fn(), emit: vi.fn(),
}; };
const session = { const session = {
clientId: client.id,
conversationId: CONVERSATION_ID, conversationId: CONVERSATION_ID,
cleanup: vi.fn(), cleanup: vi.fn(),
assistantText: '', assistantText: '',
@@ -47,7 +52,7 @@ describe('ChatGateway redaction boundary', (): void => {
pendingToolCalls: new Map(), pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' }, scope: { userId: 'user-1', tenantId: 'tenant-1' },
}; };
gateway.clientSessions.set(client.id, session); gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session);
gateway.relayEvent(client, CONVERSATION_ID, { gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update', type: 'message_update',
@@ -139,6 +144,51 @@ describe('ChatGateway redaction boundary', (): void => {
}); });
}); });
it('isolates concurrent conversation streams sharing one Discord socket', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'discord-client',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
const firstConversation = 'Nova:discord:thread-1';
const secondConversation = 'Nova:discord:thread-2';
const createSession = (conversationId: string) => ({
clientId: client.id,
conversationId,
cleanup: vi.fn(),
assistantText: '',
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
});
const firstSession = createSession(firstConversation);
const secondSession = createSession(secondConversation);
gateway.clientSessions.set(clientConversationKey(client.id, firstConversation), firstSession);
gateway.clientSessions.set(clientConversationKey(client.id, secondConversation), secondSession);
gateway.relayEvent(client, firstConversation, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'first response ' },
});
gateway.relayEvent(client, secondConversation, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'second response ' },
});
expect(firstSession.assistantText).toBe('first response ');
expect(secondSession.assistantText).toBe('second response ');
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: firstConversation,
text: 'first response ',
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: secondConversation,
text: 'second response ',
});
});
it('persists only redacted assistant content with classifications', (): void => { it('persists only redacted assistant content with classifications', (): void => {
const { gateway, brain } = buildGateway(); const { gateway, brain } = buildGateway();
const client = { const client = {
@@ -147,7 +197,8 @@ describe('ChatGateway redaction boundary', (): void => {
data: { user: { id: 'user-1' } }, data: { user: { id: 'user-1' } },
emit: vi.fn(), emit: vi.fn(),
}; };
gateway.clientSessions.set(client.id, { gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), {
clientId: client.id,
conversationId: CONVERSATION_ID, conversationId: CONVERSATION_ID,
cleanup: vi.fn(), cleanup: vi.fn(),
assistantText: CANARY, assistantText: CANARY,
+234 -75
View File
@@ -17,6 +17,7 @@ import {
parseDiscordInteractionBindings, parseDiscordInteractionBindings,
resolveDiscordInteractionActorId, resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding, resolveDiscordInteractionBinding,
type DiscordAttachment,
type DiscordIngressEnvelope, type DiscordIngressEnvelope,
type DiscordIngressPayload, type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin'; } from '@mosaicstack/discord-plugin';
@@ -30,6 +31,7 @@ import type {
SystemReloadPayload, SystemReloadPayload,
RoutingDecisionInfo, RoutingDecisionInfo,
AbortPayload, AbortPayload,
ChannelAttachmentDto,
} from '@mosaicstack/types'; } from '@mosaicstack/types';
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
import { import {
@@ -56,6 +58,7 @@ import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
/** Per-client state tracking streaming accumulation for persistence. */ /** Per-client state tracking streaming accumulation for persistence. */
interface ClientSession { interface ClientSession {
clientId: string;
conversationId: string; conversationId: string;
cleanup: () => void; cleanup: () => void;
/** Accumulated assistant response text for the current turn. */ /** Accumulated assistant response text for the current turn. */
@@ -76,6 +79,68 @@ interface ClientSession {
*/ */
const modelOverrides = new Map<string, string>(); const modelOverrides = new Map<string, string>();
const MAX_REDACTION_BUFFER_LENGTH = 8_192; const MAX_REDACTION_BUFFER_LENGTH = 8_192;
const MAX_CHANNEL_ATTACHMENTS = 10;
const MAX_ATTACHMENT_METADATA_BYTES = 16_384;
const MAX_ATTACHMENT_ID_LENGTH = 128;
const MAX_ATTACHMENT_NAME_LENGTH = 255;
const MAX_ATTACHMENT_URL_LENGTH = 2_048;
const MAX_ATTACHMENT_MIME_LENGTH = 255;
function isSafeAttachmentUrl(value: string): boolean {
if (value.length === 0 || value.length > MAX_ATTACHMENT_URL_LENGTH) return false;
try {
const url = new URL(value);
return (
url.protocol === 'https:' &&
!url.username &&
!url.password &&
!url.hash &&
url.search.length === 0
);
} catch {
return false;
}
}
function hasValidAttachmentBounds(value: {
id: string;
name: string;
url: string;
sizeBytes?: number;
}): boolean {
return (
value.id.length > 0 &&
value.id.length <= MAX_ATTACHMENT_ID_LENGTH &&
value.name.length > 0 &&
value.name.length <= MAX_ATTACHMENT_NAME_LENGTH &&
isSafeAttachmentUrl(value.url) &&
(value.sizeBytes === undefined || (Number.isFinite(value.sizeBytes) && value.sizeBytes >= 0))
);
}
function isDiscordAttachment(value: unknown): value is DiscordAttachment {
if (typeof value !== 'object' || value === null) return false;
const attachment = value as Partial<DiscordAttachment>;
return (
typeof attachment.id === 'string' &&
typeof attachment.name === 'string' &&
typeof attachment.url === 'string' &&
(attachment.contentType === null ||
(typeof attachment.contentType === 'string' &&
attachment.contentType.length <= MAX_ATTACHMENT_MIME_LENGTH)) &&
(attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') &&
hasValidAttachmentBounds(attachment as DiscordAttachment)
);
}
function hasValidAttachmentArray(value: unknown, guard: (attachment: unknown) => boolean): boolean {
return (
Array.isArray(value) &&
value.length <= MAX_CHANNEL_ATTACHMENTS &&
JSON.stringify(value).length <= MAX_ATTACHMENT_METADATA_BYTES &&
value.every(guard)
);
}
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
if (typeof value !== 'object' || value === null) return false; if (typeof value !== 'object' || value === null) return false;
@@ -88,23 +153,49 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo
return false; return false;
} }
const payload = envelope.payload as Record<string, unknown>; const payload = envelope.payload as Record<string, unknown>;
return [ return (
payload['correlationId'], [
payload['messageId'], payload['correlationId'],
payload['guildId'], payload['messageId'],
payload['channelId'], payload['guildId'],
payload['userId'], payload['channelId'],
payload['conversationId'], payload['userId'],
payload['content'], payload['conversationId'],
].every((field: unknown): boolean => typeof field === 'string'); payload['content'],
].every((field: unknown): boolean => typeof field === 'string') &&
(payload['threadId'] === undefined || typeof payload['threadId'] === 'string') &&
(payload['attachments'] === undefined ||
hasValidAttachmentArray(payload['attachments'], isDiscordAttachment))
);
}
function isChannelAttachment(value: unknown): value is ChannelAttachmentDto {
if (typeof value !== 'object' || value === null) return false;
const attachment = value as Partial<ChannelAttachmentDto>;
return (
typeof attachment.id === 'string' &&
typeof attachment.name === 'string' &&
typeof attachment.url === 'string' &&
(attachment.mimeType === null ||
(typeof attachment.mimeType === 'string' &&
attachment.mimeType.length <= MAX_ATTACHMENT_MIME_LENGTH)) &&
(attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') &&
hasValidAttachmentBounds(attachment as ChannelAttachmentDto)
);
} }
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto { function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
if (typeof value !== 'object' || value === null) return false; if (typeof value !== 'object' || value === null) return false;
const payload = value as { content?: unknown; conversationId?: unknown }; const payload = value as {
content?: unknown;
conversationId?: unknown;
attachments?: unknown;
};
return ( return (
typeof payload.content === 'string' && typeof payload.content === 'string' &&
(payload.conversationId === undefined || typeof payload.conversationId === 'string') (payload.conversationId === undefined || typeof payload.conversationId === 'string') &&
(payload.attachments === undefined ||
hasValidAttachmentArray(payload.attachments, isChannelAttachment))
); );
} }
@@ -174,20 +265,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
handleDisconnect(client: Socket): void { handleDisconnect(client: Socket): void {
this.logger.log(`Client disconnected: ${client.id}`); this.logger.log(`Client disconnected: ${client.id}`);
const session = this.clientSessions.get(client.id); for (const [key, session] of this.clientSessions) {
if (session) { if (session.clientId !== client.id) continue;
session.cleanup(); session.cleanup();
this.agentService.removeChannel( this.agentService.removeChannel(
session.conversationId, session.conversationId,
`websocket:${client.id}`, `websocket:${client.id}`,
session.scope, session.scope,
); );
this.clientSessions.delete(client.id); this.clientSessions.delete(key);
this.textEgressBuffers.delete(key);
this.thinkingEgressBuffers.delete(key);
this.overflowedEgress.delete(`${key}:agent:text`);
this.overflowedEgress.delete(`${key}:agent:thinking`);
} }
this.textEgressBuffers.delete(client.id); }
this.thinkingEgressBuffers.delete(client.id);
this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); private clientConversationKey(client: Pick<Socket, 'id'>, conversationId: string): string {
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); return `${client.id}\u0000${conversationId}`;
} }
private getClientScope(client: Socket): ActorTenantScope | null { private getClientScope(client: Socket): ActorTenantScope | null {
@@ -218,7 +313,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
discordIngress = this.resolveDiscordIngress(client, rawData); discordIngress = this.resolveDiscordIngress(client, rawData);
if (!discordIngress) return; if (!discordIngress) return;
data = { conversationId: discordIngress.conversationId, content: discordIngress.content }; data = {
conversationId: discordIngress.conversationId,
content: discordIngress.content,
...(discordIngress.attachments
? {
attachments: discordIngress.attachments.map(
(attachment): ChannelAttachmentDto => ({
id: attachment.id,
name: attachment.name,
url: attachment.url,
mimeType: attachment.contentType,
...(attachment.sizeBytes !== undefined
? { sizeBytes: attachment.sizeBytes }
: {}),
}),
),
}
: {}),
};
} else { } else {
if (!isChatSocketMessage(rawData)) { if (!isChatSocketMessage(rawData)) {
this.logger.warn(`Rejected malformed chat message from ${client.id}`); this.logger.warn(`Rejected malformed chat message from ${client.id}`);
@@ -227,6 +340,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
data = rawData; data = rawData;
} }
const conversationId = data.conversationId ?? uuid(); const conversationId = data.conversationId ?? uuid();
const clientConversationKey = this.clientConversationKey(client, conversationId);
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordIngress && !discordServiceUserId) { if (discordIngress && !discordServiceUserId) {
this.logger.warn( this.logger.warn(
@@ -281,7 +395,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
this.logger.log( this.logger.log(
`Using /model override "${modelOverride}" for conversation=${conversationId}`, `Using /model override "${modelOverride}" for conversation=${conversationId}`,
); );
} else if (!resolvedProvider && !resolvedModelId) { } else if (!resolvedProvider && !resolvedModelId && !discordIngress) {
// No explicit provider/model from client — use routing engine (M4-012) // No explicit provider/model from client — use routing engine (M4-012)
try { try {
const routingDecision = await this.routingEngine.resolve(data.content, userId); const routingDecision = await this.routingEngine.resolve(data.content, userId);
@@ -304,12 +418,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
} }
let resolvedAgentConfigId = data.agentId;
if (discordIngress) {
const binding = this.discordBindingFor(discordIngress, 'send');
const agentConfig = binding
? await this.brain.agents.findById(binding.agentConfigId)
: undefined;
if (!binding || !agentConfig || agentConfig.name !== binding.instanceId) {
throw new Error('Configured Discord logical agent is not provisioned');
}
resolvedAgentConfigId = agentConfig.id;
}
// M5-004: Use existingSessionId as sessionId when available (session reuse) // M5-004: Use existingSessionId as sessionId when available (session reuse)
const sessionIdToCreate = existingSessionId ?? conversationId; const sessionIdToCreate = existingSessionId ?? conversationId;
agentSession = await this.agentService.createSession(sessionIdToCreate, { agentSession = await this.agentService.createSession(sessionIdToCreate, {
provider: resolvedProvider, provider: resolvedProvider,
modelId: resolvedModelId, modelId: resolvedModelId,
agentConfigId: data.agentId, agentConfigId: resolvedAgentConfigId,
userId, userId,
tenantId: scope.tenantId, tenantId: scope.tenantId,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
@@ -360,6 +486,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
discordUserId: discordIngress?.userId, discordUserId: discordIngress?.userId,
} }
: {}), : {}),
...(data.attachments && data.attachments.length > 0
? {
channelAttachments: data.attachments.map(
(attachment): ChannelAttachmentDto => ({
...attachment,
name: redactSensitiveContent(attachment.name).content,
url: redactSensitiveContent(attachment.url).content,
}),
),
}
: {}),
classifications: redactSensitiveContent(data.content).classifications, classifications: redactSensitiveContent(data.content).classifications,
}, },
}, },
@@ -374,7 +511,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
// Always clean up previous listener to prevent leak // Always clean up previous listener to prevent leak
const existing = this.clientSessions.get(client.id); const existing = this.clientSessions.get(clientConversationKey);
if (existing) { if (existing) {
existing.cleanup(); existing.cleanup();
} }
@@ -389,10 +526,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
); );
// Preserve routing decision from the existing client session if we didn't get a new one // Preserve routing decision from the existing client session if we didn't get a new one
const prevClientSession = this.clientSessions.get(client.id); const prevClientSession = this.clientSessions.get(clientConversationKey);
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision; const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
this.clientSessions.set(client.id, { this.clientSessions.set(clientConversationKey, {
clientId: client.id,
conversationId, conversationId,
cleanup, cleanup,
assistantText: '', assistantText: '',
@@ -438,7 +576,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
// Dispatch to agent // Dispatch to agent
try { try {
await this.agentService.prompt(conversationId, data.content, scope); await this.agentService.prompt(conversationId, data.content, scope, data.attachments);
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`, `Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
@@ -645,9 +783,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}; };
// Emit to all clients currently subscribed to this conversation // Emit to all clients currently subscribed to this conversation
for (const [clientId, session] of this.clientSessions) { for (const session of this.clientSessions.values()) {
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) { if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
const socket = this.server.sockets.sockets.get(clientId); const socket = this.server.sockets.sockets.get(session.clientId);
if (socket?.connected) { if (socket?.connected) {
socket.emit('session:info', payload); socket.emit('session:info', payload);
} }
@@ -677,16 +815,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
!this.durableSessions !this.durableSessions
) )
return; return;
const binding = resolveDiscordInteractionBinding( const binding = this.discordBindingFor(ingress, 'approve');
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
ingress.guildId,
ingress.channelId,
ingress.userId,
'approve',
);
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim(); const agentName = binding?.instanceId;
if (!actorId || !agentName || binding.instanceId !== agentName) { if (!actorId || !agentName) {
this.logger.warn( this.logger.warn(
`Rejected Discord approval without a matching runtime agent from ${client.id}`, `Rejected Discord approval without a matching runtime agent from ${client.id}`,
); );
@@ -774,13 +906,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions) if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
return; return;
const binding = resolveDiscordInteractionBinding( const binding = this.discordBindingFor(ingress, 'stop');
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
ingress.guildId,
ingress.channelId,
ingress.userId,
'stop',
);
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
if (!actorId) return; if (!actorId) return;
@@ -854,17 +980,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return null; return null;
} }
try { try {
const binding = resolveDiscordInteractionBinding( const binding = this.discordBindingFor(payload, operation);
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
payload.guildId,
payload.channelId,
payload.userId,
operation,
);
if (!binding) { if (!binding) {
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
return null; return null;
} }
const expectedConversationId = `${binding.instanceId}:discord:${payload.threadId ?? payload.channelId}`;
if (payload.conversationId !== expectedConversationId) {
this.logger.warn(
`Rejected Discord ingress for a different logical agent from ${client.id}`,
);
return null;
}
} catch { } catch {
this.logger.warn( this.logger.warn(
`Rejected Discord ingress without valid binding configuration from ${client.id}`, `Rejected Discord ingress without valid binding configuration from ${client.id}`,
@@ -880,6 +1007,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return payload; return payload;
} }
private discordBindingFor(
payload: DiscordIngressPayload,
operation: 'send' | 'approve' | 'stop',
) {
return resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
payload.guildId,
payload.channelId,
payload.userId,
operation,
);
}
private readDiscordAllowlist(name: string): string[] { private readDiscordAllowlist(name: string): string[] {
return (process.env[name] ?? '') return (process.env[name] ?? '')
.split(',') .split(',')
@@ -958,11 +1098,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const messages = await this.brain.conversations.findMessages(conversationId, userId); const messages = await this.brain.conversations.findMessages(conversationId, userId);
if (messages.length === 0) return []; if (messages.length === 0) return [];
return messages.map((msg) => ({ return messages.map((msg) => {
role: msg.role as 'user' | 'assistant' | 'system', const attachments = this.persistedChannelAttachments(msg.metadata);
content: msg.content, return {
createdAt: msg.createdAt, role: msg.role as 'user' | 'assistant' | 'system',
})); content: msg.content,
createdAt: msg.createdAt,
...(attachments ? { attachments } : {}),
};
});
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Failed to load conversation history for conversation=${conversationId}`, `Failed to load conversation history for conversation=${conversationId}`,
@@ -972,6 +1116,14 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
} }
private persistedChannelAttachments(metadata: unknown): readonly ChannelAttachmentDto[] | null {
if (typeof metadata !== 'object' || metadata === null) return null;
const attachments = (metadata as { channelAttachments?: unknown }).channelAttachments;
return hasValidAttachmentArray(attachments, isChannelAttachment)
? (attachments as readonly ChannelAttachmentDto[])
: null;
}
private appendAndFlushRedactedEgress( private appendAndFlushRedactedEgress(
client: Socket, client: Socket,
conversationId: string, conversationId: string,
@@ -979,18 +1131,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
buffers: Map<string, string>, buffers: Map<string, string>,
delta: string, delta: string,
): void { ): void {
const key = this.egressKey(client, eventName); const sessionKey = this.clientConversationKey(client, conversationId);
const key = this.egressKey(client, conversationId, eventName);
if (this.overflowedEgress.has(key)) return; if (this.overflowedEgress.has(key)) return;
const buffered = `${buffers.get(client.id) ?? ''}${delta}`; const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`;
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) { if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
buffers.delete(client.id); buffers.delete(sessionKey);
this.overflowedEgress.add(key); this.overflowedEgress.add(key);
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' }); client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
return; return;
} }
buffers.set(client.id, buffered); buffers.set(sessionKey, buffered);
this.flushRedactedEgress(client, conversationId, eventName, buffers, false); this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
} }
@@ -1006,21 +1159,22 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
buffers: Map<string, string>, buffers: Map<string, string>,
final: boolean, final: boolean,
): void { ): void {
const key = this.egressKey(client, eventName); const sessionKey = this.clientConversationKey(client, conversationId);
const key = this.egressKey(client, conversationId, eventName);
if (this.overflowedEgress.has(key)) { if (this.overflowedEgress.has(key)) {
if (final) this.overflowedEgress.delete(key); if (final) this.overflowedEgress.delete(key);
return; return;
} }
const buffered = buffers.get(client.id) ?? ''; const buffered = buffers.get(sessionKey) ?? '';
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
const released = buffered.slice(0, releaseLength); const released = buffered.slice(0, releaseLength);
const pending = buffered.slice(releaseLength); const pending = buffered.slice(releaseLength);
if (pending) { if (pending) {
buffers.set(client.id, pending); buffers.set(sessionKey, pending);
} else { } else {
buffers.delete(client.id); buffers.delete(sessionKey);
} }
if (released) { if (released) {
@@ -1089,8 +1243,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return retainedFrom; return retainedFrom;
} }
private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string { private egressKey(
return `${client.id}:${eventName}`; client: Socket,
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
): string {
return `${this.clientConversationKey(client, conversationId)}:${eventName}`;
} }
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
@@ -1101,26 +1259,27 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return; return;
} }
const sessionKey = this.clientConversationKey(client, conversationId);
switch (event.type) { switch (event.type) {
case 'agent_start': { case 'agent_start': {
// Reset accumulation buffers for the new turn // Reset accumulation buffers for the new turn
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(sessionKey);
if (cs) { if (cs) {
cs.assistantText = ''; cs.assistantText = '';
cs.toolCalls = []; cs.toolCalls = [];
cs.pendingToolCalls.clear(); cs.pendingToolCalls.clear();
} }
this.textEgressBuffers.set(client.id, ''); this.textEgressBuffers.set(sessionKey, '');
this.thinkingEgressBuffers.set(client.id, ''); this.thinkingEgressBuffers.set(sessionKey, '');
this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:thinking'));
client.emit('agent:start', { conversationId }); client.emit('agent:start', { conversationId });
break; break;
} }
case 'agent_end': { case 'agent_end': {
// Gather usage stats from the Pi session // Gather usage stats from the Pi session
const activeClientSession = this.clientSessions.get(client.id); const activeClientSession = this.clientSessions.get(sessionKey);
const agentSession = activeClientSession const agentSession = activeClientSession
? this.agentService.getSession(conversationId, activeClientSession.scope) ? this.agentService.getSession(conversationId, activeClientSession.scope)
: undefined; : undefined;
@@ -1173,7 +1332,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
// Persist the assistant message with metadata // Persist the assistant message with metadata
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(sessionKey);
const userId = (client.data.user as { id: string } | undefined)?.id; const userId = (client.data.user as { id: string } | undefined)?.id;
if (cs && userId && cs.assistantText.trim().length > 0) { if (cs && userId && cs.assistantText.trim().length > 0) {
const metadata: Record<string, unknown> = { const metadata: Record<string, unknown> = {
@@ -1225,7 +1384,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const assistantEvent = event.assistantMessageEvent; const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type === 'text_delta') { if (assistantEvent.type === 'text_delta') {
// Keep raw stream material in memory only; persist and emit only redacted text. // Keep raw stream material in memory only; persist and emit only redacted text.
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(sessionKey);
if (cs) { if (cs) {
cs.assistantText += assistantEvent.delta; cs.assistantText += assistantEvent.delta;
} }
@@ -1250,7 +1409,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'tool_execution_start': { case 'tool_execution_start': {
// Track pending tool call for later recording // Track pending tool call for later recording
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(sessionKey);
if (cs) { if (cs) {
cs.pendingToolCalls.set(event.toolCallId, { cs.pendingToolCalls.set(event.toolCallId, {
toolName: event.toolName, toolName: event.toolName,
@@ -1267,7 +1426,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'tool_execution_end': { case 'tool_execution_end': {
// Finalise tool call record // Finalise tool call record
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(sessionKey);
if (cs) { if (cs) {
const pending = cs.pendingToolCalls.get(event.toolCallId); const pending = cs.pendingToolCalls.get(event.toolCallId);
cs.toolCalls.push({ cs.toolCalls.push({
@@ -1,3 +1,4 @@
import { Logger } from '@nestjs/common';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CommandExecutorService } from './command-executor.service.js'; import { CommandExecutorService } from './command-executor.service.js';
import type { SlashCommandPayload } from '@mosaicstack/types'; import type { SlashCommandPayload } from '@mosaicstack/types';
@@ -12,6 +13,7 @@ const mockRegistry = {
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true }, { name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
{ name: 'prdy', aliases: [], 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: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
], ],
skills: [], skills: [],
})), })),
@@ -72,17 +74,30 @@ const mockChatGateway = {
broadcastSessionInfo: vi.fn(), broadcastSessionInfo: vi.fn(),
}; };
function buildService(): CommandExecutorService { const mockMcpClient = {
reconnectServer: vi.fn().mockResolvedValue(undefined),
getServerStatuses: vi.fn(() => []),
getToolDefinitions: vi.fn(() => []),
};
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 {
return new CommandExecutorService( return new CommandExecutorService(
mockRegistry as never, mockRegistry as never,
mockAgentService as never, mockAgentService as never,
mockSystemOverride as never, mockSystemOverride as never,
mockSessionGC as never, mockSessionGC as never,
mockRedis as never, redis as never,
mockBrain as never, mockBrain as never,
null, null,
mockChatGateway as never, mockChatGateway as never,
null, mcpClient as never,
); );
} }
@@ -131,6 +146,22 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(ttl).toBe(300); 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 // /provider with no args — returns usage
it('/provider with no args returns usage message', async () => { it('/provider with no args returns usage message', async () => {
const payload: SlashCommandPayload = { command: 'provider', conversationId }; const payload: SlashCommandPayload = { command: 'provider', conversationId };
@@ -242,4 +273,124 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(result.command).toBe('tools'); expect(result.command).toBe('tools');
expect(result.message).toContain('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,6 +36,12 @@ const authorization = {
), ),
}; };
const mockMcpClient = {
getServerStatuses: vi.fn(() => []),
getToolDefinitions: vi.fn(() => []),
reconnectServer: vi.fn().mockResolvedValue(undefined),
};
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService { function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
return new CommandExecutorService( return new CommandExecutorService(
registry as never, registry as never,
@@ -46,7 +52,7 @@ function buildExecutor(authorizationService: unknown = authorization): CommandEx
{ agents: {} } as never, { agents: {} } as never,
null, null,
null, null,
null, mockMcpClient as never,
authorizationService as never, authorizationService as never,
); );
} }
@@ -23,7 +23,10 @@ export class CommandExecutorService {
@Inject(AgentService) private readonly agentService: AgentService, @Inject(AgentService) private readonly agentService: AgentService,
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService, @Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService, @Inject(SessionGCService) private readonly sessionGC: SessionGCService,
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'], // On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
@Optional()
@Inject(COMMANDS_REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(BRAIN) private readonly brain: Brain, @Inject(BRAIN) private readonly brain: Brain,
@Optional() @Optional()
@Inject(forwardRef(() => ReloadService)) @Inject(forwardRef(() => ReloadService))
@@ -31,9 +34,7 @@ export class CommandExecutorService {
@Optional() @Optional()
@Inject(forwardRef(() => ChatGateway)) @Inject(forwardRef(() => ChatGateway))
private readonly chatGateway: ChatGateway | null, private readonly chatGateway: ChatGateway | null,
@Optional() @Inject(McpClientService) private readonly mcpClient: McpClientService,
@Inject(McpClientService)
private readonly mcpClient: McpClientService | null,
@Optional() @Optional()
@Inject(CommandAuthorizationService) @Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService | null = null, private readonly authorization: CommandAuthorizationService | null = null,
@@ -156,8 +157,13 @@ export class CommandExecutorService {
}; };
} }
} catch (err) { } catch (err) {
this.logger.error(`Command /${command} failed: ${err}`); this.logger.error(`Command /${command} failed`, err);
return { command, conversationId, success: false, message: String(err) }; return {
command,
conversationId,
success: false,
message: 'Command failed due to an internal error.',
};
} }
} }
@@ -333,11 +339,11 @@ export class CommandExecutorService {
data: { agentId: newAgent.id, agentName: newAgent.name }, data: { agentId: newAgent.id, agentName: newAgent.name },
}; };
} catch (err) { } catch (err) {
this.logger.error(`Failed to create agent: ${err}`); this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
return { return {
command: 'agent', command: 'agent',
success: false, success: false,
message: `Failed to create agent: ${String(err)}`, message: 'Failed to create agent due to an internal error.',
conversationId, conversationId,
}; };
} }
@@ -388,11 +394,11 @@ export class CommandExecutorService {
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model }, data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
}; };
} catch (err) { } catch (err) {
this.logger.error(`Failed to switch agent "${agentName}": ${err}`); this.logger.error(`Failed to switch agent "${agentName}"`, err);
return { return {
command: 'agent', command: 'agent',
success: false, success: false,
message: `Failed to switch agent: ${String(err)}`, message: 'Failed to switch agent due to an internal error.',
conversationId, conversationId,
}; };
} }
@@ -443,14 +449,16 @@ export class CommandExecutorService {
byte.toString(16).padStart(2, '0'), byte.toString(16).padStart(2, '0'),
).join(''); ).join('');
const key = `mosaic:auth:poll:${tokenHash}`; const key = `mosaic:auth:poll:${tokenHash}`;
// Persist only a short-lived token digest. The raw token is delivered only by if (this.redis) {
// the authenticated dashboard flow, never in chat output or command metadata. // Persist only a short-lived token digest. The raw token is delivered only by
await this.redis.set( // the authenticated dashboard flow, never in chat output or command metadata.
key, await this.redis.set(
JSON.stringify({ status: 'pending', provider: providerName, userId }), key,
'EX', JSON.stringify({ status: 'pending', provider: providerName, userId }),
300, 'EX',
); 300,
);
}
return { return {
command: 'provider', command: 'provider',
success: true, success: true,
@@ -538,15 +546,6 @@ export class CommandExecutorService {
args: string | null, args: string | null,
conversationId: string, conversationId: string,
): Promise<SlashCommandResultPayload> { ): 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'; const action = args?.trim().split(/\s+/)[0] ?? 'status';
switch (action) { switch (action) {
@@ -603,11 +602,12 @@ export class CommandExecutorService {
message: `MCP server "${serverName}" reconnected successfully.`, message: `MCP server "${serverName}" reconnected successfully.`,
}; };
} catch (err) { } catch (err) {
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
return { return {
command: 'mcp', command: 'mcp',
conversationId, conversationId,
success: false, success: false,
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`, message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
}; };
} }
} }
@@ -11,6 +11,8 @@
* - Unknown command returns descriptive error * - Unknown command returns descriptive error
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; 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 { CommandRegistryService } from './command-registry.service.js';
import { CommandExecutorService } from './command-executor.service.js'; import { CommandExecutorService } from './command-executor.service.js';
import type { SlashCommandPayload } from '@mosaicstack/types'; import type { SlashCommandPayload } from '@mosaicstack/types';
@@ -47,6 +49,12 @@ const mockBrain = {
}, },
}; };
const mockMcpClient = {
getServerStatuses: vi.fn(() => []),
getToolDefinitions: vi.fn(() => []),
reconnectServer: vi.fn().mockResolvedValue(undefined),
};
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
function buildRegistry(): CommandRegistryService { function buildRegistry(): CommandRegistryService {
@@ -65,7 +73,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
mockBrain as never, mockBrain as never,
null, // reloadService (optional) null, // reloadService (optional)
null, // chatGateway (optional) null, // chatGateway (optional)
null, // mcpClient (optional) mockMcpClient as never,
); );
} }
@@ -153,6 +161,15 @@ 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 ─────────────────────────────────────────────────────────── // ─── Executor Tests ───────────────────────────────────────────────────────────
describe('CommandExecutorService — integration', () => { describe('CommandExecutorService — integration', () => {
@@ -259,4 +276,14 @@ describe('CommandExecutorService — integration', () => {
expect(result.command).toBe(cmd); 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.');
});
}); });
+22 -6
View File
@@ -1,7 +1,10 @@
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common'; import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue'; 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 { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.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 { ReloadModule } from '../reload/reload.module.js';
import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandAuthorizationService } from './command-authorization.service.js';
import { CommandExecutorService } from './command-executor.service.js'; import { CommandExecutorService } from './command-executor.service.js';
@@ -12,17 +15,26 @@ import { COMMANDS_REDIS } from './commands.tokens.js';
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
@Module({ @Module({
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)], imports: [
GCModule,
McpClientModule,
forwardRef(() => ReloadModule),
forwardRef(() => ChatModule),
],
providers: [ providers: [
{ {
provide: COMMANDS_QUEUE_HANDLE, provide: COMMANDS_QUEUE_HANDLE,
useFactory: (): QueueHandle => { 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;
return createQueue(); return createQueue();
}, },
inject: [MOSAIC_CONFIG],
}, },
{ {
provide: COMMANDS_REDIS, provide: COMMANDS_REDIS,
useFactory: (handle: QueueHandle) => handle.redis, useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
inject: [COMMANDS_QUEUE_HANDLE], inject: [COMMANDS_QUEUE_HANDLE],
}, },
CommandRegistryService, CommandRegistryService,
@@ -38,9 +50,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
], ],
}) })
export class CommandsModule implements OnApplicationShutdown { export class CommandsModule implements OnApplicationShutdown {
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {} constructor(
@Optional()
@Inject(COMMANDS_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> { async onApplicationShutdown(): Promise<void> {
await this.handle.close().catch(() => {}); await this.handle?.close().catch(() => {});
} }
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { loadConfig, type MosaicConfig } from '@mosaicstack/config'; import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
import { resolveGatewayConfigPath } from '../env.js';
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG'; export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
@@ -8,7 +9,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
providers: [ providers: [
{ {
provide: MOSAIC_CONFIG, provide: MOSAIC_CONFIG,
useFactory: (): MosaicConfig => loadConfig(), useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
}, },
], ],
exports: [MOSAIC_CONFIG], exports: [MOSAIC_CONFIG],
@@ -0,0 +1,19 @@
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 } from '@nestjs/common'; import { Inject, Injectable, Optional } from '@nestjs/common';
import { import {
InteractionCoordinationClient, InteractionCoordinationClient,
type CoordinationObservation, type CoordinationObservation,
@@ -13,6 +13,7 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
export const COORDINATION_PORT = Symbol('COORDINATION_PORT'); export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG'); 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 HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
const MAX_TRACKED_HANDOFFS = 1_000; const MAX_TRACKED_HANDOFFS = 1_000;
@@ -60,6 +61,8 @@ export class InteractionCoordinationService {
constructor( constructor(
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort, @Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig, @Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
@Optional()
@Inject(HANDOFF_ID_FACTORY)
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {} ) {}
+133
View File
@@ -0,0 +1,133 @@
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,6 +5,8 @@ import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js'; import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js'; import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.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 { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js'; import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js'; import { FederationAuthGuard, FederationScopeService } from './server/index.js';
@@ -12,7 +14,13 @@ import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js'; import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({ @Module({
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController], controllers: [
EnrollmentController,
FederationController,
CapabilitiesController,
ListController,
GetController,
],
providers: [ providers: [
AdminGuard, AdminGuard,
CaService, CaService,
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard, FederationAuthGuard,
FederationScopeService, FederationScopeService,
FederationListQueryService, FederationListQueryService,
FederationGetQueryService,
], ],
exports: [ exports: [
CaService, CaService,
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard, FederationAuthGuard,
FederationScopeService, FederationScopeService,
FederationListQueryService, FederationListQueryService,
FederationGetQueryService,
], ],
}) })
export class FederationModule {} export class FederationModule {}
@@ -0,0 +1,348 @@
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',
});
});
});
@@ -0,0 +1,207 @@
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();
});
});
@@ -0,0 +1,311 @@
/**
* 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 };
}
}
@@ -0,0 +1,100 @@
/**
* 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 } };
}
}
+15 -5
View File
@@ -1,5 +1,7 @@
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common'; import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue'; 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 { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js'; import { REDIS } from './gc.tokens.js';
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
providers: [ providers: [
{ {
provide: GC_QUEUE_HANDLE, provide: GC_QUEUE_HANDLE,
useFactory: (): QueueHandle => { 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;
return createQueue(); return createQueue();
}, },
inject: [MOSAIC_CONFIG],
}, },
{ {
provide: REDIS, provide: REDIS,
useFactory: (handle: QueueHandle) => handle.redis, useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
inject: [GC_QUEUE_HANDLE], inject: [GC_QUEUE_HANDLE],
}, },
SessionGCService, SessionGCService,
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
exports: [SessionGCService], exports: [SessionGCService],
}) })
export class GCModule implements OnApplicationShutdown { export class GCModule implements OnApplicationShutdown {
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {} constructor(
@Optional()
@Inject(GC_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> { async onApplicationShutdown(): Promise<void> {
await this.handle.close().catch(() => {}); await this.handle?.close().catch(() => {});
} }
} }
@@ -119,6 +119,19 @@ describe('SessionGCService', () => {
).resolves.toEqual({ allowed: true }); ).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 () => { it('collect() returns sessionId in result', async () => {
const result = await service.collect('test-session-id'); const result = await service.collect('test-session-id');
expect(result.sessionId).toBe('test-session-id'); expect(result.sessionId).toBe('test-session-id');
+15 -8
View File
@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, Optional } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue'; import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log'; import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js'; import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -21,7 +21,10 @@ function escapeRedisGlobLiteral(value: string): string {
@Injectable() @Injectable()
export class SessionGCService { export class SessionGCService {
constructor( constructor(
@Inject(REDIS) private readonly redis: QueueHandle['redis'], // Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
@Optional()
@Inject(REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(LOG_SERVICE) private readonly logService: LogService, @Inject(LOG_SERVICE) private readonly logService: LogService,
) {} ) {}
@@ -29,8 +32,10 @@ export class SessionGCService {
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking). * 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 * KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes. * 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[]> { private async scanKeys(pattern: string): Promise<string[]> {
if (!this.redis) return [];
const collected: string[] = []; const collected: string[] = [];
let cursor = '0'; let cursor = '0';
do { do {
@@ -47,12 +52,14 @@ export class SessionGCService {
async collect(sessionId: string): Promise<GCResult> { async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} }; const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys // 1. Valkey: delete all session-scoped keys (skipped on Local tier).
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`; if (this.redis) {
const valkeyKeys = await this.scanKeys(pattern); const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
if (valkeyKeys.length > 0) { const valkeyKeys = await this.scanKeys(pattern);
await this.redis.del(...valkeyKeys); if (valkeyKeys.length > 0) {
result.cleaned.valkeyKeys = valkeyKeys.length; await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
} }
// 2. PG: demote hot-tier agent logs for this session only. // 2. PG: demote hot-tier agent logs for this session only.
@@ -0,0 +1,164 @@
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);
});
});
@@ -0,0 +1,46 @@
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;
}
}
}
@@ -0,0 +1,90 @@
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,
);
}
}
}
@@ -0,0 +1,138 @@
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();
});
});
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,116 @@
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,
};
}
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { HarnessRegistry } from './harness.registry.js';
import { HarnessService } from './harness.service.js';
import { 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 },
HarnessSelectionRepository,
HarnessSelectionService,
],
exports: [HARNESS_REGISTRY, HARNESS_SERVICE],
})
export class HarnessModule {}
@@ -0,0 +1,69 @@
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);
});
});
@@ -0,0 +1,100 @@
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()];
}
}
@@ -0,0 +1,227 @@
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
@@ -0,0 +1,285 @@
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';
@@ -0,0 +1,11 @@
/**
* 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(...)`.
*/
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;
@@ -0,0 +1,107 @@
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);
});
});
@@ -0,0 +1,248 @@
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,
);
}
}
@@ -0,0 +1,157 @@
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');
}
});
});
}
+9 -3
View File
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
@Injectable() @Injectable()
export class CronService implements OnModuleInit, OnModuleDestroy { export class CronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CronService.name); private readonly logger = new Logger(CronService.name);
private readonly registeredWorkers: Worker<MosaicJobData>[] = []; private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
constructor( constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService, @Inject(SummarizationService) private readonly summarization: SummarizationService,
@@ -26,6 +26,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
) {} ) {}
async onModuleInit(): Promise<void> { 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 summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
@@ -39,7 +45,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => { const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
await this.summarization.runSummarization(); await this.summarization.runSummarization();
}); });
this.registeredWorkers.push(summarizationWorker); if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
// M6-005: Tier management repeatable job // M6-005: Tier management repeatable job
await this.queueService.addRepeatableJob( await this.queueService.addRepeatableJob(
@@ -51,7 +57,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => { const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
await this.summarization.runTierManagement(); await this.summarization.runTierManagement();
}); });
this.registeredWorkers.push(tierWorker); if (tierWorker) this.registeredWorkers.push(tierWorker);
// Retire any repeatable global GC schedule created by older deployments. // Retire any repeatable global GC schedule created by older deployments.
// Session cleanup is now triggered only by an authorized session lifecycle operation. // Session cleanup is now triggered only by an authorized session lifecycle operation.
+164
View File
@@ -0,0 +1,164 @@
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,
);
});
+3 -15
View File
@@ -1,18 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { config } from 'dotenv'; import './env.js';
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 './tracing.js';
import 'reflect-metadata'; import 'reflect-metadata';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
@@ -26,6 +13,7 @@ import { mountAuthHandler } from './auth/auth.controller.js';
import { mountMcpHandler } from './mcp/mcp.controller.js'; import { mountMcpHandler } from './mcp/mcp.controller.js';
import { McpService } from './mcp/mcp.service.js'; import { McpService } from './mcp/mcp.service.js';
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage'; import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
import { resolveGatewayConfigPath } from './env.js';
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap'); const logger = new Logger('Bootstrap');
@@ -37,7 +25,7 @@ async function bootstrap(): Promise<void> {
// Pre-flight: assert all external services required by the configured tier // Pre-flight: assert all external services required by the configured tier
// are reachable. Runs before NestFactory.create() so failures are visible // are reachable. Runs before NestFactory.create() so failures are visible
// immediately with actionable remediation hints. // immediately with actionable remediation hints.
const mosaicConfig = loadConfig(); const mosaicConfig = loadConfig(resolveGatewayConfigPath());
try { try {
await detectAndAssertTier(mosaicConfig); await detectAndAssertTier(mosaicConfig);
} catch (err) { } catch (err) {
@@ -0,0 +1,44 @@
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) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
serverEntry.error = message; serverEntry.error = 'Connection failed (see server logs).';
serverEntry.connected = false; serverEntry.connected = false;
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`); this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
} }
@@ -24,6 +24,7 @@ const ENV_KEYS = [
'DISCORD_ALLOWED_CHANNEL_IDS', 'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS', 'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME', 'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CONFIG_ID',
] as const; ] as const;
const savedEnv = new Map<string, string | undefined>(); const savedEnv = new Map<string, string | undefined>();
@@ -33,12 +34,14 @@ function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service'; process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord'; process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
process.env['MOSAIC_AGENT_NAME'] = 'Nova'; process.env['MOSAIC_AGENT_NAME'] = 'Nova';
process.env['MOSAIC_AGENT_CONFIG_ID'] = 'agent-config-nova';
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001'; process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001'; process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { pairedUsers: {
@@ -141,7 +144,7 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
userId: 'user-001', userId: 'user-001',
conversationId: 'discord-channel-001', conversationId: 'Nova:discord:channel-001',
content: 'hello Tess', content: 'hello Tess',
...overrides, ...overrides,
}; };
@@ -153,6 +156,7 @@ describe('Discord ingress security', () => {
JSON.stringify([ JSON.stringify([
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { 'user-001': 'admin' }, pairedUsers: { 'user-001': 'admin' },
@@ -170,6 +174,7 @@ describe('Discord ingress security', () => {
[ [
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
@@ -307,41 +312,46 @@ describe('Discord ingress security', () => {
); );
}); });
it.each([ it('rejects approval when the durable session targets a different logical agent', async () => {
[ configureDiscordEnv();
'binding', const { gateway, client, durable } = discordGateway('admin');
() => { durable.getSnapshot.mockResolvedValueOnce({
process.env['MOSAIC_AGENT_NAME'] = 'Other'; identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}, });
],
[
'durable session',
(durable: { getSnapshot: ReturnType<typeof vi.fn> }) => {
durable.getSnapshot.mockResolvedValueOnce({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
},
],
])(
'rejects approval when the %s targets a different runtime agent',
async (_source, configure) => {
configureDiscordEnv();
const { gateway, client, durable } = discordGateway('admin');
configure(durable);
await gateway.handleDiscordApproval( await gateway.handleDiscordApproval(
client as never, client as never,
ingressEnvelope('/approve', 'mismatched-agent-approve'), ingressEnvelope('/approve', 'mismatched-agent-approve'),
); );
expect(client.emit).toHaveBeenCalledWith('discord:approval', { expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001', correlationId: 'correlation-001',
success: false, success: false,
approvalId: undefined, approvalId: undefined,
expiresAt: undefined, expiresAt: undefined,
}); });
}, });
);
it('rejects privileged envelopes with a forged current conversation route', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'forged-approval-route', {
conversationId: 'Nova:discord:other-channel',
}),
);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope('/stop forged', 'forged-stop-route', {
conversationId: 'Nova:discord:other-channel',
}),
);
expect(client.emit).not.toHaveBeenCalledWith('discord:approval', expect.anything());
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
});
it('rejects unpaired and non-admin Discord users for approval and stop', async () => { it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
configureDiscordEnv(); configureDiscordEnv();
@@ -398,6 +408,267 @@ describe('Discord ingress security', () => {
]); ]);
}); });
it.each([
'https://user:[email protected]/diagram.png',
'https://cdn.example.test/diagram.png?token=secret',
'https://cdn.example.test/diagram.png?X-Amz-Signature=secret',
'https://cdn.example.test/diagram.png?auth=secret',
'https://cdn.example.test/diagram.png?hm=secret',
])('rejects credential-bearing attachment URLs before gateway dispatch', async (url) => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleMessage(
client as never,
ingressEnvelope('', `credential-url-${url.length}`, {
conversationId: 'Nova:discord:channel-001',
attachments: [
{ id: 'attachment-credential', name: 'diagram.png', url, contentType: 'image/png' },
],
}),
);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
});
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
configureDiscordEnv();
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
{
instanceId: 'Orion',
agentConfigId: 'agent-config-orion',
guildId: 'guild-001',
channelId: 'channel-002',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
const brain = {
agents: {
findById: vi.fn((id: string) =>
Promise.resolve({
id,
name: id === 'agent-config-orion' ? 'Orion' : 'Nova',
}),
),
},
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const routingEngine = { resolve: vi.fn() };
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
routingEngine as never,
);
const client = {
id: 'discord-client-new-session',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('start configured session', 'configured-session-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
await gateway.handleMessage(
client as never,
ingressEnvelope('start second configured session', 'configured-session-002', {
channelId: 'channel-002',
conversationId: 'Orion:discord:channel-002',
}),
);
expect(createSession).toHaveBeenCalledWith(
'Nova:discord:channel-001',
expect.objectContaining({
agentConfigId: 'agent-config-nova',
userId: 'discord-service',
tenantId: 'tenant-discord',
}),
);
expect(createSession).toHaveBeenCalledWith(
'Orion:discord:channel-002',
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
);
expect(routingEngine.resolve).not.toHaveBeenCalled();
});
it('retains validated persisted attachments in resumed conversation history', async () => {
const attachment = {
id: 'attachment-history',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
mimeType: 'image/png',
sizeBytes: 4_096,
};
const gateway = new ChatGateway(
{} as never,
{} as never,
{
conversations: {
findMessages: vi.fn().mockResolvedValue([
{
role: 'user',
content: '',
createdAt: new Date('2026-07-14T12:00:00.000Z'),
metadata: { channelAttachments: [attachment] },
},
]),
},
} as never,
{} as never,
{} as never,
{} as never,
) as unknown as {
loadConversationHistory(
conversationId: string,
userId: string,
): Promise<Array<{ attachments?: readonly (typeof attachment)[] }>>;
};
await expect(
gateway.loadConversationHistory('Nova:discord:channel-001', 'discord-service'),
).resolves.toEqual([expect.objectContaining({ attachments: [attachment] })]);
});
it('rejects malformed signed attachment payloads before gateway dispatch', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
const malformedPayload: Record<string, unknown> = {
...createPayload({
messageId: 'malformed-attachments-001',
conversationId: 'Nova:discord:channel-001',
}),
attachments: { id: 'not-an-array' },
};
const envelope = createDiscordIngressEnvelope(
malformedPayload as unknown as DiscordIngressPayload,
SERVICE_TOKEN,
);
await gateway.handleMessage(client as never, envelope);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
});
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
configureDiscordEnv();
const prompt = vi.fn().mockResolvedValue(undefined);
const addMessage = vi.fn().mockResolvedValue(undefined);
const session = {
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(session),
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt,
};
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
const client = {
id: 'discord-client-001',
data: { discordService: true },
emit: vi.fn(),
};
const attachment = {
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
contentType: 'image/png',
sizeBytes: 4_096,
};
await gateway.handleMessage(
client as never,
ingressEnvelope('', 'attachment-message-001', {
conversationId: 'Nova:discord:channel-001',
attachments: [attachment],
}),
);
const expectedAttachment = {
id: attachment.id,
name: attachment.name,
url: attachment.url,
mimeType: attachment.contentType,
sizeBytes: attachment.sizeBytes,
};
expect(prompt).toHaveBeenCalledWith(
'Nova:discord:channel-001',
'',
{ userId: 'discord-service', tenantId: 'tenant-discord' },
[expectedAttachment],
);
expect(addMessage).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: 'Nova:discord:channel-001',
metadata: expect.objectContaining({ channelAttachments: [expectedAttachment] }),
}),
'discord-service',
);
});
it('accepts a thread message through its allowed bound parent channel', () => { it('accepts a thread message through its allowed bound parent channel', () => {
const emitted = vi.fn(); const emitted = vi.fn();
const plugin = new DiscordPlugin({ const plugin = new DiscordPlugin({
@@ -410,6 +681,7 @@ describe('Discord ingress security', () => {
interactionBindings: [ interactionBindings: [
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
+14
View File
@@ -61,6 +61,16 @@ function requiredDiscordAllowlist(name: string): string[] {
return value; 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[] { function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = []; const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN']; const discordToken = process.env['DISCORD_BOT_TOKEN'];
@@ -82,6 +92,10 @@ function createPluginRegistry(): IChannelPlugin[] {
guildId: discordGuildId, guildId: discordGuildId,
gatewayUrl: discordGatewayUrl, gatewayUrl: discordGatewayUrl,
serviceToken: discordServiceToken, serviceToken: discordServiceToken,
messageRateLimitPerMinute: optionalPositiveInteger(
'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE',
),
threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'),
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
@@ -0,0 +1,23 @@
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,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import type { ActorTenantScope } from '../auth/session-scope.js'; import type { ActorTenantScope } from '../auth/session-scope.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) => const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
`${scope.tenantId}:${scope.userId}:${sessionId}`; `${scope.tenantId}:${scope.userId}:${sessionId}`;
@@ -15,16 +17,45 @@ interface OverrideFragment {
addedAt: number; addedAt: number;
} }
@Injectable() interface LocalOverrideEntry {
export class SystemOverrideService { condensed: string;
private readonly logger = new Logger(SystemOverrideService.name); fragments: OverrideFragment[];
private readonly handle: QueueHandle; }
constructor() { @Injectable()
this.handle = createQueue(); export class SystemOverrideService implements OnApplicationShutdown {
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>();
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(() => {});
} }
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> { 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 // Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope)); const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
const fragments: OverrideFragment[] = existing const fragments: OverrideFragment[] = existing
@@ -54,10 +85,14 @@ export class SystemOverrideService {
} }
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> { 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)); return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
} }
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> { async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
if (!this.handle) return;
const pipeline = this.handle.redis.pipeline(); const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS); pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS); pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
@@ -65,6 +100,11 @@ export class SystemOverrideService {
} }
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> { 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( await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId, scope), SESSION_SYSTEM_KEY(sessionId, scope),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
@@ -0,0 +1,36 @@
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.',
});
});
});
+65 -6
View File
@@ -8,7 +8,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq'; import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
import type { LogService } from '@mosaicstack/log'; import type { LogService } from '@mosaicstack/log';
import type { MosaicConfig } from '@mosaicstack/config';
import { LOG_SERVICE } from '../log/log.tokens.js'; 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'; import type { JobDto, JobStatus } from './queue-admin.dto.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -108,21 +110,42 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly connection: ConnectionOptions; private readonly connection: ConnectionOptions;
private readonly queues = new Map<string, Queue<MosaicJobData>>(); private readonly queues = new Map<string, Queue<MosaicJobData>>();
private readonly workers = new Map<string, Worker<MosaicJobData>>(); private readonly workers = new Map<string, Worker<MosaicJobData>>();
/** False on Local tier — BullMQ/Redis operations become no-ops. */
private readonly enabled: boolean;
constructor( constructor(
@Optional() @Optional()
@Inject(LOG_SERVICE) @Inject(LOG_SERVICE)
private readonly logService: LogService | null, private readonly logService: LogService | null,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) { ) {
this.connection = getConnection(); 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;
} }
onModuleInit(): void { onModuleInit(): void {
this.logger.log('QueueService initialised (BullMQ)'); 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',
);
}
} }
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
await this.closeAll(); if (this.enabled) {
await this.closeAll();
}
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -131,8 +154,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Get or create a BullMQ Queue for the given queue name. * 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> { getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
if (!this.enabled) return null;
let queue = this.queues.get(name) as Queue<T> | undefined; let queue = this.queues.get(name) as Queue<T> | undefined;
if (!queue) { if (!queue) {
queue = new Queue<T>(name, { connection: this.connection }); queue = new Queue<T>(name, { connection: this.connection });
@@ -144,6 +169,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Add a BullMQ repeatable job (cron-style). * Add a BullMQ repeatable job (cron-style).
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent. * Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
* No-op on Local tier.
*/ */
async addRepeatableJob<T extends MosaicJobData>( async addRepeatableJob<T extends MosaicJobData>(
queueName: string, queueName: string,
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
data: T, data: T,
cronExpression: string, cronExpression: string,
): Promise<void> { ): Promise<void> {
const queue = this.getQueue<T>(queueName); if (!this.enabled) {
this.logger.debug(
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return;
}
const queue = this.getQueue<T>(queueName)!;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
await (queue as Queue<any>).add(jobName, data, { await (queue as Queue<any>).add(jobName, data, {
repeat: { pattern: cronExpression }, repeat: { pattern: cronExpression },
@@ -167,7 +199,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* safe retirement of previously registered system-wide jobs. * safe retirement of previously registered system-wide jobs.
*/ */
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> { 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); const queue = this.getQueue(queueName);
if (!queue) return 0;
const jobs = await queue.getRepeatableJobs(); const jobs = await queue.getRepeatableJobs();
const matchingJobs = jobs.filter((job) => job.name === jobName); const matchingJobs = jobs.filter((job) => job.name === jobName);
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key))); await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
@@ -182,8 +221,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Register a Worker for the given queue name with error handling and * Register a Worker for the given queue name with error handling and
* exponential backoff. * exponential backoff.
* Returns null on Local tier where BullMQ is disabled.
*/ */
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> { 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;
}
const worker = new Worker<T>( const worker = new Worker<T>(
queueName, queueName,
async (job) => { async (job) => {
@@ -240,8 +289,12 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Return queue health statistics for all managed queues. * Return queue health statistics for all managed queues.
* Returns an empty healthy result on Local tier.
*/ */
async getHealthStatus(): Promise<QueueHealthStatus> { async getHealthStatus(): Promise<QueueHealthStatus> {
if (!this.enabled) {
return { queues: {}, healthy: true };
}
const queues: QueueHealthStatus['queues'] = {}; const queues: QueueHealthStatus['queues'] = {};
let healthy = true; let healthy = true;
@@ -272,8 +325,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* List jobs across all managed queues, optionally filtered by status. * List jobs across all managed queues, optionally filtered by status.
* BullMQ jobs are fetched by state type from each queue. * BullMQ jobs are fetched by state type from each queue.
* Returns empty array on Local tier.
*/ */
async listJobs(status?: JobStatus): Promise<JobDto[]> { async listJobs(status?: JobStatus): Promise<JobDto[]> {
if (!this.enabled) return [];
const jobs: JobDto[] = []; const jobs: JobDto[] = [];
const states: JobStatus[] = status const states: JobStatus[] = status
? [status] ? [status]
@@ -300,8 +355,10 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id"). * Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ * The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
* job IDs are not globally unique they are scoped to their queue. * 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 }> { 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('__'); const sep = compositeId.lastIndexOf('__');
if (sep === -1) { if (sep === -1) {
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' }; return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
@@ -333,6 +390,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Pause a queue by name. * Pause a queue by name.
*/ */
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> { 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); const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` }; if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.pause(); await queue.pause();
@@ -344,6 +402,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Resume a paused queue by name. * Resume a paused queue by name.
*/ */
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> { 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); const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` }; if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.resume(); await queue.resume();
@@ -1,5 +1,8 @@
import { Logger } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
import { ReloadService } from './reload.service.js'; import { ReloadService } from './reload.service.js';
import { CommandExecutorService } from '../commands/command-executor.service.js';
function createMockCommandRegistry() { function createMockCommandRegistry() {
return { return {
@@ -104,3 +107,85 @@ describe('ReloadService', () => {
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow(); 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,
);
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();
});
});
+4 -2
View File
@@ -58,7 +58,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
await plugin.onUnload(); await plugin.onUnload();
reloaded.push(name); reloaded.push(name);
} catch (err) { } catch (err) {
errors.push(`${name}: unload failed — ${err}`); this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
errors.push(`${name}: unload failed (internal error)`);
} }
} }
} }
@@ -69,7 +70,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
try { try {
await plugin.onLoad(); await plugin.onLoad();
} catch (err) { } catch (err) {
errors.push(`${name}: load failed ${err}`); this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
errors.push(`${name}: load failed (internal error)`);
} }
} }
} }
@@ -0,0 +1,104 @@
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,7 +1,11 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';
import { ProjectBootstrapService } from './project-bootstrap.service.js'; import {
ProjectBootstrapService,
type BootstrapProjectResult,
} from './project-bootstrap.service.js';
import { CreateWorkspaceDto } from './workspace.dto.js';
@Controller('api/workspaces') @Controller('api/workspaces')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@@ -11,20 +15,14 @@ export class WorkspaceController {
@Post() @Post()
async create( async create(
@CurrentUser() user: { id: string }, @CurrentUser() user: { id: string },
@Body() @Body() dto: CreateWorkspaceDto,
body: { ): Promise<BootstrapProjectResult> {
name: string;
description?: string;
teamId?: string;
repoUrl?: string;
},
) {
return this.bootstrap.bootstrap({ return this.bootstrap.bootstrap({
name: body.name, name: dto.name,
description: body.description, description: dto.description,
userId: user.id, userId: user.id,
teamId: body.teamId, teamId: dto.teamId,
repoUrl: body.repoUrl, repoUrl: dto.repoUrl,
}); });
} }
} }
@@ -0,0 +1,33 @@
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,11 +1,33 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { BadRequestException } from '@nestjs/common';
import { WorkspaceService } from './workspace.service.js'; import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } 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,
}));
describe('WorkspaceService', () => { describe('WorkspaceService', () => {
let service: WorkspaceService; let service: WorkspaceService;
beforeEach(() => { beforeEach(() => {
execFileMock.mockReset();
execFileMock.mockImplementation((_command, _args, _options, callback) => {
callback(null, '', '');
});
service = new WorkspaceService(); service = new WorkspaceService();
}); });
@@ -76,4 +98,69 @@ 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,10 +1,30 @@
import { Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
const execFileAsync = promisify(execFile); 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 { export interface WorkspaceProject {
id: string; id: string;
@@ -39,14 +59,32 @@ export class WorkspaceService {
* If repoUrl is provided, clone instead of init. * If repoUrl is provided, clone instead of init.
*/ */
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> { async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
if (repoUrl !== undefined) {
assertAllowedRepositoryUrl(repoUrl);
}
const workspacePath = this.resolvePath(project); const workspacePath = this.resolvePath(project);
// Create directory // Create directory
await fs.mkdir(workspacePath, { recursive: true }); await fs.mkdir(workspacePath, { recursive: true });
if (repoUrl) { if (repoUrl !== undefined) {
// Clone existing repo // Clone existing repo. Defense in depth keeps dangerous local helpers
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath }); // 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 },
);
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`); this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
} else { } else {
// Init new git repo // Init new git repo
+30
View File
@@ -0,0 +1,30 @@
<!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>
+10 -4
View File
@@ -3,22 +3,26 @@
"version": "0.0.2", "version": "0.0.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "next build", "build": "node ../../scripts/build-web.mjs",
"dev": "next dev", "build:vite": "vite build",
"dev": "next dev -p 3101",
"dev:vite": "vite",
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"start": "next start" "start": "next start -p 3101"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/design-tokens": "workspace:^", "@mosaicstack/design-tokens": "workspace:^",
"@mosaicstack/types": "workspace:^",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"next": "^16.0.0", "next": "^16.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2",
"socket.io-client": "^4.8.0", "socket.io-client": "^4.8.0",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
@@ -28,9 +32,11 @@
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.5",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^2.0.0" "vite": "^8.2.1",
"vitest": "^3.2.7"
} }
} }
@@ -3,41 +3,56 @@
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation'; import { useParams, useSearchParams } from 'next/navigation';
import { api } from '@/lib/api';
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
import { signIn } from '@/lib/auth-client'; import { signIn } from '@/lib/auth-client';
import { getSsoProvider } from '@/lib/sso-providers'; import type { SsoProviderDiscovery } from '@/lib/sso';
export default function AuthProviderRedirectPage(): React.ReactElement { export default function AuthProviderRedirectPage(): React.ReactElement {
const params = useParams<{ provider: string }>(); const params = useParams<{ provider: string }>();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const providerId = typeof params.provider === 'string' ? params.provider : ''; const providerId = typeof params.provider === 'string' ? params.provider : '';
const provider = getSsoProvider(providerId); const requestedCallbackURL = searchParams.get('callbackURL');
const callbackURL = searchParams.get('callbackURL') ?? '/chat'; const [providerName, setProviderName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { 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; let cancelled = false;
async function redirectToProvider(): Promise<void> { async function redirectToProvider(): Promise<void> {
const result = await signIn.oauth2({ try {
providerId: activeProvider.id, const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
callbackURL, const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
}); if (cancelled) return;
if (!cancelled && result?.error) { const provider = providers.find((candidate) => candidate.id === providerId);
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`); 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.');
}
} }
} }
@@ -46,19 +61,22 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [callbackURL, provider]); }, [providerId, requestedCallbackURL]);
return ( return (
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center"> <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> <h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
<p className="mt-2 text-sm text-text-secondary"> <p className="mt-2 text-sm text-text-secondary">
{provider {providerName
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...` ? `Redirecting you to ${providerName}...`
: 'Preparing your sign-in request...'} : 'Preparing your sign-in request...'}
</p> </p>
{error ? ( {error ? (
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"> <div
role="alert"
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
<p>{error}</p> <p>{error}</p>
<Link <Link
href="/login" href="/login"
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from './api';
describe('api', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('fetches the supplied relative path with credentials and a JSON body', async () => {
const fetchMock = vi.fn<typeof fetch>();
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await expect(
api<{ ok: boolean }>('/api/projects', {
method: 'POST',
body: { name: 'Mosaic' },
}),
).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith(
'/api/projects',
expect.objectContaining({
method: 'POST',
credentials: 'include',
body: JSON.stringify({ name: 'Mosaic' }),
headers: expect.objectContaining({
Accept: 'application/json',
'Content-Type': 'application/json',
}),
}),
);
});
it('throws the gateway JSON error with its statusCode', async () => {
const fetchMock = vi.fn<typeof fetch>();
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await expect(api('/api/admin/users')).rejects.toMatchObject({
name: 'Error',
message: 'Forbidden',
statusCode: 403,
});
});
});
+1 -3
View File
@@ -1,5 +1,3 @@
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
export interface ApiRequestInit extends Omit<RequestInit, 'body'> { export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
body?: unknown; body?: unknown;
} }
@@ -25,7 +23,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
} }
const res = await fetch(`${GATEWAY_URL}${path}`, { const res = await fetch(path, {
credentials: 'include', credentials: 'include',
...rest, ...rest,
headers, headers,
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('auth client origin contract', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
const fetchMock = vi.fn<typeof fetch>();
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ session: null, user: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const { authClient } = await import('./auth-client');
await authClient.getSession();
expect(fetchMock).toHaveBeenCalledOnce();
const firstCall = fetchMock.mock.calls.at(0);
expect(firstCall).toBeDefined();
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
expect(requestURL.origin).toBe(window.location.origin);
expect(requestURL.pathname).toBe('/api/auth/get-session');
});
});
+2 -1
View File
@@ -1,8 +1,9 @@
import { createAuthClient } from 'better-auth/react'; import { createAuthClient } from 'better-auth/react';
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins'; import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
// every browser request on the current origin in development and production.
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
plugins: [adminClient(), genericOAuthClient()], plugins: [adminClient(), genericOAuthClient()],
}); });
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { resolveAuthCallbackURL } from './auth-redirect';
const CURRENT_ORIGIN = 'https://mosaic.example';
describe('resolveAuthCallbackURL', () => {
it('preserves a canonical same-origin path with search and hash', () => {
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
'/projects?view=active#current',
);
});
it.each([
null,
'chat',
'//evil.example',
'/..//evil.com',
'/..//evil.com/x',
'/./..//evil.com',
'/../..//evil.com',
'/foo/..//evil.com',
'/\\evil.example',
'/\n//evil.example',
'/\r//evil.example',
'/\t//evil.example',
'https://evil.example/phish',
])('falls back to chat for an unsafe callback target %#', (candidate) => {
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
});
});
+23
View File
@@ -0,0 +1,23 @@
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
/**
* Return a canonical same-origin path for post-auth navigation.
*
* Parsing before comparing origins rejects protocol-relative URLs, backslash
* variants, and control characters that the WHATWG parser normalizes away.
*/
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
try {
const expectedOrigin = new URL(currentOrigin).origin;
const resolved = new URL(candidate, expectedOrigin);
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
return DEFAULT_AUTH_CALLBACK_URL;
}
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
} catch {
return DEFAULT_AUTH_CALLBACK_URL;
}
}
+61
View File
@@ -0,0 +1,61 @@
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
// the public `@mosaicstack/types` package. `import type` is erased at compile
// time, so this introduces no runtime dependency — it only reuses the exact
// payload shapes instead of redeclaring them.
import type { Socket } from 'socket.io-client';
import type {
AbortPayload,
AgentEndPayload,
AgentStartPayload,
AgentTextPayload,
AgentThinkingPayload,
ChatMessagePayload,
ClientToServerEvents,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
SessionInfoPayload,
SessionUsagePayload,
SetThinkingPayload,
SkillCommandDef,
SlashCommandApprovalResultPayload,
SlashCommandPayload,
SlashCommandResultPayload,
SystemReloadPayload,
ToolEndPayload,
ToolStartPayload,
} from '@mosaicstack/types';
export type {
AbortPayload,
AgentEndPayload,
AgentStartPayload,
AgentTextPayload,
AgentThinkingPayload,
ChatMessagePayload,
ClientToServerEvents,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
SessionInfoPayload,
SessionUsagePayload,
SetThinkingPayload,
SkillCommandDef,
SlashCommandApprovalResultPayload,
SlashCommandPayload,
SlashCommandResultPayload,
SystemReloadPayload,
ToolEndPayload,
ToolStartPayload,
};
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { ioMock } = vi.hoisted(() => ({
ioMock: vi.fn(),
}));
vi.mock('socket.io-client', () => ({
io: ioMock,
}));
import { destroySocket, getSocket } from './socket';
interface MockChatSocket {
on: ReturnType<typeof vi.fn>;
offAny: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
/** Test-only helper: fires every handler registered for `event` via
* `.on`, mirroring how a real socket.io-client instance invokes its own
* listeners (e.g. calling the registered `disconnect` handler(s) on a
* real transient disconnect). */
trigger(event: string): void;
}
function createMockSocket(): MockChatSocket {
const handlers = new Map<string, Set<() => void>>();
const mockSocket: MockChatSocket = {
on: vi.fn((event: string, handler: () => void) => {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)?.add(handler);
return mockSocket;
}),
offAny: vi.fn(() => mockSocket),
disconnect: vi.fn(() => mockSocket),
trigger(event: string): void {
for (const handler of handlers.get(event) ?? []) handler();
},
};
return mockSocket;
}
let currentMock!: MockChatSocket;
describe('chat socket', () => {
beforeEach(() => {
ioMock.mockReset();
// A fresh object per io() call so identity assertions (same singleton vs.
// a genuinely new instance) are meaningful.
ioMock.mockImplementation(() => {
currentMock = createMockSocket();
return currentMock;
});
});
afterEach(() => {
destroySocket();
});
it('creates one same-origin /chat namespace socket', () => {
const first = getSocket();
const second = getSocket();
expect(first).toBe(second);
expect(ioMock).toHaveBeenCalledOnce();
expect(ioMock).toHaveBeenCalledWith('/chat', {
withCredentials: true,
autoConnect: false,
transports: ['websocket', 'polling'],
});
});
it('keeps the same singleton instance across a transient disconnect', () => {
const first = getSocket();
// socket.ts must not react to a real socket's `disconnect` event by
// nulling the singleton — it registers no such handler at all now.
// Actually fire every handler registered via `.on('disconnect', ...)`
// (mirroring a real socket.io-client reconnect) instead of merely
// calling getSocket() again: this is what makes the test fail if
// production reintroduces `socket.on('disconnect', () => { socket =
// null; })`, since that handler would run here and null the singleton
// before the next getSocket() call.
currentMock.trigger('disconnect');
const second = getSocket();
expect(second).toBe(first);
expect(ioMock).toHaveBeenCalledOnce();
});
it('only creates a new singleton after an explicit destroySocket()', () => {
const first = getSocket();
destroySocket();
const second = getSocket();
expect(second).not.toBe(first);
expect(ioMock).toHaveBeenCalledTimes(2);
});
});
+17 -13
View File
@@ -1,23 +1,27 @@
import { io, type Socket } from 'socket.io-client'; import { io } from 'socket.io-client';
import type { ChatSocket } from './chat-contract';
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242'; let socket: ChatSocket | null = null;
let socket: Socket | null = null; export function getSocket(): ChatSocket {
export function getSocket(): Socket {
if (!socket) { if (!socket) {
socket = io(`${GATEWAY_URL}/chat`, { // socket.io-client 4.8.3's `io()` factory declaration always returns the
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
// generics), so this one cast is the unavoidable boundary between that and the
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
// with no further assertions.
socket = io('/chat', {
withCredentials: true, withCredentials: true,
autoConnect: false, autoConnect: false,
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
}); }) as unknown as ChatSocket;
// Reset singleton reference when socket is fully closed so the next // A transient `disconnect` (network blip, server restart) must NOT null
// getSocket() call creates a fresh instance instead of returning a // the singleton: socket.io-client auto-reconnects this same instance,
// closed/dead socket. // and its listeners stay registered across that reconnect. Nulling here
socket.on('disconnect', () => { // previously orphaned those listeners on the next getSocket() call by
socket = null; // handing back a brand-new, unconnected instance. Only destroySocket()
}); // (an explicit, intentional teardown) may reset the singleton.
} }
return socket; return socket;
} }
-48
View File
@@ -1,48 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
describe('sso-providers', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('returns the enabled providers in login button order', () => {
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
expect(getEnabledSsoProviders()).toEqual([
{
id: 'workos',
buttonLabel: 'Continue with WorkOS',
description: 'Enterprise SSO via WorkOS',
enabled: true,
href: '/auth/provider/workos',
},
{
id: 'keycloak',
buttonLabel: 'Continue with Keycloak',
description: 'Enterprise SSO via Keycloak',
enabled: true,
href: '/auth/provider/keycloak',
},
]);
});
it('marks disabled providers without exposing them in the enabled list', () => {
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
expect(getSsoProvider('keycloak')).toEqual({
id: 'keycloak',
buttonLabel: 'Continue with Keycloak',
description: 'Enterprise SSO via Keycloak',
enabled: false,
href: '/auth/provider/keycloak',
});
});
it('returns null for unknown providers', () => {
expect(getSsoProvider('authentik')).toBeNull();
});
});
-53
View File
@@ -1,53 +0,0 @@
export type SsoProviderId = 'workos' | 'keycloak';
export interface SsoProvider {
id: SsoProviderId;
buttonLabel: string;
description: string;
enabled: boolean;
href: string;
}
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
workos: {
id: 'workos',
buttonLabel: 'Continue with WorkOS',
description: 'Enterprise SSO via WorkOS',
},
keycloak: {
id: 'keycloak',
buttonLabel: 'Continue with Keycloak',
description: 'Enterprise SSO via Keycloak',
},
};
export function getEnabledSsoProviders(): SsoProvider[] {
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
.map((providerId) => getSsoProvider(providerId))
.filter((provider): provider is SsoProvider => provider?.enabled === true);
}
export function getSsoProvider(providerId: string): SsoProvider | null {
if (!isSsoProviderId(providerId)) {
return null;
}
return {
...PROVIDER_METADATA[providerId],
enabled: isSsoProviderEnabled(providerId),
href: `/auth/provider/${providerId}`,
};
}
function isSsoProviderId(value: string): value is SsoProviderId {
return value === 'workos' || value === 'keycloak';
}
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
switch (providerId) {
case 'workos':
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
case 'keycloak':
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
}
}
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { ThemeProvider } from '@/providers/theme-provider';
import { createAppRouter } from '@/routes';
import '@/app/globals.css';
const container = document.getElementById('root');
if (!container) {
throw new Error('missing #root element');
}
createRoot(container).render(
<StrictMode>
<ThemeProvider>
<RouterProvider router={createAppRouter()} />
</ThemeProvider>
</StrictMode>,
);
+67
View File
@@ -0,0 +1,67 @@
import type { ReactElement } from 'react';
import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom';
import { LoginPage } from '@/spa/pages/login';
import { RegisterPage } from '@/spa/pages/register';
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
import { ChatPage } from '@/spa/pages/chat';
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
import { ProjectDetailPage } from '@/spa/pages/project-detail';
import { ProjectsPage } from '@/spa/pages/projects';
import {
ProjectDetailRouteErrorBoundary,
ProjectsRouteErrorBoundary,
TasksRouteErrorBoundary,
} from '@/spa/pages/resource-route-error-boundaries';
import { TasksPage } from '@/spa/pages/tasks';
import { AuthGuard, GuestGuard } from '@/spa/guards';
import { Placeholder } from '@/spa/placeholder';
function GuestLayout(): ReactElement {
return (
<div className="flex min-h-screen items-center justify-center bg-surface-bg px-4 py-8">
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
<Outlet />
</div>
</div>
);
}
export const routes: RouteObject[] = [
{
element: <GuestGuard />,
children: [
{
element: <GuestLayout />,
children: [
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{ path: '/auth/provider/:provider', element: <SsoCallbackPage /> },
],
},
],
},
{
element: <AuthGuard />,
children: [
{ path: '/', element: <Navigate to="/chat" replace /> },
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
{
path: '/projects',
element: <ProjectsPage />,
errorElement: <ProjectsRouteErrorBoundary />,
},
{
path: '/projects/:id',
element: <ProjectDetailPage />,
errorElement: <ProjectDetailRouteErrorBoundary />,
},
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
{ path: '/settings', element: <Placeholder title="Settings" /> },
{ path: '/admin', element: <Placeholder title="Admin" /> },
],
},
];
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
return createBrowserRouter(routes);
}
@@ -0,0 +1,280 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { CommandsPanel } from './commands-panel';
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let root: Root | null;
let container: HTMLElement | null;
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(node);
});
}
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
container = null;
});
describe('CommandsPanel', () => {
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={{
conversationId: 'c1',
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
success: true,
approvalId: 'ap1',
expiresAt: '2026-01-01T00:00:00.000Z',
// Free-text server message claims a different, less alarming target
// than what will actually be sent — the UI must not rely on this.
message: 'This will only affect the staging environment.',
}}
pendingApproval={{ command: 'deploy', args: 'prod' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
// The exact frozen combined action is visible...
expect(container?.textContent).toContain('/deploy');
expect(container?.textContent).toContain('prod');
// ...and the misleading server free-text is never shown next to it.
expect(container?.textContent).not.toContain('staging environment');
});
it('does not throw when a manifest commands entry is null', async () => {
const manifest = {
commands: [
null,
{
name: 'model',
aliases: [],
description: 'Change the active model',
scope: 'core',
execution: 'socket',
available: true,
},
],
skills: [null],
version: 1,
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
await expect(
render(
<CommandsPanel
manifest={manifest}
results={[]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
),
).resolves.not.toThrow();
expect(container?.textContent).toContain('model');
});
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={{
conversationId: 'c1',
command: 'deploy',
success: true,
approvalId: 'ap1',
expiresAt: '2026-01-01T00:00:00.000Z',
}}
pendingApproval={{ command: 'deploy' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent?.toLowerCase()).toContain('no args');
});
it('renders skills from a skills-only manifest', async () => {
await render(
<CommandsPanel
manifest={{
commands: [],
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
version: 1,
}}
results={[]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('brave-search');
expect(container?.textContent).toContain('Search the web');
});
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
const approval = {
conversationId: 'c1',
command: 'deploy',
success: { truthy: 'object' },
approvalId: { also: 'object' },
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={approval}
pendingApproval={{ command: 'deploy', args: 'prod' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
button.textContent?.includes('Run approved command'),
),
).toBe(false);
});
it('shows the guarded server-provided denial reason for a denied approval', async () => {
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={{
conversationId: 'c1',
command: 'deploy',
success: false,
message: 'Not authorized',
}}
pendingApproval={{ command: 'deploy', args: 'prod' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('Not authorized');
});
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
await render(
<CommandsPanel
manifest={null}
results={[]}
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
pendingApproval={{ command: 'deploy', args: 'prod' }}
hasConversation
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('Denied.');
});
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
await render(
<CommandsPanel
manifest={null}
results={[
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
{ conversationId: 'c1', command: 'deploy', success: false },
]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
expect(container?.textContent).toContain('Unknown model');
expect(container?.textContent).toContain('Command failed.');
});
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
const hostileMessage = 'y'.repeat(50_000);
await render(
<CommandsPanel
manifest={null}
results={[
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
);
const text = container?.textContent ?? '';
expect(text.length).toBeLessThan(hostileMessage.length);
});
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
const manifest = {
commands: 'not-an-array',
skills: null,
version: 1,
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
await expect(
render(
<CommandsPanel
manifest={manifest}
results={[]}
approval={null}
pendingApproval={null}
hasConversation={false}
onExecute={vi.fn()}
onApprove={vi.fn()}
onRunApproved={vi.fn()}
/>,
),
).resolves.not.toThrow();
});
});
+164
View File
@@ -0,0 +1,164 @@
import { useState, type ReactElement } from 'react';
import type { PendingApproval } from './use-chat-connection';
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
import { asNonEmptyString, asString } from './runtime-guards';
import type {
CommandManifest,
SlashCommandApprovalResultPayload,
SlashCommandResultPayload,
} from '@/lib/chat-contract';
/** Stable fallback copy shown for a failed command only when the server's
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
* malformed the structured contract reason itself is otherwise shown
* directly, never a raw thrown exception, stack trace, or object value. */
const COMMAND_FAILURE_COPY = 'Command failed.';
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
* stored command:result message at ingestion, but this component must never
* assume every caller went through that path bounding again here means a
* hostile/oversized message can never force an unbounded render. */
function boundMessage(value: string): string {
return value.length > MAX_COMMAND_MESSAGE_CHARS
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
: value;
}
interface CommandsPanelProps {
manifest: CommandManifest | null;
results: SlashCommandResultPayload[];
approval: SlashCommandApprovalResultPayload | null;
pendingApproval: PendingApproval | null;
hasConversation: boolean;
onExecute: (input: { command: string; args?: string }) => void;
onApprove: (input: { command: string; args?: string }) => void;
onRunApproved: () => void;
}
export function CommandsPanel({
manifest,
results,
approval,
pendingApproval,
hasConversation,
onExecute,
onApprove,
onRunApproved,
}: CommandsPanelProps): ReactElement {
const [command, setCommand] = useState('');
const [args, setArgs] = useState('');
// Defense-in-depth: the reducer already normalizes success/approvalId
// before storing `approval`, but a matching command string alone must
// never be trusted here either — require the literal boolean `true` and a
// non-empty string approvalId, not merely truthy values.
const canRunApproved =
approval?.success === true &&
typeof approval.approvalId === 'string' &&
approval.approvalId.length > 0 &&
!!pendingApproval &&
pendingApproval.command === approval.command;
// A manifest arrives from the server as untyped JSON at runtime — guard
// both collections before mapping so a malformed manifest cannot throw.
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
return (
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
{commands.length > 0 ? (
<ul aria-label="Available commands" className="flex flex-col gap-1">
{commands.map((cmd, index) => (
<li key={asString(cmd?.name) || `cmd-${index}`}>
<strong>/{asString(cmd?.name)}</strong> {asString(cmd?.description)}
</li>
))}
</ul>
) : null}
{skills.length > 0 ? (
<ul aria-label="Available skills" className="flex flex-col gap-1">
{skills.map((skill, index) => (
<li key={asString(skill?.name) || `skill-${index}`}>
<strong>/skill:{asString(skill?.name)}</strong> {asString(skill?.description)}
</li>
))}
</ul>
) : null}
<div className="flex flex-wrap items-center gap-2">
<input
aria-label="Command name"
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder="command"
/>
<input
aria-label="Command arguments"
value={args}
onChange={(event) => setArgs(event.target.value)}
placeholder="args (optional)"
/>
<button
type="button"
disabled={!hasConversation || !command.trim()}
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
>
Run command
</button>
<button
type="button"
disabled={!hasConversation || !command.trim()}
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
>
Request approval
</button>
</div>
{approval ? (
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
{/* A successful approval shows stable client copy only never
the server-controlled approval.message or echoed
approval.command as the primary confirmation. The frozen local
pendingApproval below (not this line) is the sole authoritative
statement of what will run. A denial, by contrast, is not an
execution authority and safely surfaces the guarded structured
reason the server gave (e.g. "Not authorized"), falling back to
a stable copy only when absent/malformed. */}
<span>
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
</span>
{canRunApproved && pendingApproval ? (
<>
{/* Authoritative frozen local command+args what the click below
will actually emit. The server's `approval` above is display-only
and must never be trusted to represent the executed payload. */}
<span>
Will run: /{pendingApproval.command}{' '}
{pendingApproval.args ? pendingApproval.args : '(no args)'}
</span>
<button type="button" onClick={onRunApproved}>
Run approved command
</button>
</>
) : null}
</div>
) : null}
{results.length > 0 ? (
<ul aria-label="Command results" className="flex flex-col gap-1">
{results.map((result, index) => (
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
{result.success
? typeof result.message === 'string' && result.message
? `${boundMessage(result.message)}`
: ''
: `${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
</li>
))}
</ul>
) : null}
</section>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useState, type KeyboardEvent, type ReactElement } from 'react';
interface ComposerProps {
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
onStop: () => void;
streaming: boolean;
/** True from local send time through server turn startup/ack and
* throughout streaming a superset of `streaming` that also covers the
* pre-ack window where a second send could otherwise slip through. */
sending: boolean;
hasConversation: boolean;
}
export function Composer({
onSend,
onStop,
streaming,
sending,
hasConversation,
}: ComposerProps): ReactElement {
const [content, setContent] = useState('');
const [provider, setProvider] = useState('');
const [modelId, setModelId] = useState('');
const busy = streaming || sending;
function submit(): void {
if (busy) return;
const trimmed = content.trim();
if (!trimmed) return;
onSend({
content: trimmed,
provider: provider.trim() || undefined,
modelId: modelId.trim() || undefined,
});
setContent('');
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
}
return (
<form
onSubmit={(event) => {
event.preventDefault();
submit();
}}
className="flex flex-col gap-2 border-t p-4"
>
<div className="flex flex-wrap gap-2">
<input
aria-label="Provider"
value={provider}
onChange={(event) => setProvider(event.target.value)}
placeholder="Provider (optional)"
className="rounded border px-2 py-1 text-xs"
/>
<input
aria-label="Model"
value={modelId}
onChange={(event) => setModelId(event.target.value)}
placeholder="Model (optional)"
className="rounded border px-2 py-1 text-xs"
/>
</div>
<div className="flex items-end gap-2">
<textarea
aria-label="Message"
value={content}
onChange={(event) => setContent(event.target.value)}
onKeyDown={handleKeyDown}
rows={2}
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={!content.trim() || busy}
className="rounded px-3 py-2 text-sm font-medium"
>
Send
</button>
<button
type="button"
aria-label="Stop"
disabled={!hasConversation || !streaming}
onClick={onStop}
className="rounded px-3 py-2 text-sm font-medium"
>
Stop
</button>
</div>
</form>
);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
* flood any of these collections; caps keep memory/render cost flat instead
* of growing unboundedly for the lifetime of the connection.
*/
/** Max characters retained for the in-flight streamed text/thinking buffers. */
export const MAX_STREAM_CHARS = 20_000;
/** Max transcript turns retained (oldest dropped first). */
export const MAX_MESSAGES = 500;
/** Max tool-call entries (including anomaly entries) retained per turn history. */
export const MAX_TOOLS = 200;
/** Max slash-command results retained. */
export const MAX_COMMAND_RESULTS = 200;
/** Max commands/skills accepted from a single manifest push. */
export const MAX_MANIFEST_ITEMS = 500;
/** Max executed approval IDs remembered for single-flight dedup. */
export const MAX_EXECUTED_APPROVAL_IDS = 200;
/** Max characters retained for a single command:result message a hostile
* or malfunctioning gateway must not be able to push an unbounded curated
* success/failure reason into state (or, defensively, onto the page). */
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;

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