Compare commits

..
Author SHA1 Message Date
jason.woltje 2f5a8d2cec feat(skills): skill lifecycle + ms-* skill set completion (#40, #41, #42)
- scripts/skill.sh: install (bundled or path) / activate / deactivate /
  uninstall (refuses while enabled) / list
- skills-enabled + skills-available dirs under the data root; a skill not
  in skills-enabled is not enabled or available for use
- pi adapter: MOSAIC_SKILLS -> --skill per dir; --no-skills when none
- agent.sh: seat definitions declare skills[]; resolution against
  skills-enabled refuses the launch loudly when missing
- ms-* skills completed (owner-authored canon, hands-off): ms-tools
  adapted to the runtime, ms-file-read/write/agent/conductor bodies
  written in the owner's style; ms-agent-watch + ms-unslop untouched
- tasks/USER.md onboarding fixtures; suite hardening (nested def path,
  user seed, mock-adapter dispatch evidence)

Suites: config 24, task 74, release 14, conductor 17, verify PASS.
RELEASE 0.0.12 packaged; health-gated activation on merge.

Closes #40, closes #41, closes #42
2026-09-03 16:23:57 -05:00
jason.woltje e14ad9ab52 Merge: skill lifecycle, skills completion, M16 self-determination hardening, M20 decision
- skill.sh lifecycle (install/activate/deactivate/uninstall/list)
- 8 ms-* skills completed (owner canon preserved)
- seat skills dispatch + enabled-dir resolution
- M16: ensure at launch, drift warnings, recursion guard
- M20 decision: packages/* monorepo at usurpation

Closes #40, closes #41, closes #42
2026-09-03 16:03:05 -05:00
jason.woltje 9fd16b9739 feat(release): recursion guard for the health gate; run-task drift warning; M20 packages/* decision recorded (#39)
- release.sh health gate runs with MOSAIC_ENSURE_SKIP=1: the gated task run
  cannot re-enter release self-determination
- run-task.sh warns on release drift instead of silently using a stale image
- ROADMAP: M20 decision recorded (packages/* monorepo at usurpation,
  continuity-first); restructure sequenced as M20 phase 1

Closes #39
2026-09-03 15:58:46 -05:00
jason.woltje 9051ad179b docs(roadmap): restructure sequencing (M20 phase 1, not first) + skills-as-discipline doctrine 2026-09-03 14:50:59 -05:00
jason.woltje 2f649ed930 docs: README release ensure row 2026-09-03 14:43:25 -05:00
jason.woltje db330c12c7 feat(release): self-determination - ensure at launch, drift warnings, M20 packages/* decision (#38)
- release.sh ensure: aligned no-op; drift -> package-if-needed + health-gated
  activate (M3 gate-then-flip, automated)
- ensure_release_aligned in common.sh: invoked by hello/verify/agent;
  MOSAIC_ENSURE_SKIP guards recursion; run-task warns on drift without
  auto-aligning (workers/suites never trigger builds or model gates)
- ROADMAP: M20 decision recorded - v2 adopts packages/* monorepo at usurpation
- BUILD-LOG Phase 20 + tool-race process note

Live-verified: post-reset pointer loss auto-restored via health-gated
ensure; drift warning fires on desired-version bump; idempotent no-op on
aligned state.

Closes #38
2026-09-03 14:42:09 -05:00
jason.woltje 0a17d29bce docs(roadmap): flush 2026-09-03 14:28:58 -05:00
jason.woltje 3458f6a7ad docs(roadmap): M17 skill lifecycle (skills-enabled/available, role-scoped subsets, --skill negates --no-skills); M20 stack succession path + monorepo question 2026-09-03 14:28:45 -05:00
jason.woltje b033952cd5 docs(harvest): pattern ledger from stack/next + fleet runtime (12 patterns, skips, owner-corrected M17-M19 designs) 2026-09-03 13:55:30 -05:00
jason.woltje c102980ad4 docs(plan): CURRENT.md - queue aligned to ROADMAP (M16 next, CI deferred) 2026-09-03 12:59:28 -05:00
jason.woltje 58b96cb715 docs(plan): ROADMAP.md - M16 release self-determination, M17 ms-tools skill, M18 seat-role restriction, M19 auth tooling; CI deferred per owner 2026-09-03 12:57:26 -05:00
jason.woltje dd3ff944a1 chore(release): 0.0.11 2026-09-03 12:30:52 -05:00
jason.woltje 8eb81ebec1 feat(onboard): user onboarding - no default USER.md, guided creation (#37)
- bootstrap no longer creates user/USER.md (owner direction)
- scripts/onboard.sh: name REQUIRED (interactive loop or --name),
  optional fields prompted (profession, marital, age, gender, education,
  location, timezone, skillset, interests, hobbies, pets); flag-driven
  non-interactive mode for automation
- templates/USER.md: canon skeleton, placeholder rendering, unfilled
  optional = (not provided)
- agent.sh: auto-runs onboarding when profile missing (TTY gate);
  headless run-task warns and continues without user context
- user profile dispatched to all launches (M14 layer)

Closes #37 (onboarding requirements from owner layout review)
2026-09-03 12:30:42 -05:00
jason.woltje 530597cc84 docs(plan): CURRENT.md flush 2026-09-03 11:59:49 -05:00
jason.woltje 9a0d44f96a docs(plan): CURRENT.md - deduplicated completed log (marked correction), M15 review queued 2026-09-03 11:59:37 -05:00
jason.woltje 121b331c6c docs(log): back-fill phases 16-19 (M10, M12, M14, M15) - recorded retroactively with ground-truth sources 2026-09-03 11:59:14 -05:00
jason.woltje 34e06e7de7 Merge M15: agent seats - per-agent SOUL and role contracts
Closes #36
2026-09-03 11:56:38 -05:00
jason.woltje 9bd4f1c405 feat(agents): agent seats - per-agent SOUL, role, definitions dir (#36)
- agents/<name>/ holds agent.json (strictly validated: version, name,
  role?, capabilities?, workspace?, session?) + SOUL.md (persona prose)
- agent.sh: definition loading (quote-safe node defaults file), runtime
  SOUL copy to dataRoot/agents/<name>/, MOSAIC_AGENT_SOUL_FILE ->
  loader fills the SOUL slot from the seat's persona (contract SOUL =
  default persona; governance never overridden)
- seat.json written once at instantiation (seatVersion, name, role, at)
- identity section gains agent role; compose passthrough for role+SOUL
- live user context (M14) + seat SOUL compose the full persona:
  governance -> persona -> identity -> user -> mission
- RELEASE -> 0.0.10; packaged and health-gated activated
- example seat committed: agents/researcher

Closes #36
2026-09-03 11:56:38 -05:00
jason.woltje a7b612435b docs: BUILD-LOG Phase 15, CURRENT.md - M13 shipped 2026-09-03 11:25:41 -05:00
jason.woltje 87f10772ce Merge M13: interactive TUI agent + TOOLS.md
Closes #35
2026-09-03 11:24:56 -05:00
jason.woltje 7db4c5c2ed feat(agent): interactive TUI launcher + identity + TOOLS.md (#35)
- scripts/agent.sh <name>: launches interactive pi TUI in the container
  with contracts + optional mission + agent identity + named session +
  optional workspace/tools; the Mosaic alternative to vanilla pi
- pi adapter: MOSAIC_INTERACTIVE branch (clean TUI, no -p, no initial
  prompt); headless exec rebuilt via positional args (no word-splitting
  on the request); MOSAIC_AGENT_NAME optional in headless
- loader: AGENT IDENTITY section when the launcher names the agent
- compose: fixed command removed (request defaults live in run-agent.sh);
  MOSAIC_INTERACTIVE/MOSAIC_AGENT_NAME passthrough
- docs/TOOLS.md: full on-demand tool reference; AGENTS.md routes to it
- RELEASE -> 0.0.8 (container change); build verified

Closes #35
2026-09-03 11:24:56 -05:00
jason.woltje 0273a84549 docs: AGENTS.md - session recovery shim, invariants canon, session registry
- AGENTS.md at root: pi loads it automatically at every session start
  (conductor-level sessions; workers deliberately exclude it via
  --no-context-files). Deliberately short: invariants, session protocol,
  role model, command surface, data map, pointers - depth stays in docs/.
- docs/SESSIONS.md: append-only session registry, mandatory per session.
- Recovery rule encoded: compaction/restart loses nothing - AGENTS.md +
  CURRENT.md + git log + suites reconstruct state; never guess.
2026-09-03 11:02:19 -05:00
jason.woltje 3b674b7a66 Merge: roles/ directory convention - root is bootstrap-only 2026-09-03 10:56:07 -05:00
jason.woltje 527bc581ca refactor(layout): role contracts move to roles/ - root is bootstrap-only
Owner direction: the repository root holds first-class, bootstrap-required
configuration only. conductor-policy.json is a ROLE contract (the
conductor's authority), one of scores of future role contracts
(agent-policy, coder-policy, ...) - such files get a dedicated home.

- roles/conductor-policy.json (git mv)
- conductor-apply.sh + test-conductor.sh read the new path
- CONDUCTOR.md records the roles/ convention

Closes UX follow-up from owner layout review; no issue (convention change).
2026-09-03 10:56:07 -05:00
jason.woltje 1249714a9a docs(plan): CURRENT.md - M12 shipped 2026-09-03 07:04:37 -05:00
jason.woltje e175616885 feat(conductor): auto-apply policy gate for worker patches (#34)
- conductor-policy.json (tracked, strictly validated): enabled switch,
  path allowlist globs, gating suites - the autonomy decision lives in a
  declarative file the owner controls
- scripts/conductor-apply.sh <runId> [--dry-run]: succeeded-run check ->
  clean target tree -> diff from worker workspace -> allowlist -> syntax
  gates (node/bash/json) -> apply -> policy suites -> attribution commit;
  ANY failure reverts the tree; push is never automatic
- scripts/test-conductor.sh: 17 sandbox cases covering every gate incl.
  suite-failure auto-revert and disabled policy
- policy defaults: scripts/docs/tasks/missions/adapters + README; all
  three suites gate

Closes #34
2026-09-03 07:03:29 -05:00
jason.woltje 6955717612 Merge M11: session forking from a common ancestor
Closes #33
2026-09-03 06:43:02 -05:00
jason.woltje 88d9cf750f feat(sessions): sessionForkFrom - branch conversations from a common ancestor (#33)
- task schema: optional sessionForkFrom (source session name); requires
  session target; self-fork rejected
- runner: resolves source newest .jsonl (fail 4 if none/outside dataRoot);
  passes MOSAIC_SESSION_FORK + MOSAIC_SESSION_DIR; result records lineage
- pi adapter: --fork <source> --session-dir <target> when forking;
  ephemeral default unchanged; plain session resume unchanged
- compose passthrough; RELEASE -> 0.0.7 (adapter changed)
- suite +9 cases (58 total): plumbing via mock stderr, validation
  negatives, live fork - child recalls ancestor code word, ancestor
  session file untouched

Closes #33
2026-09-03 06:43:02 -05:00
jason.woltje c038706eed docs(plan): CURRENT.md - M10 shipped, retention next in review 2026-09-03 06:33:54 -05:00
jason.woltje 8622c9d826 Merge M10: run-record retention
Closes #32
2026-09-03 06:33:27 -05:00
jason.woltje 88eef507b0 feat(retention): run-record pruning - keep newest N, dry-run default (#32)
- mosaic-task.mjs prune [--keep=N] [--yes]: default keep 50; without
  --yes lists candidates without deleting
- only r-* directories under the runs root; symlinks skipped;
  sessions/workspaces/state/config untouched (asserted by suite sentinels)
- append-only receipt runs/.pruned.log records every pruned id
- test-task.sh: +8 retention cases (dry-run no-delete, keep-N, newest
  kept, receipt, isolation, invalid keep, empty no-op)

Also: suite hardening - prune section scopes its config per-command
(no export/unset leaking into later sections); duplicated check()
removed; latest_reason hoisted to helpers; status colors now green OK /
red FAIL (terminal-only, NO_COLOR-aware) per owner UX feedback.

Closes #32
2026-09-03 06:33:27 -05:00
jason.woltje 439bea6915 ui(test): green OK/PASS, red FAIL - terminal-only, NO_COLOR-aware
Owner feedback: grep match-highlighting made the word 'policy' red while
status words were plain - counter-indicative. Suites + verify now emit
ANSI colors (green success, red failure) when stdout is a terminal;
piped/machine-parsed output stays plain, honoring NO_COLOR. Word 'ok'
promoted to 'OK' for scannability.

Verified byte-level via forced-pty run; piped output unchanged; suites
41/24/14 + verify green.
2026-09-03 06:23:47 -05:00
jason.woltje fad8a4718c Merge M9: mission-level capability policy
Closes #30
2026-09-03 06:16:01 -05:00
jason.woltje 2ff49adff4 feat(policy): mission-level capability policy - least-privilege intersection (#30)
- mission schema: optional capabilities.tools (same validation as task)
- merge semantics in runTask: neither -> none; mission only -> mission;
  task only -> task; both -> intersection (task narrows, never widens);
  empty intersection -> tool-free run with an explicit stderr note
- result.json records EFFECTIVE tools; task/mission snapshots remain the
  immutable declaration of intent
- adapters unchanged; host-side only (no image change, 0.0.6 still active)
- task suite +5 cases (41 total): all four merge cases asserted from run
  evidence + invalid mission capabilities rejected

Policy decision recorded: missions govern; tasks cannot escalate.

Closes #30
2026-09-03 06:16:01 -05:00
jason.woltje 44c476ebbf fix(ops): show displays retriedFrom lineage (#29)
result.json recorded lineage correctly; the human-facing show command
omitted the field. Found by owner test: show | grep retriedFrom was
empty on a run whose result.json contained it.

Closes #29
2026-09-03 06:09:51 -05:00
jason.woltje cde480eb60 docs(plan): CURRENT.md — retry lineage shipped, M9 queued for decision 2026-09-03 05:31:24 -05:00
jason.woltje 5808248707 Merge retry lineage + relative mission resolution
Closes #28
2026-09-03 05:30:57 -05:00
jason.woltje afd5827db8 fix(retry): lineage tracking + relative mission path resolution (#28)
- retryRun rewrites a snapshot's relative mission path to the run's own
  recorded mission.json (absolute) before execution — retries stay
  faithful to what originally ran
- runTask accepts options.retriedFrom; retry records lineage in
  result.json (additive optional field, no schema break)
- task suite +4 cases: retry succeeds, lineage recorded, mission section
  present after retry (36 total), missing-run retry exits 4

Closes #28
2026-09-03 05:30:57 -05:00
jason.woltje d9cc990376 Merge M8: conductor loop - self-orchestration
Closes #25, closes #26, closes #27
2026-09-02 22:42:52 -05:00
jason.woltje 83c4e9851e feat(orchestration): retry <runId> — authored by headless pi worker (#26, #27)
Collaboration record (conductor loop, docs/plans/CONDUCTOR.md):
- round 1 (worker session worker-1, 2m28s): retry implemented per spec
- conductor live test exposed spec gap: direct invocation lacked
  launcher env exports
- round 2 (same worker session, 59s): spawnEnv made self-sufficient,
  but used PI_* where compose interpolates MOSAIC_*
- conductor hotfix: 3-line rename to MOSAIC_PROVIDER/MOSAIC_MODEL/
  MOSAIC_DATA_ROOT

Final: node scripts/mosaic-task.mjs retry <runId> re-executes a run's
task snapshot as a new run; live retry replied REMEMBERED; all suites
green (24/32/14 + verify).

Known limitation: retrying a run whose task used a RELATIVE mission path
resolves it against the temp dir; lineage tracking deferred.

Closes #25, closes #26, closes #27
2026-09-02 22:42:52 -05:00
jason.woltje 22508170a2 docs(plan): CURRENT.md — single next-action pointer for cadence-driven work 2026-09-02 22:25:56 -05:00
jason.woltje 90a67d050e Merge M7: operator ergonomics + release 0.0.6
Closes #24
2026-09-02 22:14:18 -05:00
jason.woltje 24bdef75fa feat(ops): run inspection, release 0.0.6, docs (#24)
- mosaic-task.mjs show <runId>: full record + snapshots + artifacts;
  uppercase-tolerant id validation; missing/traversal ids exit 4
- list: task/workspace/session columns
- RELEASE -> 0.0.6; README workspaces/capabilities/sessions sections;
  BUILD-LOG Phases 9-11; autonomous-run tracker results filled

Closes #24
2026-09-02 22:14:18 -05:00
jason.woltje 4e2a413640 Merge M6: named sessions - persistence and resume
Closes #22, closes #23
2026-09-02 22:08:34 -05:00
jason.woltje be55549700 feat(sessions): named persistent sessions with resume (L1) (#22, #23)
- task schema: optional session (named id) -> persistent session dir at
  dataRoot/sessions/<name>, isolated per name
- pi adapter: --session-dir when declared (ephemeral --no-session stays
  the default otherwise); -c resumes the most recent session when present
- compose passthrough; result.json records session
- fixtures: tasks/session-demo-1.json (teach) + session-demo-2.json (recall)
- E2E: teach -> REMEMBERED + host-side session JSONL; resume -> recalled
  'mosaico' exactly; single continued session file

Closes #22, closes #23
2026-09-02 22:08:34 -05:00
jason.woltje ddb1554e5b Merge M5: task workspaces + capability envelope
Closes #20, closes #21
2026-09-02 22:06:43 -05:00
jason.woltje b017e66e17 test(capabilities): workspace/tooling selftests + live demo fixture (#21)
- mock plumbing cases: workspace path + tools delivered (asserted from
  run-record stderr), host workspace created, absent fields = empty vars
- validation negatives: unknown tool, workspace traversal
- tasks/workspace-demo.json: pi uses bash inside the persistent demo
  workspace; host-visible proof.txt verified live

Closes #21
2026-09-02 22:06:43 -05:00
jason.woltje 172368612c feat(capabilities): task workspaces + tools allowlist plumbing (#20)
- task schema: optional workspace (absent | :run ephemeral | named
  persistent under dataRoot/workspaces) and capabilities.tools (pi
  documented tool allowlist); strict validation, traversal-proof names
- runner: creates host workspace, passes MOSAIC_WORKSPACE (container
  path) + MOSAIC_TOOLS; result.json records both
- pi adapter: cds into workspace; --tools when allowlist present else
  --no-tools
- mock adapter: logs delivered MOSAIC_* vars to stderr as deterministic
  plumbing evidence (dash prints 'export K=v', so use env not export)

Closes #20
2026-09-02 22:03:46 -05:00
jason.woltje 1387231e57 docs(plan): autonomous work run tracker (M5-M7 scope, test plan, review checklist) 2026-09-02 21:57:30 -05:00
jason.woltje 0292392e64 Merge M4: runtime adapter seam
Closes #16, closes #17, closes #18, closes #19
2026-09-02 21:30:35 -05:00
jason.woltje 594b8d711c docs(adapters): adapter seam docs + recorded M4 E2E, release 0.0.5 (#19)
- README: Runtime adapters section (contract summary, selection, mission
  injection point); BUILD-LOG Phase 8 entries
- E2E: 24+24+14 selftests green; verify PASS; 0.0.5 packaged and
  health-gated activated; mission-bearing fixture task succeeded through
  the real pi adapter; config checksum unchanged

Closes #19
2026-09-02 21:30:35 -05:00
jason.woltje 3c1ffd2c2d test(adapters): seam selftests — deterministic mock cases + mission injection (#18)
- test-config: absent adapter defaults to pi; mock validates; unknown
  adapter exits 2; env exports MOSAIC_ADAPTER (24 cases total)
- test-task: mock adapter gate pass/mismatch (no provider needed),
  mismatch reason asserted, unknown adapter fails closed, mission section
  injected into generated prompt asserted by content (24 cases total)
- harness fixes: helpers defined before use; per-case config files (no
  cross-case leakage); newest-run selection for the live case; deduped
  accidentally duplicated live block

Closes #18
2026-09-02 21:28:57 -05:00
jason.woltje 4ebb123ba3 feat(adapters): sanctioned mission directives injection (#17)
- load-contracts.sh: MOSAIC_MISSION_FILE (readable) appends a MISSION
  (runtime) section — objective + directives — after the immutable
  contracts; unreadable path is a hard error, absent env changes nothing
- mosaic-task.mjs: exports MOSAIC_MISSION_FILE as the run snapshot's
  container path (/var/lib/mosaic/runs/<id>/mission.json), with an
  outside-dataRoot guard; also exports the configured adapter

Verified: contract-only prompt has no mission section; mission-bearing
run shows objective + directives in the generated prompt, snapshot
recorded, real provider returns exactly MOSAIC_HELLO_OK.

Closes #17
2026-09-02 21:19:22 -05:00
jason.woltje bb5cecb348 feat(adapters): adapter contract, dispatch, pi + mock adapters (#16)
- adapters/README.md: the harness boundary contract (env in, response on
  stdout, diagnostics stderr, exit 0 success)
- adapters/pi: extracted current invocation unchanged
- adapters/mock: deterministic MOSAIC_MOCK_RESPONSE echo (test-only)
- run-agent.sh: name-validated dispatch to adapters/<name>/adapter.sh
- config: optional execution.adapter (pi|mock), default pi, configVersion
  stays 1 — existing configs remain valid; selection authority is the
  config file (load_config exports it)
- compose: MOSAIC_ADAPTER / MOSAIC_MOCK_RESPONSE passthrough; Containerfile
  installs adapters read-only; RELEASE -> 0.0.5

Verified: hello unchanged; mock verbatim via config; unknown adapter and
path-traversal names refused in-container; invalid adapter exits 2.

Closes #16
2026-09-02 21:18:07 -05:00
jason.woltje 88f55d9135 test(task): live failures self-report evidence; wrong-exit no longer masks (#15)
- live hello failure dumps latest run result.json + stderr tail before
  sandbox cleanup destroys them
- wrong-expectExact case asserts reason == expect-mismatch (was: any
  exit 1, which masked compose-level failures)
- repair dangling if/else from the docker-guard refactor

Closes #15
2026-09-02 20:56:58 -05:00
jason.woltje e7e1bd26eb fix(launcher): resolve release identity for direct task runs (#14)
M3 made MOSAIC_IMAGE_TAG required in compose, but run-task.sh never
called load_release — direct task runs failed in compose before any
model call. release.sh paths masked it by exporting the tag to children.

Found by owner-run test-task.sh; failure receipts were in the run
records' stderr.txt.

Closes #14
2026-09-02 20:44:31 -05:00
jason.woltje 5d86b8fa93 Merge M3: release model and safe updates
Closes #10, closes #11, closes #12, closes #13
2026-09-02 20:24:33 -05:00
jason.woltje 35ea464661 docs(release): release model usage + recorded M3 drills (#13)
- README: Release model section (package/activate/rollback/status,
  pointer + append-only log, gate-then-flip guarantee)
- BUILD-LOG Phase 7: drills recorded (update, refusal, rollback),
  two harness/product corrections documented

Drill evidence: 0.0.3 -> 0.0.4 update with unchanged config checksum and
green verify; fault-injected refusal left pointer untouched; health-gated
rollback restored 0.0.3; full append-only event history.

Closes #13
2026-09-02 20:24:33 -05:00
jason.woltje e87ecdb3e5 test(release): release-layer selftests (#12)
14 cases: RELEASE validation (valid/invalid/missing), tag consistency,
status on empty state, fault-injected refusal with no pointer + single
valid refusal log line, healthy activation, pointer fields, repeat
activation append-only log, rollback-without-previous refusal.

Harness fix learned the hard way: restore RELEASE from backup inline
after the missing-file case (mv-back restored the mutated file); single
exit trap self-heals the repo state.

Closes #12
2026-09-02 20:23:18 -05:00
jason.woltje a947db7bfd feat(release): package/activate/rollback/status with health gate (#11)
- activate: image-presence pre-check + M2 task-runner health gate
  (tasks/hello-marker.json exact marker) before atomic pointer replace
  (tmp+rename); every attempt appended to activation-log.jsonl
- --fault-injection flips the health expectation to prove the refusal path
- rollback: health-gated re-activation of the previous activated imageTag
  from the log; refuses when the image is gone or no previous exists
- status: release, tag, pointer, recent log; safe on empty state
- state lives under <dataRoot>/state/ (config-independent, reset-scoped)

Verified: activate OK; fault-injected refuse with pointer unchanged;
rollback-without-previous refuse.

Closes #11
2026-09-02 20:20:32 -05:00
jason.woltje a35ea62ab1 feat(release): RELEASE identity + image tag single-sourcing (#10)
- RELEASE file: single source of release version (0.0.X until declared stable)
- common.sh load_release(): validates version, derives
  MOSAIC_IMAGE_TAG=mosaic-poc-agent:<pi>-r<release> from the pinned pi dep
- compose.yaml: image tag is required env; build/hello/verify call load_release
- verify.sh derives the image name instead of hardcoding it
- package.json version aligned to the same 0.0.X line

Closes #10
2026-09-02 20:18:17 -05:00
jason.woltje f6c93dcb5c Merge M2: mission and task abstraction
Closes #6, closes #7, closes #8, closes #9
2026-09-02 19:58:53 -05:00
jason.woltje 7f0408d417 feat(task): fixture mission/task, docs, recorded M2 E2E (#9)
- missions/hello.json + tasks/hello-marker.json committed fixtures
- README: Missions & tasks section (usage, run record layout, M2 scope note)
- BUILD-LOG: Phase 6 before/after entries

E2E: fixture run succeeded with exactly MOSAIC_HELLO_OK; wrong expectExact
recorded status failed (expect-mismatch) and exited 1; runs listed; config
checksum unchanged.

Closes #9
2026-09-02 19:58:53 -05:00
jason.woltje cfd2a19bd7 test(task): mission/task selftests — schema negatives + live runs (#8)
18 cases: validation negatives (unknown keys, versions, ids, prompt,
expectExact NUL, timeout range, missing/invalid mission, writes-nothing)
plus live cases: exact-marker success, wrong expectExact fails, distinct
run dirs, result.json contents, list output.

Closes #8
2026-09-02 19:57:34 -05:00
jason.woltje d2a9e26395 feat(task): mission/task schemas, validation, and run runner (#6) (#7)
- scripts/mosaic-task.mjs: validate | run | list
- Strict v1 schemas: unknown keys rejected; ids/prompt/expectExact/
  timeoutSeconds bounds enforced; optional mission file resolved against
  the task file and validated too
- run: executes through the config-driven container path with stdin
  detached (issue #5 class), SIGKILL timeout (default 120s), trimmed
  response capture
- Immutable run records under <dataRoot>/runs/r-<utcstamp>-<rand>/:
  task.json + mission.json snapshots (write-once), stderr.txt, result.json
- expectExact gate: mismatch -> status failed, exit 1; result.json is
  always written
- scripts/run-task.sh: load_config + bootstrap_runtime_dir before exec
- M2 scope: mission directives are snapshotted for provenance, not yet
  injected into the runtime prompt (later policy layer)

Closes #6, closes #7
2026-09-02 19:56:29 -05:00
jason.woltje 0734b1f3a5 fix(launcher): detach stdin on agent container run (#5)
pi print mode reads piped stdin until EOF; an attached terminal stdin
blocked the one-shot run forever. Automated contexts (closed stdin)
never exposed it. Request text comes from the compose command.

Proven: tail -f /dev/null | scripts/hello.sh now returns MOSAIC_HELLO_OK
in ~4s (previously timed out at 30s); verify.sh remains green.

Closes #5
2026-09-02 19:50:07 -05:00
jason.woltje ce6420f3de Merge M1: configuration-driven Hello World
Closes #1, closes #2, closes #3, closes #4
2026-09-02 18:32:50 -05:00
jason.woltje 81f58b15c8 fix(launcher): ensure configured data root before verify mount; docs for M1 (#4)
- verify.sh now calls bootstrap_runtime_dir after load_config; previously a
  reset-then-verify flow let Docker auto-create a root-owned mount source
- common.sh: fail with clear guidance when data root exists but is not writable
- README: configuration section, bootstrap usage, selftest entry point
- BUILD-LOG: Phase 5 entries with corrections

E2E (clean slate): 20/20 selftests; bootstrap idempotent; config-driven
hello/verify MOSAIC_HELLO_OK; negative marker exit 1; reset + rerun green;
config checksum unchanged across the entire flow.

Closes #4
2026-09-02 18:32:50 -05:00
jason.woltje 0c2113f710 test(config): sandboxed config-layer selftests (#3)
20 cases: bootstrap create/idempotency, missing config, malformed JSON,
unknown keys/version/backend/environment, relative and non-canonical
dataRoot, filesystem root, home dir, ancestor-of-config, control chars,
symlinked config file, env export resolution, validation-writes-nothing.

Closes #3
2026-09-02 18:30:46 -05:00
jason.woltje 900a506c1f feat(config): wire launcher scripts and compose to config.json (#2)
- common.sh: load_config() exports MOSAIC_DATA_ROOT/PROVIDER/MODEL; fails closed
- compose.yaml: dataRoot mount and provider/model are required env (:? errors)
- build/hello/verify load config before any mutation; no silent bootstrap
- reset.sh: target resolved from configured dataRoot; all safety checks kept

Verified: compose fails without launcher env; verify/reset fail on missing
config; config-driven hello+verify pass; symlink refusal with sandboxed
config (canary survived); config checksum unchanged across reset+rerun.

Closes #2
2026-09-02 18:30:08 -05:00
jason.woltje c3d29e796a feat(config): config module with idempotent bootstrap and strict v1 validation (#1)
- scripts/mosaic-config.mjs: bootstrap | validate | env operations
- Exclusive creation (O_EXCL 'wx'); existing config validated, never rewritten
- Strict schema: unknown keys rejected, configVersion===1, backend docker only
- dataRoot guards: absolute, canonical, not root/home/ancestor-of-config
- MOSAIC_CONFIG override for sandboxed tests; exit codes 0/2/3
- scripts/bootstrap.sh: explicit bootstrap entry point

Closes #1
2026-09-02 18:28:14 -05:00
jason.woltje c2365ae519 chore: baseline container POC and atomic foundation plan
- Containerized Pi hello-world proof (image mosaic-poc-agent:0.84.4, non-root)
- Four immutable contract fixtures loaded into a generated system prompt
- build/hello/verify/reset scripts with exact-match gating and reset safety
- Documented Pi discovery (v0.84.4, -p mode, --system-prompt, container auth)
- Append-only BUILD-LOG with corrections; deferred layers in LAYERS.md
- Architecture plan: docs/plans/2026-09-02_atomic-mosaic-foundation.md
2026-09-02 18:24:36 -05:00
2018 changed files with 9842 additions and 381340 deletions
+20 -149
View File
@@ -1,153 +1,24 @@
# ─────────────────────────────────────────────────────────────────────────────
# Mosaic — Environment Variables Reference
# Copy this file to .env and fill in the values for your deployment.
# Lines beginning with # are comments; optional vars are commented out.
# ─────────────────────────────────────────────────────────────────────────────
# Non-secret runtime settings for the mosaic-poc-agent container.
# Copy to .env if you want to override the defaults in compose.yaml.
#
# NEVER put credentials in this file. Authentication is supplied at
# runtime only, via one of the two documented paths:
# 1. read-only mounted pi auth file (default: ~/.pi/agent/auth.json,
# override the host path with PI_AUTH_FILE)
# 2. provider API key environment variable (ZAI_API_KEY or
# ANTHROPIC_API_KEY), passed through by compose.yaml when set
# Model provider (built-in pi provider name)
PI_PROVIDER=zai
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
# Full connection string used by the gateway, ORM, and migration runner.
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
# Model ID within the provider
PI_MODEL=glm-5.3-flash
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
# PG_HOST_PORT=5433
# Optional: alternative host path of the pi credential file mounted
# read-only at /home/node/.pi/agent/auth.json in the container
#PI_AUTH_FILE=/home/jwoltje/.pi/agent/auth.json
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
VALKEY_URL=redis://localhost:6380
# Docker Compose host-port override for the Valkey container (default: 6380)
# VALKEY_HOST_PORT=6380
# ─── Gateway ─────────────────────────────────────────────────────────────────
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
GATEWAY_PORT=14242
# Comma-separated list of allowed CORS origins.
# Must include the web app origin in production.
GATEWAY_CORS_ORIGIN=http://localhost:3000
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
# REQUIRED — random secret used to sign sessions and tokens.
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
# Public base URL of the gateway (used by BetterAuth for callback URLs)
BETTER_AUTH_URL=http://localhost:14242
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
# Public gateway URL — accessible from the browser, not just the server.
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Service name shown in traces
OTEL_SERVICE_NAME=mosaic-gateway
# ─── AI Providers ────────────────────────────────────────────────────────────
# Ollama (local models — set OLLAMA_BASE_URL to enable)
# OLLAMA_BASE_URL=http://localhost:11434
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
# OLLAMA_HOST=http://localhost:11434
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
# OLLAMA_MODELS=llama3.2,codellama,mistral
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
# ANTHROPIC_API_KEY=sk-ant-...
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
# OPENAI_API_KEY=sk-...
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
# ZAI_API_KEY=...
# Custom providers — JSON array of provider configs
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
# MOSAIC_CUSTOM_PROVIDERS=
# ─── Embedding Service ───────────────────────────────────────────────────────
# OpenAI-compatible embeddings endpoint (default: OpenAI)
# EMBEDDING_API_URL=https://api.openai.com/v1
# EMBEDDING_MODEL=text-embedding-3-small
# ─── Log Summarization Service ───────────────────────────────────────────────
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
# SUMMARIZATION_API_URL=https://api.openai.com/v1
# SUMMARIZATION_MODEL=gpt-4o-mini
# Cron schedule for summarization job (default: every 6 hours)
# SUMMARIZATION_CRON=0 */6 * * *
# Cron schedule for log tier management (default: daily at 03:00)
# TIER_MANAGEMENT_CRON=0 3 * * *
# ─── Agent ───────────────────────────────────────────────────────────────────
# Filesystem sandbox root for agent file tools (default: process.cwd())
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
# Comma-separated list of tool names available to non-admin users.
# Leave unset to allow all tools for all authenticated users.
# AGENT_USER_TOOLS=read_file,list_directory,search_files
# System prompt injected into every agent session (optional)
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
# ─── MCP Servers ─────────────────────────────────────────────────────────────
# JSON array of MCP server configs — set to enable MCP tool integration.
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
# ─── Coordinator ─────────────────────────────────────────────────────────────
# Root directory used to scope coordinator (worktree/repo) operations.
# Defaults to the monorepo root auto-detected from process.cwd().
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
# DISCORD_BOT_TOKEN=
# DISCORD_GUILD_ID=
# DISCORD_GATEWAY_URL=http://localhost:14242
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_GATEWAY_URL=http://localhost:14242
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
# AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET=
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
# WORKOS_ISSUER=https://your-company.authkit.app
# WORKOS_CLIENT_ID=client_...
# WORKOS_CLIENT_SECRET=sk_live_...
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
# Legacy alternative if you prefer to compose the issuer from separate vars:
# KEYCLOAK_URL=https://auth.example.com
# KEYCLOAK_REALM=master
# KEYCLOAK_CLIENT_ID=mosaic
# KEYCLOAK_CLIENT_SECRET=
# The web login page discovers configured providers dynamically from
# GET /api/sso/providers. No NEXT_PUBLIC_* provider feature flag is required.
# Optional: documented env-var auth alternative (secret! set in your
# shell or a gitignored .env, never commit)
#ZAI_API_KEY=
#ANTHROPIC_API_KEY=
+6 -23
View File
@@ -1,25 +1,8 @@
logs/
node_modules
dist
.turbo
.next
coverage
# build/deps
node_modules/
# runtime credentials — never commit, never copy into the image
.env
.env.local
*.tsbuildinfo
.pnpm-store
__pycache__/
docs/.obsidian
secrets/
# Step-CA dev password — real file is gitignored; commit only the .example
infra/step-ca/dev-password
# Scratch dirs created by the framework git-wrapper shell test harnesses
.mosaic-test-work/
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
# loading it, then unlink. They are untracked but were not ignored, so turbo's
# package traversal hashed them and intermittently failed CI with "Package
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
# file vanished mid-scan. Ignoring them removes the race.
*.timestamp-*.mjs
# generated runtime state lives in /home/jwoltje/.mosaic-dev (outside this project)
-1
View File
@@ -1 +0,0 @@
npx lint-staged
-1
View File
@@ -1 +0,0 @@
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
-9
View File
@@ -1,9 +0,0 @@
{
"**/*.{ts,tsx,js,jsx}": [
"prettier --write",
"eslint --fix"
],
"**/*.{json,md,yaml,yml}": [
"prettier --write"
]
}
-78
View File
@@ -1,78 +0,0 @@
# Repo Mosaic Linkage
This repository is attached to the machine-wide Mosaic framework.
## Load Order for Agents
1. `~/.config/mosaic/STANDARDS.md`
2. `AGENTS.md` (this repository)
3. `.mosaic/repo-hooks.sh` (repo-specific automation hooks)
## Purpose
- Keep universal standards in `~/.config/mosaic`
- Keep repo-specific behavior in this repo
- Avoid copying large runtime configs into each project
## Optional Quality Rails
Use `.mosaic/quality-rails.yml` to track whether quality rails are enabled for this repo.
Apply a template:
```bash
~/.config/mosaic/bin/mosaic-quality-apply --template <template> --target .
```
Verify enforcement:
```bash
~/.config/mosaic/bin/mosaic-quality-verify --target .
```
## Optional Matrix Orchestrator Rail
Repo-local orchestrator state lives in `.mosaic/orchestrator/`.
Run one cycle:
```bash
~/.config/mosaic/bin/mosaic-orchestrator-matrix-cycle
~/.config/mosaic/bin/mosaic-orchestrator-run --once
```
Run continuously:
```bash
~/.config/mosaic/bin/mosaic-orchestrator-run --poll-sec 10
```
Bridge events to Matrix:
```bash
~/.config/mosaic/bin/mosaic-orchestrator-matrix-publish
~/.config/mosaic/bin/mosaic-orchestrator-matrix-consume
```
Run until queue is drained (syncs from `docs/tasks.md` first):
```bash
~/.config/mosaic/bin/mosaic-orchestrator-drain
```
Set worker command if auto-detect does not match your CLI:
```bash
export MOSAIC_WORKER_EXEC="codex -p"
# or
export MOSAIC_WORKER_EXEC="opencode -p"
```
Use repo helper (foreground or detached):
```bash
bash scripts/agent/orchestrator-daemon.sh drain
bash scripts/agent/orchestrator-daemon.sh start
bash scripts/agent/orchestrator-daemon.sh status
bash scripts/agent/orchestrator-daemon.sh stop
```
-18
View File
@@ -1,18 +0,0 @@
{
"enabled": false,
"transport": "matrix",
"matrix": {
"control_room_id": "",
"workspace_id": "",
"homeserver_url": "",
"access_token": "",
"bot_user_id": ""
},
"worker": {
"runtime": "codex",
"command_template": "bash scripts/agent/orchestrator-worker.sh {task_file}",
"timeout_seconds": 7200,
"max_attempts": 1
},
"quality_gates": ["pnpm lint", "pnpm typecheck", "pnpm test"]
}
-1
View File
@@ -1 +0,0 @@
-4
View File
@@ -1,4 +0,0 @@
{
"last_published_line": 0,
"since": null
}
-14
View File
@@ -1,14 +0,0 @@
{
"schema_version": 1,
"mission_id": "mvp-20260312",
"name": "MVP",
"description": "",
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
"created_at": "2026-03-13T00:44:02Z",
"status": "active",
"task_prefix": "",
"quality_gates": "",
"milestone_version": "0.0.1",
"milestones": [],
"sessions": []
}
-19
View File
@@ -1,19 +0,0 @@
{
"generated_at": "2026-03-13T00:44:51Z",
"runtime": "claude",
"mission_id": "mvp-20260312",
"mission_name": "MVP",
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
"quality_gates": "",
"current_milestone": {
"id": "",
"name": ""
},
"next_task": "",
"progress": {
"tasks_done": 0,
"tasks_total": 0,
"pct": 0
},
"current_branch": ""
}
-1
View File
@@ -1 +0,0 @@
-8
View File
@@ -1,8 +0,0 @@
{
"session_id": "claude-20260312-194506-357136",
"runtime": "claude",
"pid": 357136,
"started_at": "2026-03-13T00:45:06Z",
"project_path": "/home/jwoltje/src/mosaic-mono-v1",
"milestone_id": ""
}
-4
View File
@@ -1,4 +0,0 @@
{
"running_task_id": null,
"updated_at": null
}
-3
View File
@@ -1,3 +0,0 @@
{
"tasks": []
}
-10
View File
@@ -1,10 +0,0 @@
enabled: false
template: ""
# Set enabled: true and choose one template:
# - typescript-node
# - typescript-nextjs
# - monorepo
#
# Apply manually:
# ~/.mosaic/bin/mosaic-quality-apply --template <template> --target <repo>
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
# Optional repo-specific hooks used by scripts/agent/*.sh
# Called by session-start.sh
# mosaic_hook_session_start() {
# echo "Run repo-specific startup checks"
# }
# Called by critical.sh
# mosaic_hook_critical() {
# echo "Run repo-specific critical queries"
# }
# Called by session-end.sh
# mosaic_hook_session_end() {
# echo "Run repo-specific end-of-session checks"
# }
-5
View File
@@ -1,5 +0,0 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
# Non-root checkouts use their own HOME. Override without editing this file via
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
store-dir=${HOME}/.local/share/pnpm/store
-18
View File
@@ -1,18 +0,0 @@
pnpm-lock.yaml
**/next-env.d.ts
**/dist
**/node_modules
**/drizzle
**/.next
# Python build/test artifacts — same category as node_modules/dist/.next above.
# Prettier must never scan generated trees; without these a local venv poisons
# `pnpm format:check` with thousands of third-party files.
**/venv
**/__pycache__
**/.mypy_cache
**/.pytest_cache
**/htmlcov
.claude/
docs/tess/TASKS.md
docs/scratchpads/
packages/mosaic/src/fleet/testdata/documentation-publication-v1/inline-migration-v1.json
-6
View File
@@ -1,6 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"printWidth": 100
}
-40
View File
@@ -1,40 +0,0 @@
# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea
# registry CI already publishes to. Reuses the exact kaniko + auth pattern
# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker
# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest`
# for their install step.
#
# Rebuild ONLY when the dependency set or the image recipe changes — a normal
# code push must not trigger a 25-min image build. `path` applies to push/PR
# events; `event: tag` (releases) rebuilds unconditionally so a tagged release
# always ships a fresh base.
when:
- event: tag
- event: [push, manual]
branch: main
path:
include:
- 'pnpm-lock.yaml'
- 'Dockerfile.ci'
steps:
build-ci-base:
image: gcr.io/kaniko-project/executor:debug
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
# Lockfile-hash tag: an immutable identity for the exact dep set baked
# into this image. `:latest` is the mutable pointer pipelines consume.
LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12)
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH"
/kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS
-151
View File
@@ -1,151 +0,0 @@
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
# store. The install step resolves from the baked store (--prefer-offline)
# instead of paying a ~731s cold fetch + native compile every run.
variables:
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
when:
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
# push CI is restricted to protected branches (main) so a feature-branch push no
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
# CI load on the storage-constrained runner with zero loss of gating (branch
# protection requires no push/ci status context; main still gets full push CI).
- event: [pull_request, manual]
- event: push
branch: main
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
# This avoids from_secret which is blocked on pull_request events.
# If the env vars aren't set, turbo falls back to local cache only.
steps:
install:
image: *node_image
commands:
- corepack enable
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
# the baked pnpm store.
- pnpm install --frozen-lockfile --prefer-offline
# Blocking gate: public framework package must contain no operator-specific
# personal data or private $HOME defaults. Runs early (no node_modules needed).
sanitization:
image: *node_image
commands:
- apk add --no-cache bash
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
# Resident line-count ceiling over framework-owned resident files
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
# Test-membership guard (#1017): also first link of test:framework-shell.
# Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link
# [0] of the pnpm chain, so severing that chain would silence it together
# with everything it guards; this direct line keeps one instrument running.
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
# keep mode is a single cp-based path that must not depend on rsync), and that a
# corrupt/empty/missing manifest aborts fail-closed leaving operator files
# untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
# from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
# the retained, operator-scoped pre-update backup is taken before any mutation
# (0700/0600, secret never logged, retention-pruned) and that the post-sync
# verify net restores any operator file a manifest bug lets the sync touch. The
# migration matrix pins the v2→v3 contract-file semantics. Pure bash, no
# node_modules — runs early alongside sanitization.
upgrade-guard:
image: *node_image
commands:
- apk add --no-cache bash rsync
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
typecheck:
image: *node_image
commands:
- *enable_pnpm
- pnpm typecheck
depends_on:
- install
- sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck
lint:
image: *node_image
commands:
- *enable_pnpm
- pnpm lint
depends_on:
- typecheck
format:
image: *node_image
commands:
- *enable_pnpm
- pnpm format:check
depends_on:
- typecheck
test:
image: *node_image
environment:
# Avoid the namespace-level Woodpecker DB service named "postgres".
# The Kubernetes backend exposes service containers by step name.
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
commands:
- *enable_pnpm
# openssl (#912) is the wake HMAC signer: the digest H1/H2, beacon B12,
# and install I8 legs hard-require it in CI. It is baked into ci-base via
# Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this
# `apk add` guarantees openssl is present on PR pipelines too (and is a
# fast no-op once the rebuilt image already ships it).
- apk add --no-cache openssl
# Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an
# installed `pi` binary at exactly this measured version — the test
# boots Pi's real tool registry to prove the read-only carve-out
# resolves to real, unshadowed builtins, and fails loud (by design)
# when the runtime is absent or drifts. The canonical Pi is
# @earendil-works/[email protected] exactly (@mariozechner/* is
# embedded-legacy). Step-level install because ci-base image publishes
# are currently blocked on registry auth; fold into Dockerfile.ci once
# that is fixed, keeping this as a fast no-op guard.
- npm install -g @earendil-works/[email protected]
# postgresql-client (pg_isready) is baked into ci-base.
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- |
ready=0
for i in $(seq 1 60); do
if pg_isready -h ci-postgres -p 5432 -U mosaic; then
ready=1
break
fi
echo "Waiting for ci-postgres ($i/60)..."
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "ci-postgres did not become ready" >&2
exit 1
fi
# Run migrations (DATABASE_URL is set in environment above)
- pnpm --filter @mosaicstack/db run db:migrate
# Run all tests
- pnpm test
depends_on:
- typecheck
services:
ci-postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_USER: mosaic
POSTGRES_PASSWORD: mosaic
POSTGRES_DB: mosaic
-296
View File
@@ -1,296 +0,0 @@
# Build, publish npm packages, and push Docker images
# Runs on main for stable publishes and on next for integration-line prereleases/images
variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
# toolchain + warm pnpm store. Kills the second cold install publish pays.
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
# (tags) always build everything. Exclude-list keeps the default SAFE: any
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
- &image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main, next]
event: [push, manual, tag]
steps:
install:
image: *node_image
commands:
- corepack enable
# Resolve from the baked pnpm store instead of a cold network fetch.
- pnpm install --frozen-lockfile --prefer-offline
build:
image: *node_image
commands:
- *enable_pnpm
- pnpm build
depends_on:
- install
publish-npm:
image: *node_image
# Publish only when a publishable package changed (or on a release tag); a
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
when:
- event: tag
- event: [push, manual]
branch: main
path:
include:
- 'packages/**'
environment:
NPM_TOKEN:
from_secret: gitea_token
commands:
- *enable_pnpm
# Configure auth for Gitea npm registry
- |
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
# Publish non-private packages to Gitea.
#
# The only publish failure we tolerate is "version already exists" —
# that legitimately happens when only some packages were bumped in
# the merge. Any other failure (registry 404, auth error, network
# error) MUST fail the pipeline loudly: the previous
# `|| echo "... continuing"` fallback silently hid a 404 from the
# Gitea org rename and caused every @mosaicstack/* publish to fall
# on the floor while CI still reported green.
- |
# Portable sh (Alpine ash) — avoid bashisms like PIPESTATUS.
set +e
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public >/tmp/publish.log 2>&1
EXIT=$?
set -e
cat /tmp/publish.log
if [ "$EXIT" -eq 0 ]; then
echo "[publish] all packages published successfully"
exit 0
fi
# Hard registry / auth / network errors → fatal. Match npm's own
# error lines specifically to avoid false positives on arbitrary
# log text that happens to contain "E404" etc.
if grep -qE "npm (error|ERR!) code (E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND)" /tmp/publish.log; then
echo "[publish] FATAL: registry/auth/network error detected — failing pipeline" >&2
exit 1
fi
# Only tolerate the explicit "version already published" case.
# npm returns this as E403 with body "You cannot publish over..."
# or EPUBLISHCONFLICT depending on version.
if grep -qE "EPUBLISHCONFLICT|You cannot publish over|previously published" /tmp/publish.log; then
echo "[publish] some packages already at this version — continuing (non-fatal)"
exit 0
fi
echo "[publish] FATAL: publish failed with unrecognized error — failing pipeline" >&2
exit 1
depends_on:
- build
publish-next-npm:
image: *node_image
# Durable @next integration-line publish. Runs only on next; never writes
# the latest dist-tag and never commits the computed prerelease versions.
when:
- event: [push, manual]
branch: next
environment:
NPM_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
commands:
- *enable_pnpm
- |
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
exit 1
fi
if [ -z "$CI_PIPELINE_NUMBER" ]; then
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
exit 1
fi
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins'];
const updated = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
}
}
}
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
}
const [, major, minor, patch] = stableMatch;
const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
}
for (const root of roots) walk(root);
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line);
NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
exit 1
fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
# image: *node_image
# environment:
# NPM_TOKEN:
# from_secret: npmjs_token
# commands:
# - *enable_pnpm
# - apk add --no-cache jq bash
# - bash scripts/publish-npmjs.sh
# depends_on:
# - build
# when:
# - event: [tag]
build-gateway:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next gateway publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
depends_on:
- build
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
depends_on:
- build
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
depends_on:
- build
+95 -99
View File
@@ -1,119 +1,115 @@
# Agent Guidelines — Mosaic Stack
# AGENTS.md — Mosaic Stack rebuild (`mosaicstack/stack-v2`)
## Required Load Order
Operational context for any agent session working in this repository.
Read top to bottom; it is deliberately short — depth lives in the files it
points to, not here.
1. `~/.config/mosaic/SOUL.md`
2. `~/.config/mosaic/STANDARDS.md`
3. `~/.config/mosaic/AGENTS.md`
4. `~/.config/mosaic/guides/E2E-DELIVERY.md`
5. `AGENTS.md` (this file)
6. Runtime-specific guide: `~/.config/mosaic/runtime/<runtime>/RUNTIME.md`
## What this repository is
## Project Context
A standalone rebuild of Mosaic Stack: a file-based, fail-closed
orchestration foundation that dispatches sandboxed headless pi workers to do
real work, with immutable run records as evidence. Thirteen-plus tagged
milestones (`git tag -l`) from `poc-container-hello-v0` to today; suites
green at every step. Not production software — a proven foundation.
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.
## Non-negotiable invariants (the canon)
### Stack
1. **Root is bootstrap-only.** First-class system configuration lives at the
repository root; everything else gets a dedicated directory (`roles/`,
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
config — created only by `scripts/bootstrap.sh`, never overwritten,
fail-closed on any problem. Repo-scoped role authority lives in
`roles/*.json` (versioned, reviewed commits only).
3. **Secrets** never enter the repository or container images; auth is
runtime-only (read-only mount or environment variable).
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
tasks are declarative JSON with strict schemas.
5. **Run records** under `<dataRoot>/runs/` are write-once evidence — never
rewritten, only pruned via `prune` with a receipt.
6. **Fail closed**: missing or invalid config/policy refuses the operation.
Never improvise around a refusal; diagnose it.
7. **Policy**: missions govern tasks (least-privilege intersection — a task
narrows, never widens). Role authority is declared in `roles/` and changes
only via reviewed commits.
8. **Git**: commit only after suites are green; push only `main`; never
force-push. `scripts/conductor-apply.sh` commits locally — push stays an
explicit act.
9. **Append-only logs**: BUILD-LOG.md (phases), `activation-log.jsonl`,
`.pruned.log`, docs/SESSIONS.md. Corrections are new entries, never edits.
- **API:** NestJS with Fastify (`apps/gateway`)
- **Web:** Next.js 16 with React 19 (`apps/web`)
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
- **Authentication:** BetterAuth (`packages/auth`)
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
- **Queue:** Valkey 8 (`packages/queue`)
- **Build:** pnpm workspaces and Turborepo
- **CI:** Woodpecker CI
- **Observability:** OpenTelemetry and Jaeger
## Session protocol (mandatory)
### Package Map
- **Register** your session in `docs/SESSIONS.md` — one append-only line
(date, actor, scope, outcome). Never rewrite or remove entries.
- **Cadence**: read `docs/plans/CURRENT.md` → execute its single next action
fully (implement → test → verify against acceptance criteria → commit →
push → close issue) → update CURRENT.md → register in SESSIONS.md.
- "next" means one action. A batch mandate ("run the queue") repeats the
loop until green or blocked. Blocked means stop and report, never improvise.
- Substantial work gets a Gitea issue and a BUILD-LOG phase entry
(before/after, with corrections recorded honestly).
| Package | Purpose | Key Dependencies |
| ------------------ | ----------------------------- | -------------------------------- |
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
| `apps/web` | Next.js dashboard | React 19, Tailwind |
| `packages/types` | Shared TypeScript contracts | class-validator |
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
| `packages/brain` | Structured data layer | @mosaicstack/db |
| `packages/queue` | Valkey task queue and MCP | ioredis |
| `packages/coord` | Mission coordination | @mosaicstack/queue |
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
| `plugins/discord` | Discord channel plugin | discord.js |
| `plugins/telegram` | Telegram channel plugin | Telegraf |
## Role model
## Architecture and Code Conventions
- **Conductor**: a system-scoped role — not an agent, not a daemon. Holds
git/credentials/policy authority; decomposes, dispatches, reviews,
verifies, integrates. Protocol: `docs/plans/CONDUCTOR.md`. Exists only
when invoked; push is never automatic.
- **Workers**: headless pi via `scripts/run-task.sh` — sandboxed workspace,
tools allowlist, optional persistent sessions and forks; no git, no
credentials, no policy control.
- Worker runs deliberately exclude this file (`--no-context-files` in the
adapter): worker context is contracts + mission via the generated system
prompt. This file is for conductor-level sessions.
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.
## Command surface
## Development Workflow
`scripts/bootstrap.sh` (idempotent) · `build.sh` · `hello.sh` ·
`verify.sh` · `run-task.sh run <task.json>` · `release.sh
package|activate|rollback|status` · `reset.sh` (**danger**: wipes the data
root; triple-safety-checked) · `mosaic-task.mjs validate|run|show|list|retry|prune` ·
`agent.sh <name>` (interactive TUI agent) ·
suites: `test-config.sh`, `test-task.sh`, `test-release.sh`,
`test-conductor.sh`.
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
Full reference — usage, fields, exit codes, safety notes:
`docs/TOOLS.md` (read on demand; do not rely on this summary for detail).
```bash
pnpm install --frozen-lockfile
pnpm preflight
## Data map (canon)
# Optional local queue service only; do not start the full Compose stack.
docker compose up -d valkey
```
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
auto-written).
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
- `sessions/` — pi JSONL session trees, one directory per named session
- `workspaces/` — agent file effects (persistent or `:run` ephemeral)
- `state/` — release pointer + append-only activation/auto-apply logs
- Ownership is per-directory; nothing shares state. Directory map and
lifecycle rules: README.md "Data map" section.
The pre-push hook requires:
## Pointers (depth lives here)
```bash
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
```
- `docs/plans/CURRENT.md` — THE next action (single source of "what now")
- `docs/plans/ROADMAP.md` — agreed milestone path (M16+)
- `docs/plans/CONDUCTOR.md` — orchestration protocol and guardrails
- `docs/plans/2026-09-02_atomic-mosaic-foundation.md` — architecture, invariants
- `docs/plans/2026-09-03_autonomous-run.md` — batch-run tracker
- `BUILD-LOG.md` — append-only build/verification history with corrections
- `LAYERS.md` — implemented vs deferred layers
- `docs/SESSIONS.md` — session registry
- `adapters/README.md` — the harness adapter contract
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
Software delivery also requires the applicable tests. Common repository commands are:
## Recovery rule
```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
```
Compacted, restarted, or new? Nothing that matters is lost: this file +
`docs/plans/CURRENT.md` + `git log --oneline -10` + the suites reconstruct
the full state. **Never guess** — verify with the suites; the run records
and logs hold the receipts.
## Database and Local Runtime Safety
## Version pin
- 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)
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
| Value | When to use | Budget |
| --------- | ----------------------------------------------------------- | -------------------------- |
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
**Full schema:**
```
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
```
- `status`: `not-started` | `in-progress` | `done` | `failed` | `blocked` | `needs-qa`
- `agent`: model value from table above (set before spawning)
- `estimate`: token budget e.g. `8K`, `25K`
`@earendil-works/pi-coding-agent` is pinned exactly (see `package.json` /
`RELEASE`); never install unversioned. Release identity: `RELEASE` file
(0.0.X until declared stable); image tags derive from it.
+371
View File
@@ -0,0 +1,371 @@
# Minimal Mosaic Stack container proof of concept
## Purpose
Build the smallest isolated container that can:
- launch Pi
- load a small set of Mosaic-style contract files
- send one real request to a model
- return a known response.
This is a standalone experiment. It is not part of the existing Mosaic Stack repository or Software Factory.
## Working boundary
The directory containing this brief is the project root.
### Do not read, copy, mount, import, or modify anything from:
- `/home/jwoltje/.mosaic`
- `/home/jwoltje/.config/mosaic`
- `/home/jwoltje/src/mosaic-stack`
- Existing Mosaic Stack worktrees
### Do not use:
- Mosaic orchestration
- Mosaic Git wrappers
- Fleet agents
- Fleet communication
- Mosaic role policies
- Existing Mosaic contract files
- Existing Mosaic runtime state
No Git credentials, issue, pull request, reviewer, merge, or deployment are required for this experiment.
Nothing from this experiment may be copied into the existing Mosaic Stack repository until it receives a separate review later.
## Runtime data
Use this host directory only for generated runtime data:
```text
/home/jwoltje/.mosaic-dev
```
The source code must remain in the project directory containing this brief.
Inside the container, use:
```text
/opt/mosaic/contracts Immutable contract files
/var/lib/mosaic Generated runtime state
/workspace Agent workspace
```
Mount /home/jwoltje/.mosaic-dev at /var/lib/mosaic.
### Required proof
The finished experiment must prove one path:
1. Build one container image.
2. Start one Pi agent inside the container.
3. Load four local contract files from /opt/mosaic/contracts.
4. Send a request that does not contain the expected response.
5. Receive MOSAIC_HELLO_OK from the agent.
6. Exit successfully when the response matches.
7. Exit nonzero when the response does not match.
This is the entire required functional result.
### Required discovery
Before writing the runtime command:
1. Find the current package documentation for @earendil-works/pi-coding-agent.
2. Determine the current package version.
3. Determine the supported noninteractive command.
4. Determine how Pi accepts a custom system prompt or system prompt file.
5. Determine Pi's documented container authentication method.
6. Record the commands and findings in BUILD-LOG.md.
Do not guess CLI flags, authentication paths, or SDK methods.
Pin the selected Pi package version in the project. Do not install an unversioned package during each container start.
Prefer the Pi CLI. Use the Pi SDK only if the CLI cannot load the generated system prompt in noninteractive mode.
### Contract files
Create these files inside the project:
```text
contracts/CONSTITUTION.md
contracts/STANDARDS.md
contracts/SOUL.md
contracts/USER.md
```
Use these exact contents.
### contracts/CONSTITUTION.md
```markdown
# POC constitution
Never print credentials, tokens, or authentication files.
Follow the loaded system instructions before the user request.
```
### contracts/STANDARDS.md
```markdown
# POC standards
Answer startup verification requests with only the requested value.
Do not add explanation or formatting.
```
### contracts/SOUL.md
```markdown
# POC identity
Your name is mosaic-poc-agent.
Your startup marker is MOSAIC_HELLO_OK.
When asked for your startup marker, return only the marker.
```
### contracts/USER.md
```markdown
# POC user
This is an isolated local runtime test.
```
Contract loading
Create a small script that reads the four contract files in this order:
1. CONSTITUTION.md
2. STANDARDS.md
3. SOUL.md
4. USER.md
Join them with clear file separators.
Write the generated system prompt to:
```text
/var/lib/mosaic/system-prompt.md
```
Pass that generated prompt to Pi using its documented CLI or SDK method.
Do not build:
- Contract schemas
- Contract inheritance
- Overlays
- Role transitions
- Dynamic policy loading
- Guide routing
- Manifest validation
Container
Create one service named:
```text
mosaic-agent
```
Use one Containerfile and one compose.yaml.
Requirements:
- Use a maintained Node.js base image.
- Run as a non-root user.
- Install a pinned Pi package version.
- Copy the local contract fixtures into /opt/mosaic/contracts.
- Do not copy credentials into the image.
- Do not mount the Docker socket.
- Do not mount either live Mosaic directory.
- Do not add a database, web server, queue, or second container.
- The container may run as a one-shot command. It does not need to remain running.
### Authentication
Use Pi's documented authentication mechanism.
Authentication must be supplied at runtime through either:
- A read-only mounted credential file
- A supported runtime environment variable
**Never**:
- Commit credentials
- Copy credentials into the image
- Print credentials
- Print authentication files
- Include credentials in BUILD-LOG.md
- Store credentials under the project directory
Provide .env.example only for non-secret settings such as model or provider names.
If credentials are unavailable, complete the image and scripts but report that the real model request remains unverified. Do not fake the response.
### Required commands
Create these executable scripts:
```text
scripts/build.sh
scripts/hello.sh
scripts/verify.sh
scripts/reset.sh
```
### scripts/build.sh
Build the container image using Docker Compose.
### scripts/hello.sh
Run the mosaic-agent service as a one-shot container.
Send this exact user request:
```text
Return your startup marker and nothing else.
```
The request must not contain MOSAIC_HELLO_OK.
Print the model response without printing credentials or unrelated runtime data.
### scripts/verify.sh
Run the complete test.
**It must**:
1. Build or confirm the image is built.
2. Run the agent request.
3. Remove surrounding whitespace from the response.
4. Compare the response with MOSAIC_HELLO_OK.
5. Exit 0 only when they match exactly.
6. Exit nonzero with a clear error when they do not match.
### scripts/reset.sh
Delete generated POC state only when all checks pass:
1. The resolved path is exactly /home/jwoltje/.mosaic-dev.
2. The path is not a symbolic link.
3. The directory contains a .mosaic-poc-root ownership marker created by this project.
Refuse to delete anything if a check fails.
## Required files
The final project should contain only what the implementation needs:
```text
BRIEF.md
BUILD-LOG.md
README.md
LAYERS.md
Containerfile
compose.yaml
package.json
package-lock.json
.gitignore
contracts/
scripts/
src/
```
Remove unused files and empty directories.
Build log
Create BUILD-LOG.md.
Treat it as append-only.
Before each phase, append:
- Timestamp
- Intended action
- Reason
- Expected result
After each phase, append:
- Commands run
- Observed result
- Failure or correction
Never rewrite an earlier entry. Add a correction as a new entry.
Do not record credentials.
Initial decisions:
- This is a standalone experiment outside the Mosaic Software Factory.
- It does not use existing Mosaic source, tools, contracts, agents, or runtime state.
- The first proof uses one Pi agent and four small local contract files.
- The only required model result is MOSAIC_HELLO_OK.
- Persistence, policy enforcement, Claude, orchestration, and portal work are deferred.
## Acceptance criteria
The experiment passes when:
1. scripts/build.sh exits 0.
2. The image contains the four local contract files.
3. The image contains no credentials.
4. The container has no mounts from ~/.mosaic or ~/.config/mosaic.
5. scripts/hello.sh performs a real model request.
6. The request does not contain the expected marker.
7. The agent returns exactly MOSAIC_HELLO_OK.
8. scripts/verify.sh exits 0.
9. Changing the expected value makes scripts/verify.sh exit nonzero.
10. scripts/reset.sh refuses unsafe paths.
11. Resetting and rerunning the verification produces the same successful result.
## Deferred layers
Document these in LAYERS.md. Do not implement them.
- L0: Container builds and returns MOSAIC_HELLO_OK.
- L1: Persist and resume a named Pi session.
- L2: Add a fixed tool permission policy.
- L3: Load full versioned contract bundles.
- L4: Add Claude as a second runtime.
- L5: Add multiple agents and communication.
- L6: Add orchestration, knowledge storage, and portal features.
## Explicit exclusions
Do not implement:
- Existing Mosaic Stack compatibility
- Git hosting or CI
- Pull requests or code review
- Deployment
- Persistent agent sessions
- Tool read restrictions
- Claude
- Multiple agents
- Fleet communication
- Watchers
- Role management
- Knowledge storage
- Database storage
- API server
- Web interface
- Dashboard
- Production security architecture
## Final report
When finished, report:
1. Files created.
2. Pi package version.
3. Exact build command.
4. Exact verification command.
5. Verification output with credentials removed.
6. Whether the real model request passed.
7. Any remaining failure.
8. Anything implemented beyond this brief.
Do not describe the experiment as production-ready.
+440
View File
@@ -0,0 +1,440 @@
# BUILD-LOG
Append-only build log for the Minimal Mosaic Stack container proof of concept.
Each phase records the plan before it runs and the observed result after it runs.
No credentials are recorded in this file.
---
## Phase 1: Pi package discovery
### Entry 1.1 — before
- Timestamp: 2026-02-02 (session start, local)
- Intended action: Locate the current package documentation for `@earendil-works/pi-coding-agent`, determine the current version, the supported noninteractive command, the custom system prompt mechanism, and the documented container authentication method.
- Reason: The brief forbids guessing CLI flags, authentication paths, or SDK methods; all runtime commands must be derived from the package documentation.
- Expected result: Documented answers for all five discovery questions, recorded below, with the Pi package version pinned in the project.
### Entry 1.2 — after
- Timestamp: 2026-02-02
- Commands run:
- Read `README.md` of the locally installed `@earendil-works/pi-coding-agent` package
- Read `docs/containerization.md`, excerpts of `docs/models.md` and `docs/providers.md`
- `grep '"version"' .../pi-coding-agent/package.json`
- `npm view @earendil-works/pi-coding-agent version`
- Inspected host auth store structure (keys only, values never printed)
- Observed result:
1. **Package documentation**: full docs ship inside the installed package (`README.md` plus a `docs/` directory including `docs/containerization.md`).
2. **Current package version**: `0.84.4` — the locally installed version and the npm registry latest are identical. Selected version to pin: `0.84.4`.
3. **Supported noninteractive command**: `pi -p` / `pi --print` — "Print response and exit". Documented in the CLI Reference. Print mode also merges piped stdin into the initial prompt (not used here).
4. **Custom system prompt**: documented CLI flag `--system-prompt <text>` — "Replace default prompt (context files and skills still appended)". Because the generated contract prompt must fully control behavior, the run also passes the documented discovery-off switches (`--no-context-files`, `--no-skills`, `--no-extensions`, `--no-prompt-templates`, `--no-themes`) so nothing is appended from ambient files. (`APPEND_SYSTEM.md` / `.pi/SYSTEM.md` file conventions exist but the CLI flag is the documented noninteractive path.)
5. **Documented container authentication**: `docs/containerization.md` documents the "Plain Docker" pattern: run the whole `pi` process in the container and pass provider API keys at runtime via environment variables (example: `docker run -e ANTHROPIC_API_KEY ...`). The documented alternative credential store is `auth.json` written by `/login` under the pi agent directory (`~/.pi/agent/auth.json`).
- Decision: supply the credential at runtime as a **read-only mounted credential file** (host `auth.json`, which contains a static API-key entry for the built-in `zai` provider) mounted at the container pi agent directory, and additionally allow the documented environment-variable path (`ZAI_API_KEY` / `ANTHROPIC_API_KEY`) as an alternative. Provider and model are non-secret settings supplied via `.env` (`PI_PROVIDER=zai`, `PI_MODEL=glm-5.3-flash`).
- Failure or correction: none. Host check confirmed no API-key environment variables are exported on the host, so the read-only auth.json mount is the working runtime credential path for this experiment.
---
## Phase 2: Project scaffold
### Entry 2.1 — before
- Timestamp: 2026-02-02
- Intended action: Create the contract fixtures (exact brief contents), the contract loader (`src/load-contracts.sh`), the one-shot agent runner (`src/run-agent.sh`), the four required scripts (`scripts/build.sh`, `hello.sh`, `verify.sh`, `reset.sh`), `Containerfile`, `compose.yaml`, pinned `package.json` + `package-lock.json`, `.gitignore`, `README.md`, `LAYERS.md`.
- Reason: Implement exactly the file set the brief requires, with no extra machinery (no schemas, overlays, manifests, or policy loading).
- Expected result: A complete project whose only remaining unknown is whether the pinned image builds and the real model request returns `MOSAIC_HELLO_OK`.
### Entry 2.2 — after
- Timestamp: 2026-02-02
- Commands run: file creation; `npm install --package-lock-only --ignore-scripts` to generate the lockfile from the pinned dependency.
- Observed result: All files created; `package-lock.json` pins `@earendil-works/[email protected]` (exact, no range).
- Failure or correction: none.
---
## Phase 3: Container image build
### Entry 3.1 — before
- Timestamp: 2026-02-02
- Intended action: Run `scripts/build.sh` (Docker Compose build) to produce image `mosaic-poc-agent:0.84.4` from `node:24-bookworm-slim` with the pinned Pi, the four contract fixtures at `/opt/mosaic/contracts`, and a non-root user (uid/gid 1000).
- Reason: Phase 1 of the required proof path; `node:24-bookworm-slim` is the maintained base image used in Pi's own documented containerization example.
- Expected result: `docker compose build` exits 0 and the image contains the contracts, the runner scripts, and the pinned `pi` binary, with no credentials baked in.
### Entry 3.2 — after
- Timestamp: 2026-02-02
- Commands run: `scripts/build.sh`; `docker run --rm mosaic-poc-agent:0.84.4 --version`; `id` via `--entrypoint`; contract listing; credential file scan.
- Observed result:
- Build exit 0; image tagged `mosaic-poc-agent:0.84.4`.
- `pi --version` inside the image reports `0.84.4` (and this run also executed the contract loader successfully, writing `/var/lib/mosaic/system-prompt.md`).
- Container user is `uid=1000(node) gid=1000(node)` — non-root.
- All four contract files present at `/opt/mosaic/contracts` with read-only permissions (0555).
- Credential scan: no `auth.json` or other auth files exist in the image; `/home/node/.pi/agent/` is empty in the image.
- Failure or correction:
1. First build failed: Docker Compose expects `Dockerfile` by default; fixed by setting `build.dockerfile: Containerfile` in `compose.yaml`.
2. Second build failed: `useradd` exit 4 (uid 1000 already exists) because the maintained node image ships a `node` user at uid/gid 1000. Fixed by reusing the built-in `node` user (same 1000:1000 host mapping) instead of creating a duplicate `mosaic` user; container paths updated from `/home/mosaic/...` to `/home/node/...` in `Containerfile`, `compose.yaml`, `README.md`, `.env.example`.
---
## Phase 4: Runtime verification
### Entry 4.1 — before
- Timestamp: 2026-02-02
- Intended action: Run `scripts/hello.sh` (one-shot request: "Return your startup marker and nothing else."), then `scripts/verify.sh` (exact-match gate against `MOSAIC_HELLO_OK`), then the negative test (`EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh` must exit nonzero), then the `scripts/reset.sh` safety tests and a final rerun after reset.
- Reason: Phases 27 of the required proof path plus acceptance criteria 511.
- Expected result: hello prints only the marker; verify exits 0; negative test exits nonzero; reset refuses unsafe paths and succeeds on the real path; rerun after reset reproduces the success.
### Entry 4.2 — after
- Timestamp: 2026-02-02
- Commands run: `scripts/hello.sh`; `scripts/verify.sh`; `EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh`; `scripts/reset.sh` (refusal tests: missing marker, symlink with canary file, then real reset, then missing dir); `scripts/build.sh && scripts/verify.sh` after reset; `docker compose config` mount inspection.
- Observed result:
- `hello.sh`: stdout exactly `MOSAIC_HELLO_OK` — a real model request (provider `zai`, model `glm-5.3-flash`, auth via the read-only mounted auth.json credential file). The request string contains no marker.
- `verify.sh`: `PASS: response matches expected marker`, exit 0.
- Negative test: `FAIL: response does not match expected marker` (expected `MOSAIC_NOT_OK`, actual `MOSAIC_HELLO_OK`), exit 1.
- `reset.sh` refusal tests: missing marker → exit 1, nothing deleted; symlink (with canary file at the target) → exit 1, canary survived; real path with marker → removed, exit 0; missing dir → "nothing to remove", exit 0.
- Rerun after reset: build + verify → PASS, exit 0 (criterion 11).
- Resolved compose mounts: only `/home/jwoltje/.mosaic-dev → /var/lib/mosaic` (rw) and `~/.pi/agent/auth.json → /home/node/.pi/agent/auth.json` (read-only). No `~/.mosaic` or `~/.config/mosaic` mounts, no Docker socket.
- Failure or correction:
1. First hello run: the contract loader's status line was printed on stdout, mixing runtime data into the model response stream and contaminating the exact-match capture. Fixed by sending the loader's status message to stderr (`src/load-contracts.sh`), rebuilt the image, reran: stdout is exactly the model response.
- Credential check: no credential material appears in this log, in hello/verify output, or in the image (image scan found no auth files).
## Result
All 11 acceptance criteria demonstrated. The real model request passed.
---
## Phase 5: Configuration-driven Hello World (M1)
### Entry 5.1 — before
- Timestamp: 2026-09-03
- Intended action: Make the container POC configuration-driven. Baseline committed and tagged `poc-container-hello-v0`. Milestone M1 tracked in Gitea (issues #1-#4): (T1) config module with idempotent bootstrap and strict v1 validation; (T2) wire scripts and compose to config.json with fail-closed behavior; (T3) sandboxed config selftests; (T4) E2E verification and documentation.
- Reason: Per docs/plans/2026-09-02_atomic-mosaic-foundation.md — config.json must be the sole discovery entry point; updates and runs must never corrupt or invent configuration.
- Expected result: All M1 acceptance criteria pass; Hello World reproducible from configuration alone.
### Entry 5.2 — after
- Timestamp: 2026-09-03
- Commands run: `scripts/test-config.sh` (20 cases); fail-closed checks (compose without launcher env, verify/reset with missing config); `scripts/bootstrap.sh`; config-driven `scripts/hello.sh`, `scripts/verify.sh`, negative marker test, sandboxed reset symlink refusal (canary survived), real reset + bootstrap + build + verify; config checksum comparison across the entire flow.
- Observed result:
- Config selftests: 20 passed, 0 failed.
- Fail-closed confirmed: compose exits 1 without launcher env; verify/reset exit 1 on missing config before any mutation.
- Bootstrap created `~/.config/mosaic-dev/config.json` exclusively; second run validated without rewriting (content + mtime unchanged).
- Config-driven hello/verify returned exactly `MOSAIC_HELLO_OK`; verify exit 0; negative marker test exit 1.
- Reset refused symlinked dataRoot; canary file survived; real reset removed only the configured data root.
- config.json checksum unchanged across hello/verify/reset/bootstrap/build/verify.
- Failure or correction:
1. Selftest harness bug: `cfg` helper invoked without a body for the symlink case (`$2: unbound variable`). Fixed in the harness; product code unaffected.
2. E2E rerun-after-reset failure: `verify.sh` did not ensure the configured data root existed before the container mount. With the data root absent, Docker auto-created the host path as root:root, and the container's uid-1000 user could not write the generated system prompt. Fixed by calling `bootstrap_runtime_dir` in `verify.sh`; also hardened it to fail with a clear message when the data root exists but is not writable (root-owned leftover). Clean-slate E2E rerun: all steps green.
- Credential check: no credential material in config, scripts, logs, or test output.
## Result (M1)
Configuration-driven Hello World verified. `main` merged with M1 and tagged `config-hello-v1`.
---
## Phase 6: Mission and task abstraction (M2)
### Entry 6.1 — before
- Timestamp: 2026-09-03
- Intended action: Add the first mission/task layer, host-side only (Gitea milestone M2, issues #6-#9): strict v1 schemas for missions and tasks, a task runner executing through the proven config-driven container path, immutable write-once run records under `<dataRoot>/runs/`, `expectExact` gating, timeouts, selftests, fixtures, and docs.
- Reason: The foundation plan's following layer — mission (objective + directives), task (bounded unit), run (one attempt), result (immutable evidence) — must exist as data and records before any policy or multi-agent work.
- Expected result: `scripts/run-task.sh tasks/hello-marker.json` succeeds with exactly `MOSAIC_HELLO_OK`; wrong expectations fail; every run leaves an immutable record; configuration remains untouched.
### Entry 6.2 — after
- Timestamp: 2026-09-03
- Commands run: `scripts/test-task.sh` (18 cases incl. live runs); `scripts/run-task.sh validate` / `run` on committed fixtures; `node scripts/mosaic-task.mjs list`; config checksum comparison across runs.
- Observed result:
- Selftests: 18 passed, 0 failed (schema negatives; live exact-marker success; wrong expectExact fails; distinct run dirs; result.json contents; list).
- Fixture run: status `succeeded`, response exactly `MOSAIC_HELLO_OK`, mission snapshot recorded.
- Run records written once under `<dataRoot>/runs/r-<utcstamp>-<rand>/`; reruns never clobber.
- Failure or correction: none this phase.
- Credential check: no credential material in task data, run records, or logs.
## Result (M2)
Mission/task layer verified end-to-end. `main` merged with M2 and tagged `mission-task-v1`.
---
## Phase 7: Release model and safe updates (M3)
### Entry 7.1 — before
- Timestamp: 2026-09-03
- Intended action: Add the release substrate (Gitea milestone M3, issues #10-#13): RELEASE file single-sources the version (0.0.X line per owner direction), image tags derive from it, scripts/release.sh provides package/activate/rollback/status, activation is health-gated by the M2 task runner, pointer + append-only log under <dataRoot>/state/.
- Reason: The owner's top invariant — updates must never corrupt a working installation — needs a mechanism, not a convention: gate-then-flip with recorded history and rollback.
- Expected result: Update, refusal, and rollback drills all green with config checksums unchanged.
### Entry 7.2 — after
- Timestamp: 2026-09-03
- Commands run: scripts/test-release.sh (14 cases); recorded drills: update (0.0.3 -> 0.0.4 package+activate+verify), fault-injected refusal, rollback to 0.0.3.
- Observed result:
- Selftests: 14 passed, 0 failed.
- Update drill: packaged and activated r0.0.4 after exact-marker health gate; verify green under the new tag; config checksum unchanged.
- Refusal drill: health-gate fault injection -> activation refused (exit 1), pointer untouched, refusal appended to the log.
- Rollback drill: health-gated rollback to r0.0.3; pointer restored; log records package/activate/refused/rollback history append-only.
- Failure or correction:
1. release.sh initially failed with missing state/ directory (no mkdir before pointer/log writes); fixed.
2. Selftest harness mutated the repo RELEASE and restored the mutated copy (mv-back bug) plus a second trap replacing the first; fixed with inline backup restore and one self-healing exit trap. Product code unaffected.
- Credential check: no credential material in release state, logs, or drills.
## Result (M3)
Release model and safe updates verified by drills. `main` merged with M3 and tagged `release-model-v1`.
---
## Phase 8: Runtime adapter seam (M4)
### Entry 8.1 — before
- Timestamp: 2026-09-03
- Intended action: Formalize the harness boundary (Gitea milestone M4, issues #16-#19): documented adapter contract under /opt/mosaic/adapters/<name>/adapter.sh; run-agent.sh becomes a dispatcher; pi extracted unchanged; deterministic mock adapter for provider-free seam tests; config gains optional execution.adapter (default pi, configVersion unchanged); mission directives gain their sanctioned injection point via the run snapshot; RELEASE bumps to 0.0.5 with a health-gated activation.
- Reason: Future harnesses (Claude, Codex, OpenCode) must be additive — one directory each — and mission content needs a single sanctioned path into the runtime.
- Expected result: All suites green including new deterministic seam cases; 0.0.5 activated by health gate; mission-bearing run recorded.
### Entry 8.2 — after
- Timestamp: 2026-09-03
- Commands run: scripts/test-config.sh; scripts/test-task.sh; scripts/test-release.sh; manual seam drills (mock verbatim, unknown/traversal adapter refusal); mission injection checks; release package + activate for 0.0.5.
- Observed result:
- Config suite 24/24 (adapter default/validation/env export).
- Task suite 24/24 including deterministic mock cases (gate pass, expect-mismatch with reason, unknown adapter fail-closed) and mission injection asserted by prompt content.
- Release suite 14/14; image mosaic-poc-agent:0.84.4-r0.0.5 packaged and activated via exact-marker health gate.
- Mission directives now flow: task -> run snapshot -> container env -> generated prompt MISSION (runtime) section.
- Failure or correction:
1. Selection authority settled: load_config always exports MOSAIC_ADAPTER from config; environment overrides for scripts are therefore not a supported selection path (by design).
2. Selftest harness: three authoring defects fixed (helpers used before definition; one config file reused across cases leaking adapter state; a static mission fixture asserted against distinctive seam directives; plus an accidentally duplicated live block removed).
- Credential check: no credential material in adapters, prompts, run records, or logs.
## Result (M4)
Adapter seam verified; harness boundary is now additive by construction. `main` merged with M4 and tagged `adapter-seam-v1`.
---
## Phase 9: Workspaces + capability envelope (M5)
### Entry 9.1 — before
- Timestamp: 2026-09-03
- Intended action: Optional task workspace (absent / ":run" ephemeral / named persistent) and capabilities.tools allowlist (pi built-ins); runner plumbing via MOSAIC_WORKSPACE/MOSAIC_TOOLS; pi adapter maps to cwd + --tools; mock adapter logs delivered MOSAIC_* vars for deterministic assertions (Gitea #20, #21).
- Reason: Agents that only answer text cannot do work; the workspace+tools pair is the smallest real capability step, bounded by the container.
- Expected result: plumbing asserted via run-record stderr; live pi writes a host-visible file.
### Entry 9.2 — after
- Timestamp: 2026-09-03
- Commands run: build; mock plumbing run; validate negatives; full task suite; live workspace demo.
- Observed result: task suite 32/32; MOSAIC_WORKSPACE/MOSAIC_TOOLS asserted in run record; dataRoot/workspaces/<name> created host-side; live pi used bash to write proof.txt into the demo workspace (host-visible).
- Failure or correction: (1) batch edit dropped SUPPORTED_TOOLS const (runtime ReferenceError, exit 1 instead of 2) — restored; (2) mock env dump used `export` which this dash prints as `export K='v'` — switched to `env`; (3) selftest fed a mismatching mock response to the plain-task case — test bug, fixed.
## Phase 10: Named sessions (M6)
### Entry 10.1 — before
- Timestamp: 2026-09-03
- Intended action: Optional task.session name -> persistent session dir dataRoot/sessions/<name> via pi --session-dir; resume most recent with -c when present; isolation per name; teach/recall demo fixtures (Gitea #22, #23).
- Reason: L1 persistence is the prerequisite for any multi-step agent work.
- Expected result: session dir populated after first run; second run recalls taught context.
### Entry 10.2 — after
- Timestamp: 2026-09-03
- Commands run: build; mock plumbing run; live teach/recall E2E; task suite.
- Observed result: task suite 32/32; teach run replied REMEMBERED and session JSONL persisted host-side; recall run resumed (-c) and answered exactly 'mosaico'; single continued session file (no duplicate sessions).
- Failure or correction: none. Design note: ephemeral (--no-session) remains the default when no session is declared.
## Phase 11: Operator ergonomics (M7)
### Entry 11.1 — before
- Timestamp: 2026-09-03
- Intended action: mosaic-task.mjs show <runId> (full record + snapshots + artifacts, traversal-safe), list with workspace/session columns, RELEASE -> 0.0.6, package + health-gated activate, docs (Gitea #24).
- Reason: Run records are only as valuable as they are inspectable; release activation closes the loop on container-content changes.
- Expected result: show works for real/missing/traversal ids; suites green; 0.0.6 active.
### Entry 11.2 — after
- Timestamp: 2026-09-03
- Commands run: build; show on real/missing/traversal ids; full sweep; release package + activate.
- Observed result: config 24/24, task 32/32, release 14/14, verify PASS; 0.0.6 packaged and activated via exact-marker health gate.
- Failure or correction: showRun initially rejected valid run ids (lowercase-only regex vs uppercase timestamp) and crashed on missing ids (uncaught readdir) — both fixed and covered.
## Autonomous run result
M5 tagged `workspace-capabilities-v1`, M6 tagged `sessions-v1`, M7 tagged `operator-ergonomics-v1`; release 0.0.6 active. Tracker: docs/plans/2026-09-03_autonomous-run.md.
---
## Phase 12: Conductor loop — self-orchestration (M8)
### Entry 12.1 — before
- Timestamp: 2026-09-03
- Intended action: Stand up the poor-man orchestration loop per docs/plans/CONDUCTOR.md: conductor (host, holds git) mirrors the repo into a worker workspace, dispatches a headless pi worker (session worker-1, tools read/write/edit/bash) to implement retry <runId>, reviews the diff, integrates, verifies (Gitea #25, #26, #27).
- Reason: The owner asked for circular task processing with agent workers; the stack now has every primitive needed — this proves it on the stack itself.
- Expected result: worker-authored retry merged with suites green and a live retry verified.
### Entry 12.2 — after
- Timestamp: 2026-09-03
- Commands run: repo mirror clone; worker dispatch (tasks/worker-retry.json); diff review; apply; live retry; refinement dispatch (tasks/worker-retry-refine.json); second review; reverse+reapply combined patch; conductor interpolation fix; live retry; full sweep.
- Observed result:
- Worker round 1: implemented retry correctly per spec in 2m28s; diff reviewed clean.
- Live retry exposed a spec gap (direct invocation lacks launcher env exports).
- Worker round 2 (same session, 59s): made spawnEnv self-sufficient, but used PI_* names where compose interpolates MOSAIC_*.
- Conductor hotfix: 3-line rename to MOSAIC_PROVIDER/MOSAIC_MODEL/MOSAIC_DATA_ROOT (too trivial for a worker round).
- Final: live retry succeeded (replied REMEMBERED, new run recorded); all suites green.
- Failure or correction: three rounds total — one spec gap (conductor), one naming mismatch (worker), one trivial rename (conductor). Each was caught by mechanical verification (run record stderr), never by hope.
- Attribution: feature authored by headless pi worker (glm-5.3-flash) in sessions worker-1; conductor reviewed, integrated, and hotfixed.
## Result (M8)
Conductor loop proven end-to-end on the stack itself. `main` merged with M8; release 0.0.6 remains active (retry is host-side only, no image change).
---
## Phase 13: Mission-level capability policy (M9)
### Entry 13.1 — before
- Timestamp: 2026-09-03
- Intended action: Missions may declare capabilities.tools as governing constraints (Gitea #30); merge semantics = least-privilege intersection (task narrows, never widens; empty intersection = tool-free run). Host-side only.
- Reason: First mechanical restriction layer — the trust model becomes enforced, not instructed.
- Expected result: all four merge cases asserted from run evidence; suites green.
### Entry 13.2 — after
- Observed: four merge cases verified deterministically via run-record stderr (mission-only, task-only, narrowed, emptied); invalid mission capabilities exit 2; task suite 41/41.
- Failure or correction: selftest harness could not express ABSENT vs EMPTY fields via its printf helper — fixed with an ABSENT marker; two suite config-leak defects fixed (per-command env scoping). Product unaffected.
## Phase 14: Session forking (M11)
### Entry 14.1 — before
- Timestamp: 2026-09-03
- Intended action: sessionForkFrom task field branches the source session's newest file (pi --fork) into the target session dir; ancestor untouched; RELEASE -> 0.0.7 with health-gated activation (Gitea #33).
- Reason: Owner flagged conversation forking from a common ancestor as a desired property; pi JSONL trees make it native.
- Expected result: forked child recalls ancestor context; ancestor file untouched; suites green; 0.0.7 active.
### Entry 14.2 — after
- Observed: mock plumbing asserts fork source + target delivery; validation rejects fork-without-target and self-fork (exit 2); ghost source exits 4; live fork: child recalled 'mosaico' from ancestor context while the ancestor session file remained untouched (file-level assertion); suites 58/24/14 + verify green; 0.0.7 packaged and activated via health gate.
- Failure or correction: retryRun-style self-assignment bug in validation (compared null to target) — caught by negative test, fixed.
## Result (M11)
Session forking verified. `main` merged with M11, tagged `session-fork-v1`; release 0.0.7 active.
---
## Phase 15: Interactive TUI agent + TOOLS.md (M13)
### Entry 15.1 — before
- Timestamp: 2026-09-03
- Intended action: Add scripts/agent.sh — an interactive TUI launcher (contracts + optional mission + agent identity + named session + optional workspace/tools) — and the pi-adapter interactive branch; remove the fixed compose command; add docs/TOOLS.md as the on-demand reference AGENTS.md routes to; RELEASE -> 0.0.8 (Gitea #35).
- Reason: The owner's bootstrap model is vanilla pi sessions directed by AGENTS.md, graduating to governed TUI agents — the first the system itself launches.
- Expected result: TUI agent launches with contracts+identity context; headless paths unchanged; TOOLS.md consolidates the reference.
### Entry 15.2 — after
- Observed: mock plumbing asserts agent name/session/workspace/mission delivery; identity section asserted in generated prompt; headless hello + suites green (24/58/17/14 + verify); 0.0.8 packaged and health-gated activated.
- Failure or correction:
1. Regression: pi adapter rewrite made MOSAIC_AGENT_NAME unconditionally required, breaking headless paths — caught by task suite (empty-stderr exit-nonzero), fixed (optional in headless; identity section simply omitted).
2. Regression: unquoted $REQUEST_ARG word-split the request into positional args — fixed with positional-argument building (set -- ... "$@").
3. Mission fixture wording (objective named the agent) invited the model to append its name after the marker, tripping the strict gate — fixture tightened; strict gate kept by design.
- Conductor session env hygiene: sandbox config exports now scoped per-command after a leak broke cross-suite runs.
## Result (M13)
Interactive TUI agent launched and verified; TOOLS.md reference shipped. `main` merged with M13, tagged `interactive-agent-v1`; release 0.0.8 active.
---
## Phase 16: Run-record retention (M10) — recorded retroactively
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled entry; the original phase entry was lost to editor races - see correction note below)
- Summary: `mosaic-task.mjs prune [--keep=N] [--yes]` - keep newest N run records, dry-run by default, append-only `.pruned.log` receipt, sessions/workspaces/state untouched. 8 suite cases.
- Observed: dry-run deletes nothing; keep-N honored; newest kept; receipt written; isolation asserted.
- Correction (recorded): suite-hardening edits (prune section config scoping, duplicate helper removal) were applied in the same phase.
## Phase 17: Conductor auto-apply policy (M12) — recorded retroactively
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled)
- Summary: `conductor-policy.json` (tracked, strictly validated: enabled switch, path allowlist, gating suites) + `scripts/conductor-apply.sh <runId>` - succeeded-run check, clean target tree, allowlist, syntax gates, apply, suites, attribution commit; any failure reverts; push never automatic. 17 sandbox suite cases.
- Observed: all gates green; suite-failure auto-revert verified; disabled policy refuses with exit 2.
- Decision recorded: review moves to after-the-fact (history revertible) for worker patches under the policy; push remains explicit.
## Phase 18: Live user context (M14) — recorded retroactively
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled)
- Summary: USER.md removed from immutable contracts (wrong owner - user info is user-owned live context); `<dataRoot>/user/*.md` dispatched (sorted) into every agent launch's generated prompt; bootstrap seeds `user/USER.md` once; loader layers now governance -> persona -> identity -> mission -> user.
- Observed: user edit propagates to next launch (TUI or headless) without rebuilds; contract-only prompts unchanged when no user dir present.
- Correction (recorded): first implementation pass did not regenerate the container - caught by owner test (edits to repo/mirror copies of USER.md did not propagate; the design, not the test, was the defect).
## Phase 19: Agent seats + roles/ convention (M15) — recorded retroactively
- Timestamp of work: 2026-09-03; recorded: 2026-09-03 (back-filled)
- Summary: `agents/<name>/` holds `agent.json` (strict validation: version, name, role?, capabilities?, workspace?, session?) + `SOUL.md` persona; `agent.sh` validates, copies runtime SOUL to `dataRoot/agents/<name>/`, sets `MOSAIC_AGENT_SOUL_FILE`; loader fills the SOUL slot from the seat persona (contract SOUL = default); identity section gains role; per-agent default workspace; compose passthroughs.
- Observed: launch with seat definition replaces the contract persona in the generated prompt; role in identity; seat record written once; suites green; RELEASE 0.0.10 packaged, health-gated activated.
- Correction (recorded): RELEASE was not bumped when container content changed - tag r0.0.9 rebuilt with different content (invariant lapse; r0.0.9 was never active). Restored: RELEASE 0.0.10 packaged beside, health-gated, activated.
- Conventions recorded per owner direction: repository root holds bootstrap-required configuration only; role contracts live in `roles/`.
## Backfill note
Phases 16-19 were recorded retroactively on 2026-09-03 after editor-session races
left them unwritten at the time of work. Ground truth for each entry: the git
history (commit subjects/bodies), the Gitea milestone/issue records (#32, #34,
#35, #36), and the suite files themselves. No facts were reconstructed from
memory alone.
---
## Phase 20: Release self-determination (M16)
### Entry 20.1 — before
- Timestamp: 2026-09-03
- Intended action: The system determines what is installed and aligns itself; the user never manually runs release.sh. `release.sh ensure` (align-or-noop) invoked automatically by human-facing launchers (hello, verify, agent); run-task reports drift without auto-aligning (workers/suites must not trigger builds or model gates mid-automation).
- Reason: Owner direction — intelligence in operation; post-reset pointer loss required a manual release command, which contradicts the self-healing design.
- Expected result: post-reset pointer loss auto-restores via health-gated ensure; drift warns on run-task; aligned state is a no-op; M20 packages/* decision recorded.
### Entry 20.2 — after
- Timestamp: 2026-09-03
- Commands run: ensure with pointer removed (live drift); ensure idempotence; drift-warning demo via RELEASE bump without packaging; full suites.
- Observed result: drift detected (active: none, desired: 0.0.11) -> health-gated activate -> aligned; second ensure no-op; run-task drift warning fired on desired-version bump without packaging; all suites green (24/68/17/14 + verify).
- Failure or correction:
1. First run-task edit anchor missed (inline comment mismatch) — reapplied with exact text.
2. RELEASE was temporarily bumped to 0.0.12 without packaging for the drift demo — restored to 0.0.11; state pointer remained aligned.
- M20 decision recorded in ROADMAP.md: v2 adopts packages/* monorepo structure at usurpation (owner, continuity-first).
- Process note (tool races): mechanism confirmed — batching a file write/edit and a dependent bash command in one parallel block runs the bash before the write flushes. Consequences: lost doc updates, stale anchors, one premature grep. Operating rule: dependent calls sequential; every write verified before claimed complete.
## Result (M16)
Release self-determination live: the system aligns itself to RELEASE without manual commands. Suites 24/68/17/14 + verify green.
## Phase 21: M16 hardening + M20 decision
- Recursion guard: release.sh's health-gate task run sets MOSAIC_ENSURE_SKIP
so the gate's run-task cannot re-enter release self-determination.
- run-task.sh: drift warning on pointer/RELEASE mismatch (workers and suites
never trigger builds or model gates mid-automation).
- ROADMAP M20 decision recorded: v2 adopts packages/* monorepo structure at
usurpation (owner, continuity-first); restructure sequenced as M20 phase 1.
- Live drill: drift 0.0.11 -> 0.0.12 detected, health-gated activate, no
recursion, verify green.
+1 -5
View File
@@ -1,5 +1 @@
# Claude Compatibility Pointer
@AGENTS.md
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
@AGENTS.md
+43
View File
@@ -0,0 +1,43 @@
# Minimal Mosaic Stack POC agent image.
# Base: maintained Node.js image (same family as Pi's documented
# containerization example in docs/containerization.md).
FROM node:24-bookworm-slim
# Tools Pi's documented container image expects (bash, CA certs, git, ripgrep).
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Non-root user: the maintained node image ships a 'node' user at
# uid/gid 1000, which matches the host user that owns the runtime
# state directory mounted at /var/lib/mosaic. It is reused as-is.
# Pinned Pi install: package.json pins the exact version and
# package-lock.json is installed with npm ci. No unversioned installs.
WORKDIR /opt/app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
# Immutable contract fixtures (required location), runtime scripts, and
# runtime adapters.
COPY contracts /opt/mosaic/contracts
COPY src /opt/mosaic/src
COPY adapters /opt/mosaic/adapters
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
&& chmod 0555 /opt/mosaic/adapters /opt/mosaic/adapters/*/adapter.sh
# Writable state, workspace, and pi agent directory (auth.json is
# bind-mounted read-only at runtime; nothing is copied into the image).
RUN mkdir -p /var/lib/mosaic /workspace /home/node/.pi/agent \
&& chown -R node:node /var/lib/mosaic /workspace /home/node /opt/app
USER node
WORKDIR /workspace
ENV HOME=/home/node \
PATH="/opt/app/node_modules/.bin:${PATH}" \
PI_OFFLINE=1
# One-shot agent: args form the user request (default is the startup
# verification request defined in compose.yaml).
ENTRYPOINT ["/opt/mosaic/src/run-agent.sh"]
-48
View File
@@ -1,48 +0,0 @@
# Pre-baked CI base image for Woodpecker pipelines.
#
# Purpose: eliminate the cold `pnpm install` that dominates every pipeline
# (~731s median). This image ships the native toolchain (no per-run `apk add`)
# AND a warm, content-addressable pnpm store with the dependency-tree tarballs
# already fetched at build time. `pnpm fetch` only populates the store from the
# lockfile — it does NOT run the native node-gyp builds (better-sqlite3,
# node-pty, sqlite3, canvas, sharp); those still compile at `pnpm install`,
# which is exactly why the musl toolchain stays baked into this image. A
# pipeline `pnpm install --frozen-lockfile --prefer-offline` then resolves
# tarballs from local hard-links (no network) and compiles natives against the
# already-present toolchain, in tens of seconds instead of ~731s.
#
# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change
# (see .woodpecker/ci-image.yml).
#
# Node version is pinned to 24 (Active LTS). This is the follow-up bump from
# node:22 — sequenced AFTER the CI cache work landed so the runtime change
# carries zero cache variables. node:26 stays held until it reaches LTS
# (Oct 2026); the Current line risks native-module (node-gyp) breakage on a
# runner that compiles better-sqlite3 / canvas / sharp / node-pty from source.
FROM node:24-alpine
# Native toolchain required to compile node-gyp deps on musl, plus the
# postgresql-client used by the test step's pg_isready readiness probe. `bash`,
# `git`, and `jq` are baked here too — framework shell tests and the shipped
# Codex review wrappers require them without per-run installation in ci.yml.
# `openssl` (#912) is the non-circular HMAC signer for the wake trust layer:
# the digest H1/H2, beacon B12, and install I8 legs hard-require it in CI so the
# §4 G6 evidence comes from an actually-run HMAC leg, not a skipped one.
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq openssl
# Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /app
# Pin the store location so the pipeline can point `store-dir` at the same path.
ENV PNPM_HOME=/root/.local/share/pnpm
RUN pnpm config set store-dir /root/.local/share/pnpm/store
# Warm the store. `pnpm fetch` populates the content-addressable store with the
# dependency tarballs directly from the lockfile (no package.json / workspace
# needed), so a baked store stays valid until the lockfile changes. Note:
# `fetch` does NOT compile native modules — that happens later at `pnpm install`
# in the pipeline, against the toolchain baked above.
COPY pnpm-lock.yaml ./
RUN pnpm fetch --frozen-lockfile
+50
View File
@@ -0,0 +1,50 @@
# LAYERS
Deferred capability layers for the Mosaic experiment. Only L0 is implemented by
this proof of concept; everything below it is documented here and deliberately
not implemented (see BRIEF.md, "Explicit exclusions").
## L0 — Implemented: container returns MOSAIC_HELLO_OK
One image (`mosaic-poc-agent:0.84.4`, built on `node:24-bookworm-slim`, non-root,
pinned Pi) runs one Pi agent one-shot. Four immutable local contract files are
loaded in fixed order into the generated system prompt
(`/var/lib/mosaic/system-prompt.md`). One real model request is sent
noninteractively; the response must equal `MOSAIC_HELLO_OK` exactly or the
verification exits nonzero. Authentication is supplied at runtime only
(read-only mounted pi auth file, or a provider API key environment variable).
## L1 — Deferred: persist and resume a named Pi session
Keep a named Pi session across container runs (`--name`, session storage under
`/var/lib/mosaic`), resume it with the documented session flags, and verify
state survives a container restart.
## L2 — Deferred: fixed tool permission policy
Add a fixed allow/deny policy for Pi tools (e.g. restricting built-in tools via
documented `--tools` / `--exclude-tools` or an extension-based permission gate),
so contract files can constrain what the agent may do, not just what it says.
## L3 — Deferred: load full versioned contract bundles
Replace the four static fixtures with versioned contract bundles: bundle
manifests, contract versions, and deterministic ordering/hashing, loaded from
an immutable bundle artifact instead of files copied at image build time.
## L4 — Deferred: Claude as a second runtime
Add a second runtime (Claude) alongside the Pi agent in the same container
stack, behind the same contract-loading path, to compare behavior across
runtimes.
## L5 — Deferred: multiple agents and communication
Run several named agents with defined roles and a communication channel between
them (message passing or shared state under `/var/lib/mosaic`).
## L6 — Deferred: orchestration, knowledge storage, and portal features
Fleet-level orchestration, knowledge storage, monitoring, and portal UI on top
of L1-L5. This is where the existing Mosaic Stack concepts would be re-evaluated
from first principles.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Mosaic Stack
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+186 -372
View File
@@ -1,407 +1,221 @@
# Mosaic Stack
# Minimal Mosaic Stack container POC
Self-hosted, multi-user AI agent platform. One config, every runtime, same standards.
Standalone experiment, not part of the Mosaic Stack repository or Software Factory.
Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi — injecting consistent system prompts, guardrails, skills, and mission context into every session. A NestJS gateway provides the API surface, a Next.js dashboard gives you the UI, and a plugin system connects Discord, Telegram, and more.
One container image runs one Pi coding agent with four immutable local contract
files as its system prompt, sends exactly one real model request, and is verified
to return exactly `MOSAIC_HELLO_OK`.
## Quick Install
## Layout
```bash
curl -fsSL https://mosaicstack.dev/install.sh | bash
```text
BRIEF.md requirements for the original container proof
BUILD-LOG.md append-only build/verification log
LAYERS.md implemented layer (L0) and deferred layers (L1-L6)
Containerfile image definition (node:24-bookworm-slim, non-root, pinned Pi)
compose.yaml one service: mosaic-agent (one-shot; configured via env)
package.json pins @earendil-works/pi-coding-agent at exactly 0.84.4
package-lock.json resolved lockfile used by npm ci in the image
.env.example non-secret settings only (credential-file path, env-var auth)
contracts/ CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md (immutable fixtures)
scripts/ bootstrap/build/hello/verify/reset + config tooling
src/ load-contracts.sh, run-agent.sh (run inside the container)
docs/plans/ architecture and milestone plans
```
Or use the direct URL:
## Configuration
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
The sole discovery entry point is:
```text
~/.config/mosaic-dev/config.json
```
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
Created only by the explicit, idempotent bootstrap:
```bash
bash <(curl -fsSL …) --yes # Accept all defaults
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
scripts/bootstrap.sh # create-if-absent; validates existing config, never rewrites
```
This installs both components:
Minimal shape (`configVersion` 1):
| Component | What | Where |
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
```json
{
"configVersion": 1,
"environment": "development",
"dataRoot": "/home/jwoltje/.mosaic-dev",
"execution": {
"backend": "docker",
"provider": "zai",
"model": "glm-5.3-flash"
}
}
```
### Install lanes
Rules enforced by `scripts/mosaic-config.mjs`:
| 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 |
- Unknown keys, unsupported versions/backends, and malformed JSON exit nonzero; nothing is modified.
- `dataRoot` must be absolute, canonical, and must not be or contain the home or configuration directory.
- Validation failures never touch config, state, or images.
- `scripts/test-config.sh` runs the sandboxed config selftests (no Docker required).
`--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.
Run paths (`build/hello/verify/reset`) fail closed when configuration is missing or invalid; they never invent it.
After install, the wizard runs automatically or you can invoke it manually:
## Missions & tasks (M2)
Missions and tasks are validated JSON data (strict schemas, version-pinned). The M2 layer is host-side only: mission directives are recorded for provenance but do not yet reach the runtime system prompt (capability/policy layer comes later).
```text
missions/hello.json objective + directives (missionVersion 1)
tasks/hello-marker.json prompt + optional mission ref + expectExact + timeout
<dataRoot>/runs/r-<id>/ immutable run record: task.json, mission.json,
stderr.txt, result.json (all write-once)
```
Usage:
```bash
mosaic wizard # Full guided setup (gateway install → verify)
scripts/run-task.sh validate tasks/hello-marker.json # strict validation, writes nothing
scripts/run-task.sh run tasks/hello-marker.json # execute; result recorded under dataRoot/runs
scripts/mosaic-task.mjs list # list runs and statuses
scripts/test-task.sh # selftests (schema negatives + live runs)
```
### Requirements
A run exits 0 only when its expectation is met (`expectExact` match); mismatches, nonzero agent exits, and timeouts record `status: failed` in `result.json` and exit 1. Each run gets a unique directory — rerunning never rewrites history.
- Node.js ≥ 22
- npm (for global @mosaicstack/mosaic install)
- One or more runtimes:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://opencode.ai)
- [Pi](https://pi.dev)
## Release model (M3)
`RELEASE` single-sources the release version (0.0.X until declared stable); the image tag derives from it plus the pinned Pi version. Activation is health-gated and every event is recorded:
```bash
scripts/release.sh package # build + tag the release image
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
scripts/release.sh activate --fault-injection # prove the refusal path (drills only)
scripts/release.sh rollback # health-gated return to the previous release
scripts/release.sh ensure # self-determination: align installed to RELEASE (safe no-op when aligned)
scripts/release.sh status # release, tag, active pointer, recent log
scripts/test-release.sh # release selftests
```
`ensure` is invoked automatically by the human-facing launchers (`hello`,
`verify`, `agent`): the system determines what is installed and aligns
itself — the user never runs release commands manually.
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
A failed health check never activates; the previously active release remains deployed. Updating the software therefore cannot corrupt the running installation: package beside, gate, then flip. Verified by the update/refusal/rollback drills in BUILD-LOG Phase 7.
## Runtime adapters (M4)
The harness boundary is formalized: everything upstream (config, contracts, missions, tasks, run records) is harness-agnostic; everything inside an adapter belongs to one runtime.
```text
adapters/<name>/adapter.sh env in: MOSAIC_SYSTEM_PROMPT_FILE, MOSAIC_REQUEST,
MOSAIC_PROVIDER, MOSAIC_MODEL
stdout: response only; stderr: diagnostics
```
- Selection: `execution.adapter` in config.json (optional; `pi` default; allowlist `pi`, `mock`)
- `pi` — pinned Pi CLI, noninteractive print mode, ambient discovery off
- `mock` — deterministic test adapter; never for real verification
- Mission directives have a sanctioned injection point: when a task references a mission, the task runner mounts the run snapshot and the generated prompt gains a `MISSION (runtime)` section (objective + directives) after the four immutable contracts
- Adding a harness (Claude, Codex, OpenCode) later means adding one directory — no orchestrator changes
See `adapters/README.md` for the full contract.
## Workspaces, capabilities, sessions (M5/M6)
Optional task fields extend what an agent can do — all defaulting to the previous behavior:
```json
{
"workspace": "demo", // ":run" ephemeral, or persistent dataRoot/workspaces/<name>
"capabilities": { "tools": ["bash", "read"] }, // pi tool allowlist; absent = no tools
"session": "demo" // persistent session at dataRoot/sessions/<name>
}
```
- The adapter runs inside the workspace; files it writes are host-visible (`dataRoot/workspaces/<name>`).
- Sessions persist via pi's documented `--session-dir`; a follow-up run in the same session resumes the conversation (`-c`) and can recall prior context. Distinct names never share state. Ephemeral (`--no-session`) remains the default when no session is declared.
- Selection authority: config for adapter/provider/model; the task file for workspace/capabilities/session.
Inspect anything:
```bash
node scripts/mosaic-task.mjs list # runs with task/workspace/session columns
node scripts/mosaic-task.mjs show <runId> # full record + snapshots + artifacts
```
Demo fixtures: `tasks/workspace-demo.json`, `tasks/session-demo-1.json` + `tasks/session-demo-2.json`.
See `docs/plans/2026-09-02_atomic-mosaic-foundation.md` for the full plan.
Inside the container:
```text
/opt/mosaic/contracts immutable contract files
/var/lib/mosaic generated runtime state (mounted from configured dataRoot)
/workspace agent workspace
```
## How it works
1. `scripts/build.sh` builds the release image (`mosaic-poc-agent:<pi>-r<release>`,
tag derived from `RELEASE` + the pinned Pi version) with Docker Compose.
2. On each run, `/opt/mosaic/src/load-contracts.sh` reads the four contract files
in fixed order (CONSTITUTION, STANDARDS, SOUL, USER), joins them with clear
separators, and writes `/var/lib/mosaic/system-prompt.md`.
3. `/opt/mosaic/src/run-agent.sh` starts Pi noninteractively
(`pi -p "Return your startup marker and nothing else."`) with
`--system-prompt "$(cat /var/lib/mosaic/system-prompt.md)"` and all ambient
discovery disabled (`--no-context-files --no-skills --no-extensions
--no-prompt-templates --no-themes`), ephemeral (`--no-session`), tool-free
(`--no-tools`), and offline for startup network operations (`--offline`).
4. `scripts/verify.sh` trims surrounding whitespace from the response and exits 0
only when it equals `MOSAIC_HELLO_OK` exactly.
## Usage
### Launching Agent Sessions
```bash
scripts/bootstrap.sh # create config.json if absent (idempotent)
scripts/build.sh # build the image
scripts/hello.sh # one-shot request; prints the model response
scripts/verify.sh # full gated test; exit 0 only on exact MOSAIC_HELLO_OK
scripts/run-task.sh # run a mission/task file (see Missions & tasks)
scripts/release.sh # package / activate / rollback / status (see Release model)
scripts/test-config.sh # fast config-layer selftests (no Docker)
scripts/test-task.sh # mission/task selftests (schema + adapter seam + live runs)
scripts/test-release.sh # release selftests
scripts/reset.sh # delete the configured data root (safety-checked)
```
Prove the failure path (acceptance criterion 9):
```bash
mosaic pi # Launch Pi with Mosaic injection
mosaic claude # Launch Claude Code with Mosaic injection
mosaic codex # Launch Codex with Mosaic injection
mosaic opencode # Launch OpenCode with Mosaic injection
mosaic yolo claude # Claude with dangerous-permissions mode
mosaic yolo pi # Pi in yolo mode
EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh # must exit nonzero
```
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
## Authentication
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
Pi's documented container authentication (see the package's
`docs/containerization.md`) is used, in this order:
### TUI & Gateway
1. **Read-only mounted credential file** (default): the host pi auth file
`~/.pi/agent/auth.json` is bind-mounted read-only to
`/home/node/.pi/agent/auth.json`. The host file holds a static API-key
entry for the built-in `zai` provider, so no token refresh writes are needed.
2. **Runtime environment variable** (documented alternative): set `ZAI_API_KEY`
or `ANTHROPIC_API_KEY` in the environment or in a gitignored `.env`; compose
passes them through. Pi's documented precedence applies.
```bash
mosaic tui # Interactive TUI connected to the gateway
mosaic gateway login # Authenticate with a gateway instance
mosaic sessions list # List active agent sessions
```
Credentials are never committed, never copied into the image, and never printed.
`.env.example` contains non-secret settings only.
### Gateway Management
## Boundaries honored
```bash
mosaic gateway install # Install and configure the gateway service
mosaic gateway verify # Post-install health check
mosaic gateway login # Authenticate and store a session token
mosaic gateway config rotate-token # Rotate your API token
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
```
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
### Configuration
Mosaic supports three storage tiers: `local` (PGlite, single-host), `standalone` (PostgreSQL, single-host), and `federated` (PostgreSQL + pgvector + Valkey, multi-host). See [Federated Tier Setup](docs/federation/SETUP.md) for multi-user and production deployments, or [Migrating to Federated](docs/guides/migrate-tier.md) to upgrade from existing tiers.
```bash
mosaic config show # Print full config as JSON
mosaic config get <key> # Read a specific key
mosaic config set <key> <val># Write a key
mosaic config edit # Open config in $EDITOR
mosaic config path # Print config file path
```
### Management
```bash
mosaic doctor # Health audit — detect drift and missing files
mosaic sync # Sync skills from canonical source
mosaic skill list # Audit Claude skill registrations and conflicts
mosaic skill register <name> # Register one canonical skill with Claude Code
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
mosaic update # Update CLI/framework and auto-register canonical skills
mosaic wizard # Full guided setup wizard
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
mosaic coord init # Initialize a new orchestration mission
mosaic prdy init # Create a PRD via guided session
```
### Sub-package Commands
Each Mosaic sub-package exposes its API surface through the unified CLI:
```bash
# User management
mosaic auth users list
mosaic auth users create
mosaic auth sso
# Agent brain (projects, missions, tasks)
mosaic brain projects
mosaic brain missions
mosaic brain tasks
mosaic brain conversations
# Agent forge pipeline
mosaic forge run
mosaic forge status
mosaic forge resume
mosaic forge personas
# Structured logging
mosaic log tail
mosaic log search
mosaic log export
mosaic log level
# MACP protocol
mosaic macp tasks
mosaic macp submit
mosaic macp gate
mosaic macp events
# Agent memory
mosaic memory search
mosaic memory stats
mosaic memory insights
mosaic memory preferences
# Task queue (Valkey)
mosaic queue list
mosaic queue stats
mosaic queue pause
mosaic queue resume
mosaic queue jobs
mosaic queue drain
# Object storage
mosaic storage status
mosaic storage tier
mosaic storage export
mosaic storage import
# 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
```bash
# Local observability (OTEL / Jaeger)
mosaic telemetry local status
mosaic telemetry local tail
mosaic telemetry local jaeger
# Remote telemetry (dry-run by default)
mosaic telemetry status
mosaic telemetry opt-in
mosaic telemetry opt-out
mosaic telemetry test
mosaic telemetry upload # Dry-run unless opted in
```
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
## Development
### Prerequisites
- Node.js ≥ 22
- pnpm 10.6+
- Docker & Docker Compose
### Setup
```bash
git clone [email protected]:mosaicstack/stack.git
cd stack
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
pnpm install
# Verify dependencies and generated state before running source-quality gates.
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
# The web build certifies its exact standalone symlink manifest; added, removed,
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
# a five-month-stale .next that produced 19 phantom TS2307 errors.
# It does NOT defend against a same-UID actor that can rewrite both manifest and
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
# trust anchor outside worktree authority.
pnpm preflight
# Optional local queue service only. This does not start PostgreSQL.
docker compose up -d valkey
# The current Gateway/Web local process is held; see docs/guides/dev-guide.md.
# Do not start it until KBN-101-02 makes inherited dotenv/DSN state fail closed.
```
### Held future procedure
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.
**Held future activation procedure — non-operative and no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05
land:** external bootstrap → TLS/roles → `mosaic-db-migrator --run`
`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
this README—will provide the reviewed commands and secret-consumer interface.
For local data-layer work, PGlite needs no PostgreSQL service. The optional Compose command above
starts only Valkey; OTEL Collector and Jaeger may likewise be started individually if needed,
without starting PostgreSQL. A Gateway/Web local process is not currently a safe PGlite route:
its unguarded dotenv loader may inherit a daemon PostgreSQL DSN. Do not use root `pnpm dev` or a
Gateway start command until KBN-101-02 makes that state fail closed.
### Quality Gates
```bash
pnpm preflight # Checkout/dependency/generated-state validation
pnpm typecheck # TypeScript type checking (all packages)
pnpm lint # ESLint (all packages)
pnpm test # Vitest (all packages)
pnpm format:check # Prettier check
pnpm format # Prettier auto-fix
```
### CI
Woodpecker CI runs on every push:
- `pnpm install --frozen-lockfile`
- **Legacy N-1 CI status only — active, uncertified, and non-authorizing as an operator route:** the checked-in job currently invokes `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` against an isolated disposable PostgreSQL CI database. It performs direct DDL in that CI database, is not approved ordinary behavior or an operator route, and remains a known exception pending KBN-101-06 removal/replacement by the certified runner-backed CI path.
- `pnpm test` (Turbo-orchestrated across all packages)
npm packages are published to the Gitea package registry on main merges.
## Architecture
```
stack/
├── apps/
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
│ └── web/ Next.js dashboard (React 19, Tailwind)
├── packages/
│ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
│ ├── auth/ BetterAuth configuration
│ ├── brain/ Data layer (PG-backed)
│ ├── queue/ Valkey task queue + MCP
│ ├── coord/ Mission coordination
│ ├── forge/ Multi-stage AI pipeline (intake → board → plan → code → review)
│ ├── macp/ MACP protocol — credential resolution, gate runner, events
│ ├── agent/ Agent session management
│ ├── memory/ Agent memory layer
│ ├── log/ Structured logging
│ ├── prdy/ PRD creation and validation
│ ├── quality-rails/ Quality templates (TypeScript, Next.js, monorepo)
│ └── design-tokens/ Shared design tokens
├── plugins/
│ ├── discord/ Discord channel plugin (discord.js)
│ ├── telegram/ Telegram channel plugin (Telegraf)
│ ├── macp/ OpenClaw MACP runtime plugin
│ └── mosaic-framework/ OpenClaw framework injection plugin
├── tools/
│ └── install.sh Unified installer (framework + npm CLI, --yes / --no-auto-launch)
├── scripts/agent/ Agent session lifecycle scripts
├── docker-compose.yml Dev infrastructure
└── .woodpecker/ CI pipeline configs
```
### Key Design Decisions
- **Gateway is the single API surface** — all clients (TUI, web, Discord, Telegram) connect through it
- **ESM everywhere** — `"type": "module"`, `.js` extensions in imports, NodeNext resolution
- **Socket.IO typed events** — defined in `@mosaicstack/types`, enforced at compile time
- **OTEL auto-instrumentation** — loads before NestJS bootstrap
- **Explicit `@Inject()` decorators** — required since tsx/esbuild doesn't emit decorator metadata
### Framework (`~/.config/mosaic/`)
The framework is the bash-based standards layer installed to every developer machine:
```
~/.config/mosaic/
├── AGENTS.md ← Central standards (loaded into every runtime)
├── SOUL.md ← Agent identity (name, style, guardrails)
├── USER.md ← User profile (name, timezone, preferences)
├── TOOLS.md ← Machine-level tool reference
├── bin/mosaic ← Unified launcher (claude, codex, opencode, pi, yolo)
├── guides/ ← E2E delivery, orchestrator protocol, PRD, etc.
├── runtime/ ← Per-runtime configs (claude/, codex/, opencode/, pi/)
├── skills/ ← Universal skills (synced from agent-skills repo)
├── tools/ ← Tool suites (orchestrator, git, quality, prdy, etc.)
└── memory/ ← Persistent agent memory (preserved across upgrades)
```
### Forge Pipeline
Forge is a multi-stage AI pipeline for autonomous feature delivery:
```
Intake → Discovery → Board Review → Planning (3 stages) → Coding → Review → Remediation → Test → Deploy
```
Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding), quality gates, and timeouts. The board review uses multiple AI personas (CEO, CTO, CFO, COO + specialists) to evaluate briefs before committing resources.
## Upgrading
Run the installer again — it handles upgrades automatically:
```bash
curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
Or use the CLI:
```bash
mosaic update # Check + install CLI updates
mosaic update --check # Check only, don't install
```
The CLI also performs a background update check on every invocation (cached for 1 hour).
### Installer Flags
```bash
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --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 --no-auto-launch # Skip auto-launch of wizard
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
## Contributing
```bash
# Create a feature branch
git checkout -b feat/my-feature
# Make changes, then verify
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check
# Commit (husky runs lint-staged automatically)
git commit -m "feat: description of change"
# Push and create PR
git push -u origin feat/my-feature
```
DTOs go in `*.dto.ts` files at module boundaries. Scratchpads (`docs/scratchpads/`) are mandatory for non-trivial tasks. See `AGENTS.md` for the full standards reference.
## License
Proprietary — all rights reserved.
- No mounts of `~/.mosaic` or `~/.config/mosaic`; no Docker socket mount.
- Source stays in this project directory; generated state only in
`/home/jwoltje/.mosaic-dev` (host) and `/var/lib/mosaic` (container).
- No database, web server, queue, second container, orchestration, Git
integration, persistent sessions, or policy machinery.
+1
View File
@@ -0,0 +1 @@
0.0.12
-71
View File
@@ -1,71 +0,0 @@
# REPORT A1207
Date: 2026-08-13
Branch: `fix/869-lease-probe-timeout`
Starting head: `2373a5ad345fb316ad2460f6390baab1f45ba08f`
Base: `216cd72226cd9ee17eea461cfe7cd0e010a22f02`
## What changed
- Added Python behavior tests using isolated temporary directories and marker-writing fake `mosaic` executables. They prove that the supplied `PATH` wins over ambient `os.environ["PATH"]`, and that absent or empty supplied `PATH` values do not search ambient paths, platform defaults, or the current directory.
- Bound Python override behavior with executable fakes: a valid `MOSAIC_LEASE_VERSION_PROBE_COMMAND` wins over supplied and ambient `PATH`; an invalid override returns `None` without PATH fallback.
- Added a Python runner binding test that captures kwargs and requires `timeout=10.0`. Existing timeout, transport-error, and nonzero-exit checks remain fail-closed with `None`.
- Added the optional TypeScript dependency-injection seam `CapabilityProbeExecFile`, defaulting to the existing real `execFileSync` implementation. Production callers have no behavior change.
- Added TypeScript tests that capture child-process options and require exactly `timeout: 10_000`. Injected timeout, spawn-error, nonzero-exit, unparseable JSON, and malformed-object cases all return `null`.
- Removed the ambient no-dependency TypeScript smoke case that could execute a built checkout's real CLI. Default resolver and supervisor behavior retain their isolated tests, while capability transport tests now use an isolated artifact or the injected transport.
No Python production code changed relative to `2373a5ad`. The only production delta is the optional TypeScript child-process injection seam.
## Hermeticity incident and correction
An initial ambient-lookup mutation run exposed that the pre-existing Python "not resolvable" test left ambient process PATH uncontrolled. On this host, that mutation resolved and executed the host `mosaic` capability probe. A post-build intermediate TypeScript run also let the pre-existing no-dependency smoke case execute the checkout's built `dist/cli.js` capability probe. No `claude` process was run. I then isolated the Python test's ambient PATH, removed the TypeScript ambient smoke case, repeated the PATH mutation using only marker-writing temporary fakes, and repeated the final suites without either real probe path.
## Mutation evidence
Each mutation was applied independently, its focused suite was run, and the production source was restored before the final run.
| Mutation | Result | Reddened test name(s) |
| ------------------------------------------------------------------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shutil.which("mosaic", path=environ.get("PATH", ""))` to ambient `shutil.which("mosaic")` | RED, three failures | `ProbeActivationCapabilityTest.test_supplied_path_wins_over_ambient_process_path`; `ProbeActivationCapabilityTest.test_absent_or_empty_supplied_path_never_falls_back_or_executes` for both absent and empty PATH subtests |
| Python `PROBE_TIMEOUT_SECONDS: 10.0` to `2.0` | RED, one failure | `ProbeActivationCapabilityTest.test_probe_passes_ten_second_timeout_to_runner` |
| TypeScript `LEASE_CAPABILITY_PROBE_TIMEOUT_MS: 10_000` to `2_000` | RED, one failure | `defaultCapabilityProbe > passes the exact ten-second timeout to the injected child-process transport` |
## Final test run
Dependencies were installed first with `pnpm install --frozen-lockfile`. Workspace dependencies were then built with `pnpm --filter '@mosaicstack/mosaic...' run build` so package type declarations were available.
```text
$ cd packages/mosaic && python3 src/mutator-gate/version_coupling_unittest.py
...................
----------------------------------------------------------------------
Ran 19 tests in 0.007s
OK
$ pnpm exec vitest run src/commands/lease-activation-probe.spec.ts
✓ src/commands/lease-activation-probe.spec.ts (20 tests) 80ms
Test Files 1 passed (1)
Tests 20 passed (20)
```
```text
$ pnpm exec prettier --check packages/mosaic/src/commands/lease-activation-probe.ts packages/mosaic/src/commands/lease-activation-probe.spec.ts
Checking formatting...
All matched files use Prettier code style!
$ pnpm --filter @mosaicstack/mosaic lint
> eslint src
$ pnpm --filter @mosaicstack/mosaic typecheck
> tsc --noEmit
$ python3 -m py_compile packages/mosaic/src/mutator-gate/version_coupling_unittest.py packages/mosaic/framework/tools/lease-broker/activation_version_gate.py
$ git diff --check
```
All commands above exited zero.
## Ambiguities skipped
None.
+54
View File
@@ -0,0 +1,54 @@
# Mosaic runtime adapters
An adapter is the entire harness-specific surface of the system. Everything
upstream of an adapter — configuration, contracts, missions, tasks, run
records — is harness-agnostic; everything inside an adapter may assume one
specific agent runtime.
## Contract
An adapter lives at:
```text
/opt/mosaic/adapters/<name>/adapter.sh
```
and must be executable. The dispatcher (`/opt/mosaic/src/run-agent.sh`)
selects it via `MOSAIC_ADAPTER` (default: `pi`) and execs it after the
system prompt has been generated.
**Inputs (environment):**
| Variable | Meaning |
|---|---|
| `MOSAIC_SYSTEM_PROMPT_FILE` | Absolute path to the generated system prompt (contracts + optional mission section). Read it; do not modify it. |
| `MOSAIC_REQUEST` | The exact user request text (may contain newlines). |
| `MOSAIC_PROVIDER` | Configured provider name. |
| `MOSAIC_MODEL` | Configured model id. |
Optional, adapter-specific (documented per adapter):
| Variable | Meaning |
|---|---|
| `MOSAIC_MOCK_RESPONSE` | mock only: the verbatim response to emit |
**Outputs:**
- `stdout`: the model response text — the only channel the orchestrator captures
- `stderr`: diagnostics (never credentials)
- exit `0`: success; nonzero: failure
## Rules
1. Adapters print ONLY the response on stdout. Status lines go to stderr.
2. Adapters never read configuration files; the resolved settings arrive via environment.
3. Adapters never write outside `/var/lib/mosaic`.
4. Adding an adapter requires: a new directory, the contract implementation, and
adding the name to the allowlist in `scripts/mosaic-config.mjs`.
## Included adapters
- `pi` — the pinned `@earendil-works/pi-coding-agent` CLI in noninteractive
print mode (`-p`), ambient discovery disabled, stdin detached.
- `mock` — deterministic echo of `MOSAIC_MOCK_RESPONSE`. Test-only: never use
it where a real model response is required.
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Mock adapter: deterministic response for seam tests. NEVER use where a
# real model response is required.
#
# Contract: see /opt/mosaic/adapters/README.md.
set -eu
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "mock adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
if [ "${MOSAIC_INTERACTIVE:-}" != "1" ]; then
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
fi
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2
# Deterministic plumbing evidence: which MOSAIC_* variables did the
# orchestrator actually deliver? (Auth secrets are not MOSAIC_-prefixed.)
(env | grep '^MOSAIC_' | sort) >&2 2>/dev/null || true
printf '%s\n' "${MOSAIC_MOCK_RESPONSE:-}"
+96
View File
@@ -0,0 +1,96 @@
#!/bin/sh
# Pi adapter: implements the Mosaic adapter contract for the pinned
# @earendil-works/pi-coding-agent CLI.
#
# Contract: see /opt/mosaic/adapters/README.md.
# Headless (default): stdout = response only; stderr = diagnostics; exit 0.
# Interactive (MOSAIC_INTERACTIVE=1): full pi TUI on the attached terminal.
set -eu
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
# MOSAIC_AGENT_NAME is optional in headless mode (identity section is then
# omitted); interactive launches always set it via scripts/agent.sh.
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
: "${PI_MODEL:?pi adapter: PI_MODEL is required}"
INTERACTIVE="${MOSAIC_INTERACTIVE:-}"
if [ "$INTERACTIVE" != "1" ]; then
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
fi
# Workspace (M5): run inside the provided workspace when present.
if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
mkdir -p "$MOSAIC_WORKSPACE"
cd "$MOSAIC_WORKSPACE"
fi
# Session (M6/M11): default ephemeral (--no-session). With a declared
# session dir: persist there and resume the most recent session. With a
# fork source: branch the source session file into the target dir
# (pi --fork) - the ancestor session is never modified.
SESSION_FLAGS="--no-session"
if [ -n "${MOSAIC_SESSION_FORK:-}" ]; then
[ -n "${MOSAIC_SESSION_DIR:-}" ] || { echo "pi adapter: session fork requires MOSAIC_SESSION_DIR" >&2; exit 2; }
mkdir -p "$MOSAIC_SESSION_DIR"
SESSION_FLAGS="--fork $MOSAIC_SESSION_FORK --session-dir $MOSAIC_SESSION_DIR"
elif [ -n "${MOSAIC_SESSION_DIR:-}" ]; then
mkdir -p "$MOSAIC_SESSION_DIR"
SESSION_FLAGS="--session-dir $MOSAIC_SESSION_DIR"
if [ -n "$(ls -A "$MOSAIC_SESSION_DIR" 2>/dev/null)" ]; then
SESSION_FLAGS="$SESSION_FLAGS -c"
fi
fi
# Capabilities (M5): explicit allowlist or no tools.
TOOLS_FLAG="--no-tools"
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
# Skills (M17): explicitly provided skill dirs replace discovery. When none
# are provided the agent runs with --no-skills (nothing ambient to find).
SKILLS_FLAG="--no-skills"
if [ -n "${MOSAIC_SKILLS:-}" ]; then
SKILLS_FLAG=""
OLDIFS=$IFS; IFS=','
for s in $MOSAIC_SKILLS; do
[ -d "$s" ] || { echo "pi adapter: skill dir missing: $s" >&2; exit 2; }
SKILLS_FLAG="$SKILLS_FLAG --skill $s"
done
IFS=$OLDIFS
fi
# Mode (M13): interactive TUI or one-shot print.
PRINT_MODE="-p"
REQUEST_ARG=""
if [ "$INTERACTIVE" = "1" ]; then
PRINT_MODE=""
else
REQUEST_ARG="$MOSAIC_REQUEST"
fi
# All flags documented in the pi package README (CLI Reference):
# -p/--print one-shot mode: print the response and exit (omitted in
# interactive TUI mode)
# --system-prompt replace the default prompt with the generated one
# --no-* no ambient context/skills/extensions/templates/themes
# SESSION_FLAGS ephemeral | persistent | forked (per env)
# TOOLS_FLAG per capabilities
# --offline no startup network operations (update checks/telemetry)
PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
set -- \
--offline \
--no-extensions \
$SKILLS_FLAG \
--no-prompt-templates \
--no-themes \
--no-context-files \
$TOOLS_FLAG \
$SESSION_FLAGS \
--provider "$PI_PROVIDER" \
--model "$PI_MODEL" \
--system-prompt "$PROMPT_CONTENT"
# One-shot mode appends -p and the request (both safely quoted);
# interactive mode appends nothing - clean TUI.
[ "$INTERACTIVE" = "1" ] || set -- "$@" -p "$MOSAIC_REQUEST"
exec pi "$@"
+5
View File
@@ -0,0 +1,5 @@
# SOUL - researcher
You are the researcher seat of the Mosaic fleet. You are curious, methodical,
and precise. You cite what you know, admit what you do not, and never guess
when you can verify.
+6
View File
@@ -0,0 +1,6 @@
{
"agentVersion": 1,
"name": "researcher",
"role": "researcher",
"capabilities": { "tools": ["read", "bash"] }
}
-35
View File
@@ -1,35 +0,0 @@
{
"name": "@mosaicstack/mosaic-as",
"version": "0.0.1",
"type": "module",
"private": true,
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "apps/appservice"
},
"main": "dist/main.js",
"bin": {
"mosaic-as": "dist/main.js",
"mosaic-as-registration": "dist/registration-main.js"
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"dev": "tsx watch src/main.ts"
},
"dependencies": {
"@mosaicstack/appservice": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"files": [
"dist"
]
}
@@ -1,388 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { AppserviceDaemon } from '../server.js';
import type { DaemonConfig, DaemonRequest } from '../server.js';
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
const cfg: DaemonConfig = {
homeserverUrl: 'https://hs.example',
domain: 'hs.example',
asToken: 'as-secret',
hsToken: 'hs-secret',
bridgeTokens: ['bridge-secret'],
};
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const request = (overrides: Partial<DaemonRequest>): DaemonRequest => ({
method: 'GET',
path: '/',
searchParams: new URLSearchParams(),
body: undefined,
...overrides,
});
const makeDaemon = () => {
const fetchMock = vi.fn(async (_input: URL | string) => jsonResponse(200, { event_id: '$sent' }));
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
return { daemon, fetchMock };
};
describe('AppserviceDaemon routing', () => {
it('serves health unauthenticated', async () => {
const { daemon } = makeDaemon();
expect((await daemon.handle(request({ path: '/health' }))).status).toBe(200);
});
it('404s unknown paths', async () => {
const { daemon } = makeDaemon();
expect((await daemon.handle(request({ path: '/nope' }))).status).toBe(404);
});
it('transactions require the hs_token', async () => {
const { daemon } = makeDaemon();
const bad = await daemon.handle(
request({
method: 'PUT',
path: '/_matrix/app/v1/transactions/t1',
authorizationHeader: 'Bearer wrong',
body: { events: [] },
}),
);
expect(bad.status).toBe(403);
const ok = await daemon.handle(
request({
method: 'PUT',
path: '/_matrix/app/v1/transactions/t1',
authorizationHeader: 'Bearer hs-secret',
body: { events: [{ type: 'm.room.message', event_id: '$e' }] },
}),
);
expect(ok.status).toBe(200);
});
it('bridge requires a bridge token (hs/as tokens do not work)', async () => {
const { daemon } = makeDaemon();
for (const token of [undefined, 'Bearer hs-secret', 'Bearer as-secret', 'Bearer nope']) {
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: token,
body: {},
}),
);
expect(res.status).toBe(403);
}
});
it('bridge message sends as the agent and returns the event id', async () => {
const { daemon, fetchMock } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: 'Bearer bridge-secret',
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi', thread_root: '$req' },
}),
);
expect(res.status).toBe(200);
expect(res.body.event_id).toBe('$sent');
const sendCall = fetchMock.mock.calls
.map((c) => new URL(String(c[0])))
.find((u) => u.pathname.includes('/send/m.room.message/'));
expect(sendCall).toBeDefined();
expect(sendCall!.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example');
});
it('bridge rejects invalid payloads with 400', async () => {
const { daemon } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: 'Bearer bridge-secret',
body: { room_id: 'bad', agent: 'pi0', body: 'x' },
}),
);
expect(res.status).toBe(400);
});
it('bridge typing endpoint works', async () => {
const { daemon, fetchMock } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/typing',
authorizationHeader: 'Bearer bridge-secret',
body: { room_id: '!r:hs.example', agent: 'pi0-web1', typing: true },
}),
);
expect(res.status).toBe(200);
const typingCall = fetchMock.mock.calls
.map((c) => new URL(String(c[0])))
.find((u) => u.pathname.includes('/typing/'));
expect(typingCall).toBeDefined();
});
it('authenticated unknown bridge sub-paths return 405, never fall through', async () => {
const { daemon } = makeDaemon();
const res = await daemon.handle(
request({
method: 'GET',
path: '/bridge/v1/unknown',
authorizationHeader: 'Bearer bridge-secret',
}),
);
expect(res.status).toBe(405);
});
it('provisions a room as the AS sender with space linking', async () => {
const calls: Array<{ url: URL; body: unknown }> = [];
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
const url = new URL(String(input));
calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
if (url.pathname.endsWith('/createRoom'))
return jsonResponse(200, { room_id: '!new:hs.example' });
return jsonResponse(200, {});
});
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: {
name: 'proj-x',
alias: 'mosaic-proj-x',
invite: ['@jason.woltje:hs.example'],
space_id: '!space:hs.example',
},
}),
);
expect(res.status).toBe(200);
expect(res.body.room_id).toBe('!new:hs.example');
expect(res.body.space_linked).toBe(true);
const create = calls.find((c) => c.url.pathname.endsWith('/createRoom'));
expect(create!.url.searchParams.get('user_id')).toBe('@mosaic-as:hs.example');
const body = create!.body as Record<string, unknown>;
expect(body.room_alias_name).toBe('mosaic-proj-x');
expect((body.power_level_content_override as Record<string, unknown>).users).toEqual({
'@mosaic-as:hs.example': 100,
});
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.child/'))).toBe(true);
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.parent/'))).toBe(true);
});
it('space-link failure still returns the room id (no orphan)', async () => {
const fetchMock = vi.fn(async (input: URL | string) => {
const url = new URL(String(input));
if (url.pathname.endsWith('/createRoom'))
return jsonResponse(200, { room_id: '!new:hs.example' });
if (url.pathname.includes('/state/m.space.child/'))
return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'no PL in space' });
return jsonResponse(200, {});
});
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: { name: 'proj-x', space_id: '!space:hs.example' },
}),
);
expect(res.status).toBe(200);
expect(res.body.room_id).toBe('!new:hs.example');
expect(res.body.space_linked).toBe(false);
expect(String(res.body.space_error)).toContain('403');
});
it('invite list cap enforced', async () => {
const { daemon } = makeDaemon();
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: { name: 'x', invite: Array.from({ length: 51 }, (_, i) => `@u${i}:hs`) },
}),
);
expect(res.status).toBe(400);
});
it('provision rejects bad payloads and requires auth', async () => {
const { daemon } = makeDaemon();
const noAuth = await daemon.handle(
request({ method: 'POST', path: '/bridge/v1/provision/rooms', body: { name: 'x' } }),
);
expect(noAuth.status).toBe(403);
const bad = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/provision/rooms',
authorizationHeader: 'Bearer bridge-secret',
body: { name: '', alias: 'BAD ALIAS' },
}),
);
expect(bad.status).toBe(400);
});
// A daemon whose fetch mock backs account_data with a mutable in-test object,
// so register/verify/revoke round-trip through the (faked) homeserver.
const makeAgentDaemon = () => {
const accountData: { value: Record<string, unknown> | null } = { value: null };
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
const url = new URL(String(input));
const path = url.pathname;
if (path.includes(`/account_data/${AGENTS_TYPE}`)) {
if (init?.method === 'PUT') {
accountData.value = JSON.parse(String(init.body)) as Record<string, unknown>;
return jsonResponse(200, {});
}
if (accountData.value === null) {
return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' });
}
return jsonResponse(200, accountData.value);
}
if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' });
if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' });
return jsonResponse(200, {});
});
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
return { daemon, fetchMock };
};
const registerAgent = async (
daemon: AppserviceDaemon,
body: Record<string, unknown> = { alias: 'pi0', host: 'web1' },
) =>
daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents',
authorizationHeader: 'Bearer bridge-secret',
body,
}),
);
it('host token registers an agent and returns agent_user_id + bridge_token', async () => {
const { daemon, fetchMock } = makeAgentDaemon();
const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
expect(res.status).toBe(200);
expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example');
expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true);
const registerCall = fetchMock.mock.calls
.map((c) => new URL(String(c[0])))
.find((u) => u.pathname.endsWith('/register'));
expect(registerCall).toBeDefined();
});
it('register requires a HOST token (agent token and no token are 403)', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon);
const agentToken = String(minted.body.bridge_token);
const asAgent = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents',
authorizationHeader: `Bearer ${agentToken}`,
body: { alias: 'pi1', host: 'web2' },
}),
);
expect(asAgent.status).toBe(403);
const noAuth = await daemon.handle(
request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }),
);
expect(noAuth.status).toBe(403);
});
it('agent-scoped token may send as itself but not as another agent', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
const agentToken = String(minted.body.bridge_token);
const self = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
}),
);
expect(self.status).toBe(200);
const other = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' },
}),
);
expect(other.status).toBe(403);
expect(other.body.error).toBe('token not scoped to this agent');
});
it('revoked agent token is rejected on messages', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
const agentToken = String(minted.body.bridge_token);
const revoke = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents/revoke',
authorizationHeader: 'Bearer bridge-secret',
body: { agent_user_id: '@agent-pi0-web1:hs.example' },
}),
);
expect(revoke.status).toBe(200);
expect(revoke.body.revoked).toBe(1);
const afterRevoke = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
}),
);
expect(afterRevoke.status).toBe(403);
});
it('GET /bridge/v1/agents lists registered agents (host only)', async () => {
const { daemon } = makeAgentDaemon();
await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' });
const res = await daemon.handle(
request({
method: 'GET',
path: '/bridge/v1/agents',
authorizationHeader: 'Bearer bridge-secret',
}),
);
expect(res.status).toBe(200);
const agents = res.body.agents as Array<Record<string, unknown>>;
expect(agents).toHaveLength(1);
expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example');
expect(agents[0]?.display_name).toBe('Pi Zero');
});
it('empty bridge token list denies everything', async () => {
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
const res = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/typing',
authorizationHeader: 'Bearer bridge-secret',
body: {},
}),
);
expect(res.status).toBe(403);
});
});
-23
View File
@@ -1,23 +0,0 @@
import type { DaemonConfig } from './server.js';
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`missing required env var ${name}`);
return value;
};
export function configFromEnv(): DaemonConfig & { port: number } {
return {
homeserverUrl: required('MOSAIC_AS_HOMESERVER_URL'),
domain: required('MOSAIC_AS_DOMAIN'),
asToken: required('MOSAIC_AS_TOKEN'),
hsToken: required('MOSAIC_HS_TOKEN'),
userPrefix: process.env.MOSAIC_AS_USER_PREFIX ?? 'agent-',
senderLocalpart: process.env.MOSAIC_AS_SENDER_LOCALPART ?? 'mosaic-as',
bridgeTokens: (process.env.MOSAIC_AS_BRIDGE_TOKENS ?? '')
.split(',')
.map((t) => t.trim())
.filter(Boolean),
port: Number(process.env.MOSAIC_AS_PORT ?? 8008),
};
}
-67
View File
@@ -1,67 +0,0 @@
import http from 'node:http';
import { configFromEnv } from './config.js';
import { AppserviceDaemon } from './server.js';
const cfg = configFromEnv();
const daemon = new AppserviceDaemon(cfg);
const MAX_BODY_BYTES = 1024 * 1024;
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
let received = 0;
let rejected = false;
req.on('data', (chunk: Buffer) => {
received += chunk.length;
if (received > MAX_BODY_BYTES) {
rejected = true;
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'request body too large' }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (rejected) return;
void (async () => {
const url = new URL(req.url ?? '/', 'http://localhost');
let body: unknown;
try {
const raw = Buffer.concat(chunks).toString();
body = raw ? JSON.parse(raw) : undefined;
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ errcode: 'M_NOT_JSON', error: 'invalid json' }));
return;
}
const result = await daemon.handle({
method: req.method ?? 'GET',
path: url.pathname,
searchParams: url.searchParams,
authorizationHeader: req.headers.authorization,
body,
});
res.writeHead(result.status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result.body));
})().catch((error: unknown) => {
console.error('request failed:', error);
if (res.headersSent) {
res.destroy();
return;
}
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'internal error' }));
});
});
});
server.listen(cfg.port, () => {
console.log(
`mosaic-as listening on :${cfg.port} (homeserver ${cfg.homeserverUrl}, domain ${cfg.domain})`,
);
if (cfg.bridgeTokens.length === 0) {
console.warn('WARNING: MOSAIC_AS_BRIDGE_TOKENS is empty — bridge API will deny all requests');
}
});
-10
View File
@@ -1,10 +0,0 @@
import { buildRegistration, registrationToYaml } from '@mosaicstack/appservice';
import { configFromEnv } from './config.js';
// Prints the Synapse registration YAML (mosaic-as.yaml) for the current env.
// Usage: MOSAIC_AS_URL=http://mosaic-as:8008 mosaic-as-registration > mosaic-as.yaml
const cfg = configFromEnv();
const url = process.env.MOSAIC_AS_URL;
if (!url) throw new Error('missing required env var MOSAIC_AS_URL');
process.stdout.write(registrationToYaml(buildRegistration(cfg, { url })));
-225
View File
@@ -1,225 +0,0 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import {
AgentTokenStore,
AppserviceIntent,
TransactionHandler,
validateBridgeMessage,
validateBridgeTyping,
validateProvisionRoom,
validateRegisterAgent,
validateRevokeAgent,
} from '@mosaicstack/appservice';
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
export interface DaemonConfig extends AppserviceConfig {
/** Bearer tokens accepted on /bridge/v1/* (one per agent-comms host daemon). */
bridgeTokens: string[];
}
export interface DaemonRequest {
method: string;
/** URL path without query string. */
path: string;
searchParams: URLSearchParams;
authorizationHeader?: string;
body: unknown;
}
export interface DaemonResponse {
status: number;
body: Record<string, unknown>;
}
// Compare equal-length HMAC digests so neither content nor LENGTH of the
// stored secret is observable through timing.
const HMAC_KEY = randomBytes(32);
const digest = (value: string): Buffer => createHmac('sha256', HMAC_KEY).update(value).digest();
const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
/**
* Resolved identity for an authenticated /bridge/v1/* caller. Host principals
* (the agent-comms host daemons) are unrestricted; agent principals are scoped
* to a single virtual user and may only act as themselves.
*/
export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null;
/**
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
* Application Service transactions endpoint (Synapse-facing) plus the
* internal bridge API v1 (agent-comms daemon-facing). main.ts binds this to
* node:http; tests drive it directly.
*/
export class AppserviceDaemon {
readonly intent: AppserviceIntent;
private readonly transactions: TransactionHandler;
private readonly agents: AgentTokenStore;
constructor(
private readonly cfg: DaemonConfig,
fetchImpl?: typeof fetch,
private readonly log: (line: string) => void = (line) => console.log(line),
) {
this.intent = new AppserviceIntent(cfg, fetchImpl);
this.agents = new AgentTokenStore(this.intent);
this.transactions = new TransactionHandler({
hsToken: cfg.hsToken,
onEvent: (event) => this.onEvent(event),
onError: (error, txnId) => this.log(`txn ${txnId} handler error: ${String(error)}`),
});
}
/** v1: the daemon only observes; room logic lives in the agent-comms daemons. */
private onEvent(event: MatrixEvent): void {
if (event.type === 'm.room.message') {
this.log(
`event ${event.event_id ?? '?'} in ${event.room_id ?? '?'} from ${event.sender ?? '?'}`,
);
}
}
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
* host tokens win (timing-safe compare); otherwise a magt_* bearer is looked
* up in the agent token store; anything else is rejected. */
private async bridgeAuthorized(
authorizationHeader: string | undefined,
): Promise<BridgePrincipal> {
if (!authorizationHeader?.startsWith('Bearer ')) return null;
const presented = authorizationHeader.slice('Bearer '.length);
if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) {
return { kind: 'host' };
}
const agentUserId = await this.agents.verifyToken(presented);
if (agentUserId) return { kind: 'agent', agentUserId };
return null;
}
async handle(req: DaemonRequest): Promise<DaemonResponse> {
if (req.method === 'GET' && req.path === '/health') {
return { status: 200, body: { ok: true } };
}
const txnMatch = req.method === 'PUT' ? TXN_PATH.exec(req.path) : null;
if (txnMatch?.[1] !== undefined) {
return this.transactions.handle(txnMatch[1], req.body, {
authorizationHeader: req.authorizationHeader,
accessTokenParam: req.searchParams.get('access_token') ?? undefined,
});
}
if (req.path.startsWith('/bridge/v1/')) {
const principal = await this.bridgeAuthorized(req.authorizationHeader);
if (!principal) {
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
}
try {
if (req.method === 'POST' && req.path === '/bridge/v1/agents') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' },
};
}
validateRegisterAgent(req.body);
const { agentUserId, token } = await this.agents.register({
alias: req.body.alias,
host: req.body.host,
displayName: req.body.display_name,
});
this.log(`registered agent ${agentUserId}`);
return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' },
};
}
validateRevokeAgent(req.body);
const revoked = await this.agents.revoke(req.body.agent_user_id);
this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`);
return { status: 200, body: { revoked } };
}
if (req.method === 'GET' && req.path === '/bridge/v1/agents') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' },
};
}
const agents = await this.agents.list();
return { status: 200, body: { agents } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
validateBridgeMessage(req.body);
if (
principal.kind === 'agent' &&
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
) {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
};
}
const eventId = await this.intent.sendAsAgent({
roomId: req.body.room_id,
agent: req.body.agent,
body: req.body.body,
threadRoot: req.body.thread_root,
msgtype: req.body.msgtype,
extraContent: req.body.extra_content,
});
return { status: 200, body: { event_id: eventId ?? null } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
validateBridgeTyping(req.body);
if (
principal.kind === 'agent' &&
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
) {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
};
}
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
return { status: 200, body: {} };
}
if (req.method === 'POST' && req.path === '/bridge/v1/provision/rooms') {
validateProvisionRoom(req.body);
const result = await this.intent.createRoom({
name: req.body.name,
alias: req.body.alias,
topic: req.body.topic,
invite: req.body.invite,
spaceId: req.body.space_id,
});
this.log(
`provisioned room ${result.roomId} (${req.body.name}) space_linked=${result.spaceLinked}`,
);
return {
status: 200,
body: {
room_id: result.roomId,
space_linked: result.spaceLinked,
...(result.spaceError ? { space_error: result.spaceError } : {}),
},
};
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log(`bridge error ${req.method} ${req.path}: ${message}`);
return { status: 400, body: { error: message } };
}
// Explicit: never fall out of the authenticated bridge block, so future
// sub-paths cannot accidentally route around the auth guard above.
return { status: 405, body: { error: 'unsupported bridge method/path' } };
}
return { status: 404, body: { error: 'not found' } };
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
-94
View File
@@ -1,94 +0,0 @@
{
"name": "@mosaicstack/gateway",
"version": "0.0.6",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "apps/gateway"
},
"type": "module",
"main": "dist/main.js",
"bin": {
"mosaic-gateway": "dist/main.js"
},
"files": [
"dist"
],
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"scripts": {
"build": "tsc",
"dev": "tsx watch src/main.ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@fastify/helmet": "^13.0.2",
"@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@mosaicstack/agent": "workspace:^",
"@mosaicstack/auth": "workspace:^",
"@mosaicstack/brain": "workspace:^",
"@mosaicstack/config": "workspace:^",
"@mosaicstack/coord": "workspace:^",
"@mosaicstack/db": "workspace:^",
"@mosaicstack/discord-plugin": "workspace:^",
"@mosaicstack/log": "workspace:^",
"@mosaicstack/memory": "workspace:^",
"@mosaicstack/queue": "workspace:^",
"@mosaicstack/storage": "workspace:^",
"@mosaicstack/telegram-plugin": "workspace:^",
"@mosaicstack/types": "workspace:^",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-fastify": "^11.0.0",
"@nestjs/platform-socket.io": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.0.0",
"@opentelemetry/auto-instrumentations-node": "^0.72.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
"@opentelemetry/resources": "^2.6.0",
"@opentelemetry/sdk-metrics": "^2.6.0",
"@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@peculiar/x509": "^2.0.0",
"@sinclair/typebox": "^0.34.48",
"better-auth": "^1.5.5",
"bullmq": "^5.71.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.3.1",
"fastify": "^5.0.0",
"ioredis": "^5.10.0",
"jose": "^6.2.2",
"node-cron": "^4.2.1",
"openai": "^6.32.0",
"postgres": "^3.4.8",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"socket.io": "^4.8.0",
"uuid": "^11.0.0",
"undici": "^7.24.6",
"zod": "^4.3.6"
},
"devDependencies": {
"@nestjs/testing": "^11.1.18",
"@swc/core": "^1.15.24",
"@swc/helpers": "^0.5.21",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0",
"supertest": "^7.2.2",
"tsx": "^4.0.0",
"typescript": "^5.8.0",
"unplugin-swc": "^1.5.9",
"vitest": "^2.0.0"
}
}
@@ -1,605 +0,0 @@
/**
* Integration tests for conversation persistence and context resume (M1-008).
*
* Verifies the full flow end-to-end using in-memory mocks:
* 1. User messages are persisted when sent via ChatGateway.
* 2. Assistant responses are persisted with metadata on agent:end.
* 3. Conversation history is loaded and injected into context on session resume.
* 4. The search endpoint returns matching messages.
*/
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { ConversationHistoryMessage } from '../agent/agent.service.js';
import { ConversationsController } from '../conversations/conversations.controller.js';
import type { Message } from '@mosaicstack/brain';
// ---------------------------------------------------------------------------
// Shared test data
// ---------------------------------------------------------------------------
const USER_ID = 'user-test-001';
const CONV_ID = 'conv-test-001';
function makeConversation(overrides?: Record<string, unknown>) {
return {
id: CONV_ID,
userId: USER_ID,
title: null,
projectId: null,
archived: false,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...overrides,
};
}
function makeMessage(
role: 'user' | 'assistant' | 'system',
content: string,
overrides?: Record<string, unknown>,
) {
return {
id: `msg-${role}-${Math.random().toString(36).slice(2)}`,
conversationId: CONV_ID,
role,
content,
metadata: null,
createdAt: new Date('2026-01-01T00:01:00Z'),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Helper: build a mock ConversationsRepo
// ---------------------------------------------------------------------------
function createMockBrain(options?: {
conversation?: ReturnType<typeof makeConversation> | undefined;
messages?: ReturnType<typeof makeMessage>[];
searchResults?: Array<{
messageId: string;
conversationId: string;
conversationTitle: string | null;
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: Date;
}>;
}) {
const conversation = options?.conversation;
const messages = options?.messages ?? [];
const searchResults = options?.searchResults ?? [];
return {
conversations: {
findAll: vi.fn().mockResolvedValue(conversation ? [conversation] : []),
findById: vi.fn().mockResolvedValue(conversation),
create: vi.fn().mockResolvedValue(conversation ?? makeConversation()),
update: vi.fn().mockResolvedValue(conversation),
remove: vi.fn().mockResolvedValue(true),
findMessages: vi.fn().mockResolvedValue(messages),
addMessage: vi.fn().mockImplementation((data: unknown) => {
const d = data as {
conversationId: string;
role: 'user' | 'assistant' | 'system';
content: string;
metadata?: Record<string, unknown>;
};
return Promise.resolve(makeMessage(d.role, d.content, { metadata: d.metadata ?? null }));
}),
searchMessages: vi.fn().mockResolvedValue(searchResults),
},
};
}
// ---------------------------------------------------------------------------
// 1. ConversationsRepo: addMessage persists user message
// ---------------------------------------------------------------------------
describe('ConversationsRepo.addMessage — user message persistence', () => {
it('persists a user message and returns the saved record', async () => {
const brain = createMockBrain({ conversation: makeConversation() });
const result = await brain.conversations.addMessage(
{
conversationId: CONV_ID,
role: 'user',
content: 'Hello, agent!',
metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
},
USER_ID,
);
expect(brain.conversations.addMessage).toHaveBeenCalledOnce();
expect(result).toBeDefined();
expect(result!.role).toBe('user');
expect(result!.content).toBe('Hello, agent!');
expect(result!.conversationId).toBe(CONV_ID);
});
it('returns undefined when conversation does not belong to the user', async () => {
// Simulate the repo enforcement: ownership mismatch returns undefined
const brain = createMockBrain({ conversation: undefined });
brain.conversations.addMessage = vi.fn().mockResolvedValue(undefined);
const result = await brain.conversations.addMessage(
{ conversationId: CONV_ID, role: 'user', content: 'Hello' },
'other-user',
);
expect(result).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 2. ConversationsRepo.addMessage — assistant response with metadata
// ---------------------------------------------------------------------------
describe('ConversationsRepo.addMessage — assistant response metadata', () => {
it('persists assistant message with model, provider, tokens and toolCalls metadata', async () => {
const assistantMetadata = {
timestamp: '2026-01-01T00:02:00.000Z',
model: 'claude-3-5-sonnet-20241022',
provider: 'anthropic',
toolCalls: [
{
toolCallId: 'tc-001',
toolName: 'read_file',
args: { path: '/foo/bar.ts' },
isError: false,
},
],
tokenUsage: {
input: 1000,
output: 250,
cacheRead: 0,
cacheWrite: 0,
total: 1250,
},
};
const brain = createMockBrain({ conversation: makeConversation() });
const result = await brain.conversations.addMessage(
{
conversationId: CONV_ID,
role: 'assistant',
content: 'Here is the file content you requested.',
metadata: assistantMetadata,
},
USER_ID,
);
expect(result).toBeDefined();
expect(result!.role).toBe('assistant');
expect(result!.content).toBe('Here is the file content you requested.');
expect(result!.metadata).toMatchObject({
model: 'claude-3-5-sonnet-20241022',
provider: 'anthropic',
tokenUsage: { input: 1000, output: 250, total: 1250 },
});
expect((result!.metadata as Record<string, unknown>)['toolCalls']).toHaveLength(1);
expect(
(
(result!.metadata as Record<string, unknown>)['toolCalls'] as Array<Record<string, unknown>>
)[0]!['toolName'],
).toBe('read_file');
});
});
// ---------------------------------------------------------------------------
// 3. ChatGateway.loadConversationHistory — session resume loads history
// ---------------------------------------------------------------------------
describe('Conversation resume — history loading', () => {
it('maps DB messages to ConversationHistoryMessage shape', () => {
// Simulate what ChatGateway.loadConversationHistory does:
// convert DB Message rows to ConversationHistoryMessage for context injection.
const dbMessages = [
makeMessage('user', 'What is the capital of France?', {
createdAt: new Date('2026-01-01T00:01:00Z'),
}),
makeMessage('assistant', 'The capital of France is Paris.', {
createdAt: new Date('2026-01-01T00:01:05Z'),
}),
makeMessage('user', 'And Germany?', { createdAt: new Date('2026-01-01T00:02:00Z') }),
makeMessage('assistant', 'The capital of Germany is Berlin.', {
createdAt: new Date('2026-01-01T00:02:05Z'),
}),
];
// Replicate the mapping logic from ChatGateway
const history: ConversationHistoryMessage[] = dbMessages.map((msg) => ({
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content,
createdAt: msg.createdAt,
}));
expect(history).toHaveLength(4);
expect(history[0]).toEqual({
role: 'user',
content: 'What is the capital of France?',
createdAt: new Date('2026-01-01T00:01:00Z'),
});
expect(history[1]).toEqual({
role: 'assistant',
content: 'The capital of France is Paris.',
createdAt: new Date('2026-01-01T00:01:05Z'),
});
expect(history[2]!.role).toBe('user');
expect(history[3]!.role).toBe('assistant');
});
it('returns empty array when conversation has no messages', async () => {
const brain = createMockBrain({ conversation: makeConversation(), messages: [] });
const messages = await brain.conversations.findMessages(CONV_ID, USER_ID);
expect(messages).toHaveLength(0);
// Gateway produces empty history → no context injection
const history: ConversationHistoryMessage[] = (messages as Message[]).map((msg) => ({
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content,
createdAt: msg.createdAt,
}));
expect(history).toHaveLength(0);
});
it('returns empty array when conversation does not belong to the user', async () => {
const brain = createMockBrain({ conversation: undefined });
brain.conversations.findMessages = vi.fn().mockResolvedValue([]);
const messages = await brain.conversations.findMessages(CONV_ID, 'other-user');
expect(messages).toHaveLength(0);
});
it('preserves message order (ascending by createdAt)', async () => {
const ordered = [
makeMessage('user', 'First', { createdAt: new Date('2026-01-01T00:01:00Z') }),
makeMessage('assistant', 'Second', { createdAt: new Date('2026-01-01T00:01:05Z') }),
makeMessage('user', 'Third', { createdAt: new Date('2026-01-01T00:02:00Z') }),
];
const brain = createMockBrain({ conversation: makeConversation(), messages: ordered });
const messages = await brain.conversations.findMessages(CONV_ID, USER_ID);
expect(messages[0]!.content).toBe('First');
expect(messages[1]!.content).toBe('Second');
expect(messages[2]!.content).toBe('Third');
});
});
// ---------------------------------------------------------------------------
// 4. AgentService.buildHistoryPromptSection — context injection format
// ---------------------------------------------------------------------------
describe('AgentService — buildHistoryPromptSection (context injection)', () => {
/**
* Replicate the private method logic to test it in isolation.
* The real method lives in AgentService but is private; we mirror the
* exact logic here so the test is independent of the service's constructor.
*/
function buildHistoryPromptSection(
history: ConversationHistoryMessage[],
contextWindow: number,
_sessionId: string,
): string {
const TOKEN_BUDGET = Math.floor(contextWindow * 0.8);
const HISTORY_HEADER = '## Conversation History (resumed session)\n\n';
const formatMessage = (msg: ConversationHistoryMessage): string => {
const roleLabel =
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
return `**${roleLabel}:** ${msg.content}`;
};
const estimateTokens = (text: string) => Math.ceil(text.length / 4);
const formatted = history.map((msg) => formatMessage(msg));
const fullHistory = formatted.join('\n\n');
const fullTokens = estimateTokens(HISTORY_HEADER + fullHistory);
if (fullTokens <= TOKEN_BUDGET) {
return HISTORY_HEADER + fullHistory;
}
// History exceeds budget — summarize oldest messages, keep recent verbatim
const SUMMARY_RESERVE = Math.floor(TOKEN_BUDGET * 0.2);
const verbatimBudget = TOKEN_BUDGET - SUMMARY_RESERVE;
let verbatimTokens = 0;
let verbatimCutIndex = history.length;
for (let i = history.length - 1; i >= 0; i--) {
const t = estimateTokens(formatted[i]!);
if (verbatimTokens + t > verbatimBudget) break;
verbatimTokens += t;
verbatimCutIndex = i;
}
const summarizedMessages = history.slice(0, verbatimCutIndex);
const verbatimMessages = history.slice(verbatimCutIndex);
let summaryText = '';
if (summarizedMessages.length > 0) {
const topics = summarizedMessages
.filter((m) => m.role === 'user')
.map((m) => m.content.slice(0, 120).replace(/\n/g, ' '))
.join('; ');
summaryText =
`**Previous conversation summary** (${summarizedMessages.length} messages omitted for brevity):\n` +
`Topics discussed: ${topics || '(no user messages in summarized portion)'}`;
}
const verbatimSection = verbatimMessages.map((m) => formatMessage(m)).join('\n\n');
const parts: string[] = [HISTORY_HEADER];
if (summaryText) parts.push(summaryText);
if (verbatimSection) parts.push(verbatimSection);
return parts.join('\n\n');
}
it('includes header and all messages when history fits within context budget', () => {
const history: ConversationHistoryMessage[] = [
{ role: 'user', content: 'Hello', createdAt: new Date() },
{ role: 'assistant', content: 'Hi there!', createdAt: new Date() },
];
const result = buildHistoryPromptSection(history, 8192, 'session-1');
expect(result).toContain('## Conversation History (resumed session)');
expect(result).toContain('**User:** Hello');
expect(result).toContain('**Assistant:** Hi there!');
});
it('labels roles correctly (user, assistant, system)', () => {
const history: ConversationHistoryMessage[] = [
{ role: 'system', content: 'You are helpful.', createdAt: new Date() },
{ role: 'user', content: 'Ping', createdAt: new Date() },
{ role: 'assistant', content: 'Pong', createdAt: new Date() },
];
const result = buildHistoryPromptSection(history, 8192, 'session-2');
expect(result).toContain('**System:** You are helpful.');
expect(result).toContain('**User:** Ping');
expect(result).toContain('**Assistant:** Pong');
});
it('summarizes old messages when history exceeds 80% of context window', () => {
// Create enough messages to exceed a tiny context window budget
const longContent = 'A'.repeat(200);
const history: ConversationHistoryMessage[] = Array.from({ length: 20 }, (_, i) => ({
role: (i % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant',
content: `${longContent} message ${i}`,
createdAt: new Date(),
}));
// Use a small context window so history definitely exceeds 80%
const result = buildHistoryPromptSection(history, 512, 'session-3');
// Should contain the summary prefix
expect(result).toContain('messages omitted for brevity');
expect(result).toContain('Topics discussed:');
});
it('returns only header for empty history', () => {
const result = buildHistoryPromptSection([], 8192, 'session-4');
// With empty history, the full history join is '' and the section is just the header
expect(result).toContain('## Conversation History (resumed session)');
});
});
// ---------------------------------------------------------------------------
// 5. ConversationsController.search — GET /api/conversations/search
// ---------------------------------------------------------------------------
describe('ConversationsController — search endpoint', () => {
let brain: ReturnType<typeof createMockBrain>;
let controller: ConversationsController;
beforeEach(() => {
const searchResults = [
{
messageId: 'msg-001',
conversationId: CONV_ID,
conversationTitle: 'Test Chat',
role: 'user' as const,
content: 'What is the capital of France?',
createdAt: new Date('2026-01-01T00:01:00Z'),
},
{
messageId: 'msg-002',
conversationId: CONV_ID,
conversationTitle: 'Test Chat',
role: 'assistant' as const,
content: 'The capital of France is Paris.',
createdAt: new Date('2026-01-01T00:01:05Z'),
},
];
brain = createMockBrain({ searchResults });
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
});
it('returns matching messages for a valid search query', async () => {
const results = await controller.search({ q: 'France' }, { id: USER_ID });
expect(brain.conversations.searchMessages).toHaveBeenCalledWith(USER_ID, 'France', 20, 0);
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({
messageId: 'msg-001',
role: 'user',
content: 'What is the capital of France?',
});
expect(results[1]).toMatchObject({
messageId: 'msg-002',
role: 'assistant',
content: 'The capital of France is Paris.',
});
});
it('uses custom limit and offset when provided', async () => {
await controller.search({ q: 'Paris', limit: 5, offset: 10 }, { id: USER_ID });
expect(brain.conversations.searchMessages).toHaveBeenCalledWith(USER_ID, 'Paris', 5, 10);
});
it('throws BadRequestException when query is empty', async () => {
await expect(controller.search({ q: '' }, { id: USER_ID })).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(controller.search({ q: ' ' }, { id: USER_ID })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('trims whitespace from query before passing to repo', async () => {
await controller.search({ q: ' Berlin ' }, { id: USER_ID });
expect(brain.conversations.searchMessages).toHaveBeenCalledWith(
USER_ID,
'Berlin',
expect.any(Number),
expect.any(Number),
);
});
it('returns empty array when no messages match', async () => {
brain.conversations.searchMessages = vi.fn().mockResolvedValue([]);
const results = await controller.search({ q: 'xyzzy-no-match' }, { id: USER_ID });
expect(results).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 6. ConversationsController — messages CRUD
// ---------------------------------------------------------------------------
describe('ConversationsController — message CRUD', () => {
it('listMessages returns 404 when conversation is not owned by user', async () => {
const brain = createMockBrain({ conversation: undefined });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('listMessages returns the messages for an owned conversation', async () => {
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
expect(result).toHaveLength(2);
expect(result[0]!.role).toBe('user');
expect(result[1]!.role).toBe('assistant');
});
it('addMessage returns the persisted message', async () => {
const brain = createMockBrain({ conversation: makeConversation() });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const result = await controller.addMessage(
CONV_ID,
{ role: 'user', content: 'Persisted content' },
{ id: USER_ID },
);
expect(result).toBeDefined();
expect(result.role).toBe('user');
expect(result.content).toBe('Persisted content');
});
});
// ---------------------------------------------------------------------------
// 7. End-to-end persistence flow simulation
// ---------------------------------------------------------------------------
describe('End-to-end persistence flow', () => {
it('simulates a full conversation: persist user message → persist assistant response → resume with history', async () => {
// ── Step 1: Conversation is created ────────────────────────────────────
const brain = createMockBrain({ conversation: makeConversation() });
await brain.conversations.create({ id: CONV_ID, userId: USER_ID });
expect(brain.conversations.create).toHaveBeenCalledOnce();
// ── Step 2: User message is persisted ──────────────────────────────────
const userMsg = await brain.conversations.addMessage(
{
conversationId: CONV_ID,
role: 'user',
content: 'Explain monads in simple terms.',
metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
},
USER_ID,
);
expect(userMsg).toBeDefined();
expect(userMsg!.role).toBe('user');
// ── Step 3: Assistant response is persisted with metadata ───────────────
const assistantMeta = {
timestamp: '2026-01-01T00:01:10.000Z',
model: 'claude-3-5-sonnet-20241022',
provider: 'anthropic',
toolCalls: [],
tokenUsage: { input: 500, output: 120, cacheRead: 0, cacheWrite: 0, total: 620 },
};
const assistantMsg = await brain.conversations.addMessage(
{
conversationId: CONV_ID,
role: 'assistant',
content: 'A monad is a design pattern that wraps values in a context...',
metadata: assistantMeta,
},
USER_ID,
);
expect(assistantMsg).toBeDefined();
expect(assistantMsg!.role).toBe('assistant');
// ── Step 4: On session resume, history is loaded ────────────────────────
const storedMessages = [
makeMessage('user', 'Explain monads in simple terms.', {
createdAt: new Date('2026-01-01T00:01:00Z'),
metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
}),
makeMessage('assistant', 'A monad is a design pattern that wraps values in a context...', {
createdAt: new Date('2026-01-01T00:01:10Z'),
metadata: assistantMeta,
}),
];
brain.conversations.findMessages = vi.fn().mockResolvedValue(storedMessages);
const dbMessages = await brain.conversations.findMessages(CONV_ID, USER_ID);
expect(dbMessages).toHaveLength(2);
// ── Step 5: History is mapped for context injection ─────────────────────
const history: ConversationHistoryMessage[] = (dbMessages as Message[]).map((msg) => ({
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content,
createdAt: msg.createdAt,
}));
expect(history[0]).toMatchObject({
role: 'user',
content: 'Explain monads in simple terms.',
});
expect(history[1]).toMatchObject({
role: 'assistant',
content: 'A monad is a design pattern that wraps values in a context...',
});
// ── Step 6: History roles are valid for injection ───────────────────────
for (const msg of history) {
expect(['user', 'assistant', 'system']).toContain(msg.role);
expect(typeof msg.content).toBe('string');
expect(msg.createdAt).toBeInstanceOf(Date);
}
});
});
@@ -1,485 +0,0 @@
/**
* Integration test: Cross-user data isolation (M2-007)
*
* Verifies that every repository query path is scoped to the requesting user —
* no user can read, write, or enumerate another user's records.
*
* Test strategy:
* - Two real users (User A, User B) are inserted directly into the database.
* - Realistic data (conversations + messages, agent configs, preferences,
* insights) is created for each user.
* - A shared system agent is inserted so both users can see it via
* findAccessible().
* - All assertions are made against the live database (no mocks).
* - All inserted rows are cleaned up in the afterAll hook.
*
* Requires: DATABASE_URL pointing at a running PostgreSQL instance with
* pgvector enabled and the Mosaic schema already applied.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createDb } from '@mosaicstack/db';
import { createConversationsRepo } from '@mosaicstack/brain';
import { createAgentsRepo } from '@mosaicstack/brain';
import { createPreferencesRepo, createInsightsRepo } from '@mosaicstack/memory';
import { users, conversations, messages, agents, preferences, insights } from '@mosaicstack/db';
import { eq } from '@mosaicstack/db';
import type { DbHandle } from '@mosaicstack/db';
// ─── Fixed IDs so the afterAll cleanup is deterministic ──────────────────────
const USER_A_ID = 'test-iso-user-a';
const USER_B_ID = 'test-iso-user-b';
const CONV_A_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
const CONV_B_ID = 'bbbbbbbb-0000-0000-0000-000000000001';
const MSG_A_ID = 'aaaaaaaa-0000-0000-0000-000000000002';
const MSG_B_ID = 'bbbbbbbb-0000-0000-0000-000000000002';
const AGENT_A_ID = 'aaaaaaaa-0000-0000-0000-000000000003';
const AGENT_B_ID = 'bbbbbbbb-0000-0000-0000-000000000003';
const AGENT_SYS_ID = 'ffffffff-0000-0000-0000-000000000001';
const PREF_A_ID = 'aaaaaaaa-0000-0000-0000-000000000004';
const PREF_B_ID = 'bbbbbbbb-0000-0000-0000-000000000004';
const INSIGHT_A_ID = 'aaaaaaaa-0000-0000-0000-000000000005';
const INSIGHT_B_ID = 'bbbbbbbb-0000-0000-0000-000000000005';
// ─── Test fixture ─────────────────────────────────────────────────────────────
let handle: DbHandle;
let dbAvailable = false;
beforeAll(async () => {
try {
handle = createDb();
const db = handle.db;
// Insert two users
await db
.insert(users)
.values([
{
id: USER_A_ID,
name: 'Isolation Test User A',
email: '[email protected]',
emailVerified: false,
},
{
id: USER_B_ID,
name: 'Isolation Test User B',
email: '[email protected]',
emailVerified: false,
},
])
.onConflictDoNothing();
// Conversations — one per user
await db
.insert(conversations)
.values([
{ id: CONV_A_ID, userId: USER_A_ID, title: 'User A conversation' },
{ id: CONV_B_ID, userId: USER_B_ID, title: 'User B conversation' },
])
.onConflictDoNothing();
// Messages — one per conversation
await db
.insert(messages)
.values([
{
id: MSG_A_ID,
conversationId: CONV_A_ID,
role: 'user',
content: 'Hello from User A',
},
{
id: MSG_B_ID,
conversationId: CONV_B_ID,
role: 'user',
content: 'Hello from User B',
},
])
.onConflictDoNothing();
// Agent configs — private agents (one per user) + one system agent
await db
.insert(agents)
.values([
{
id: AGENT_A_ID,
name: 'Agent A (private)',
provider: 'test',
model: 'test-model',
ownerId: USER_A_ID,
isSystem: false,
},
{
id: AGENT_B_ID,
name: 'Agent B (private)',
provider: 'test',
model: 'test-model',
ownerId: USER_B_ID,
isSystem: false,
},
{
id: AGENT_SYS_ID,
name: 'Shared System Agent',
provider: 'test',
model: 'test-model',
ownerId: null,
isSystem: true,
},
])
.onConflictDoNothing();
// Preferences — one per user (same key, different values)
await db
.insert(preferences)
.values([
{
id: PREF_A_ID,
userId: USER_A_ID,
key: 'theme',
value: 'dark',
category: 'appearance',
},
{
id: PREF_B_ID,
userId: USER_B_ID,
key: 'theme',
value: 'light',
category: 'appearance',
},
])
.onConflictDoNothing();
// Insights — no embedding to keep the fixture simple; embedding-based search
// is tested separately with a zero-vector that falls outside maxDistance
await db
.insert(insights)
.values([
{
id: INSIGHT_A_ID,
userId: USER_A_ID,
content: 'User A insight',
source: 'user',
category: 'general',
relevanceScore: 1.0,
},
{
id: INSIGHT_B_ID,
userId: USER_B_ID,
content: 'User B insight',
source: 'user',
category: 'general',
relevanceScore: 1.0,
},
])
.onConflictDoNothing();
dbAvailable = true;
} catch {
// Database is not reachable (e.g., CI environment without Postgres on port 5433).
// All tests in this suite will be skipped.
}
});
// Skip all tests in this file when the database is not reachable (e.g., CI without Postgres).
beforeEach((ctx) => {
if (!dbAvailable) {
ctx.skip();
}
});
afterAll(async () => {
if (!handle) return;
const db = handle.db;
// Delete in dependency order (FK constraints)
await db.delete(messages).where(eq(messages.id, MSG_A_ID));
await db.delete(messages).where(eq(messages.id, MSG_B_ID));
await db.delete(conversations).where(eq(conversations.id, CONV_A_ID));
await db.delete(conversations).where(eq(conversations.id, CONV_B_ID));
await db.delete(agents).where(eq(agents.id, AGENT_A_ID));
await db.delete(agents).where(eq(agents.id, AGENT_B_ID));
await db.delete(agents).where(eq(agents.id, AGENT_SYS_ID));
await db.delete(preferences).where(eq(preferences.id, PREF_A_ID));
await db.delete(preferences).where(eq(preferences.id, PREF_B_ID));
await db.delete(insights).where(eq(insights.id, INSIGHT_A_ID));
await db.delete(insights).where(eq(insights.id, INSIGHT_B_ID));
await db.delete(users).where(eq(users.id, USER_A_ID));
await db.delete(users).where(eq(users.id, USER_B_ID));
await handle.close();
});
// ─── Conversations isolation ──────────────────────────────────────────────────
describe('ConversationsRepo — cross-user isolation', () => {
it('User A can find their own conversation by id', async () => {
const repo = createConversationsRepo(handle.db);
const conv = await repo.findById(CONV_A_ID, USER_A_ID);
expect(conv).toBeDefined();
expect(conv!.id).toBe(CONV_A_ID);
});
it('User B cannot find User A conversation by id (returns undefined)', async () => {
const repo = createConversationsRepo(handle.db);
const conv = await repo.findById(CONV_A_ID, USER_B_ID);
expect(conv).toBeUndefined();
});
it('User A cannot find User B conversation by id (returns undefined)', async () => {
const repo = createConversationsRepo(handle.db);
const conv = await repo.findById(CONV_B_ID, USER_A_ID);
expect(conv).toBeUndefined();
});
it('findAll returns only own conversations for User A', async () => {
const repo = createConversationsRepo(handle.db);
const convs = await repo.findAll(USER_A_ID);
const ids = convs.map((c) => c.id);
expect(ids).toContain(CONV_A_ID);
expect(ids).not.toContain(CONV_B_ID);
});
it('findAll returns only own conversations for User B', async () => {
const repo = createConversationsRepo(handle.db);
const convs = await repo.findAll(USER_B_ID);
const ids = convs.map((c) => c.id);
expect(ids).toContain(CONV_B_ID);
expect(ids).not.toContain(CONV_A_ID);
});
});
// ─── Messages isolation ───────────────────────────────────────────────────────
describe('ConversationsRepo.findMessages — cross-user isolation', () => {
it('User A can read messages from their own conversation', async () => {
const repo = createConversationsRepo(handle.db);
const msgs = await repo.findMessages(CONV_A_ID, USER_A_ID);
const ids = msgs.map((m) => m.id);
expect(ids).toContain(MSG_A_ID);
});
it('User B cannot read messages from User A conversation (returns empty array)', async () => {
const repo = createConversationsRepo(handle.db);
const msgs = await repo.findMessages(CONV_A_ID, USER_B_ID);
expect(msgs).toHaveLength(0);
});
it('User A cannot read messages from User B conversation (returns empty array)', async () => {
const repo = createConversationsRepo(handle.db);
const msgs = await repo.findMessages(CONV_B_ID, USER_A_ID);
expect(msgs).toHaveLength(0);
});
it('addMessage is rejected when user does not own the conversation', async () => {
const repo = createConversationsRepo(handle.db);
const result = await repo.addMessage(
{
conversationId: CONV_A_ID,
role: 'user',
content: 'Attempted injection by User B',
},
USER_B_ID,
);
expect(result).toBeUndefined();
});
});
// ─── Agent configs isolation ──────────────────────────────────────────────────
describe('AgentsRepo.findAccessible — cross-user isolation', () => {
it('User A sees their own private agent', async () => {
const repo = createAgentsRepo(handle.db);
const accessible = await repo.findAccessible(USER_A_ID);
const ids = accessible.map((a) => a.id);
expect(ids).toContain(AGENT_A_ID);
});
it('User A does NOT see User B private agent', async () => {
const repo = createAgentsRepo(handle.db);
const accessible = await repo.findAccessible(USER_A_ID);
const ids = accessible.map((a) => a.id);
expect(ids).not.toContain(AGENT_B_ID);
});
it('User B does NOT see User A private agent', async () => {
const repo = createAgentsRepo(handle.db);
const accessible = await repo.findAccessible(USER_B_ID);
const ids = accessible.map((a) => a.id);
expect(ids).not.toContain(AGENT_A_ID);
});
it('Both users can see the shared system agent', async () => {
const repo = createAgentsRepo(handle.db);
const accessibleA = await repo.findAccessible(USER_A_ID);
const accessibleB = await repo.findAccessible(USER_B_ID);
expect(accessibleA.map((a) => a.id)).toContain(AGENT_SYS_ID);
expect(accessibleB.map((a) => a.id)).toContain(AGENT_SYS_ID);
});
it('findSystem returns the system agent for any caller', async () => {
const repo = createAgentsRepo(handle.db);
const system = await repo.findSystem();
const ids = system.map((a) => a.id);
expect(ids).toContain(AGENT_SYS_ID);
});
it('update with ownerId prevents User B from modifying User A agent', async () => {
const repo = createAgentsRepo(handle.db);
const result = await repo.update(AGENT_A_ID, { model: 'hacked' }, USER_B_ID);
expect(result).toBeUndefined();
// Verify the agent was not actually mutated
const unchanged = await repo.findById(AGENT_A_ID);
expect(unchanged?.model).toBe('test-model');
});
it('remove prevents User B from deleting User A agent', async () => {
const repo = createAgentsRepo(handle.db);
const deleted = await repo.remove(AGENT_A_ID, USER_B_ID);
expect(deleted).toBe(false);
// Verify the agent still exists
const still = await repo.findById(AGENT_A_ID);
expect(still).toBeDefined();
});
});
// ─── Preferences isolation ────────────────────────────────────────────────────
describe('PreferencesRepo — cross-user isolation', () => {
it('User A can retrieve their own preferences', async () => {
const repo = createPreferencesRepo(handle.db);
const prefs = await repo.findByUser(USER_A_ID);
const ids = prefs.map((p) => p.id);
expect(ids).toContain(PREF_A_ID);
});
it('User A preferences do not contain User B preferences', async () => {
const repo = createPreferencesRepo(handle.db);
const prefs = await repo.findByUser(USER_A_ID);
const ids = prefs.map((p) => p.id);
expect(ids).not.toContain(PREF_B_ID);
});
it('User B preferences do not contain User A preferences', async () => {
const repo = createPreferencesRepo(handle.db);
const prefs = await repo.findByUser(USER_B_ID);
const ids = prefs.map((p) => p.id);
expect(ids).not.toContain(PREF_A_ID);
});
it('findByUserAndKey is scoped to the requesting user', async () => {
const repo = createPreferencesRepo(handle.db);
// Both users have key "theme" — each should only see their own value
const prefA = await repo.findByUserAndKey(USER_A_ID, 'theme');
const prefB = await repo.findByUserAndKey(USER_B_ID, 'theme');
expect(prefA).toBeDefined();
// Drizzle returns JSONB values as parsed JS values; '"dark"' (JSON string) → 'dark'
expect(prefA!.value).toBe('dark');
expect(prefB).toBeDefined();
expect(prefB!.value).toBe('light');
});
it('remove is scoped to the requesting user (cannot delete another user pref)', async () => {
const repo = createPreferencesRepo(handle.db);
// User B tries to delete User A's "theme" preference — should silently fail
const deleted = await repo.remove(USER_B_ID, 'theme');
// This only deletes USER_B's own "theme" row; re-insert it for afterAll cleanup
expect(deleted).toBe(true); // deletes User B's OWN theme pref
// User A's theme pref must be untouched
const prefA = await repo.findByUserAndKey(USER_A_ID, 'theme');
expect(prefA).toBeDefined();
// Re-insert User B's preference so afterAll cleanup still finds it
await repo.upsert({
id: PREF_B_ID,
userId: USER_B_ID,
key: 'theme',
value: 'light',
category: 'appearance',
});
});
});
// ─── Insights isolation ───────────────────────────────────────────────────────
describe('InsightsRepo — cross-user isolation', () => {
it('User A can retrieve their own insights', async () => {
const repo = createInsightsRepo(handle.db);
const list = await repo.findByUser(USER_A_ID);
const ids = list.map((i) => i.id);
expect(ids).toContain(INSIGHT_A_ID);
});
it('User A insights do not contain User B insights', async () => {
const repo = createInsightsRepo(handle.db);
const list = await repo.findByUser(USER_A_ID);
const ids = list.map((i) => i.id);
expect(ids).not.toContain(INSIGHT_B_ID);
});
it('User B insights do not contain User A insights', async () => {
const repo = createInsightsRepo(handle.db);
const list = await repo.findByUser(USER_B_ID);
const ids = list.map((i) => i.id);
expect(ids).not.toContain(INSIGHT_A_ID);
});
it('findById is scoped to the requesting user', async () => {
const repo = createInsightsRepo(handle.db);
const own = await repo.findById(INSIGHT_A_ID, USER_A_ID);
const cross = await repo.findById(INSIGHT_A_ID, USER_B_ID);
expect(own).toBeDefined();
expect(cross).toBeUndefined();
});
it('searchByEmbedding returns only own insights', async () => {
const repo = createInsightsRepo(handle.db);
// Our test insights have no embedding — the query filters WHERE embedding IS NOT NULL
// so the result set is empty, which already proves no cross-user leakage.
// Using a 1536-dimension zero vector as the query embedding.
const zeroVector = Array<number>(1536).fill(0);
const resultsA = await repo.searchByEmbedding(USER_A_ID, zeroVector, 50, 2.0);
const resultsB = await repo.searchByEmbedding(USER_B_ID, zeroVector, 50, 2.0);
// The raw SQL query returns row objects directly (not wrapped in { insight }).
// Cast via unknown to extract id safely regardless of the return shape.
const toId = (r: unknown): string =>
((r as Record<string, unknown>)['id'] as string | undefined) ??
((r as Record<string, Record<string, unknown>>)['insight']?.['id'] as string | undefined) ??
'';
const idsInA = resultsA.map(toId);
const idsInB = resultsB.map(toId);
// User B's insight must never appear in User A's search results
expect(idsInA).not.toContain(INSIGHT_B_ID);
// User A's insight must never appear in User B's search results
expect(idsInB).not.toContain(INSIGHT_A_ID);
});
it('update is scoped to the requesting user', async () => {
const repo = createInsightsRepo(handle.db);
const result = await repo.update(INSIGHT_A_ID, USER_B_ID, { content: 'hacked' });
expect(result).toBeUndefined();
// Verify the insight was not mutated
const unchanged = await repo.findById(INSIGHT_A_ID, USER_A_ID);
expect(unchanged?.content).toBe('User A insight');
});
it('remove is scoped to the requesting user', async () => {
const repo = createInsightsRepo(handle.db);
const deleted = await repo.remove(INSIGHT_A_ID, USER_B_ID);
expect(deleted).toBe(false);
// Verify the insight still exists
const still = await repo.findById(INSIGHT_A_ID, USER_A_ID);
expect(still).toBeDefined();
});
});
@@ -1,64 +0,0 @@
/**
* Test B — Gateway boot refuses (fail-fast) when PG is unreachable.
*
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
* (Valkey must be running; only PG is intentionally misconfigured.)
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.pg-unreachable.integration.test.ts
*
* Skipped when FEDERATED_INTEGRATION !== '1'.
*/
import net from 'node:net';
import { beforeAll, describe, expect, it } from 'vitest';
import { TierDetectionError, detectAndAssertTier } from '@mosaicstack/storage';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const VALKEY_URL = 'redis://localhost:6380';
/**
* Reserves a guaranteed-closed port at runtime by binding to an ephemeral OS
* port (port 0) and immediately releasing it. The OS will not reassign the
* port during the TIME_WAIT window, so it remains closed for the duration of
* this test.
*/
async function reserveClosedPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (typeof addr !== 'object' || !addr) return reject(new Error('no addr'));
const port = addr.port;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}
describe.skipIf(!run)('federated boot — PG unreachable', () => {
let badPgUrl: string;
beforeAll(async () => {
const closedPort = await reserveClosedPort();
badPgUrl = `postgresql://mosaic:mosaic@localhost:${closedPort}/mosaic`;
});
it('detectAndAssertTier throws TierDetectionError with service: postgres when PG is down', async () => {
const brokenConfig = {
tier: 'federated' as const,
storage: {
type: 'postgres' as const,
url: badPgUrl,
enableVector: true,
},
queue: {
type: 'bullmq',
url: VALKEY_URL,
},
};
await expect(detectAndAssertTier(brokenConfig)).rejects.toSatisfy(
(err: unknown) => err instanceof TierDetectionError && err.service === 'postgres',
);
}, 10_000);
});
@@ -1,50 +0,0 @@
/**
* Test A — Gateway boot succeeds when federated services are up.
*
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-boot.success.integration.test.ts
*
* Skipped when FEDERATED_INTEGRATION !== '1'.
*/
import postgres from 'postgres';
import { afterAll, describe, expect, it } from 'vitest';
import { detectAndAssertTier } from '@mosaicstack/storage';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const VALKEY_URL = 'redis://localhost:6380';
const federatedConfig = {
tier: 'federated' as const,
storage: {
type: 'postgres' as const,
url: PG_URL,
enableVector: true,
},
queue: {
type: 'bullmq',
url: VALKEY_URL,
},
};
describe.skipIf(!run)('federated boot — success path', () => {
let sql: ReturnType<typeof postgres> | undefined;
afterAll(async () => {
if (sql) {
await sql.end({ timeout: 2 }).catch(() => {});
}
});
it('detectAndAssertTier resolves without throwing when federated services are up', async () => {
await expect(detectAndAssertTier(federatedConfig)).resolves.toBeUndefined();
}, 10_000);
it('pgvector extension is registered (pg_extension row exists)', async () => {
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
const rows = await sql`SELECT * FROM pg_extension WHERE extname = 'vector'`;
expect(rows).toHaveLength(1);
}, 10_000);
});
@@ -1,43 +0,0 @@
/**
* Test C — pgvector extension is functional end-to-end.
*
* Creates a temp table with a vector(3) column, inserts a row, and queries it
* back — confirming the extension is not just registered but operational.
*
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test src/__tests__/integration/federated-pgvector.integration.test.ts
*
* Skipped when FEDERATED_INTEGRATION !== '1'.
*/
import postgres from 'postgres';
import { afterAll, describe, expect, it } from 'vitest';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
let sql: ReturnType<typeof postgres> | undefined;
afterAll(async () => {
if (sql) {
await sql.end({ timeout: 2 }).catch(() => {});
}
});
describe.skipIf(!run)('federated pgvector — functional end-to-end', () => {
it('vector ops round-trip: INSERT [1,2,3] and SELECT returns [1,2,3]', async () => {
sql = postgres(PG_URL, { max: 1, connect_timeout: 5, idle_timeout: 5 });
await sql`CREATE TEMP TABLE t (id int, embedding vector(3))`;
await sql`INSERT INTO t VALUES (1, '[1,2,3]')`;
const rows = await sql`SELECT embedding FROM t`;
expect(rows).toHaveLength(1);
// The postgres driver returns vector columns as strings like '[1,2,3]'.
// Normalise by parsing the string representation.
const raw = rows[0]?.['embedding'] as string;
const parsed = JSON.parse(raw) as number[];
expect(parsed).toEqual([1, 2, 3]);
}, 10_000);
});
@@ -1,243 +0,0 @@
/**
* Federation M2 E2E test — peer-add enrollment flow (FED-M2-10).
*
* Covers MILESTONES.md acceptance test #6:
* "`peer add <url>` on Server A yields an `active` peer record with a valid cert + key"
*
* This test simulates two gateways using a single bootstrapped NestJS app:
* - "Server A": the admin API that generates a keypair and stores the cert
* - "Server B": the enrollment endpoint that signs the CSR
* Both share the same DB + Step-CA in the test environment.
*
* Prerequisites:
* docker compose -f docker-compose.federated.yml --profile federated up -d
*
* Run:
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
* STEP_CA_URL=https://localhost:9000 \
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
* pnpm --filter @mosaicstack/gateway test \
* src/__tests__/integration/federation-m2-e2e.integration.test.ts
*
* Obtaining Step-CA credentials:
* # Extract provisioner key from running container:
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
* # Copy root cert from container:
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
*
* Skipped unless both FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1 are set.
*/
import * as crypto from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test } from '@nestjs/testing';
import { ValidationPipe } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import supertest from 'supertest';
import {
createDb,
type Db,
type DbHandle,
federationPeers,
federationGrants,
federationEnrollmentTokens,
inArray,
eq,
} from '@mosaicstack/db';
import * as schema from '@mosaicstack/db';
import { DB } from '../../database/database.module.js';
import { AdminGuard } from '../../admin/admin.guard.js';
import { FederationModule } from '../../federation/federation.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { EnrollmentService } from '../../federation/enrollment.service.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const stepCaRun =
run &&
process.env['STEP_CA_AVAILABLE'] === '1' &&
!!process.env['STEP_CA_URL'] &&
!!process.env['STEP_CA_PROVISIONER_KEY_JSON'] &&
!!process.env['STEP_CA_ROOT_CERT_PATH'];
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = crypto.randomUUID();
describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () => {
let handle: DbHandle;
let db: Db;
let app: NestFastifyApplication;
let agent: ReturnType<typeof supertest>;
let grantsService: GrantsService;
let enrollmentService: EnrollmentService;
const createdTokenGrantIds: string[] = [];
const createdGrantIds: string[] = [];
const createdPeerIds: string[] = [];
const createdUserIds: string[] = [];
beforeAll(async () => {
process.env['BETTER_AUTH_SECRET'] ??= 'test-e2e-sealing-key';
handle = createDb(PG_URL);
db = handle.db;
const moduleRef = await Test.createTestingModule({
imports: [FederationModule],
providers: [{ provide: DB, useValue: db }],
})
.overrideGuard(AdminGuard)
.useValue({ canActivate: () => true })
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
await app.getHttpAdapter().getInstance().ready();
agent = supertest(app.getHttpServer());
grantsService = moduleRef.get(GrantsService);
enrollmentService = moduleRef.get(EnrollmentService);
}, 30_000);
afterAll(async () => {
if (db && createdTokenGrantIds.length > 0) {
await db
.delete(federationEnrollmentTokens)
.where(inArray(federationEnrollmentTokens.grantId, createdTokenGrantIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (db && createdGrantIds.length > 0) {
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (db && createdPeerIds.length > 0) {
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, createdPeerIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (db && createdUserIds.length > 0) {
await db
.delete(schema.users)
.where(inArray(schema.users.id, createdUserIds))
.catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
}
if (app)
await app.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
if (handle)
await handle.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e));
});
// -------------------------------------------------------------------------
// #6 — peer add: keypair → enrollment → cert storage → active peer record
// -------------------------------------------------------------------------
it('#6 — peer add flow: keypair → enrollment → cert storage → active peer record', async () => {
// Create a subject user to satisfy FK on federation_grants.subject_user_id
const userId = crypto.randomUUID();
await db
.insert(schema.users)
.values({
id: userId,
name: `e2e-user-${RUN_ID}`,
email: `e2e-${RUN_ID}@federation-test.invalid`,
emailVerified: false,
})
.onConflictDoNothing();
createdUserIds.push(userId);
// ── Step A: "Server B" setup ─────────────────────────────────────────
// Server B admin creates a grant and generates an enrollment token to
// share out-of-band with Server A's operator.
// Insert a placeholder peer on "Server B" to satisfy the grant FK
const serverBPeerId = crypto.randomUUID();
await db
.insert(federationPeers)
.values({
id: serverBPeerId,
commonName: `server-b-peer-${RUN_ID}`,
displayName: 'Server B Placeholder',
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `serial-b-${serverBPeerId}`,
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
state: 'pending',
})
.onConflictDoNothing();
createdPeerIds.push(serverBPeerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 },
peerId: serverBPeerId,
});
createdGrantIds.push(grant.id);
createdTokenGrantIds.push(grant.id);
const { token } = await enrollmentService.createToken({
grantId: grant.id,
peerId: serverBPeerId,
ttlSeconds: 900,
});
// ── Step B: "Server A" generates keypair ─────────────────────────────
const keypairRes = await agent
.post('/api/admin/federation/peers/keypair')
.send({
commonName: `e2e-peer-${RUN_ID.slice(0, 8)}`,
displayName: 'E2E Test Peer',
endpointUrl: 'https://test.invalid',
})
.set('Content-Type', 'application/json');
expect(keypairRes.status).toBe(201);
const { peerId, csrPem } = keypairRes.body as { peerId: string; csrPem: string };
expect(typeof peerId).toBe('string');
expect(csrPem).toContain('-----BEGIN CERTIFICATE REQUEST-----');
createdPeerIds.push(peerId);
// ── Step C: Enrollment (simulates Server A sending CSR to Server B) ──
const enrollRes = await agent
.post(`/api/federation/enrollment/${token}`)
.send({ csrPem })
.set('Content-Type', 'application/json');
expect(enrollRes.status).toBe(200);
const { certPem, certChainPem } = enrollRes.body as {
certPem: string;
certChainPem: string;
};
expect(certPem).toContain('-----BEGIN CERTIFICATE-----');
expect(certChainPem).toContain('-----BEGIN CERTIFICATE-----');
// ── Step D: "Server A" stores the cert ───────────────────────────────
const storeRes = await agent
.patch(`/api/admin/federation/peers/${peerId}/cert`)
.send({ certPem })
.set('Content-Type', 'application/json');
expect(storeRes.status).toBe(200);
// ── Step E: Verify peer record in DB ─────────────────────────────────
const [peer] = await db
.select()
.from(federationPeers)
.where(eq(federationPeers.id, peerId))
.limit(1);
expect(peer).toBeDefined();
expect(peer?.state).toBe('active');
expect(peer?.certPem).toContain('-----BEGIN CERTIFICATE-----');
expect(typeof peer?.certSerial).toBe('string');
expect((peer?.certSerial ?? '').length).toBeGreaterThan(0);
// clientKeyPem is a sealed ciphertext — must not be a raw PEM
expect(peer?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
// certNotAfter must be in the future
expect(peer?.certNotAfter?.getTime()).toBeGreaterThan(Date.now());
}, 60_000);
});
@@ -1,483 +0,0 @@
/**
* Federation M2 integration tests (FED-M2-09).
*
* Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8.
*
* Prerequisites:
* docker compose -f docker-compose.federated.yml --profile federated up -d
*
* Run DB-only tests (no Step-CA):
* FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \
* src/__tests__/integration/federation-m2.integration.test.ts
*
* Run all tests including Step-CA-dependent ones:
* FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \
* STEP_CA_URL=https://localhost:9000 \
* STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \
* STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \
* pnpm --filter @mosaicstack/gateway test \
* src/__tests__/integration/federation-m2.integration.test.ts
*
* Obtaining Step-CA credentials:
* # Extract provisioner key from running container:
* # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json
* # Copy root cert from container:
* # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
* # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt
*/
import * as crypto from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test } from '@nestjs/testing';
import { GoneException } from '@nestjs/common';
import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509';
import {
createDb,
type Db,
type DbHandle,
federationPeers,
federationGrants,
federationEnrollmentTokens,
inArray,
eq,
} from '@mosaicstack/db';
import * as schema from '@mosaicstack/db';
import { seal } from '@mosaicstack/auth';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { EnrollmentService } from '../../federation/enrollment.service.js';
import { CaService } from '../../federation/ca.service.js';
import { FederationScopeError } from '../../federation/scope-schema.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1';
const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
// ---------------------------------------------------------------------------
// Helpers for test data isolation
// ---------------------------------------------------------------------------
/** Unique run prefix to identify rows created by this test run. */
const RUN_ID = crypto.randomUUID();
/** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */
async function insertTestUser(db: Db, id: string): Promise<void> {
await db
.insert(schema.users)
.values({
id,
name: `test-user-${id}`,
email: `test-${id}@federation-test.invalid`,
emailVerified: false,
})
.onConflictDoNothing();
}
/** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */
async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise<void> {
await db
.insert(federationPeers)
.values({
id,
commonName: `test-peer-${RUN_ID}-${suffix}`,
displayName: `Test Peer ${suffix}`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `test-serial-${id}`,
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
state: 'pending',
})
.onConflictDoNothing();
}
// ---------------------------------------------------------------------------
// DB-only test module (CaService mocked so env vars not required)
// ---------------------------------------------------------------------------
function buildDbModule(db: Db) {
return Test.createTestingModule({
providers: [
{ provide: DB, useValue: db },
GrantsService,
{
provide: CaService,
useValue: {
issueCert: async () => {
throw new Error('CaService.issueCert should not be called in DB-only tests');
},
},
},
EnrollmentService,
],
}).compile();
}
// ---------------------------------------------------------------------------
// Test suite — DB-only (no Step-CA)
// ---------------------------------------------------------------------------
describe.skipIf(!run)('federation M2 — DB-only tests', () => {
let handle: DbHandle;
let db: Db;
let grantsService: GrantsService;
/** IDs created during this run — cleaned up in afterAll. */
const createdGrantIds: string[] = [];
const createdPeerIds: string[] = [];
const createdUserIds: string[] = [];
beforeAll(async () => {
process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod';
handle = createDb(PG_URL);
db = handle.db;
const moduleRef = await buildDbModule(db);
grantsService = moduleRef.get(GrantsService);
});
afterAll(async () => {
// Clean up in FK-safe order: tokens → grants → peers → users
if (db && createdGrantIds.length > 0) {
await db
.delete(federationEnrollmentTokens)
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdPeerIds.length > 0) {
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, createdPeerIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdUserIds.length > 0) {
await db
.delete(schema.users)
.where(inArray(schema.users.id, createdUserIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (handle)
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
});
// -------------------------------------------------------------------------
// #1 — grant create writes a pending row
// -------------------------------------------------------------------------
it('#1 — createGrant writes a pending row to DB', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 100,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'test1');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
createdGrantIds.push(grant.id);
// Verify the row exists in DB with correct shape
const [row] = await db
.select()
.from(federationGrants)
.where(eq(federationGrants.id, grant.id))
.limit(1);
expect(row).toBeDefined();
expect(row?.status).toBe('pending');
expect(row?.peerId).toBe(peerId);
expect(row?.subjectUserId).toBe(userId);
const storedScope = row?.scope as Record<string, unknown>;
expect(storedScope['resources']).toEqual(['tasks']);
expect(storedScope['max_rows_per_query']).toBe(100);
}, 15_000);
// -------------------------------------------------------------------------
// #7 — scope with unknown resource type rejected
// -------------------------------------------------------------------------
it('#7 — createGrant rejects scope with unknown resource type', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const invalidScope = {
resources: ['totally_unknown_resource'],
excluded_resources: [],
max_rows_per_query: 100,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'test7');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
await expect(
grantsService.createGrant({
subjectUserId: userId,
scope: invalidScope,
peerId,
}),
).rejects.toThrow(FederationScopeError);
}, 15_000);
// -------------------------------------------------------------------------
// #8 — listGrants returns accurate status for grants in various states
// -------------------------------------------------------------------------
it('#8 — listGrants returns accurate status for grants in various states', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['notes'],
excluded_resources: [],
max_rows_per_query: 50,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'test8');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
// Create two pending grants via GrantsService
const grantA = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
const grantB = await grantsService.createGrant({
subjectUserId: userId,
scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 },
peerId,
});
createdGrantIds.push(grantA.id, grantB.id);
// Insert a third grant directly in 'revoked' state to test status variety
const [grantC] = await db
.insert(federationGrants)
.values({
id: crypto.randomUUID(),
subjectUserId: userId,
peerId,
scope: validScope,
status: 'revoked',
revokedAt: new Date(),
})
.returning();
createdGrantIds.push(grantC!.id);
// List all grants for this peer
const allForPeer = await grantsService.listGrants({ peerId });
const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]);
const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id));
expect(ourGrants).toHaveLength(3);
const pendingGrants = ourGrants.filter((g) => g.status === 'pending');
const revokedGrants = ourGrants.filter((g) => g.status === 'revoked');
expect(pendingGrants).toHaveLength(2);
expect(revokedGrants).toHaveLength(1);
// Status-filtered query
const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' });
const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id));
expect(ourPending.every((g) => g.status === 'pending')).toBe(true);
// Verify peer list from DB also shows the peer rows with correct state
const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId));
expect(peers).toHaveLength(1);
expect(peers[0]?.state).toBe('pending');
}, 15_000);
// -------------------------------------------------------------------------
// #5 — client_key_pem encrypted at rest
// -------------------------------------------------------------------------
it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => {
const peerId = crypto.randomUUID();
const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n';
const sealed = seal(rawPem);
await db.insert(federationPeers).values({
id: peerId,
commonName: `test-peer-${RUN_ID}-sealed`,
displayName: 'Sealed Key Test Peer',
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `test-serial-sealed-${peerId}`,
certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
state: 'pending',
clientKeyPem: sealed,
});
createdPeerIds.push(peerId);
const [row] = await db
.select()
.from(federationPeers)
.where(eq(federationPeers.id, peerId))
.limit(1);
expect(row).toBeDefined();
// The stored value must NOT be a valid PEM — it's a sealed ciphertext blob
expect(row?.clientKeyPem).toBeDefined();
expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false);
// The sealed value should be non-trivial (at least 20 chars)
expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20);
}, 15_000);
});
// ---------------------------------------------------------------------------
// Test suite — Step-CA gated
// ---------------------------------------------------------------------------
describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => {
let handle: DbHandle;
let db: Db;
let grantsService: GrantsService;
let enrollmentService: EnrollmentService;
const createdGrantIds: string[] = [];
const createdPeerIds: string[] = [];
const createdUserIds: string[] = [];
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
// Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON,
// STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1
const moduleRef = await Test.createTestingModule({
providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService],
}).compile();
grantsService = moduleRef.get(GrantsService);
enrollmentService = moduleRef.get(EnrollmentService);
});
afterAll(async () => {
if (db && createdGrantIds.length > 0) {
await db
.delete(federationEnrollmentTokens)
.where(inArray(federationEnrollmentTokens.grantId, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, createdGrantIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdPeerIds.length > 0) {
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, createdPeerIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (db && createdUserIds.length > 0) {
await db
.delete(schema.users)
.where(inArray(schema.users.id, createdUserIds))
.catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
}
if (handle)
await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e));
});
/** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */
async function generateCsrPem(cn: string): Promise<string> {
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' };
const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
const csr = await Pkcs10CertificateRequestGenerator.create({
name: `CN=${cn}`,
keys: keyPair,
signingAlgorithm: alg,
});
return csr.toString('pem');
}
// -------------------------------------------------------------------------
// #2 — enrollment signs CSR and returns cert
// -------------------------------------------------------------------------
it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 100,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'ca-test2');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
createdGrantIds.push(grant.id);
const { token } = await enrollmentService.createToken({
grantId: grant.id,
peerId,
ttlSeconds: 900,
});
const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`);
const result = await enrollmentService.redeem(token, csrPem);
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----');
// Verify the issued cert parses cleanly
const cert = new PeculiarX509(result.certPem);
expect(cert.serialNumber).toBeTruthy();
}, 30_000);
// -------------------------------------------------------------------------
// #3 — token single-use; second attempt returns GoneException
// -------------------------------------------------------------------------
it('#3 — second redeem of the same token throws GoneException', async () => {
const userId = crypto.randomUUID();
const peerId = crypto.randomUUID();
const validScope = {
resources: ['notes'],
excluded_resources: [],
max_rows_per_query: 50,
};
await insertTestUser(db, userId);
await insertTestPeer(db, peerId, 'ca-test3');
createdUserIds.push(userId);
createdPeerIds.push(peerId);
const grant = await grantsService.createGrant({
subjectUserId: userId,
scope: validScope,
peerId,
});
createdGrantIds.push(grant.id);
const { token } = await enrollmentService.createToken({
grantId: grant.id,
peerId,
ttlSeconds: 900,
});
const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`);
// First redeem must succeed
const result = await enrollmentService.redeem(token, csrPem);
expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----');
// Second redeem with the same token must be rejected
await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException);
}, 30_000);
});
@@ -1,519 +0,0 @@
/**
* Federation M3 single-gateway integration tests (FED-M3-10).
*
* Covers MILESTONES.md M3 acceptance:
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
* - #7: max_rows_per_query caps list results.
*
* Strategy:
* - Real PostgreSQL via @mosaicstack/db.
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
*
* Run:
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
* src/__tests__/integration/federation-m3-list.integration.test.ts
*/
import 'reflect-metadata';
import * as crypto from 'node:crypto';
import type { ExecutionContext } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
and,
createDb,
eq,
federationGrants,
federationPeers,
inArray,
missionTasks,
missions,
projects,
tasks,
teamMembers,
teams,
type Db,
type DbHandle,
users,
} from '@mosaicstack/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
import { FederationScopeService } from '../../federation/server/scope.service.js';
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
import { ListController } from '../../federation/server/verbs/list.controller.js';
import {
makeMosaicIssuedCert,
makeSelfSignedCert,
} from '../../federation/__tests__/helpers/test-cert.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
interface TestIds {
readonly subjectUserId: string;
readonly otherUserId: string;
readonly peerId: string;
readonly revokedPeerId: string;
readonly activeGrantId: string;
readonly revokedGrantId: string;
readonly subjectProjectId: string;
readonly subjectMissionId: string;
readonly otherProjectId: string;
readonly teamId: string;
readonly unauthorizedTeamId: string;
readonly teamProjectId: string;
readonly taskIds: readonly string[];
readonly excludedTaskIds: readonly string[];
readonly subjectNoteId: string;
readonly otherUserNoteId: string;
}
function pemToDer(pem: string): Buffer {
return Buffer.from(
pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, ''),
'base64',
);
}
function makeFederationRequest(certPem: string): FastifyRequest {
return {
raw: {
socket: {
getPeerCertificate: () => ({
raw: pemToDer(certPem),
serialNumber: CERT_SERIAL_HEX,
}),
},
},
} as unknown as FastifyRequest;
}
function makeGuardContext(request: FastifyRequest): {
readonly context: ExecutionContext;
readonly sent: { statusCode?: number; payload?: unknown };
} {
const sent: { statusCode?: number; payload?: unknown } = {};
const reply = {
status: (statusCode: number) => {
sent.statusCode = statusCode;
return {
header: () => ({
send: (payload: unknown) => {
sent.payload = payload;
},
}),
};
},
} as unknown as FastifyReply;
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext;
return { context, sent };
}
async function insertUser(db: Db, id: string, label: string): Promise<void> {
await db.insert(users).values({
id,
name: `${RUN_ID}-${label}`,
email: `${RUN_ID}-${label}@federation-test.invalid`,
emailVerified: false,
});
}
async function seedFixtures(db: Db): Promise<TestIds> {
const subjectUserId = `${RUN_ID}-subject`;
const otherUserId = `${RUN_ID}-other`;
const peerId = crypto.randomUUID();
const revokedPeerId = crypto.randomUUID();
const activeGrantId = crypto.randomUUID();
const revokedGrantId = crypto.randomUUID();
const subjectProjectId = crypto.randomUUID();
const subjectMissionId = crypto.randomUUID();
const otherProjectId = crypto.randomUUID();
const teamId = crypto.randomUUID();
const unauthorizedTeamId = crypto.randomUUID();
const teamProjectId = crypto.randomUUID();
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
const subjectNoteId = crypto.randomUUID();
const otherUserNoteId = crypto.randomUUID();
await insertUser(db, subjectUserId, 'subject');
await insertUser(db, otherUserId, 'other');
await db.insert(teams).values([
{
id: teamId,
name: `${RUN_ID} allowed team`,
slug: `${RUN_ID}-allowed-team`,
ownerId: subjectUserId,
managerId: subjectUserId,
},
{
id: unauthorizedTeamId,
name: `${RUN_ID} unauthorized team`,
slug: `${RUN_ID}-unauthorized-team`,
ownerId: otherUserId,
managerId: otherUserId,
},
]);
await db.insert(teamMembers).values([
{ teamId, userId: subjectUserId, role: 'member' },
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
]);
await db.insert(projects).values([
{
id: subjectProjectId,
name: `${RUN_ID} subject personal project`,
ownerType: 'user',
ownerId: subjectUserId,
},
{
id: otherProjectId,
name: `${RUN_ID} other personal project`,
ownerType: 'user',
ownerId: otherUserId,
},
{
id: teamProjectId,
name: `${RUN_ID} unauthorized team project`,
ownerType: 'team',
teamId: unauthorizedTeamId,
},
]);
await db.insert(missions).values({
id: subjectMissionId,
name: `${RUN_ID} subject mission`,
projectId: subjectProjectId,
userId: subjectUserId,
});
await db.insert(tasks).values([
{
id: taskIds[0],
title: `${RUN_ID} visible task 1`,
missionId: subjectMissionId,
createdAt: new Date('2026-06-25T03:00:00.000Z'),
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
},
{
id: taskIds[1],
title: `${RUN_ID} visible task 2`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T02:00:00.000Z'),
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
},
{
id: taskIds[2],
title: `${RUN_ID} visible task 3`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T01:00:00.000Z'),
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
},
{
id: excludedTaskIds[0],
title: `${RUN_ID} other user task`,
projectId: otherProjectId,
createdAt: new Date('2026-06-25T04:00:00.000Z'),
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
},
{
id: excludedTaskIds[1],
title: `${RUN_ID} unauthorized team task`,
projectId: teamProjectId,
createdAt: new Date('2026-06-25T05:00:00.000Z'),
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
},
]);
await db.insert(missionTasks).values([
{
id: subjectNoteId,
missionId: subjectMissionId,
userId: subjectUserId,
notes: `${RUN_ID} subject visible note`,
createdAt: new Date('2026-06-25T03:30:00.000Z'),
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
},
{
id: otherUserNoteId,
missionId: subjectMissionId,
userId: otherUserId,
notes: `${RUN_ID} other user note on subject mission`,
createdAt: new Date('2026-06-25T04:30:00.000Z'),
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
},
]);
await db.insert(federationPeers).values([
{
id: peerId,
commonName: `${RUN_ID}-active-peer`,
displayName: `${RUN_ID} Active Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: CERT_SERIAL_HEX,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
{
id: revokedPeerId,
commonName: `${RUN_ID}-revoked-peer`,
displayName: `${RUN_ID} Revoked Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
]);
await db.insert(federationGrants).values([
{
id: activeGrantId,
peerId,
subjectUserId,
status: 'active',
scope: {
resources: ['tasks', 'notes'],
excluded_resources: [],
filters: {
tasks: { include_personal: true, include_teams: [] },
notes: { include_personal: true, include_teams: [] },
},
max_rows_per_query: 2,
},
},
{
id: revokedGrantId,
peerId,
subjectUserId,
status: 'revoked',
revokedAt: new Date(),
revokedReason: `${RUN_ID} revoked grant fixture`,
scope: {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 2,
},
},
]);
return {
subjectUserId,
otherUserId,
peerId,
revokedPeerId,
activeGrantId,
revokedGrantId,
subjectProjectId,
subjectMissionId,
otherProjectId,
teamId,
unauthorizedTeamId,
teamProjectId,
taskIds,
excludedTaskIds,
subjectNoteId,
otherUserNoteId,
};
}
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
if (!ids) {
return;
}
await db
.delete(missionTasks)
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
.catch(() => {});
await db
.delete(tasks)
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
.catch(() => {});
await db
.delete(missions)
.where(eq(missions.id, ids.subjectMissionId))
.catch(() => {});
await db
.delete(projects)
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
.catch(() => {});
await db
.delete(teamMembers)
.where(
and(
eq(teamMembers.userId, ids.subjectUserId),
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
),
)
.catch(() => {});
await db
.delete(teams)
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
.catch(() => {});
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
.catch(() => {});
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
.catch(() => {});
await db
.delete(users)
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
.catch(() => {});
}
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
let handle: DbHandle;
let db: Db;
let moduleRef: TestingModule;
let guard: FederationAuthGuard;
let listController: ListController;
let ids: TestIds | undefined;
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
ids = await seedFixtures(db);
moduleRef = await Test.createTestingModule({
controllers: [ListController],
providers: [
{ provide: DB, useValue: db },
GrantsService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
}).compile();
guard = moduleRef.get(FederationAuthGuard);
listController = moduleRef.get(ListController);
}, 30_000);
afterAll(async () => {
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
});
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
const malformedOidCert = await makeSelfSignedCert();
const request = makeFederationRequest(malformedOidCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(401);
expect(sent.payload).toMatchObject({
error: {
code: 'unauthorized',
message: expect.stringContaining('missing required OID'),
},
});
expect(request.federationContext).toBeUndefined();
});
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
expect(ids).toBeDefined();
const revokedCert = await makeMosaicIssuedCert({
grantId: ids!.revokedGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(revokedCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(403);
expect(sent.payload).toMatchObject({
error: {
code: 'forbidden',
message: 'Federation access denied',
},
});
expect(request.federationContext).toBeUndefined();
});
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('tasks', request, { limit: 100 });
const returnedIds = response.items.map((item) => item['id']);
expect(response.items).toHaveLength(2);
expect(response._truncated).toBe(true);
expect(response.nextCursor).toEqual(expect.any(String));
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
expect(returnedIds).not.toContain(ids!.taskIds[2]);
for (const excludedId of ids!.excludedTaskIds) {
expect(returnedIds).not.toContain(excludedId);
}
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('excludes another user mission task notes on the same authorized mission', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('notes', request, { limit: 10 });
const returnedIds = response.items.map((item) => item['id']);
expect(returnedIds).toEqual([ids!.subjectNoteId]);
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('fails closed for unsupported list resources', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is not supported',
},
},
status: 403,
});
});
});
@@ -1,213 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
import {
createDiscordIngressEnvelope,
DiscordPlugin,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import { InteractionController } from '../../agent/interaction.controller.js';
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
import { DurableSessionService } from '../../agent/durable-session.service.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
const SERVICE_TOKEN = 'test-discord-service-token';
const envKeys = [
'DISCORD_SERVICE_TOKEN',
'DISCORD_SERVICE_TENANT_ID',
'DISCORD_INTERACTION_BINDINGS',
'DISCORD_ALLOWED_GUILD_IDS',
'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
] as const;
const priorEnv = new Map<string, string | undefined>();
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
return {
content,
messageId,
correlationId,
guildId: 'guild-1',
channelId: 'channel-1',
userId: 'discord-admin-1',
conversationId: 'Nova:discord:channel-1',
};
}
/**
* The chat runtime router must never be exercised on the Discord approval/stop control paths —
* those paths run entirely through the command-authorization, runtime-provider and durable-session
* dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService`
* slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because
* approval/stop never resolve a chat runtime, this fixture is never triggered and the integration
* stays a GREEN cross-surface control.
*/
function failIfUsedChatRuntimeRouter() {
return {
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the Discord control path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the Discord approval/stop path');
},
};
}
function authorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
return new CommandAuthorizationService(
{
select: () => ({
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
}),
} as never,
{
get: async (key: string) => entries.get(key) ?? null,
set: async (key: string, value: string) => entries.set(key, value),
del: async (key: string) => Number(entries.delete(key)),
},
);
}
describe('interaction Discord/CLI durable-session integration', () => {
afterEach(() => {
for (const key of envKeys) {
const value = priorEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
priorEnv.clear();
});
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
for (const key of envKeys) priorEnv.set(key, process.env[key]);
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
},
},
]);
const durable = new DurableSessionService(
new InMemoryDurableSessionStore() as never,
{} as never,
);
const enrollmentRuntime = {
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
};
const controller = new InteractionController(enrollmentRuntime as never, durable);
await controller.enroll(
'Nova',
'Nova:discord:channel-1',
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
'cli-enrollment-correlation',
);
const authz = authorization();
const terminated = vi.fn().mockResolvedValue(undefined);
const runtime = new RuntimeProviderService(
{
require: () => ({
capabilities: async () => ({ supported: ['session.terminate'] }),
terminate: terminated,
}),
} as never,
{ record: async () => undefined } as never,
{
consume: (approvalId, action) =>
authz.consumeRuntimeTerminationApproval(approvalId, action),
},
);
const gateway = new ChatGateway(
failIfUsedChatRuntimeRouter() as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
authz,
runtime,
durable,
);
const client = { data: { discordService: true }, emit: vi.fn() };
const plugin = new DiscordPlugin({
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-1'],
allowedChannelIds: ['channel-1'],
allowedUserIds: ['discord-admin-1'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
},
},
],
});
const pluginInternals = plugin as unknown as {
client: { user: { id: string } };
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
handleDiscordMessage(message: unknown): void;
};
const pluginSocket = { connected: true, emit: vi.fn() };
pluginInternals.client = { user: { id: 'bot-1' } };
pluginInternals.socket = pluginSocket;
pluginInternals.handleDiscordMessage({
id: 'approve-1',
guildId: 'guild-1',
channelId: 'channel-1',
author: { id: 'discord-admin-1', bot: false },
mentions: { has: () => true },
content: '<@bot-1> /approve',
channel: { parentId: null },
attachments: new Map(),
});
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
const approval = client.emit.mock.calls.find(
([event]) => event === 'discord:approval',
)?.[1] as {
approvalId: string;
success: boolean;
};
expect(approval.success).toBe(true);
await gateway.handleDiscordStop(
client as never,
createDiscordIngressEnvelope(
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
SERVICE_TOKEN,
),
);
expect(terminated).toHaveBeenCalledWith(
'runtime-1',
approval.approvalId,
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
);
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
correlationId: 'discord-stop-correlation',
success: true,
});
});
});
@@ -1,150 +0,0 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ConversationsController } from '../conversations/conversations.controller.js';
import { MissionsController } from '../missions/missions.controller.js';
import { ProjectsController } from '../projects/projects.controller.js';
import { TasksController } from '../tasks/tasks.controller.js';
function createBrain() {
return {
conversations: {
findAll: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
findMessages: vi.fn(),
addMessage: vi.fn(),
},
projects: {
findAll: vi.fn(),
findAllForUser: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
},
missions: {
findAll: vi.fn(),
findAllByUser: vi.fn(),
findById: vi.fn(),
findByIdAndUser: vi.fn(),
findByProject: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
},
missionTasks: {
findByMissionAndUser: vi.fn(),
findByIdAndUser: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
},
tasks: {
findAll: vi.fn(),
findById: vi.fn(),
findByProject: vi.fn(),
findByMission: vi.fn(),
findByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
},
};
}
describe('Resource ownership checks', () => {
it('forbids access to another user conversation', async () => {
const brain = createBrain();
// The repo enforces ownership via the WHERE clause; it returns undefined when the
// conversation does not belong to the requesting user.
brain.conversations.findById.mockResolvedValue(undefined);
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('forbids access to another user project', async () => {
const brain = createBrain();
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
const teamsService = { canAccessProject: vi.fn().mockResolvedValue(false) };
const controller = new ProjectsController(brain as never, teamsService as never);
await expect(controller.findOne('project-1', { id: 'user-1' })).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('forbids access to a mission owned by another user', async () => {
const brain = createBrain();
// findByIdAndUser returns undefined when the mission doesn't belong to the user
brain.missions.findByIdAndUser.mockResolvedValue(undefined);
const controller = new MissionsController(brain as never);
await expect(controller.findOne('mission-1', { id: 'user-1' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('forbids access to a task owned by another project owner', async () => {
const brain = createBrain();
brain.tasks.findById.mockResolvedValue({ id: 'task-1', projectId: 'project-1' });
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
const controller = new TasksController(brain as never);
await expect(controller.findOne('task-1', { id: 'user-1' })).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('forbids creating a task with an unowned project', async () => {
const brain = createBrain();
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
const controller = new TasksController(brain as never);
await expect(
controller.create(
{
title: 'Task',
projectId: 'project-1',
},
{ id: 'user-1' },
),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('forbids listing tasks for an unowned project', async () => {
const brain = createBrain();
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
const controller = new TasksController(brain as never);
await expect(
controller.list({ id: 'user-1' }, 'project-1', undefined, undefined),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('lists only tasks for the current user owned projects when no filter is provided', async () => {
const brain = createBrain();
brain.projects.findAll.mockResolvedValue([
{ id: 'project-1', ownerId: 'user-1' },
{ id: 'project-2', ownerId: 'user-2' },
]);
brain.missions.findAll.mockResolvedValue([{ id: 'mission-1', projectId: 'project-1' }]);
brain.tasks.findAll.mockResolvedValue([
{ id: 'task-1', projectId: 'project-1' },
{ id: 'task-2', missionId: 'mission-1' },
{ id: 'task-3', projectId: 'project-2' },
]);
const controller = new TasksController(brain as never);
await expect(
controller.list({ id: 'user-1' }, undefined, undefined, undefined),
).resolves.toEqual([
{ id: 'task-1', projectId: 'project-1' },
{ id: 'task-2', missionId: 'mission-1' },
]);
});
});
@@ -1,377 +0,0 @@
/**
* M5-008: Session hardening verification tests.
*
* Verifies:
* 1. /model command switches model → session:info reflects updated modelId
* 2. /agent command switches agent config → system prompt / agentName changes
* 3. Session resume binds to a conversation (history injected via conversationHistory option)
* 4. Session metrics track token usage and message count correctly
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type {
AgentSession,
AgentSessionOptions,
ConversationHistoryMessage,
} from '../agent/agent.service.js';
import type { SessionInfoDto, SessionMetrics, SessionTokenMetrics } from '../agent/session.dto.js';
// ---------------------------------------------------------------------------
// Helpers — minimal AgentSession fixture
// ---------------------------------------------------------------------------
function makeMetrics(overrides?: Partial<SessionMetrics>): SessionMetrics {
return {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date().toISOString(),
...overrides,
};
}
function makeSession(overrides?: Partial<AgentSession>): AgentSession {
return {
id: 'session-001',
provider: 'anthropic',
modelId: 'claude-3-5-sonnet-20241022',
piSession: {} as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp',
allowedTools: null,
metrics: makeMetrics(),
...overrides,
};
}
function sessionToInfo(session: AgentSession): SessionInfoDto {
return {
id: session.id,
provider: session.provider,
modelId: session.modelId,
...(session.agentName ? { agentName: session.agentName } : {}),
createdAt: new Date(session.createdAt).toISOString(),
promptCount: session.promptCount,
channels: Array.from(session.channels),
durationMs: Date.now() - session.createdAt,
metrics: { ...session.metrics },
};
}
// ---------------------------------------------------------------------------
// Replicated AgentService methods (tested in isolation without full DI setup)
// ---------------------------------------------------------------------------
function updateSessionModel(session: AgentSession, modelId: string): void {
session.modelId = modelId;
session.metrics.modelSwitches += 1;
session.metrics.lastActivityAt = new Date().toISOString();
}
function applyAgentConfig(
session: AgentSession,
agentConfigId: string,
agentName: string,
modelId?: string,
): void {
session.agentConfigId = agentConfigId;
session.agentName = agentName;
if (modelId) {
updateSessionModel(session, modelId);
}
}
function recordTokenUsage(session: AgentSession, tokens: SessionTokenMetrics): void {
session.metrics.tokens.input += tokens.input;
session.metrics.tokens.output += tokens.output;
session.metrics.tokens.cacheRead += tokens.cacheRead;
session.metrics.tokens.cacheWrite += tokens.cacheWrite;
session.metrics.tokens.total += tokens.total;
session.metrics.lastActivityAt = new Date().toISOString();
}
function recordMessage(session: AgentSession): void {
session.metrics.messageCount += 1;
session.metrics.lastActivityAt = new Date().toISOString();
}
// ---------------------------------------------------------------------------
// 1. /model command — switches model → session:info updated
// ---------------------------------------------------------------------------
describe('/model command — model switch reflected in session:info', () => {
let session: AgentSession;
beforeEach(() => {
session = makeSession();
});
it('updates modelId when /model is called with a model name', () => {
updateSessionModel(session, 'claude-opus-4-5-20251001');
expect(session.modelId).toBe('claude-opus-4-5-20251001');
});
it('increments modelSwitches metric after /model command', () => {
expect(session.metrics.modelSwitches).toBe(0);
updateSessionModel(session, 'gpt-4o');
expect(session.metrics.modelSwitches).toBe(1);
updateSessionModel(session, 'claude-3-5-sonnet-20241022');
expect(session.metrics.modelSwitches).toBe(2);
});
it('session:info DTO reflects the new modelId after switch', () => {
updateSessionModel(session, 'claude-haiku-3-5-20251001');
const info = sessionToInfo(session);
expect(info.modelId).toBe('claude-haiku-3-5-20251001');
expect(info.metrics.modelSwitches).toBe(1);
});
it('lastActivityAt is updated after model switch', () => {
const before = session.metrics.lastActivityAt;
// Ensure at least 1ms passes
vi.setSystemTime(Date.now() + 1);
updateSessionModel(session, 'new-model');
vi.useRealTimers();
expect(session.metrics.lastActivityAt).not.toBe(before);
});
});
// ---------------------------------------------------------------------------
// 2. /agent command — switches agent config → system prompt / agentName updated
// ---------------------------------------------------------------------------
describe('/agent command — agent config applied to session', () => {
let session: AgentSession;
beforeEach(() => {
session = makeSession();
});
it('sets agentConfigId and agentName on the session', () => {
applyAgentConfig(session, 'agent-uuid-001', 'CodeReviewer');
expect(session.agentConfigId).toBe('agent-uuid-001');
expect(session.agentName).toBe('CodeReviewer');
});
it('also updates modelId when agent config carries a model', () => {
applyAgentConfig(session, 'agent-uuid-002', 'DataAnalyst', 'gpt-4o-mini');
expect(session.agentName).toBe('DataAnalyst');
expect(session.modelId).toBe('gpt-4o-mini');
expect(session.metrics.modelSwitches).toBe(1);
});
it('does NOT update modelId when agent config has no model', () => {
const originalModel = session.modelId;
applyAgentConfig(session, 'agent-uuid-003', 'Planner', undefined);
expect(session.modelId).toBe(originalModel);
expect(session.metrics.modelSwitches).toBe(0);
});
it('session:info DTO reflects agentName after /agent switch', () => {
applyAgentConfig(session, 'agent-uuid-004', 'DevBot');
const info = sessionToInfo(session);
expect(info.agentName).toBe('DevBot');
});
it('multiple /agent calls update to the latest agent', () => {
applyAgentConfig(session, 'agent-001', 'FirstAgent');
applyAgentConfig(session, 'agent-002', 'SecondAgent');
expect(session.agentConfigId).toBe('agent-002');
expect(session.agentName).toBe('SecondAgent');
});
});
// ---------------------------------------------------------------------------
// 3. Session resume — binds to conversation via conversationHistory
// ---------------------------------------------------------------------------
describe('Session resume — binds to conversation', () => {
it('conversationHistory option is preserved in session options', () => {
const history: ConversationHistoryMessage[] = [
{
role: 'user',
content: 'Hello, what is TypeScript?',
createdAt: new Date('2026-01-01T00:01:00Z'),
},
{
role: 'assistant',
content: 'TypeScript is a typed superset of JavaScript.',
createdAt: new Date('2026-01-01T00:01:05Z'),
},
];
const options: AgentSessionOptions = {
conversationHistory: history,
provider: 'anthropic',
modelId: 'claude-3-5-sonnet-20241022',
};
expect(options.conversationHistory).toHaveLength(2);
expect(options.conversationHistory![0]!.role).toBe('user');
expect(options.conversationHistory![1]!.role).toBe('assistant');
});
it('session with conversationHistory option carries the conversation binding', () => {
const CONV_ID = 'conv-resume-001';
const history: ConversationHistoryMessage[] = [
{ role: 'user', content: 'Prior question', createdAt: new Date('2026-01-01T00:01:00Z') },
];
// Simulate what ChatGateway does: pass conversationId + history to createSession
const options: AgentSessionOptions = {
conversationHistory: history,
};
// The session ID is the conversationId in the gateway
const session = makeSession({ id: CONV_ID });
expect(session.id).toBe(CONV_ID);
expect(options.conversationHistory).toHaveLength(1);
});
it('empty conversationHistory is valid (new conversation)', () => {
const options: AgentSessionOptions = {
conversationHistory: [],
};
expect(options.conversationHistory).toHaveLength(0);
});
it('resumed session preserves all message roles', () => {
const history: ConversationHistoryMessage[] = [
{ role: 'system', content: 'You are a helpful assistant.', createdAt: new Date() },
{ role: 'user', content: 'Question 1', createdAt: new Date() },
{ role: 'assistant', content: 'Answer 1', createdAt: new Date() },
{ role: 'user', content: 'Question 2', createdAt: new Date() },
];
const roles = history.map((m) => m.role);
expect(roles).toEqual(['system', 'user', 'assistant', 'user']);
});
});
// ---------------------------------------------------------------------------
// 4. Session metrics — token usage and message count
// ---------------------------------------------------------------------------
describe('Session metrics — token usage and message count', () => {
let session: AgentSession;
beforeEach(() => {
session = makeSession();
});
it('starts with zero metrics', () => {
expect(session.metrics.tokens.input).toBe(0);
expect(session.metrics.tokens.output).toBe(0);
expect(session.metrics.tokens.total).toBe(0);
expect(session.metrics.messageCount).toBe(0);
expect(session.metrics.modelSwitches).toBe(0);
});
it('accumulates token usage across multiple turns', () => {
recordTokenUsage(session, {
input: 100,
output: 50,
cacheRead: 0,
cacheWrite: 0,
total: 150,
});
recordTokenUsage(session, {
input: 200,
output: 80,
cacheRead: 10,
cacheWrite: 5,
total: 295,
});
expect(session.metrics.tokens.input).toBe(300);
expect(session.metrics.tokens.output).toBe(130);
expect(session.metrics.tokens.cacheRead).toBe(10);
expect(session.metrics.tokens.cacheWrite).toBe(5);
expect(session.metrics.tokens.total).toBe(445);
});
it('increments message count with each recordMessage call', () => {
expect(session.metrics.messageCount).toBe(0);
recordMessage(session);
expect(session.metrics.messageCount).toBe(1);
recordMessage(session);
recordMessage(session);
expect(session.metrics.messageCount).toBe(3);
});
it('session:info DTO exposes correct metrics snapshot', () => {
recordTokenUsage(session, {
input: 500,
output: 100,
cacheRead: 20,
cacheWrite: 10,
total: 630,
});
recordMessage(session);
recordMessage(session);
updateSessionModel(session, 'claude-haiku-3-5-20251001');
const info = sessionToInfo(session);
expect(info.metrics.tokens.input).toBe(500);
expect(info.metrics.tokens.output).toBe(100);
expect(info.metrics.tokens.total).toBe(630);
expect(info.metrics.messageCount).toBe(2);
expect(info.metrics.modelSwitches).toBe(1);
});
it('metrics are independent per session', () => {
const sessionA = makeSession({ id: 'session-A' });
const sessionB = makeSession({ id: 'session-B' });
recordTokenUsage(sessionA, { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, total: 150 });
recordMessage(sessionA);
// Session B should remain at zero
expect(sessionB.metrics.tokens.input).toBe(0);
expect(sessionB.metrics.messageCount).toBe(0);
// Session A should have updated values
expect(sessionA.metrics.tokens.input).toBe(100);
expect(sessionA.metrics.messageCount).toBe(1);
});
it('lastActivityAt is updated after recording tokens', () => {
const before = session.metrics.lastActivityAt;
vi.setSystemTime(new Date(Date.now() + 100));
recordTokenUsage(session, { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, total: 15 });
vi.useRealTimers();
expect(session.metrics.lastActivityAt).not.toBe(before);
});
it('lastActivityAt is updated after recording a message', () => {
const before = session.metrics.lastActivityAt;
vi.setSystemTime(new Date(Date.now() + 100));
recordMessage(session);
vi.useRealTimers();
expect(session.metrics.lastActivityAt).not.toBe(before);
});
});
@@ -1,86 +0,0 @@
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@Controller('api/admin/health')
@UseGuards(AdminGuard)
export class AdminHealthController {
constructor(
@Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {}
@Get()
async check(): Promise<HealthStatusDto> {
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
const sessions = this.agentService.listAllSessionsForSystem();
const providers = this.providerService.listProviders();
const allOk = database.status === 'ok' && cache.status === 'ok';
return {
status: allOk ? 'ok' : 'degraded',
database,
cache,
agentPool: { activeSessions: sessions.length },
providers: providers.map((p) => ({
id: p.id,
name: p.name,
available: p.available,
modelCount: p.models.length,
})),
checkedAt: new Date().toISOString(),
};
}
private async checkDatabase(): Promise<ServiceStatusDto> {
const start = Date.now();
try {
await this.db.execute(sql`SELECT 1`);
return { status: 'ok', latencyMs: Date.now() - start };
} catch (err) {
return {
status: 'error',
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}
private async checkCache(): Promise<ServiceStatusDto> {
// On Local tier there is no Redis. The cache is intentionally absent, which
// is a healthy state for this tier — report 'ok' rather than opening a new
// ioredis connection on every admin health check (which would spam
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
// signals "no cache backend to measure" for this tier.
if (this.mosaicConfig?.queue?.type === 'local') {
return { status: 'ok', latencyMs: 0 };
}
const start = Date.now();
const handle = createQueue();
try {
await handle.redis.ping();
return { status: 'ok', latencyMs: Date.now() - start };
} catch (err) {
return {
status: 'error',
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
} finally {
await handle.close().catch(() => {});
}
}
}
@@ -1,128 +0,0 @@
import {
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Optional,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AdminGuard } from './admin.guard.js';
import { QueueService } from '../queue/queue.service.js';
import type { JobDto, JobListDto, JobStatus, QueueListDto } from '../queue/queue-admin.dto.js';
@Controller('api/admin/jobs')
@UseGuards(AdminGuard)
export class AdminJobsController {
constructor(
@Optional()
@Inject(QueueService)
private readonly queueService: QueueService | null,
) {}
/**
* GET /api/admin/jobs
* List jobs across all queues. Optional ?status=active|completed|failed|waiting|delayed
*/
@Get()
async listJobs(@Query('status') status?: string): Promise<JobListDto> {
if (!this.queueService) {
return { jobs: [], total: 0 };
}
const validStatuses: JobStatus[] = ['active', 'completed', 'failed', 'waiting', 'delayed'];
const normalised = status as JobStatus | undefined;
if (normalised && !validStatuses.includes(normalised)) {
return { jobs: [], total: 0 };
}
const jobs: JobDto[] = await this.queueService.listJobs(normalised);
return { jobs, total: jobs.length };
}
/**
* POST /api/admin/jobs/:id/retry
* Retry a specific failed job. The id is "<queue>__<bullmq-job-id>".
*/
@Post(':id/retry')
@HttpCode(HttpStatus.OK)
async retryJob(@Param('id') id: string): Promise<{ ok: boolean; message: string }> {
if (!this.queueService) {
throw new NotFoundException('Queue service is not available');
}
const result = await this.queueService.retryJob(id);
if (!result.ok) {
throw new NotFoundException(result.message);
}
return result;
}
/**
* GET /api/admin/jobs/queues
* Return status for all managed queues.
*/
@Get('queues')
async listQueues(): Promise<QueueListDto> {
if (!this.queueService) {
return { queues: [] };
}
const health = await this.queueService.getHealthStatus();
const queues = Object.entries(health.queues).map(([name, stats]) => ({
name,
waiting: stats.waiting,
active: stats.active,
completed: stats.completed,
failed: stats.failed,
delayed: 0,
paused: stats.paused,
}));
return { queues };
}
/**
* POST /api/admin/jobs/queues/:name/pause
* Pause the named queue.
*/
@Post('queues/:name/pause')
@HttpCode(HttpStatus.OK)
async pauseQueue(@Param('name') name: string): Promise<{ ok: boolean; message: string }> {
if (!this.queueService) {
throw new NotFoundException('Queue service is not available');
}
const result = await this.queueService.pauseQueue(name);
if (!result.ok) {
throw new NotFoundException(result.message);
}
return result;
}
/**
* POST /api/admin/jobs/queues/:name/resume
* Resume the named queue.
*/
@Post('queues/:name/resume')
@HttpCode(HttpStatus.OK)
async resumeQueue(@Param('name') name: string): Promise<{ ok: boolean; message: string }> {
if (!this.queueService) {
throw new NotFoundException('Queue service is not available');
}
const result = await this.queueService.resumeQueue(name);
if (!result.ok) {
throw new NotFoundException(result.message);
}
return result;
}
}
@@ -1,90 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Inject,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto';
import { eq, type Db, adminTokens } from '@mosaicstack/db';
import { v4 as uuid } from 'uuid';
import { DB } from '../database/database.module.js';
import { AdminGuard } from './admin.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import type {
CreateTokenDto,
TokenCreatedDto,
TokenDto,
TokenListDto,
} from './admin-tokens.dto.js';
function hashToken(plaintext: string): string {
return createHash('sha256').update(plaintext).digest('hex');
}
function toTokenDto(row: typeof adminTokens.$inferSelect): TokenDto {
return {
id: row.id,
label: row.label,
scope: row.scope,
expiresAt: row.expiresAt?.toISOString() ?? null,
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
@Controller('api/admin/tokens')
@UseGuards(AdminGuard)
export class AdminTokensController {
constructor(@Inject(DB) private readonly db: Db) {}
@Post()
async create(
@Body() dto: CreateTokenDto,
@CurrentUser() user: { id: string },
): Promise<TokenCreatedDto> {
const plaintext = randomBytes(32).toString('hex');
const tokenHash = hashToken(plaintext);
const id = uuid();
const expiresAt = dto.expiresInDays
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
: null;
const [row] = await this.db
.insert(adminTokens)
.values({
id,
userId: user.id,
tokenHash,
label: dto.label ?? 'CLI token',
scope: dto.scope ?? 'admin',
expiresAt,
})
.returning();
return { ...toTokenDto(row!), plaintext };
}
@Get()
async list(@CurrentUser() user: { id: string }): Promise<TokenListDto> {
const rows = await this.db
.select()
.from(adminTokens)
.where(eq(adminTokens.userId, user.id))
.orderBy(adminTokens.createdAt);
return { tokens: rows.map(toTokenDto), total: rows.length };
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async revoke(@Param('id') id: string, @CurrentUser() _user: { id: string }): Promise<void> {
await this.db.delete(adminTokens).where(eq(adminTokens.id, id));
}
}
@@ -1,33 +0,0 @@
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
export class CreateTokenDto {
@IsString()
label!: string;
@IsOptional()
@IsString()
scope?: string;
@IsOptional()
@IsInt()
@Min(1)
expiresInDays?: number;
}
export interface TokenDto {
id: string;
label: string;
scope: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
}
export interface TokenCreatedDto extends TokenDto {
plaintext: string;
}
export interface TokenListDto {
tokens: TokenDto[];
total: number;
}
-146
View File
@@ -1,146 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Inject,
InternalServerErrorException,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { eq, type Db, users as usersTable } from '@mosaicstack/db';
import type { Auth } from '@mosaicstack/auth';
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
import { AdminGuard } from './admin.guard.js';
import type {
BanUserDto,
CreateUserDto,
UpdateUserRoleDto,
UserDto,
UserListDto,
} from './admin.dto.js';
type UserRow = typeof usersTable.$inferSelect;
function toUserDto(u: UserRow): UserDto {
return {
id: u.id,
name: u.name,
email: u.email,
role: u.role,
banned: u.banned ?? false,
banReason: u.banReason ?? null,
createdAt: u.createdAt.toISOString(),
updatedAt: u.updatedAt.toISOString(),
};
}
async function requireUpdated(
db: Db,
id: string,
update: Partial<Omit<UserRow, 'id' | 'createdAt'>>,
): Promise<UserDto> {
const [updated] = await db
.update(usersTable)
.set({ ...update, updatedAt: new Date() })
.where(eq(usersTable.id, id))
.returning();
if (!updated) throw new InternalServerErrorException('Update returned no rows');
return toUserDto(updated);
}
@Controller('api/admin/users')
@UseGuards(AdminGuard)
export class AdminController {
constructor(
@Inject(DB) private readonly db: Db,
@Inject(AUTH) private readonly auth: Auth,
) {}
@Get()
async listUsers(): Promise<UserListDto> {
const rows = await this.db.select().from(usersTable).orderBy(usersTable.createdAt);
const userList: UserDto[] = rows.map(toUserDto);
return { users: userList, total: userList.length };
}
@Get(':id')
async getUser(@Param('id') id: string): Promise<UserDto> {
const [user] = await this.db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
if (!user) throw new NotFoundException('User not found');
return toUserDto(user);
}
@Post()
async createUser(@Body() body: CreateUserDto): Promise<UserDto> {
// Use auth API to create user so password is properly hashed
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string; createdAt: unknown; updatedAt: unknown };
}>;
};
const result = await authApi.createUser({
body: {
name: body.name,
email: body.email,
password: body.password,
role: body.role ?? 'member',
},
});
// Re-fetch from DB to get full row with our schema
const [user] = await this.db
.select()
.from(usersTable)
.where(eq(usersTable.id, result.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found in DB');
return toUserDto(user);
}
@Patch(':id/role')
async setRole(@Param('id') id: string, @Body() body: UpdateUserRoleDto): Promise<UserDto> {
await this.ensureExists(id);
return requireUpdated(this.db, id, { role: body.role });
}
@Post(':id/ban')
@HttpCode(HttpStatus.OK)
async banUser(@Param('id') id: string, @Body() body: BanUserDto): Promise<UserDto> {
await this.ensureExists(id);
return requireUpdated(this.db, id, { banned: true, banReason: body.reason ?? null });
}
@Post(':id/unban')
@HttpCode(HttpStatus.OK)
async unbanUser(@Param('id') id: string): Promise<UserDto> {
await this.ensureExists(id);
return requireUpdated(this.db, id, { banned: false, banReason: null, banExpires: null });
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteUser(@Param('id') id: string): Promise<void> {
await this.ensureExists(id);
await this.db.delete(usersTable).where(eq(usersTable.id, id));
}
private async ensureExists(id: string): Promise<void> {
const [existing] = await this.db
.select({ id: usersTable.id })
.from(usersTable)
.where(eq(usersTable.id, id))
.limit(1);
if (!existing) throw new NotFoundException('User not found');
}
}
-56
View File
@@ -1,56 +0,0 @@
export interface UserDto {
id: string;
name: string;
email: string;
role: string;
banned: boolean;
banReason: string | null;
createdAt: string;
updatedAt: string;
}
export interface UserListDto {
users: UserDto[];
total: number;
}
export interface CreateUserDto {
name: string;
email: string;
password: string;
role?: string;
}
export interface UpdateUserRoleDto {
role: string;
}
export interface BanUserDto {
reason?: string;
}
export interface HealthStatusDto {
status: 'ok' | 'degraded' | 'error';
database: ServiceStatusDto;
cache: ServiceStatusDto;
agentPool: AgentPoolStatusDto;
providers: ProviderStatusDto[];
checkedAt: string;
}
export interface ServiceStatusDto {
status: 'ok' | 'error';
latencyMs?: number;
error?: string;
}
export interface AgentPoolStatusDto {
activeSessions: number;
}
export interface ProviderStatusDto {
id: string;
name: string;
available: boolean;
modelCount: number;
}
-121
View File
@@ -1,121 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaicstack/auth';
import type { Db } from '@mosaicstack/db';
import { eq, adminTokens, users as usersTable } from '@mosaicstack/db';
import type { FastifyRequest } from 'fastify';
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
interface UserWithRole {
id: string;
role?: string;
}
type AuthenticatedRequest = FastifyRequest & { user: unknown; session: unknown };
@Injectable()
export class AdminGuard implements CanActivate {
constructor(
@Inject(AUTH) private readonly auth: Auth,
@Inject(DB) private readonly db: Db,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
// Try bearer token auth first
const authHeader = request.raw.headers['authorization'];
if (authHeader?.startsWith('Bearer ')) {
return this.validateBearerToken(request, authHeader.slice(7));
}
// Fall back to BetterAuth session
return this.validateSession(request);
}
private async validateBearerToken(request: FastifyRequest, plaintext: string): Promise<boolean> {
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const [row] = await this.db
.select({
tokenId: adminTokens.id,
userId: adminTokens.userId,
scope: adminTokens.scope,
expiresAt: adminTokens.expiresAt,
userName: usersTable.name,
userEmail: usersTable.email,
userRole: usersTable.role,
})
.from(adminTokens)
.innerJoin(usersTable, eq(adminTokens.userId, usersTable.id))
.where(eq(adminTokens.tokenHash, tokenHash))
.limit(1);
if (!row) {
throw new UnauthorizedException('Invalid API token');
}
if (row.expiresAt && row.expiresAt < new Date()) {
throw new UnauthorizedException('API token expired');
}
if (row.userRole !== 'admin') {
throw new ForbiddenException('Admin access required');
}
// Update last-used timestamp (fire-and-forget)
this.db
.update(adminTokens)
.set({ lastUsedAt: new Date() })
.where(eq(adminTokens.id, row.tokenId))
.then(() => {})
.catch(() => {});
const req = request as AuthenticatedRequest;
req.user = { id: row.userId, name: row.userName, email: row.userEmail, role: row.userRole };
req.session = { id: `token:${row.tokenId}`, userId: row.userId };
return true;
}
private async validateSession(request: FastifyRequest): Promise<boolean> {
const headers = fromNodeHeaders(request.raw.headers);
const result = await this.auth.api.getSession({ headers });
if (!result) {
throw new UnauthorizedException('Invalid or expired session');
}
const user = result.user as UserWithRole;
let userRole = user.role;
if (!userRole) {
const [dbUser] = await this.db
.select({ role: usersTable.role })
.from(usersTable)
.where(eq(usersTable.id, user.id))
.limit(1);
userRole = dbUser?.role ?? 'member';
(user as UserWithRole).role = userRole;
}
if (userRole !== 'admin') {
throw new ForbiddenException('Admin access required');
}
const req = request as AuthenticatedRequest;
req.user = result.user;
req.session = result.session;
return true;
}
}
-19
View File
@@ -1,19 +0,0 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller.js';
import { AdminHealthController } from './admin-health.controller.js';
import { AdminJobsController } from './admin-jobs.controller.js';
import { AdminTokensController } from './admin-tokens.controller.js';
import { BootstrapController } from './bootstrap.controller.js';
import { AdminGuard } from './admin.guard.js';
@Module({
controllers: [
AdminController,
AdminHealthController,
AdminJobsController,
AdminTokensController,
BootstrapController,
],
providers: [AdminGuard],
})
export class AdminModule {}
@@ -1,102 +0,0 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Inject,
InternalServerErrorException,
Post,
} from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto';
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
import type { Auth } from '@mosaicstack/auth';
import { v4 as uuid } from 'uuid';
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
import { BootstrapSetupDto } from './bootstrap.dto.js';
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
@Controller('api/bootstrap')
export class BootstrapController {
constructor(
@Inject(AUTH) private readonly auth: Auth,
@Inject(DB) private readonly db: Db,
) {}
@Get('status')
async status(): Promise<BootstrapStatusDto> {
const [result] = await this.db.select({ total: count() }).from(usersTable);
return { needsSetup: (result?.total ?? 0) === 0 };
}
@Post('setup')
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
// Only allow setup when zero users exist
const [result] = await this.db.select({ total: count() }).from(usersTable);
if ((result?.total ?? 0) > 0) {
throw new ForbiddenException('Setup already completed — users exist');
}
// Create admin user via BetterAuth API
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string };
}>;
};
const created = await authApi.createUser({
body: {
name: dto.name,
email: dto.email,
password: dto.password,
role: 'admin',
},
});
// Verify user was created
const [user] = await this.db
.select()
.from(usersTable)
.where(eq(usersTable.id, created.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found');
// Ensure role is admin (createUser may not set it via BetterAuth)
if (user.role !== 'admin') {
await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
}
// Generate admin API token
const plaintext = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const tokenId = uuid();
const [token] = await this.db
.insert(adminTokens)
.values({
id: tokenId,
userId: user.id,
tokenHash,
label: 'Initial setup token',
scope: 'admin',
})
.returning();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: 'admin',
},
token: {
id: token!.id,
plaintext,
label: token!.label,
},
};
}
}
-31
View File
@@ -1,31 +0,0 @@
import { IsString, IsEmail, MinLength } from 'class-validator';
export class BootstrapSetupDto {
@IsString()
name!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}
export interface BootstrapStatusDto {
needsSetup: boolean;
}
export interface BootstrapResultDto {
user: {
id: string;
name: string;
email: string;
role: string;
};
token: {
id: string;
plaintext: string;
label: string;
};
}
@@ -1,190 +0,0 @@
/**
* E2E integration test — POST /api/bootstrap/setup
*
* Regression guard for the `import type { BootstrapSetupDto }` class-erasure
* bug (IUV-M01, issue #436).
*
* When `BootstrapSetupDto` is imported with `import type`, TypeScript erases
* the class at compile time. NestJS then sees `Object` as the `@Body()`
* metatype, and ValidationPipe with `whitelist:true + forbidNonWhitelisted:true`
* treats every property as non-whitelisted, returning:
*
* 400 { message: ["property email should not exist", "property password should not exist"] }
*
* The fix is a plain value import (`import { BootstrapSetupDto }`), which
* preserves the class reference so Nest can read the class-validator decorators.
*
* This test MUST fail if `import type` is re-introduced on `BootstrapSetupDto`.
* A controller unit test that constructs ValidationPipe manually won't catch
* this — only the real DI binding path exercises the metatype lookup.
*/
import 'reflect-metadata';
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import { Test } from '@nestjs/testing';
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { BootstrapController } from './bootstrap.controller.js';
import type { BootstrapResultDto } from './bootstrap.dto.js';
// ─── Minimal mock dependencies ───────────────────────────────────────────────
/**
* We use explicit `@Inject(AUTH)` / `@Inject(DB)` in the controller so we
* can provide mock values by token without spinning up the real DB or Auth.
*/
import { AUTH } from '../auth/auth.tokens.js';
import { DB } from '../database/database.module.js';
const MOCK_USER_ID = 'mock-user-id-001';
const mockAuth = {
api: {
createUser: () =>
Promise.resolve({
user: {
id: MOCK_USER_ID,
name: 'Admin',
email: '[email protected]',
},
}),
},
};
// Override db.select() so the second query (verify user exists) returns a user.
// The bootstrap controller calls select().from() twice:
// 1. count() to check zero users → returns [{total: 0}]
// 2. select().where().limit() → returns [the created user]
let selectCallCount = 0;
const mockDbWithUser = {
select: () => {
selectCallCount++;
return {
from: () => {
if (selectCallCount === 1) {
// First call: count — zero users
return Promise.resolve([{ total: 0 }]);
}
// Subsequent calls: return a mock user row
return {
where: () => ({
limit: () =>
Promise.resolve([
{
id: MOCK_USER_ID,
name: 'Admin',
email: '[email protected]',
role: 'admin',
},
]),
}),
};
},
};
},
update: () => ({
set: () => ({
where: () => Promise.resolve([]),
}),
}),
insert: () => ({
values: () => ({
returning: () =>
Promise.resolve([
{
id: 'token-id-001',
label: 'Initial setup token',
},
]),
}),
}),
};
// ─── Test suite ───────────────────────────────────────────────────────────────
describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
let app: INestApplication;
beforeAll(async () => {
selectCallCount = 0;
const moduleRef = await Test.createTestingModule({
controllers: [BootstrapController],
providers: [
{ provide: AUTH, useValue: mockAuth },
{ provide: DB, useValue: mockDbWithUser },
],
}).compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
// Mirror main.ts configuration exactly — this is what reproduced the 400.
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
// Fastify requires waiting for the adapter to be ready
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app.close();
});
it('returns 201 (not 400) when a valid {name, email, password} body is sent', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: '[email protected]', password: 'password123' })
.set('Content-Type', 'application/json');
// Before the fix (import type), Nest ValidationPipe returned 400 with
// "property email should not exist" / "property password should not exist"
// because the DTO class was erased and every field looked non-whitelisted.
expect(res.status).not.toBe(400);
expect(res.status).toBe(201);
const body = res.body as BootstrapResultDto;
expect(body.user).toBeDefined();
expect(body.user.email).toBe('[email protected]');
expect(body.token).toBeDefined();
expect(body.token.plaintext).toBeDefined();
});
it('returns 400 when extra forbidden properties are sent', async () => {
// This proves ValidationPipe IS active and working (forbidNonWhitelisted).
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({
name: 'Admin',
email: '[email protected]',
password: 'password123',
extraField: 'should-be-rejected',
})
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
});
it('returns 400 when email is invalid', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: 'not-an-email', password: 'password123' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
});
it('returns 400 when password is too short', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: '[email protected]', password: 'short' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
});
});
@@ -1,191 +0,0 @@
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { AgentService, type AgentSession } from '../agent.service.js';
import type { ActorTenantScope } from '../../auth/session-scope.js';
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
type AgentServiceInternals = {
sessions: Map<string, AgentSession>;
creating: Map<string, Promise<AgentSession>>;
};
function makeService(operatorMemory: unknown = null): AgentService {
return new AgentService(
{
getDefaultModel: vi.fn(() => null),
getRegistry: vi.fn(() => ({})),
findModel: vi.fn(),
listAvailableModels: vi.fn(() => []),
} as never,
{} as never,
{} as never,
{ available: false } as never,
{} as never,
{ getToolDefinitions: vi.fn(() => []) } as never,
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
);
}
function internals(service: AgentService): AgentServiceInternals {
return service as unknown as AgentServiceInternals;
}
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp/tess-session-ownership-test',
allowedTools: null,
userId: scope.userId,
tenantId: scope.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
describe('AgentService owner/tenant scope enforcement', () => {
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
const service = makeService();
const session = makeSession();
internals(service).sessions.set(CONVERSATION_ID, session);
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
expect(session.channels.has('websocket:owner')).toBe(true);
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() =>
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
expect(session.modelId).toBe('owner-model');
expect(() =>
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
expect(session.agentConfigId).toBe('agent-owner');
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
cleanup();
await expect(
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
await service.prompt(CONVERSATION_ID, '', OWNER_SCOPE, [
{
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
mimeType: 'image/png',
},
]);
expect(session.piSession.prompt).toHaveBeenLastCalledWith(
'\n\n[Untrusted channel attachments]\n' +
'{"id":"attachment-001","name":"diagram.png","mimeType":"image/png","url":"https://cdn.example.test/diagram.png"}',
);
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
expect(session.piSession.dispose).toHaveBeenCalled();
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
});
it('derives the operator-memory scope on the createSession production path', async () => {
const plugin = { capture: vi.fn(), search: vi.fn() };
const service = makeService(plugin);
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]);
// Session construction reaches the real scope derivation before the intentionally incomplete
// Pi test double rejects later in createAgentSession.
await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined);
expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, {
tenantId: OWNER_SCOPE.tenantId,
ownerId: OWNER_SCOPE.userId,
sessionId: CONVERSATION_ID,
});
});
it('denies a foreign actor before it can obtain another session operator-memory scope', async () => {
const plugin = { capture: vi.fn(), search: vi.fn() };
const service = makeService(plugin);
internals(service).sessions.set(CONVERSATION_ID, makeSession());
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox');
await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(buildTools).not.toHaveBeenCalled();
expect(plugin.capture).not.toHaveBeenCalled();
expect(plugin.search).not.toHaveBeenCalled();
});
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
const service = makeService();
const session = makeSession();
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
await expect(
service.createSession(CONVERSATION_ID, {
userId: FOREIGN_SCOPE.userId,
tenantId: FOREIGN_SCOPE.tenantId,
}),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
service.createSession(CONVERSATION_ID, {
userId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
}),
).resolves.toBe(session);
});
});
@@ -1,770 +0,0 @@
/**
* Provider Adapter Integration Tests — M3-012
*
* Verifies that all five provider adapters (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama)
* are properly integrated: registration, model listing, graceful degradation without
* API keys, capability matrix correctness, and ProviderCredentialsService behaviour.
*
* These tests are designed to run in CI with no real API keys; they test graceful
* degradation and static configuration rather than live network calls.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
import { AnthropicAdapter } from '../adapters/anthropic.adapter.js';
import { OpenAIAdapter } from '../adapters/openai.adapter.js';
import { OpenRouterAdapter } from '../adapters/openrouter.adapter.js';
import { ZaiAdapter } from '../adapters/zai.adapter.js';
import { OllamaAdapter } from '../adapters/ollama.adapter.js';
import { ProviderService } from '../provider.service.js';
import {
getModelCapability,
MODEL_CAPABILITIES,
findModelsByCapability,
} from '../model-capabilities.js';
// ---------------------------------------------------------------------------
// Environment helpers
// ---------------------------------------------------------------------------
const ALL_PROVIDER_KEYS = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'OPENROUTER_API_KEY',
'ZAI_API_KEY',
'ZAI_BASE_URL',
'OLLAMA_BASE_URL',
'OLLAMA_HOST',
'OLLAMA_MODELS',
'BETTER_AUTH_SECRET',
] as const;
type EnvKey = (typeof ALL_PROVIDER_KEYS)[number];
function saveAndClearEnv(): Map<EnvKey, string | undefined> {
const saved = new Map<EnvKey, string | undefined>();
for (const key of ALL_PROVIDER_KEYS) {
saved.set(key, process.env[key]);
delete process.env[key];
}
return saved;
}
function restoreEnv(saved: Map<EnvKey, string | undefined>): void {
for (const key of ALL_PROVIDER_KEYS) {
const value = saved.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
function makeRegistry(): ModelRegistry {
return ModelRegistry.inMemory(AuthStorage.inMemory());
}
// ---------------------------------------------------------------------------
// 1. Adapter registration tests
// ---------------------------------------------------------------------------
describe('AnthropicAdapter', () => {
let savedEnv: Map<EnvKey, string | undefined>;
beforeEach(() => {
savedEnv = saveAndClearEnv();
});
afterEach(() => {
restoreEnv(savedEnv);
});
it('skips registration gracefully when ANTHROPIC_API_KEY is missing', async () => {
const adapter = new AnthropicAdapter(makeRegistry());
await expect(adapter.register()).resolves.toBeUndefined();
expect(adapter.listModels()).toEqual([]);
});
it('registers and listModels returns expected models when ANTHROPIC_API_KEY is set', async () => {
process.env['ANTHROPIC_API_KEY'] = 'sk-ant-test';
const adapter = new AnthropicAdapter(makeRegistry());
await adapter.register();
const models = adapter.listModels();
expect(models.length).toBeGreaterThan(0);
const ids = models.map((m) => m.id);
expect(ids).toContain('claude-opus-4-6');
expect(ids).toContain('claude-sonnet-4-6');
expect(ids).toContain('claude-haiku-4-5');
for (const model of models) {
expect(model.provider).toBe('anthropic');
expect(model.contextWindow).toBe(200000);
}
});
it('healthCheck returns down with error when ANTHROPIC_API_KEY is missing', async () => {
const adapter = new AnthropicAdapter(makeRegistry());
const health = await adapter.healthCheck();
expect(health.status).toBe('down');
expect(health.error).toMatch(/ANTHROPIC_API_KEY/);
expect(health.lastChecked).toBeTruthy();
});
it('adapter name is "anthropic"', () => {
expect(new AnthropicAdapter(makeRegistry()).name).toBe('anthropic');
});
});
describe('OpenAIAdapter', () => {
let savedEnv: Map<EnvKey, string | undefined>;
beforeEach(() => {
savedEnv = saveAndClearEnv();
});
afterEach(() => {
restoreEnv(savedEnv);
});
it('skips registration gracefully when OPENAI_API_KEY is missing', async () => {
const adapter = new OpenAIAdapter(makeRegistry());
await expect(adapter.register()).resolves.toBeUndefined();
expect(adapter.listModels()).toEqual([]);
});
it('registers and listModels returns Codex model when OPENAI_API_KEY is set', async () => {
process.env['OPENAI_API_KEY'] = 'sk-openai-test';
const adapter = new OpenAIAdapter(makeRegistry());
await adapter.register();
const models = adapter.listModels();
expect(models.length).toBeGreaterThan(0);
const ids = models.map((m) => m.id);
expect(ids).toContain(OpenAIAdapter.CODEX_MODEL_ID);
const codex = models.find((m) => m.id === OpenAIAdapter.CODEX_MODEL_ID)!;
expect(codex.provider).toBe('openai');
expect(codex.contextWindow).toBe(128_000);
expect(codex.maxTokens).toBe(16_384);
});
it('healthCheck returns down with error when OPENAI_API_KEY is missing', async () => {
const adapter = new OpenAIAdapter(makeRegistry());
const health = await adapter.healthCheck();
expect(health.status).toBe('down');
expect(health.error).toMatch(/OPENAI_API_KEY/);
});
it('adapter name is "openai"', () => {
expect(new OpenAIAdapter(makeRegistry()).name).toBe('openai');
});
});
describe('OpenRouterAdapter', () => {
let savedEnv: Map<EnvKey, string | undefined>;
beforeEach(() => {
savedEnv = saveAndClearEnv();
// Prevent real network calls during registration — stub global fetch
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
{
id: 'openai/gpt-4o',
name: 'GPT-4o',
context_length: 128000,
top_provider: { max_completion_tokens: 4096 },
pricing: { prompt: '0.000005', completion: '0.000015' },
architecture: { input_modalities: ['text', 'image'] },
},
],
}),
}),
);
});
afterEach(() => {
restoreEnv(savedEnv);
vi.unstubAllGlobals();
});
it('skips registration gracefully when OPENROUTER_API_KEY is missing', async () => {
vi.unstubAllGlobals(); // no fetch call expected
const adapter = new OpenRouterAdapter();
await expect(adapter.register()).resolves.toBeUndefined();
expect(adapter.listModels()).toEqual([]);
});
it('registers and listModels returns models when OPENROUTER_API_KEY is set', async () => {
process.env['OPENROUTER_API_KEY'] = 'sk-or-test';
const adapter = new OpenRouterAdapter();
await adapter.register();
const models = adapter.listModels();
expect(models.length).toBeGreaterThan(0);
const first = models[0]!;
expect(first.provider).toBe('openrouter');
expect(first.id).toBe('openai/gpt-4o');
expect(first.inputTypes).toContain('image');
});
it('healthCheck returns down with error when OPENROUTER_API_KEY is missing', async () => {
vi.unstubAllGlobals(); // no fetch call expected
const adapter = new OpenRouterAdapter();
const health = await adapter.healthCheck();
expect(health.status).toBe('down');
expect(health.error).toMatch(/OPENROUTER_API_KEY/);
});
it('continues registration with empty model list when model fetch fails', async () => {
process.env['OPENROUTER_API_KEY'] = 'sk-or-test';
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
}),
);
const adapter = new OpenRouterAdapter();
await expect(adapter.register()).resolves.toBeUndefined();
expect(adapter.listModels()).toEqual([]);
});
it('adapter name is "openrouter"', () => {
expect(new OpenRouterAdapter().name).toBe('openrouter');
});
});
describe('ZaiAdapter', () => {
let savedEnv: Map<EnvKey, string | undefined>;
beforeEach(() => {
savedEnv = saveAndClearEnv();
});
afterEach(() => {
restoreEnv(savedEnv);
});
it('skips registration gracefully when ZAI_API_KEY is missing', async () => {
const adapter = new ZaiAdapter();
await expect(adapter.register()).resolves.toBeUndefined();
expect(adapter.listModels()).toEqual([]);
});
it('registers and listModels returns glm-5 when ZAI_API_KEY is set', async () => {
process.env['ZAI_API_KEY'] = 'zai-test-key';
const adapter = new ZaiAdapter();
await adapter.register();
const models = adapter.listModels();
expect(models.length).toBeGreaterThan(0);
const ids = models.map((m) => m.id);
expect(ids).toContain('glm-5');
const glm = models.find((m) => m.id === 'glm-5')!;
expect(glm.provider).toBe('zai');
});
it('healthCheck returns down with error when ZAI_API_KEY is missing', async () => {
const adapter = new ZaiAdapter();
const health = await adapter.healthCheck();
expect(health.status).toBe('down');
expect(health.error).toMatch(/ZAI_API_KEY/);
});
it('adapter name is "zai"', () => {
expect(new ZaiAdapter().name).toBe('zai');
});
});
describe('OllamaAdapter', () => {
let savedEnv: Map<EnvKey, string | undefined>;
beforeEach(() => {
savedEnv = saveAndClearEnv();
});
afterEach(() => {
restoreEnv(savedEnv);
});
it('skips registration gracefully when OLLAMA_BASE_URL is missing', async () => {
const adapter = new OllamaAdapter(makeRegistry());
await expect(adapter.register()).resolves.toBeUndefined();
expect(adapter.listModels()).toEqual([]);
});
it('registers via OLLAMA_HOST fallback when OLLAMA_BASE_URL is absent', async () => {
process.env['OLLAMA_HOST'] = 'http://localhost:11434';
const adapter = new OllamaAdapter(makeRegistry());
await adapter.register();
const models = adapter.listModels();
expect(models.length).toBeGreaterThan(0);
});
it('registers default models (llama3.2, codellama, mistral) + embedding models', async () => {
process.env['OLLAMA_BASE_URL'] = 'http://localhost:11434';
const adapter = new OllamaAdapter(makeRegistry());
await adapter.register();
const models = adapter.listModels();
const ids = models.map((m) => m.id);
// Default completion models
expect(ids).toContain('llama3.2');
expect(ids).toContain('codellama');
expect(ids).toContain('mistral');
// Embedding models
expect(ids).toContain('nomic-embed-text');
expect(ids).toContain('mxbai-embed-large');
for (const model of models) {
expect(model.provider).toBe('ollama');
}
});
it('registers custom OLLAMA_MODELS list', async () => {
process.env['OLLAMA_BASE_URL'] = 'http://localhost:11434';
process.env['OLLAMA_MODELS'] = 'phi3,gemma2';
const adapter = new OllamaAdapter(makeRegistry());
await adapter.register();
const completionIds = adapter.listModels().map((m) => m.id);
expect(completionIds).toContain('phi3');
expect(completionIds).toContain('gemma2');
expect(completionIds).not.toContain('llama3.2');
});
it('healthCheck returns down with error when OLLAMA_BASE_URL is missing', async () => {
const adapter = new OllamaAdapter(makeRegistry());
const health = await adapter.healthCheck();
expect(health.status).toBe('down');
expect(health.error).toMatch(/OLLAMA_BASE_URL/);
});
it('adapter name is "ollama"', () => {
expect(new OllamaAdapter(makeRegistry()).name).toBe('ollama');
});
});
// ---------------------------------------------------------------------------
// 2. ProviderService integration
// ---------------------------------------------------------------------------
describe('ProviderService — adapter array integration', () => {
let savedEnv: Map<EnvKey, string | undefined>;
beforeEach(() => {
savedEnv = saveAndClearEnv();
});
afterEach(() => {
restoreEnv(savedEnv);
});
it('contains all 5 adapters (ollama, anthropic, openai, openrouter, zai)', async () => {
const service = new ProviderService(null);
await service.onModuleInit();
// Exercise getAdapter for all five known provider names
const expectedProviders = ['ollama', 'anthropic', 'openai', 'openrouter', 'zai'];
for (const name of expectedProviders) {
const adapter = service.getAdapter(name);
expect(adapter, `Expected adapter "${name}" to be registered`).toBeDefined();
expect(adapter!.name).toBe(name);
}
});
it('healthCheckAll runs without crashing and returns status for all 5 providers', async () => {
const service = new ProviderService(null);
await service.onModuleInit();
const results = await service.healthCheckAll();
expect(typeof results).toBe('object');
const expectedProviders = ['ollama', 'anthropic', 'openai', 'openrouter', 'zai'];
for (const name of expectedProviders) {
const health = results[name];
expect(health, `Expected health result for provider "${name}"`).toBeDefined();
expect(['healthy', 'degraded', 'down']).toContain(health!.status);
expect(health!.lastChecked).toBeTruthy();
}
});
it('healthCheckAll reports "down" for all providers when no keys are set', async () => {
const service = new ProviderService(null);
await service.onModuleInit();
const results = await service.healthCheckAll();
// All unconfigured providers should be down (not healthy)
for (const [, health] of Object.entries(results)) {
expect(['down', 'degraded']).toContain(health.status);
}
});
it('getProvidersHealth returns entries for all 5 providers', async () => {
const service = new ProviderService(null);
await service.onModuleInit();
const healthList = service.getProvidersHealth();
const names = healthList.map((h) => h.name);
for (const expected of ['ollama', 'anthropic', 'openai', 'openrouter', 'zai']) {
expect(names).toContain(expected);
}
for (const entry of healthList) {
expect(entry).toHaveProperty('name');
expect(entry).toHaveProperty('status');
expect(entry).toHaveProperty('lastChecked');
expect(typeof entry.modelCount).toBe('number');
}
});
it('service initialises without error when all env keys are absent', async () => {
const service = new ProviderService(null);
await expect(service.onModuleInit()).resolves.toBeUndefined();
service.onModuleDestroy();
});
});
// ---------------------------------------------------------------------------
// 3. Model capability matrix
// ---------------------------------------------------------------------------
describe('Model capability matrix', () => {
const expectedModels: Array<{
id: string;
provider: string;
tier: string;
contextWindow: number;
reasoning?: boolean;
vision?: boolean;
embedding?: boolean;
}> = [
{
id: 'claude-opus-4-6',
provider: 'anthropic',
tier: 'premium',
contextWindow: 200000,
reasoning: true,
vision: true,
},
{
id: 'claude-sonnet-4-6',
provider: 'anthropic',
tier: 'standard',
contextWindow: 200000,
reasoning: true,
vision: true,
},
{
id: 'claude-haiku-4-5',
provider: 'anthropic',
tier: 'cheap',
contextWindow: 200000,
reasoning: false,
vision: true,
},
{
id: 'codex-gpt-5.4',
provider: 'openai',
tier: 'premium',
contextWindow: 128000,
},
{
id: 'glm-5',
provider: 'zai',
tier: 'standard',
contextWindow: 128000,
},
{
id: 'llama3.2',
provider: 'ollama',
tier: 'local',
contextWindow: 128000,
},
{
id: 'codellama',
provider: 'ollama',
tier: 'local',
contextWindow: 16000,
},
{
id: 'mistral',
provider: 'ollama',
tier: 'local',
contextWindow: 32000,
},
{
id: 'nomic-embed-text',
provider: 'ollama',
tier: 'local',
contextWindow: 8192,
embedding: true,
},
{
id: 'mxbai-embed-large',
provider: 'ollama',
tier: 'local',
contextWindow: 8192,
embedding: true,
},
];
it('MODEL_CAPABILITIES contains all expected model IDs', () => {
const allIds = MODEL_CAPABILITIES.map((m) => m.id);
for (const { id } of expectedModels) {
expect(allIds, `Expected model "${id}" in capability matrix`).toContain(id);
}
});
it('getModelCapability() returns correct tier and context window for each model', () => {
for (const expected of expectedModels) {
const cap = getModelCapability(expected.id);
expect(cap, `getModelCapability("${expected.id}") should be defined`).toBeDefined();
expect(cap!.provider).toBe(expected.provider);
expect(cap!.tier).toBe(expected.tier);
expect(cap!.contextWindow).toBe(expected.contextWindow);
}
});
it('Anthropic models have correct capability flags (tools, streaming, vision, reasoning)', () => {
for (const expected of expectedModels.filter((m) => m.provider === 'anthropic')) {
const cap = getModelCapability(expected.id)!;
expect(cap.capabilities.tools).toBe(true);
expect(cap.capabilities.streaming).toBe(true);
if (expected.vision !== undefined) {
expect(cap.capabilities.vision).toBe(expected.vision);
}
if (expected.reasoning !== undefined) {
expect(cap.capabilities.reasoning).toBe(expected.reasoning);
}
}
});
it('Embedding models have embedding flag=true and other flags=false', () => {
for (const expected of expectedModels.filter((m) => m.embedding)) {
const cap = getModelCapability(expected.id)!;
expect(cap.capabilities.embedding).toBe(true);
expect(cap.capabilities.tools).toBe(false);
expect(cap.capabilities.streaming).toBe(false);
expect(cap.capabilities.reasoning).toBe(false);
}
});
it('findModelsByCapability filters by tier correctly', () => {
const premiumModels = findModelsByCapability({ tier: 'premium' });
expect(premiumModels.length).toBeGreaterThan(0);
for (const m of premiumModels) {
expect(m.tier).toBe('premium');
}
});
it('findModelsByCapability filters by provider correctly', () => {
const anthropicModels = findModelsByCapability({ provider: 'anthropic' });
expect(anthropicModels.length).toBe(3);
for (const m of anthropicModels) {
expect(m.provider).toBe('anthropic');
}
});
it('findModelsByCapability filters by capability flags correctly', () => {
const reasoningModels = findModelsByCapability({ capabilities: { reasoning: true } });
expect(reasoningModels.length).toBeGreaterThan(0);
for (const m of reasoningModels) {
expect(m.capabilities.reasoning).toBe(true);
}
const embeddingModels = findModelsByCapability({ capabilities: { embedding: true } });
expect(embeddingModels.length).toBeGreaterThan(0);
for (const m of embeddingModels) {
expect(m.capabilities.embedding).toBe(true);
}
});
it('getModelCapability returns undefined for unknown model IDs', () => {
expect(getModelCapability('not-a-real-model')).toBeUndefined();
expect(getModelCapability('')).toBeUndefined();
});
it('all Anthropic models have maxOutputTokens > 0', () => {
const anthropicModels = MODEL_CAPABILITIES.filter((m) => m.provider === 'anthropic');
for (const m of anthropicModels) {
expect(m.maxOutputTokens).toBeGreaterThan(0);
}
});
});
// ---------------------------------------------------------------------------
// 4. ProviderCredentialsService — unit-level tests (encrypt/decrypt logic)
// ---------------------------------------------------------------------------
describe('ProviderCredentialsService — encryption helpers', () => {
let savedEnv: Map<EnvKey, string | undefined>;
beforeEach(() => {
savedEnv = saveAndClearEnv();
});
afterEach(() => {
restoreEnv(savedEnv);
});
/**
* The service uses module-level functions (encrypt/decrypt) that depend on
* BETTER_AUTH_SECRET. We test the behaviour through the service's public API
* using an in-memory mock DB so no real Postgres connection is needed.
*/
it('store/retrieve/remove work correctly with mock DB and BETTER_AUTH_SECRET set', async () => {
process.env['BETTER_AUTH_SECRET'] = 'test-secret-for-unit-tests-only';
// Build a minimal in-memory DB mock
const rows = new Map<
string,
{
encryptedValue: string;
credentialType: string;
expiresAt: Date | null;
metadata: null;
createdAt: Date;
updatedAt: Date;
}
>();
// We import the service but mock its DB dependency manually
// by testing the encrypt/decrypt indirectly — using the real module.
const { ProviderCredentialsService } = await import('../provider-credentials.service.js');
// Capture stored value from upsert call
let storedEncryptedValue = '';
let storedCredentialType = '';
const captureInsert = vi.fn().mockImplementation(() => ({
values: vi
.fn()
.mockImplementation((data: { encryptedValue: string; credentialType: string }) => {
storedEncryptedValue = data.encryptedValue;
storedCredentialType = data.credentialType;
rows.set('user1:anthropic', {
encryptedValue: data.encryptedValue,
credentialType: data.credentialType,
expiresAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
return {
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
};
}),
}));
const captureSelect = vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockImplementation(() => {
const row = rows.get('user1:anthropic');
return Promise.resolve(row ? [row] : []);
}),
}),
}),
});
const captureDelete = vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
});
const db = {
insert: captureInsert,
select: captureSelect,
delete: captureDelete,
};
const service = new ProviderCredentialsService(db as never);
// store
await service.store('user1', 'anthropic', 'api_key', 'sk-ant-secret-value');
// verify encrypted value is not plain text
expect(storedEncryptedValue).not.toBe('sk-ant-secret-value');
expect(storedEncryptedValue.length).toBeGreaterThan(0);
expect(storedCredentialType).toBe('api_key');
// retrieve
const retrieved = await service.retrieve('user1', 'anthropic');
expect(retrieved).toBe('sk-ant-secret-value');
// remove (clears the row)
rows.delete('user1:anthropic');
const afterRemove = await service.retrieve('user1', 'anthropic');
expect(afterRemove).toBeNull();
});
it('retrieve returns null when no credential is stored', async () => {
process.env['BETTER_AUTH_SECRET'] = 'test-secret-for-unit-tests-only';
const { ProviderCredentialsService } = await import('../provider-credentials.service.js');
const emptyDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
};
const service = new ProviderCredentialsService(emptyDb as never);
const result = await service.retrieve('user-nobody', 'anthropic');
expect(result).toBeNull();
});
it('listProviders returns only metadata, never decrypted values', async () => {
process.env['BETTER_AUTH_SECRET'] = 'test-secret-for-unit-tests-only';
const { ProviderCredentialsService } = await import('../provider-credentials.service.js');
const fakeRow = {
provider: 'anthropic',
credentialType: 'api_key',
expiresAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const listDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([fakeRow]),
}),
}),
};
const service = new ProviderCredentialsService(listDb as never);
const providers = await service.listProviders('user1');
expect(providers).toHaveLength(1);
expect(providers[0]!.provider).toBe('anthropic');
expect(providers[0]!.credentialType).toBe('api_key');
expect(providers[0]!.exists).toBe(true);
// Critically: no encrypted or plain-text value is exposed
expect(providers[0]).not.toHaveProperty('encryptedValue');
expect(providers[0]).not.toHaveProperty('value');
expect(providers[0]).not.toHaveProperty('apiKey');
});
});
@@ -1,142 +0,0 @@
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
import { ProviderService } from '../provider.service.js';
const ENV_KEYS = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'ZAI_API_KEY',
'OLLAMA_BASE_URL',
'OLLAMA_HOST',
'OLLAMA_MODELS',
'MOSAIC_CUSTOM_PROVIDERS',
] as const;
type EnvKey = (typeof ENV_KEYS)[number];
describe('ProviderService', () => {
const savedEnv = new Map<EnvKey, string | undefined>();
beforeEach(() => {
for (const key of ENV_KEYS) {
savedEnv.set(key, process.env[key]);
delete process.env[key];
}
});
afterEach(() => {
for (const key of ENV_KEYS) {
const value = savedEnv.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it('skips API-key providers when env vars are missing (no models become available)', async () => {
const service = new ProviderService(null);
await service.onModuleInit();
// Pi's built-in registry may include model definitions for all providers, but
// without API keys none of them should be available (usable).
const availableModels = service.listAvailableModels();
const availableProviderIds = new Set(availableModels.map((m) => m.provider));
expect(availableProviderIds).not.toContain('anthropic');
expect(availableProviderIds).not.toContain('openai');
expect(availableProviderIds).not.toContain('zai');
// Providers list may show built-in providers, but they should not be marked available
const providers = service.listProviders();
for (const p of providers.filter((p) => ['anthropic', 'openai', 'zai'].includes(p.id))) {
expect(p.available).toBe(false);
}
});
it('registers Anthropic provider with correct models when ANTHROPIC_API_KEY is set', async () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
const service = new ProviderService(null);
await service.onModuleInit();
const providers = service.listProviders();
const anthropic = providers.find((p) => p.id === 'anthropic');
expect(anthropic).toBeDefined();
expect(anthropic!.available).toBe(true);
expect(anthropic!.models.map((m) => m.id)).toEqual([
'claude-opus-4-6',
'claude-sonnet-4-6',
'claude-haiku-4-5',
]);
// All Anthropic models have 200k context window
for (const m of anthropic!.models) {
expect(m.contextWindow).toBe(200000);
}
});
it('registers OpenAI provider with correct models when OPENAI_API_KEY is set', async () => {
process.env['OPENAI_API_KEY'] = 'test-openai';
const service = new ProviderService(null);
await service.onModuleInit();
const providers = service.listProviders();
const openai = providers.find((p) => p.id === 'openai');
expect(openai).toBeDefined();
expect(openai!.available).toBe(true);
expect(openai!.models.map((m) => m.id)).toEqual(['codex-gpt-5-4']);
});
it('registers Z.ai provider with correct models when ZAI_API_KEY is set', async () => {
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService(null);
await service.onModuleInit();
const providers = service.listProviders();
const zai = providers.find((p) => p.id === 'zai');
expect(zai).toBeDefined();
expect(zai!.available).toBe(true);
// Pi's registry may include additional glm variants; verify our registered model is present
expect(zai!.models.map((m) => m.id)).toContain('glm-5');
});
it('registers all three providers when all keys are set', async () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
process.env['OPENAI_API_KEY'] = 'test-openai';
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService(null);
await service.onModuleInit();
const providerIds = service.listProviders().map((p) => p.id);
expect(providerIds).toContain('anthropic');
expect(providerIds).toContain('openai');
expect(providerIds).toContain('zai');
});
it('can find registered Anthropic models by provider+id', async () => {
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
const service = new ProviderService(null);
await service.onModuleInit();
const sonnet = service.findModel('anthropic', 'claude-sonnet-4-6');
expect(sonnet).toBeDefined();
expect(sonnet!.provider).toBe('anthropic');
expect(sonnet!.id).toBe('claude-sonnet-4-6');
});
it('can find registered Z.ai models by provider+id', async () => {
process.env['ZAI_API_KEY'] = 'test-zai';
const service = new ProviderService(null);
await service.onModuleInit();
const glm = service.findModel('zai', 'glm-4.5');
expect(glm).toBeDefined();
expect(glm!.provider).toBe('zai');
expect(glm!.id).toBe('glm-4.5');
});
});
@@ -1,138 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RoutingService } from '../routing.service.js';
import type { ModelInfo } from '@mosaicstack/types';
const mockModels: ModelInfo[] = [
{
id: 'claude-3-haiku',
provider: 'anthropic',
name: 'Claude 3 Haiku',
reasoning: false,
contextWindow: 200_000,
maxTokens: 4096,
inputTypes: ['text', 'image'],
cost: { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 },
},
{
id: 'claude-3-sonnet',
provider: 'anthropic',
name: 'Claude 3 Sonnet',
reasoning: true,
contextWindow: 200_000,
maxTokens: 8192,
inputTypes: ['text', 'image'],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
},
{
id: 'llama3.2',
provider: 'ollama',
name: 'Llama 3.2',
reasoning: false,
contextWindow: 128_000,
maxTokens: 4096,
inputTypes: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
];
function createMockProviderService() {
return {
listAvailableModels: vi.fn().mockReturnValue(mockModels),
findModel: vi.fn(),
getDefaultModel: vi.fn(),
getRegistry: vi.fn(),
listProviders: vi.fn(),
registerCustomProvider: vi.fn(),
};
}
describe('RoutingService', () => {
let routingService: RoutingService;
let mockProviderService: ReturnType<typeof createMockProviderService>;
beforeEach(() => {
mockProviderService = createMockProviderService();
routingService = new RoutingService(mockProviderService as never);
});
it('returns a model when no criteria specified', () => {
const result = routingService.route();
expect(result).not.toBeNull();
expect(result!.provider).toBeDefined();
expect(result!.modelId).toBeDefined();
expect(result!.score).toBeGreaterThan(0);
});
it('returns null when no models available', () => {
mockProviderService.listAvailableModels.mockReturnValue([]);
const result = routingService.route();
expect(result).toBeNull();
});
it('selects preferred model when specified', () => {
const result = routingService.route({
preferredProvider: 'anthropic',
preferredModel: 'claude-3-sonnet',
});
expect(result).not.toBeNull();
expect(result!.provider).toBe('anthropic');
expect(result!.modelId).toBe('claude-3-sonnet');
expect(result!.score).toBe(100);
});
it('disqualifies models without reasoning when required', () => {
const result = routingService.route({ requireReasoning: true });
expect(result).not.toBeNull();
expect(result!.modelId).toBe('claude-3-sonnet');
});
it('disqualifies models without image input when required', () => {
const result = routingService.route({ requireImageInput: true });
expect(result).not.toBeNull();
// Llama doesn't support images, should be excluded
expect(result!.provider).toBe('anthropic');
});
it('respects minimum context window', () => {
const result = routingService.route({ minContextWindow: 150_000 });
expect(result).not.toBeNull();
// Only anthropic models have 200k context
expect(result!.provider).toBe('anthropic');
});
it('favors cheap models when costTier is cheap', () => {
const result = routingService.route({ costTier: 'cheap' });
expect(result).not.toBeNull();
// Ollama (free) and Haiku ($0.25/M) are cheap
expect(['ollama', 'anthropic'].includes(result!.provider)).toBe(true);
if (result!.provider === 'anthropic') {
expect(result!.modelId).toBe('claude-3-haiku');
}
});
it('ranks all models and returns sorted results', () => {
const ranked = routingService.rank({ taskType: 'coding' });
expect(ranked.length).toBeGreaterThan(0);
// Should be sorted by score descending
for (let i = 1; i < ranked.length; i++) {
expect(ranked[i]!.score).toBeLessThanOrEqual(ranked[i - 1]!.score);
}
});
it('gives reasoning bonus for coding tasks', () => {
const ranked = routingService.rank({ taskType: 'coding' });
const sonnet = ranked.find((r) => r.modelId === 'claude-3-sonnet');
const haiku = ranked.find((r) => r.modelId === 'claude-3-haiku');
expect(sonnet).toBeDefined();
expect(haiku).toBeDefined();
// Sonnet (reasoning) should score higher for coding than haiku (no reasoning)
expect(sonnet!.score).toBeGreaterThan(haiku!.score);
});
it('prefers specified provider', () => {
const ranked = routingService.rank({ preferredProvider: 'ollama' });
const ollamaModel = ranked.find((r) => r.provider === 'ollama');
expect(ollamaModel).toBeDefined();
expect(ollamaModel!.reasoning).toContain('preferred provider');
});
});
@@ -1,370 +0,0 @@
import { describe, expect, it } from 'vitest';
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionTree,
RuntimeStreamEvent,
} from '@mosaicstack/types';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
import type { ActorTenantScope } from '../../auth/session-scope.js';
import {
RuntimeProviderAuditService,
RuntimeProviderService,
type RuntimeAuditEvent,
type RuntimeAuditSink,
type RuntimeApprovalVerifier,
} from '../runtime-provider-registry.service.js';
process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
const CONTEXT = {
actorScope: OWNER_SCOPE,
channelId: 'cli',
correlationId: 'correlation-1',
};
class RecordingRuntimeProvider implements AgentRuntimeProvider {
readonly id = 'fleet';
readonly receivedScopes: RuntimeScope[] = [];
readonly sentMessages: RuntimeMessage[] = [];
terminateCalls = 0;
throwAfterSend = false;
throwAuthorization = false;
constructor(private readonly supported: RuntimeCapability[]) {}
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
this.receivedScopes.push(scope);
return { supported: this.supported };
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
this.receivedScopes.push(scope);
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
this.receivedScopes.push(scope);
return [];
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
this.receivedScopes.push(scope);
return [];
}
async *streamSession(
_sessionId: string,
_cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
this.receivedScopes.push(scope);
return;
}
async sendMessage(
_sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
this.receivedScopes.push(scope);
this.sentMessages.push(message);
if (this.throwAuthorization) {
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
}
if (this.throwAfterSend) {
throw new Error('provider acknowledgement failed');
}
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
this.receivedScopes.push(scope);
return {
attachmentId: 'attachment-1',
sessionId,
mode,
expiresAt: '2026-07-12T00:00:00.000Z',
};
}
async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
}
async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
this.receivedScopes.push(scope);
this.terminateCalls += 1;
}
}
class RecordingAuditSink implements RuntimeAuditSink {
readonly events: RuntimeAuditEvent[] = [];
async record(event: RuntimeAuditEvent): Promise<void> {
this.events.push(event);
}
}
class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
async consume(): Promise<boolean> {
return false;
}
}
class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;
async consume(
_approvalRef: string,
action: Parameters<RuntimeApprovalVerifier['consume']>[1],
): Promise<boolean> {
this.consumedAction = action;
return true;
}
}
function makeService(
provider: RecordingRuntimeProvider,
audit: RuntimeAuditSink = new RecordingAuditSink(),
approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
): RuntimeProviderService {
const registry = new AgentRuntimeProviderRegistry();
registry.register(provider);
return new RuntimeProviderService(registry, audit, approval);
}
describe('RuntimeProviderService security boundary', (): void => {
it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
);
const providerScope = provider.receivedScopes[0];
expect(providerScope).toEqual({
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
});
expect(Object.isFrozen(providerScope)).toBe(true);
expect(audit.events).toContainEqual(
expect.objectContaining({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
resourceId: 'session-1',
durationMs: expect.any(Number),
}),
);
expect(JSON.stringify(audit.events)).not.toContain('hello');
expect(JSON.stringify(audit.events)).not.toContain('key-1');
});
it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let persisted: unknown;
const durableAudit = new RuntimeProviderAuditService({
logs: {
ingest: async (entry: unknown): Promise<unknown> => {
persisted = entry;
return entry;
},
},
} as never);
const service = makeService(provider, durableAudit);
await service.sendMessage(
'fleet',
'session/credential-canary=secret-value',
{ content: 'safe message', idempotencyKey: 'key-1' },
CONTEXT,
);
expect(provider.sentMessages).toHaveLength(1);
expect(JSON.stringify(persisted)).not.toContain('secret-value');
});
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider([]);
const service = makeService(provider);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/capability denied/);
expect(provider.sentMessages).toEqual([]);
});
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new DenyingApprovalVerifier();
const audit = new RecordingAuditSink();
const service = makeService(provider, audit, approval);
await expect(
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
).rejects.toThrow(/approval denied/);
expect(provider.terminateCalls).toBe(0);
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
});
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.terminate']);
const approval = new AcceptingApprovalVerifier();
const service = makeService(provider, new RecordingAuditSink(), approval);
await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);
expect(approval.consumedAction).toEqual({
providerId: 'fleet',
sessionId: 'session-1',
actorId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
channelId: CONTEXT.channelId,
correlationId: CONTEXT.correlationId,
agentName: process.env['MOSAIC_AGENT_NAME'],
});
expect(provider.terminateCalls).toBe(1);
});
it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
const unavailableAudit: RuntimeAuditSink = {
async record(): Promise<void> {
throw new Error('audit unavailable');
},
};
const service = makeService(provider, unavailableAudit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/audit unavailable/);
expect(provider.sentMessages).toEqual([]);
});
it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAfterSend = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider acknowledgement failed/);
expect(provider.sentMessages).toHaveLength(1);
expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
'requested',
'failed',
]);
expect(audit.events.at(-1)).toMatchObject({
errorCode: 'provider_error',
durationMs: expect.any(Number),
});
});
it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
provider.throwAuthorization = true;
const audit = new RecordingAuditSink();
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).rejects.toThrow(/provider authorization denied/);
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
});
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
let persisted: unknown;
const ingest = async (entry: unknown): Promise<unknown> => {
persisted = entry;
return entry;
};
const service = new RuntimeProviderAuditService({ logs: { ingest } } as never);
await service.record({
providerId: 'fleet',
operation: 'session.send',
outcome: 'succeeded',
actorId: 'owner-1',
tenantId: 'tenant-1',
channelId: 'cli',
correlationId: 'correlation-1',
resourceId: 'session-1',
durationMs: 12,
});
expect(persisted).toMatchObject({
content: 'runtime.provider.audit',
metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }),
});
expect(JSON.stringify(persisted)).not.toContain('approval');
});
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
const provider = new RecordingRuntimeProvider(['session.send']);
let auditCalls = 0;
const audit: RuntimeAuditSink = {
async record(): Promise<void> {
auditCalls += 1;
if (auditCalls === 2) {
throw new Error('completion audit unavailable');
}
},
};
const service = makeService(provider, audit);
await expect(
service.sendMessage(
'fleet',
'session-1',
{ content: 'hello', idempotencyKey: 'key-1' },
CONTEXT,
),
).resolves.toBeUndefined();
expect(provider.sentMessages).toHaveLength(1);
expect(auditCalls).toBe(2);
});
});
@@ -1,958 +0,0 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
vi.mock('../../commands/command-executor.service.js', () => ({
CommandExecutorService: class CommandExecutorService {},
}));
vi.mock('../routing/routing-engine.service.js', () => ({
RoutingEngineService: class RoutingEngineService {},
}));
import { SessionsController } from '../sessions.controller.js';
import { AgentService } from '../agent.service.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AuthGuard } from '../../auth/auth.guard.js';
import { AUTH } from '../../auth/auth.tokens.js';
import { BRAIN } from '../../brain/brain.tokens.js';
import { CommandRegistryService } from '../../commands/command-registry.service.js';
import { CommandExecutorService } from '../../commands/command-executor.service.js';
import { RoutingEngineService } from '../routing/routing-engine.service.js';
import { ChatRuntimeRouter } from '../../chat/chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../../chat/embedded-chat.runtime.js';
import { ownConversation } from '../../chat/chat-runtime.js';
import type { LegacyRuntimeStream } from '../../chat/chat-runtime.js';
import { HarnessChatRuntime } from '../../chat/harness-chat.runtime.js';
import { HarnessRegistry } from '../../harness/harness.registry.js';
import { HARNESS_CONVERSATION_SERVICE_UNAVAILABLE } from '../../harness/harness.tokens.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
promptCount: 0,
channels: [],
durationMs: 0,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
...overrides,
};
}
function makeAgentSession(owner = USER_A): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp',
allowedTools: null,
userId: owner.id,
tenantId: owner.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
/**
* A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing
* USER_A's conversation id) is never granted the session. Because every method exists and no method
* throws for a wrong shape, production runs to its real ownership decision — the RED never comes from
* a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch.
*/
function makeScopedAgentService() {
const foreign = makeAgentSession(USER_A);
return {
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
),
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
),
destroySession: vi.fn(),
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
recordMessage: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
}
type ScopedAgentService = ReturnType<typeof makeScopedAgentService>;
/**
* A structurally-complete harness conversation service that throws if any method is invoked.
* Fronted behind the legacy runtime's harness slot: the legacy path must never reach it.
*/
const failIfUsedConversationService = {
attach: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
detach: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
send: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
subscribeFrom: async function* () {
throw new Error('harness conversation service must not be reached on the legacy path');
},
} as unknown as HarnessConversationService;
/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */
const boundConversationService = {
attach: () => Promise.reject(new Error('unused')),
detach: () => Promise.reject(new Error('unused')),
send: () => Promise.reject(new Error('unused')),
subscribeFrom: async function* () {
throw new Error('unused');
},
} as unknown as HarnessConversationService;
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
const registry = new HarnessRegistry();
for (const id of adapterIds) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return registry;
}
/**
* Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime}
* that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to
* reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService.
* The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a
* call landing on it proves the router-delegation redesign is live rather than the old direct path.
*/
function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter {
const embedded = new EmbeddedChatRuntime(agentService as never);
const harness = new HarnessChatRuntime(failIfUsedConversationService);
const router = new ChatRuntimeRouter(
new HarnessRegistry(),
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
embedded,
harness,
'legacy',
);
router.onModuleInit();
return router;
}
/**
* The AgentService method names the controller/gateway must NEVER drive on the runtime at the
* delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record
* one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime
* call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text.
*/
const FORBIDDEN_AGENT_OPS = [
'getSession',
'createSession',
'onEvent',
'addChannel',
'prompt',
'setThinking',
'abort',
] as const;
/**
* Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields
* an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at
* INVOCATION time and forwards to the real method (bound to the real target, so the router's internal
* delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING
* properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a
* canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim.
*
* The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router
* at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the
* production source cannot. This replaces the earlier `source.toContain('<frozen op>')` proof — which
* a dead declaration could satisfy while production still executed a shim — with invocation evidence.
*/
function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter {
return new Proxy(target, {
get(t, prop) {
const value = Reflect.get(t, prop);
if (typeof value === 'function' && typeof prop === 'string') {
return (...args: unknown[]) => {
calls.push(prop);
return (value as (...a: unknown[]) => unknown).apply(t, args);
};
}
return value;
},
}) as ChatRuntimeRouter;
}
/**
* Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts).
*
* BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider
* (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production
* resolves whichever its constructor declares:
* - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the
* router (and its embedded fake) is never reached.
* - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never
* touched (stays at zero) and scope is observed inside the embedded fake behind the router.
* The SAME test body reds today and greens later; a method-for-method AgentService shim on the router
* records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and
* restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed
* scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link
* makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings.
*/
function buildRestModule(
directAgentService: ScopedAgentService,
embeddedAgentService: ScopedAgentService,
routerCalls: string[],
): Promise<TestingModule> {
return (
Test.createTestingModule({
controllers: [ChatController],
providers: [
{ provide: AgentService, useValue: directAgentService },
{
provide: ChatRuntimeRouter,
useFactory: () =>
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
},
],
})
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects
// AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph
// resolves and the test reds on BEHAVIOUR, not on a DI collection error.
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile()
);
}
function buildGatewayModule(
directAgentService: ScopedAgentService,
embeddedAgentService: ScopedAgentService,
routerCalls: string[],
): Promise<TestingModule> {
const brain = {
conversations: {
// The sender OWNS this durable conversation, so the browser-send admission gate lets the turn
// reach the router seam. Foreignness is asserted downstream at the in-memory agent session
// (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection
// property has its own dedicated coverage.
findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }),
},
};
return Test.createTestingModule({
providers: [
ChatGateway,
{ provide: AgentService, useValue: directAgentService },
{ provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } },
{ provide: BRAIN, useValue: brain },
{ provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } },
{ provide: CommandExecutorService, useValue: { execute: vi.fn() } },
{
provide: RoutingEngineService,
useValue: {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
},
},
{
provide: ChatRuntimeRouter,
useFactory: () =>
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
},
],
}).compile();
}
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
it('requires explicit owner+tenant scope on protected session operations', () => {
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain('listSessions(scope: ActorTenantScope)');
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain(
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
);
expect(source).toContain('scope: ActorTenantScope,');
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
expect(source).not.toContain('scope?: ActorTenantScope');
});
});
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
expect(agentService.listSessions).toHaveBeenCalledWith({
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not reveal another owner/tenant session by guessed id', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not terminate another owner/tenant session by guessed id', async () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
NotFoundException,
);
expect(agentService.destroySession).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => {
// TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot
// swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived
// scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path.
it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency
const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation
const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam
const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls);
try {
const controller = moduleRef.get(ChatController, { strict: false });
// Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN.
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toBeDefined();
// Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail.
// RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router.
// Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the
// controller @Inject(AgentService) and never calls the router). GREEN once it drives the op.
expect
.soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router')
.toContain('completeLegacyRestTurn');
// RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on
// the router. An AgentService-shaped router shim records one of these → RED, defeating the shim
// on invocation evidence (not source text). A dead named method added alongside the shim does not
// help: it is never invoked, so it never enters routerCalls while a forbidden op still does.
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today;
// restoring the direct injection keeps it failing).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the
// router (fails today; the router path is never taken).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Zero foreign mutation on either path (holds today and at GREEN).
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
// Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the
// controller no longer declares the direct embedded AgentService dependency. A negative source
// check cannot be satisfied by dead text — it only fails when the injection is present.
const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect.soft(controllerSource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
});
});
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => {
function makeSocket() {
return {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
}
// TESS test B — WebSocket send/attach.
it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
}),
).catch(() => undefined);
// RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router.
expect
.soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router')
.toContain('prepareLegacySocketTurn');
// RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN).
expect.soft(directAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(directAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
// Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency.
const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
expect.soft(gatewaySource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
});
// TESS test C — WebSocket set:thinking.
it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleSetThinking(socket as never, {
conversationId: CONVERSATION_ID,
level: 'high',
}),
).catch(() => undefined);
// RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router.
expect
.soft(routerCalls, 'gateway must invoke setLegacyThinking on the router')
.toContain('setLegacyThinking');
// RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test D — WebSocket abort.
it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }),
).catch(() => undefined);
// RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router.
expect
.soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router')
.toContain('abortLegacyTurn');
// RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal.
it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => {
// pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and
// must be refused with a fixed typed code, touching neither the embedded AgentService nor the
// harness conversation service.
const agentService = makeScopedAgentService();
const embedded = new EmbeddedChatRuntime(agentService as never);
const harnessConversation = {
attach: vi.fn(),
detach: vi.fn(),
send: vi.fn(),
subscribeFrom: vi.fn(),
};
const harness = new HarnessChatRuntime(harnessConversation as never);
const router = new ChatRuntimeRouter(
registryWith(['pi']),
boundConversationService,
embedded,
harness,
'pi-rpc',
);
router.onModuleInit();
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const gateway = new ChatGateway(
router as never,
{} as never,
brain as never,
{ getManifest: vi.fn().mockReturnValue([]) } as never,
{ execute: vi.fn() } as never,
{ resolve: vi.fn() } as never,
);
const socket = {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'route me',
}),
).catch(() => undefined);
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ code: 'runtime_unsupported' }),
);
expect(agentService.getSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(harnessConversation.attach).not.toHaveBeenCalled();
expect(harnessConversation.send).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5).
// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService
// fake (every touched method exists, so a RED can only come from behavior, never a
// `getSession is not a function` TypeError). Ownership context is minted through the
// real `ownConversation` factory — the only sanctioned way to reach a port op.
// ---------------------------------------------------------------------------
const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId };
const CONVERSATION_UNAVAILABLE_RESULT = {
ok: false,
code: 'conversation_unavailable',
retryable: false,
} as const;
/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */
function makeStream(): LegacyRuntimeStream {
return { channelId: 'websocket:test-1', onEvent: vi.fn() };
}
/**
* getSession → undefined (session missing), createSession → rejects with `err`. Exercises the
* `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves
* the turn short-circuited before any dispatch.
*/
function makeCollapsingAgentService(err: Error) {
return {
getSession: vi.fn(() => undefined),
createSession: vi.fn().mockRejectedValue(err),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
}
/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */
function makeLeaseAgentService() {
const session = makeAgentSession(USER_A);
const unsubscribe = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => unsubscribe),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
return { svc, unsubscribe, session };
}
/**
* getSession → a live owned session (REST resolveOrCreate succeeds), onEvent returns a `detach`
* spy, and `prompt` REJECTS with a non-timeout error. Drives the REST-turn catch path so the single
* idempotent teardown must clear the 120s timeout and detach the listener exactly once.
*/
function makeRejectingPromptAgentService() {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockRejectedValue(new Error('agent backend exploded')),
recordTokenUsage: vi.fn(),
};
return { svc, detach };
}
describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner'));
const runtime = new EmbeddedChatRuntime(svc as never);
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new NotFoundException('no such conversation'));
const runtime = new EmbeddedChatRuntime(svc as never);
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => {
const foreign = new EmbeddedChatRuntime(
makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never,
);
const missing = new EmbeddedChatRuntime(
makeCollapsingAgentService(new NotFoundException('no such conversation')) as never,
);
const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' });
const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' });
expect(foreignResult).toEqual(missingResult);
expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
});
});
describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => {
const { svc } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
const first = await lease.dispatch();
expect(first).toEqual({ ok: true, value: undefined });
expect(svc.prompt).toHaveBeenCalledTimes(1);
const second = await lease.dispatch();
expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false });
// Zero additional effect — the second dispatch must not prompt again.
expect(svc.prompt).toHaveBeenCalledTimes(1);
});
it('disposes once; a second dispose is a silent no-op that never re-detaches or destroys the session', async () => {
const { svc, unsubscribe, session } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
await lease.dispose();
await lease.dispose();
// Listener + channel torn down exactly once across two dispose calls.
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(svc.removeChannel).toHaveBeenCalledTimes(1);
// Disposal never terminates the underlying session or process.
expect(session.piSession.abort).not.toHaveBeenCalled();
expect(session.piSession.dispose).not.toHaveBeenCalled();
});
it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => {
const { svc, unsubscribe } = makeLeaseAgentService();
svc.addChannel = vi.fn(() => {
throw new Error('channel attach failed');
});
const runtime = new EmbeddedChatRuntime(svc as never);
// Must NOT throw out of the port — a partial setup collapses to a total safe failure.
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(false);
// Exactly what was acquired (the event listener) is rolled back.
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => {
const { svc, detach } = makeRejectingPromptAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
// A rejected `done` promise firing after completeLegacyRestTurn has already returned would
// surface as an unhandledRejection — the leak this test fences. Capture any that escape.
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const result = await runtime.completeLegacyRestTurn(ctx, {
content: 'trigger a backend failure',
});
// The rejection collapses to a total safe failure (not a timeout) — never throws out of the port.
expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false });
// The single idempotent dispose ran in the catch: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second
// detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
// Let any scheduled rejection surface on a real macrotask, then confirm none did.
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
// The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential
// `await prompt()` the timer could never even be observed, so the turn hung forever.
prompt: vi.fn(() => new Promise<void>(() => undefined)),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'a prompt that never returns',
});
// No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all
// installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
// The single idempotent dispose ran on the timeout path: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// Advancing far past the deadline fires nothing more: dispose cleared the timer.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
let rejectPrompt: (reason: unknown) => void = () => undefined;
const prompting = new Promise<void>((_resolve, reject) => {
rejectPrompt = reject;
});
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn(() => prompting),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'prompt settles after the deadline',
});
// The timeout wins the race while prompt is still pending.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
expect(detach).toHaveBeenCalledTimes(1);
// The prompt now rejects LATE — after the turn already returned its timeout result. Because
// Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late
// rejection is already observed and must not escape as an unhandledRejection.
rejectPrompt(new Error('late backend failure'));
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
});
@@ -1,191 +0,0 @@
import { Logger } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import type { ModelRegistry } from '@mariozechner/pi-coding-agent';
import type {
CompletionEvent,
CompletionParams,
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaicstack/types';
/**
* Anthropic provider adapter.
*
* Registers Claude models with the Pi ModelRegistry via the Anthropic SDK.
* Configuration is driven by environment variables:
* ANTHROPIC_API_KEY — Anthropic API key (required)
*/
export class AnthropicAdapter implements IProviderAdapter {
readonly name = 'anthropic';
private readonly logger = new Logger(AnthropicAdapter.name);
private client: Anthropic | null = null;
private registeredModels: ModelInfo[] = [];
constructor(private readonly registry: ModelRegistry) {}
async register(): Promise<void> {
const apiKey = process.env['ANTHROPIC_API_KEY'];
if (!apiKey) {
this.logger.warn('Skipping Anthropic provider registration: ANTHROPIC_API_KEY not set');
return;
}
this.client = new Anthropic({ apiKey });
const models: ModelInfo[] = [
{
id: 'claude-opus-4-6',
provider: 'anthropic',
name: 'Claude Opus 4.6',
reasoning: true,
contextWindow: 200000,
maxTokens: 32000,
inputTypes: ['text', 'image'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
{
id: 'claude-sonnet-4-6',
provider: 'anthropic',
name: 'Claude Sonnet 4.6',
reasoning: true,
contextWindow: 200000,
maxTokens: 16000,
inputTypes: ['text', 'image'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
{
id: 'claude-haiku-4-5',
provider: 'anthropic',
name: 'Claude Haiku 4.5',
reasoning: false,
contextWindow: 200000,
maxTokens: 8192,
inputTypes: ['text', 'image'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
];
this.registry.registerProvider('anthropic', {
apiKey,
baseUrl: 'https://api.anthropic.com',
api: 'anthropic' as never,
models: models.map((m) => ({
id: m.id,
name: m.name,
reasoning: m.reasoning,
input: m.inputTypes as ('text' | 'image')[],
cost: m.cost,
contextWindow: m.contextWindow,
maxTokens: m.maxTokens,
})),
});
this.registeredModels = models;
this.logger.log(
`Anthropic provider registered with models: ${models.map((m) => m.id).join(', ')}`,
);
}
listModels(): ModelInfo[] {
return this.registeredModels;
}
async healthCheck(): Promise<ProviderHealth> {
const apiKey = process.env['ANTHROPIC_API_KEY'];
if (!apiKey) {
return {
status: 'down',
lastChecked: new Date().toISOString(),
error: 'ANTHROPIC_API_KEY not configured',
};
}
const start = Date.now();
try {
const client = this.client ?? new Anthropic({ apiKey });
await client.models.list({ limit: 1 });
const latencyMs = Date.now() - start;
return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() };
} catch (err) {
const latencyMs = Date.now() - start;
const error = err instanceof Error ? err.message : String(err);
const status = error.includes('401') || error.includes('403') ? 'degraded' : 'down';
return { status, latencyMs, lastChecked: new Date().toISOString(), error };
}
}
/**
* Stream a completion from Anthropic using the messages API.
* Maps Anthropic streaming events to the CompletionEvent format.
*
* Note: Currently reserved for future direct-completion use. The Pi SDK
* integration routes completions through ModelRegistry / AgentSession.
*/
async *createCompletion(params: CompletionParams): AsyncIterable<CompletionEvent> {
const apiKey = process.env['ANTHROPIC_API_KEY'];
if (!apiKey) {
throw new Error('AnthropicAdapter: ANTHROPIC_API_KEY not configured');
}
const client = this.client ?? new Anthropic({ apiKey });
// Separate system messages from user/assistant messages
const systemMessages = params.messages.filter((m) => m.role === 'system');
const conversationMessages = params.messages.filter((m) => m.role !== 'system');
const systemPrompt =
systemMessages.length > 0 ? systemMessages.map((m) => m.content).join('\n') : undefined;
const stream = await client.messages.stream({
model: params.model,
max_tokens: params.maxTokens ?? 1024,
...(systemPrompt !== undefined ? { system: systemPrompt } : {}),
messages: conversationMessages.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
...(params.temperature !== undefined ? { temperature: params.temperature } : {}),
...(params.tools && params.tools.length > 0
? {
tools: params.tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.parameters as Anthropic.Tool['input_schema'],
})),
}
: {}),
});
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
yield { type: 'text_delta', content: event.delta.text };
} else if (event.type === 'content_block_delta' && event.delta.type === 'input_json_delta') {
yield { type: 'tool_call', name: '', arguments: event.delta.partial_json };
} else if (event.type === 'message_delta' && event.usage) {
yield {
type: 'done',
usage: {
inputTokens:
(event as { usage: { input_tokens?: number; output_tokens: number } }).usage
.input_tokens ?? 0,
outputTokens: event.usage.output_tokens,
},
};
}
}
// Emit final done event with full usage from the completed message
const finalMessage = await stream.finalMessage();
yield {
type: 'done',
usage: {
inputTokens: finalMessage.usage.input_tokens,
outputTokens: finalMessage.usage.output_tokens,
},
};
}
}
-5
View File
@@ -1,5 +0,0 @@
export { OllamaAdapter } from './ollama.adapter.js';
export { AnthropicAdapter } from './anthropic.adapter.js';
export { OpenAIAdapter } from './openai.adapter.js';
export { OpenRouterAdapter } from './openrouter.adapter.js';
export { ZaiAdapter } from './zai.adapter.js';
@@ -1,197 +0,0 @@
import { Logger } from '@nestjs/common';
import type { ModelRegistry } from '@mariozechner/pi-coding-agent';
import type {
CompletionEvent,
CompletionParams,
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaicstack/types';
/** Embedding models that Ollama ships with out of the box */
const OLLAMA_EMBEDDING_MODELS: ReadonlyArray<{
id: string;
contextWindow: number;
dimensions: number;
}> = [
{ id: 'nomic-embed-text', contextWindow: 8192, dimensions: 768 },
{ id: 'mxbai-embed-large', contextWindow: 512, dimensions: 1024 },
];
interface OllamaEmbeddingResponse {
embedding?: number[];
}
/**
* Ollama provider adapter.
*
* Registers local Ollama models with the Pi ModelRegistry via the OpenAI-compatible
* completions API. Also exposes embedding models and an `embed()` method for
* vector generation (used by EmbeddingService / M3-009).
*
* Configuration is driven by environment variables:
* OLLAMA_BASE_URL or OLLAMA_HOST — base URL of the Ollama instance
* OLLAMA_MODELS — comma-separated list of model IDs (default: llama3.2,codellama,mistral)
*/
export class OllamaAdapter implements IProviderAdapter {
readonly name = 'ollama';
private readonly logger = new Logger(OllamaAdapter.name);
private registeredModels: ModelInfo[] = [];
constructor(private readonly registry: ModelRegistry) {}
async register(): Promise<void> {
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
if (!ollamaUrl) {
this.logger.debug('Skipping Ollama provider registration: OLLAMA_BASE_URL not set');
return;
}
const modelsEnv = process.env['OLLAMA_MODELS'] ?? 'llama3.2,codellama,mistral';
const modelIds = modelsEnv
.split(',')
.map((id: string) => id.trim())
.filter(Boolean);
this.registry.registerProvider('ollama', {
baseUrl: `${ollamaUrl}/v1`,
apiKey: 'ollama',
api: 'openai-completions' as never,
models: modelIds.map((id) => ({
id,
name: id,
reasoning: false,
input: ['text'] as ('text' | 'image')[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 4096,
})),
});
// Chat / completion models
const completionModels: ModelInfo[] = modelIds.map((id) => ({
id,
provider: 'ollama',
name: id,
reasoning: false,
contextWindow: 8192,
maxTokens: 4096,
inputTypes: ['text'] as ('text' | 'image')[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}));
// Embedding models (tracked in registeredModels but not in Pi registry,
// which only handles completion models)
const embeddingModels: ModelInfo[] = OLLAMA_EMBEDDING_MODELS.map((em) => ({
id: em.id,
provider: 'ollama',
name: em.id,
reasoning: false,
contextWindow: em.contextWindow,
maxTokens: 0,
inputTypes: ['text'] as ('text' | 'image')[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}));
this.registeredModels = [...completionModels, ...embeddingModels];
this.logger.log(
`Ollama provider registered at ${ollamaUrl} with models: ${modelIds.join(', ')} ` +
`and embedding models: ${OLLAMA_EMBEDDING_MODELS.map((em) => em.id).join(', ')}`,
);
}
listModels(): ModelInfo[] {
return this.registeredModels;
}
async healthCheck(): Promise<ProviderHealth> {
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
if (!ollamaUrl) {
return {
status: 'down',
lastChecked: new Date().toISOString(),
error: 'OLLAMA_BASE_URL not configured',
};
}
const checkUrl = `${ollamaUrl}/v1/models`;
const start = Date.now();
try {
const res = await fetch(checkUrl, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
const latencyMs = Date.now() - start;
if (!res.ok) {
return {
status: 'degraded',
latencyMs,
lastChecked: new Date().toISOString(),
error: `HTTP ${res.status}`,
};
}
return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() };
} catch (err) {
const latencyMs = Date.now() - start;
const error = err instanceof Error ? err.message : String(err);
return { status: 'down', latencyMs, lastChecked: new Date().toISOString(), error };
}
}
/**
* Generate an embedding vector for the given text using Ollama's /api/embeddings endpoint.
*
* Defaults to 'nomic-embed-text' when no model is specified.
* Intended for use by EmbeddingService (M3-009).
*
* @param text - The input text to embed.
* @param model - Optional embedding model ID (default: 'nomic-embed-text').
* @returns A float array representing the embedding vector.
*/
async embed(text: string, model = 'nomic-embed-text'): Promise<number[]> {
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
if (!ollamaUrl) {
throw new Error('OllamaAdapter: OLLAMA_BASE_URL not configured');
}
const embeddingUrl = `${ollamaUrl}/api/embeddings`;
const res = await fetch(embeddingUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt: text }),
signal: AbortSignal.timeout(30000),
});
if (!res.ok) {
throw new Error(`OllamaAdapter.embed: request failed with HTTP ${res.status}`);
}
const json = (await res.json()) as OllamaEmbeddingResponse;
if (!Array.isArray(json.embedding)) {
throw new Error('OllamaAdapter.embed: unexpected response — missing embedding array');
}
return json.embedding;
}
/**
* createCompletion is reserved for future direct-completion use.
* The current integration routes completions through Pi SDK's ModelRegistry/AgentSession.
*/
async *createCompletion(_params: CompletionParams): AsyncIterable<CompletionEvent> {
throw new Error(
'OllamaAdapter.createCompletion is not yet implemented. ' +
'Use Pi SDK AgentSession for completions.',
);
// Satisfy the AsyncGenerator return type — unreachable but required for TypeScript.
yield undefined as never;
}
}
@@ -1,201 +0,0 @@
import { Logger } from '@nestjs/common';
import OpenAI from 'openai';
import type { ModelRegistry } from '@mariozechner/pi-coding-agent';
import type {
CompletionEvent,
CompletionParams,
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaicstack/types';
/**
* OpenAI provider adapter.
*
* Registers OpenAI models (including Codex gpt-5.4) with the Pi ModelRegistry.
* Configuration is driven by environment variables:
* OPENAI_API_KEY — OpenAI API key (required; adapter skips registration when absent)
*/
export class OpenAIAdapter implements IProviderAdapter {
readonly name = 'openai';
private readonly logger = new Logger(OpenAIAdapter.name);
private registeredModels: ModelInfo[] = [];
private client: OpenAI | null = null;
/** Model ID used for Codex gpt-5.4 in the Pi registry. */
static readonly CODEX_MODEL_ID = 'codex-gpt-5-4';
constructor(private readonly registry: ModelRegistry) {}
async register(): Promise<void> {
const apiKey = process.env['OPENAI_API_KEY'];
if (!apiKey) {
this.logger.debug('Skipping OpenAI provider registration: OPENAI_API_KEY not set');
return;
}
this.client = new OpenAI({ apiKey });
const codexModel = {
id: OpenAIAdapter.CODEX_MODEL_ID,
name: 'Codex gpt-5.4',
/** OpenAI-compatible completions API */
api: 'openai-completions' as never,
reasoning: false,
input: ['text', 'image'] as ('text' | 'image')[],
cost: { input: 0.003, output: 0.012, cacheRead: 0.0015, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
};
this.registry.registerProvider('openai', {
apiKey,
baseUrl: 'https://api.openai.com/v1',
models: [codexModel],
});
this.registeredModels = [
{
id: OpenAIAdapter.CODEX_MODEL_ID,
provider: 'openai',
name: 'Codex gpt-5.4',
reasoning: false,
contextWindow: 128_000,
maxTokens: 16_384,
inputTypes: ['text', 'image'] as ('text' | 'image')[],
cost: { input: 0.003, output: 0.012, cacheRead: 0.0015, cacheWrite: 0 },
},
];
this.logger.log(`OpenAI provider registered with model: ${OpenAIAdapter.CODEX_MODEL_ID}`);
}
listModels(): ModelInfo[] {
return this.registeredModels;
}
async healthCheck(): Promise<ProviderHealth> {
const apiKey = process.env['OPENAI_API_KEY'];
if (!apiKey) {
return {
status: 'down',
lastChecked: new Date().toISOString(),
error: 'OPENAI_API_KEY not configured',
};
}
const start = Date.now();
try {
// Lightweight call — list models to verify key validity
const res = await fetch('https://api.openai.com/v1/models', {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(5000),
});
const latencyMs = Date.now() - start;
if (!res.ok) {
return {
status: 'degraded',
latencyMs,
lastChecked: new Date().toISOString(),
error: `HTTP ${res.status}`,
};
}
return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() };
} catch (err) {
const latencyMs = Date.now() - start;
const error = err instanceof Error ? err.message : String(err);
return { status: 'down', latencyMs, lastChecked: new Date().toISOString(), error };
}
}
/**
* Stream a completion from OpenAI using the chat completions API.
*
* Maps OpenAI streaming chunks to the Mosaic CompletionEvent format.
*/
async *createCompletion(params: CompletionParams): AsyncIterable<CompletionEvent> {
if (!this.client) {
throw new Error(
'OpenAIAdapter: client not initialized. ' +
'Ensure OPENAI_API_KEY is set and register() was called.',
);
}
const stream = await this.client.chat.completions.create({
model: params.model,
messages: params.messages.map((m) => ({
role: m.role,
content: m.content,
})),
...(params.temperature !== undefined && { temperature: params.temperature }),
...(params.maxTokens !== undefined && { max_tokens: params.maxTokens }),
...(params.tools &&
params.tools.length > 0 && {
tools: params.tools.map((t) => ({
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
})),
}),
stream: true,
stream_options: { include_usage: true },
});
let inputTokens = 0;
let outputTokens = 0;
for await (const chunk of stream) {
const choice = chunk.choices[0];
// Accumulate usage when present (final chunk with stream_options.include_usage)
if (chunk.usage) {
inputTokens = chunk.usage.prompt_tokens;
outputTokens = chunk.usage.completion_tokens;
}
if (!choice) continue;
const delta = choice.delta;
// Text content delta
if (delta.content) {
yield { type: 'text_delta', content: delta.content };
}
// Tool call delta — emit when arguments are complete
if (delta.tool_calls) {
for (const toolCallDelta of delta.tool_calls) {
if (toolCallDelta.function?.name && toolCallDelta.function.arguments !== undefined) {
yield {
type: 'tool_call',
name: toolCallDelta.function.name,
arguments: toolCallDelta.function.arguments,
};
}
}
}
// Stream finished
if (choice.finish_reason === 'stop' || choice.finish_reason === 'tool_calls') {
yield {
type: 'done',
usage: { inputTokens, outputTokens },
};
return;
}
}
// Fallback done event when stream ends without explicit finish_reason
yield { type: 'done', usage: { inputTokens, outputTokens } };
}
}
@@ -1,212 +0,0 @@
import { Logger } from '@nestjs/common';
import OpenAI from 'openai';
import type {
CompletionEvent,
CompletionParams,
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaicstack/types';
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
interface OpenRouterModel {
id: string;
name?: string;
context_length?: number;
top_provider?: {
max_completion_tokens?: number;
};
pricing?: {
prompt?: string | number;
completion?: string | number;
};
architecture?: {
input_modalities?: string[];
};
}
interface OpenRouterModelsResponse {
data?: OpenRouterModel[];
}
/**
* OpenRouter provider adapter.
*
* Routes completions through OpenRouter's OpenAI-compatible API.
* Configuration is driven by the OPENROUTER_API_KEY environment variable.
*/
export class OpenRouterAdapter implements IProviderAdapter {
readonly name = 'openrouter';
private readonly logger = new Logger(OpenRouterAdapter.name);
private client: OpenAI | null = null;
private registeredModels: ModelInfo[] = [];
async register(): Promise<void> {
const apiKey = process.env['OPENROUTER_API_KEY'];
if (!apiKey) {
this.logger.debug('Skipping OpenRouter provider registration: OPENROUTER_API_KEY not set');
return;
}
this.client = new OpenAI({
apiKey,
baseURL: OPENROUTER_BASE_URL,
defaultHeaders: {
'HTTP-Referer': 'https://mosaic.ai',
'X-Title': 'Mosaic',
},
});
try {
this.registeredModels = await this.fetchModels(apiKey);
this.logger.log(`OpenRouter provider registered with ${this.registeredModels.length} models`);
} catch (err) {
this.logger.warn(
`OpenRouter model discovery failed: ${err instanceof Error ? err.message : String(err)}. Registering with empty model list.`,
);
this.registeredModels = [];
}
}
listModels(): ModelInfo[] {
return this.registeredModels;
}
async healthCheck(): Promise<ProviderHealth> {
const apiKey = process.env['OPENROUTER_API_KEY'];
if (!apiKey) {
return {
status: 'down',
lastChecked: new Date().toISOString(),
error: 'OPENROUTER_API_KEY not configured',
};
}
const start = Date.now();
try {
const res = await fetch(`${OPENROUTER_BASE_URL}/models`, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(5000),
});
const latencyMs = Date.now() - start;
if (!res.ok) {
return {
status: 'degraded',
latencyMs,
lastChecked: new Date().toISOString(),
error: `HTTP ${res.status}`,
};
}
return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() };
} catch (err) {
const latencyMs = Date.now() - start;
const error = err instanceof Error ? err.message : String(err);
return { status: 'down', latencyMs, lastChecked: new Date().toISOString(), error };
}
}
/**
* Stream a completion through OpenRouter's OpenAI-compatible API.
*/
async *createCompletion(params: CompletionParams): AsyncIterable<CompletionEvent> {
if (!this.client) {
throw new Error('OpenRouterAdapter is not initialized. Ensure OPENROUTER_API_KEY is set.');
}
const stream = await this.client.chat.completions.create({
model: params.model,
messages: params.messages.map((m) => ({ role: m.role, content: m.content })),
temperature: params.temperature,
max_tokens: params.maxTokens,
stream: true,
});
let inputTokens = 0;
let outputTokens = 0;
for await (const chunk of stream) {
const choice = chunk.choices[0];
if (!choice) continue;
const delta = choice.delta;
if (delta.content) {
yield { type: 'text_delta', content: delta.content };
}
if (choice.finish_reason === 'stop') {
const usage = (chunk as { usage?: { prompt_tokens?: number; completion_tokens?: number } })
.usage;
if (usage) {
inputTokens = usage.prompt_tokens ?? 0;
outputTokens = usage.completion_tokens ?? 0;
}
}
}
yield {
type: 'done',
usage: { inputTokens, outputTokens },
};
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private async fetchModels(apiKey: string): Promise<ModelInfo[]> {
const res = await fetch(`${OPENROUTER_BASE_URL}/models`, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
throw new Error(`OpenRouter models endpoint returned HTTP ${res.status}`);
}
const json = (await res.json()) as OpenRouterModelsResponse;
const data = json.data ?? [];
return data.map((model): ModelInfo => {
const inputPrice = model.pricing?.prompt
? parseFloat(String(model.pricing.prompt)) * 1000
: 0;
const outputPrice = model.pricing?.completion
? parseFloat(String(model.pricing.completion)) * 1000
: 0;
const inputModalities = model.architecture?.input_modalities ?? ['text'];
const inputTypes = inputModalities.includes('image')
? (['text', 'image'] as const)
: (['text'] as const);
return {
id: model.id,
provider: 'openrouter',
name: model.name ?? model.id,
reasoning: false,
contextWindow: model.context_length ?? 4096,
maxTokens: model.top_provider?.max_completion_tokens ?? 4096,
inputTypes: [...inputTypes],
cost: {
input: inputPrice,
output: outputPrice,
cacheRead: 0,
cacheWrite: 0,
},
};
});
}
}
@@ -1,187 +0,0 @@
import { Logger } from '@nestjs/common';
import OpenAI from 'openai';
import type {
CompletionEvent,
CompletionParams,
IProviderAdapter,
ModelInfo,
ProviderHealth,
} from '@mosaicstack/types';
import { getModelCapability } from '../model-capabilities.js';
/**
* Default Z.ai API base URL.
* Z.ai (BigModel / Zhipu AI) exposes an OpenAI-compatible API at this endpoint.
* Can be overridden via the ZAI_BASE_URL environment variable.
*/
const DEFAULT_ZAI_BASE_URL = 'https://open.bigmodel.cn/api/paas/v4';
/**
* GLM-5 model identifier on the Z.ai platform.
*/
const GLM5_MODEL_ID = 'glm-5';
/**
* Z.ai (Zhipu AI / BigModel) provider adapter.
*
* Z.ai exposes an OpenAI-compatible REST API. This adapter uses the `openai`
* SDK with a custom base URL and the ZAI_API_KEY environment variable.
*
* Configuration:
* ZAI_API_KEY — required; Z.ai API key
* ZAI_BASE_URL — optional; override the default API base URL
*/
export class ZaiAdapter implements IProviderAdapter {
readonly name = 'zai';
private readonly logger = new Logger(ZaiAdapter.name);
private client: OpenAI | null = null;
private registeredModels: ModelInfo[] = [];
async register(): Promise<void> {
const apiKey = process.env['ZAI_API_KEY'];
if (!apiKey) {
this.logger.debug('Skipping Z.ai provider registration: ZAI_API_KEY not set');
return;
}
const baseURL = process.env['ZAI_BASE_URL'] ?? DEFAULT_ZAI_BASE_URL;
this.client = new OpenAI({ apiKey, baseURL });
this.registeredModels = this.buildModelList();
this.logger.log(`Z.ai provider registered with ${this.registeredModels.length} model(s)`);
}
listModels(): ModelInfo[] {
return this.registeredModels;
}
async healthCheck(): Promise<ProviderHealth> {
const apiKey = process.env['ZAI_API_KEY'];
if (!apiKey) {
return {
status: 'down',
lastChecked: new Date().toISOString(),
error: 'ZAI_API_KEY not configured',
};
}
const baseURL = process.env['ZAI_BASE_URL'] ?? DEFAULT_ZAI_BASE_URL;
const start = Date.now();
try {
const res = await fetch(`${baseURL}/models`, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(5000),
});
const latencyMs = Date.now() - start;
if (!res.ok) {
return {
status: 'degraded',
latencyMs,
lastChecked: new Date().toISOString(),
error: `HTTP ${res.status}`,
};
}
return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() };
} catch (err) {
const latencyMs = Date.now() - start;
const error = err instanceof Error ? err.message : String(err);
return { status: 'down', latencyMs, lastChecked: new Date().toISOString(), error };
}
}
/**
* Stream a completion through Z.ai's OpenAI-compatible API.
*/
async *createCompletion(params: CompletionParams): AsyncIterable<CompletionEvent> {
if (!this.client) {
throw new Error('ZaiAdapter is not initialized. Ensure ZAI_API_KEY is set.');
}
const stream = await this.client.chat.completions.create({
model: params.model,
messages: params.messages.map((m) => ({ role: m.role, content: m.content })),
temperature: params.temperature,
max_tokens: params.maxTokens,
stream: true,
});
let inputTokens = 0;
let outputTokens = 0;
for await (const chunk of stream) {
const choice = chunk.choices[0];
if (!choice) continue;
const delta = choice.delta;
if (delta.content) {
yield { type: 'text_delta', content: delta.content };
}
if (choice.finish_reason === 'stop') {
const usage = (chunk as { usage?: { prompt_tokens?: number; completion_tokens?: number } })
.usage;
if (usage) {
inputTokens = usage.prompt_tokens ?? 0;
outputTokens = usage.completion_tokens ?? 0;
}
}
}
yield {
type: 'done',
usage: { inputTokens, outputTokens },
};
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private buildModelList(): ModelInfo[] {
const capability = getModelCapability(GLM5_MODEL_ID);
if (!capability) {
this.logger.warn(`Model capability entry not found for '${GLM5_MODEL_ID}'; using defaults`);
return [
{
id: GLM5_MODEL_ID,
provider: 'zai',
name: 'GLM-5',
reasoning: false,
contextWindow: 128000,
maxTokens: 8192,
inputTypes: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
];
}
return [
{
id: capability.id,
provider: 'zai',
name: capability.displayName,
reasoning: capability.capabilities.reasoning,
contextWindow: capability.contextWindow,
maxTokens: capability.maxOutputTokens,
inputTypes: capability.capabilities.vision ? ['text', 'image'] : ['text'],
cost: {
input: capability.costPer1kInput ?? 0,
output: capability.costPer1kOutput ?? 0,
cacheRead: 0,
cacheWrite: 0,
},
},
];
}
}
-197
View File
@@ -1,197 +0,0 @@
import {
IsArray,
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
const agentStatuses = ['idle', 'active', 'error', 'offline'] as const;
// ─── Agent Capability Declarations (M4-011) ───────────────────────────────────
/**
* Agent specialization capability fields.
* Stored inside the agent's `config` JSON as `capabilities`.
*/
export class AgentCapabilitiesDto {
/**
* Domains this agent specializes in, e.g. ['frontend', 'backend', 'devops'].
* Used by the routing engine to bias toward this agent for matching domains.
*/
@IsOptional()
@IsArray()
@IsString({ each: true })
domains?: string[];
/**
* Default model identifier for this agent.
* Influences routing when no explicit rule overrides the choice.
*/
@IsOptional()
@IsString()
@MaxLength(255)
preferredModel?: string;
/**
* Default provider for this agent.
* Influences routing when no explicit rule overrides the choice.
*/
@IsOptional()
@IsString()
@MaxLength(255)
preferredProvider?: string;
/**
* Tool categories this agent has access to, e.g. ['web-search', 'code-exec'].
*/
@IsOptional()
@IsArray()
@IsString({ each: true })
toolSets?: string[];
}
// ─── Create DTO ───────────────────────────────────────────────────────────────
export class CreateAgentConfigDto {
@IsString()
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(255)
provider!: string;
@IsString()
@MaxLength(255)
model!: string;
@IsOptional()
@IsIn(agentStatuses)
status?: 'idle' | 'active' | 'error' | 'offline';
@IsOptional()
@IsUUID()
projectId?: string;
@IsOptional()
@IsString()
@MaxLength(50_000)
systemPrompt?: string;
@IsOptional()
@IsArray()
allowedTools?: string[];
@IsOptional()
@IsArray()
skills?: string[];
@IsOptional()
@IsBoolean()
isSystem?: boolean;
/**
* General config blob. May include `capabilities` (AgentCapabilitiesDto)
* for agent specialization declarations (M4-011).
*/
@IsOptional()
@IsObject()
config?: Record<string, unknown>;
// ─── Capability shorthand fields (M4-011) ──────────────────────────────────
// These are convenience top-level fields that get merged into config.capabilities.
@IsOptional()
@IsArray()
@IsString({ each: true })
domains?: string[];
@IsOptional()
@IsString()
@MaxLength(255)
preferredModel?: string;
@IsOptional()
@IsString()
@MaxLength(255)
preferredProvider?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
toolSets?: string[];
}
// ─── Update DTO ───────────────────────────────────────────────────────────────
export class UpdateAgentConfigDto {
@IsOptional()
@IsString()
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MaxLength(255)
provider?: string;
@IsOptional()
@IsString()
@MaxLength(255)
model?: string;
@IsOptional()
@IsIn(agentStatuses)
status?: 'idle' | 'active' | 'error' | 'offline';
@IsOptional()
@IsUUID()
projectId?: string | null;
@IsOptional()
@IsString()
@MaxLength(50_000)
systemPrompt?: string | null;
@IsOptional()
@IsArray()
allowedTools?: string[] | null;
@IsOptional()
@IsArray()
skills?: string[] | null;
/**
* General config blob. May include `capabilities` (AgentCapabilitiesDto)
* for agent specialization declarations (M4-011).
*/
@IsOptional()
@IsObject()
config?: Record<string, unknown> | null;
// ─── Capability shorthand fields (M4-011) ──────────────────────────────────
@IsOptional()
@IsArray()
@IsString({ each: true })
domains?: string[] | null;
@IsOptional()
@IsString()
@MaxLength(255)
preferredModel?: string | null;
@IsOptional()
@IsString()
@MaxLength(255)
preferredProvider?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
toolSets?: string[] | null;
}
@@ -1,170 +0,0 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { CreateAgentConfigDto, UpdateAgentConfigDto } from './agent-config.dto.js';
// ─── M4-011 helpers ──────────────────────────────────────────────────────────
type CapabilityFields = {
domains?: string[] | null;
preferredModel?: string | null;
preferredProvider?: string | null;
toolSets?: string[] | null;
};
/** Extract capability shorthand fields from the DTO (undefined if none provided). */
function buildCapabilities(dto: CapabilityFields): Record<string, unknown> | undefined {
const hasAny =
dto.domains !== undefined ||
dto.preferredModel !== undefined ||
dto.preferredProvider !== undefined ||
dto.toolSets !== undefined;
if (!hasAny) return undefined;
const cap: Record<string, unknown> = {};
if (dto.domains !== undefined) cap['domains'] = dto.domains;
if (dto.preferredModel !== undefined) cap['preferredModel'] = dto.preferredModel;
if (dto.preferredProvider !== undefined) cap['preferredProvider'] = dto.preferredProvider;
if (dto.toolSets !== undefined) cap['toolSets'] = dto.toolSets;
return cap;
}
/** Merge capabilities into the config object, preserving other config keys. */
function mergeCapabilities(
existing: Record<string, unknown> | null | undefined,
capabilities: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (capabilities === undefined && existing === undefined) return undefined;
if (capabilities === undefined) return existing ?? undefined;
const base = existing ?? {};
const existingCap =
typeof base['capabilities'] === 'object' && base['capabilities'] !== null
? (base['capabilities'] as Record<string, unknown>)
: {};
return {
...base,
capabilities: { ...existingCap, ...capabilities },
};
}
@Controller('api/agents')
@UseGuards(AuthGuard)
export class AgentConfigsController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list(@CurrentUser() user: { id: string; role?: string }) {
return this.brain.agents.findAccessible(user.id);
}
@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const agent = await this.brain.agents.findById(id);
if (!agent) throw new NotFoundException('Agent not found');
if (!agent.isSystem && agent.ownerId !== user.id) {
throw new ForbiddenException('Agent does not belong to the current user');
}
return agent;
}
@Post()
async create(@Body() dto: CreateAgentConfigDto, @CurrentUser() user: { id: string }) {
// Merge capability shorthand fields into config.capabilities (M4-011)
const capabilities = buildCapabilities(dto);
const config = mergeCapabilities(dto.config, capabilities);
return this.brain.agents.create({
name: dto.name,
provider: dto.provider,
model: dto.model,
status: dto.status,
projectId: dto.projectId,
systemPrompt: dto.systemPrompt,
allowedTools: dto.allowedTools,
skills: dto.skills,
isSystem: false,
config,
ownerId: user.id,
});
}
@Patch(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateAgentConfigDto,
@CurrentUser() user: { id: string; role?: string },
) {
const agent = await this.brain.agents.findById(id);
if (!agent) throw new NotFoundException('Agent not found');
if (agent.isSystem && user.role !== 'admin') {
throw new ForbiddenException('Only admins can update system agents');
}
if (!agent.isSystem && agent.ownerId !== user.id) {
throw new ForbiddenException('Agent does not belong to the current user');
}
// Merge capability shorthand fields into config.capabilities (M4-011)
const capabilities = buildCapabilities(dto);
const baseConfig =
dto.config !== undefined
? dto.config
: (agent.config as Record<string, unknown> | null | undefined);
const config = mergeCapabilities(baseConfig ?? undefined, capabilities);
// Pass ownerId for user agents so the repo WHERE clause enforces ownership.
// For system agents (admin path) pass undefined so the WHERE matches only on id.
const ownerId = agent.isSystem ? undefined : user.id;
const updated = await this.brain.agents.update(
id,
{
name: dto.name,
provider: dto.provider,
model: dto.model,
status: dto.status,
projectId: dto.projectId,
systemPrompt: dto.systemPrompt,
allowedTools: dto.allowedTools,
skills: dto.skills,
config: capabilities !== undefined || dto.config !== undefined ? config : undefined,
},
ownerId,
);
if (!updated) throw new NotFoundException('Agent not found');
return updated;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string, @CurrentUser() user: { id: string; role?: string }) {
const agent = await this.brain.agents.findById(id);
if (!agent) throw new NotFoundException('Agent not found');
if (agent.isSystem) {
throw new ForbiddenException('Cannot delete system agents');
}
if (agent.ownerId !== user.id) {
throw new ForbiddenException('Agent does not belong to the current user');
}
// Pass ownerId so the repo WHERE clause enforces ownership at the DB level.
const deleted = await this.brain.agents.remove(id, user.id);
if (!deleted) throw new NotFoundException('Agent not found');
}
}
-98
View File
@@ -1,98 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
import { RoutingService } from './routing.service.js';
import { RoutingEngineService } from './routing/routing-engine.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
import { ProvidersController } from './providers.controller.js';
import { SessionsController } from './sessions.controller.js';
import { AgentConfigsController } from './agent-configs.controller.js';
import { InteractionController } from './interaction.controller.js';
import { RoutingController } from './routing/routing.controller.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
import {
CONNECTOR_LEASE_POLICY,
ConnectorLeaseService,
DenyConnectorLeasePolicy,
} from './connector-lease.service.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
const registry = new AgentRuntimeProviderRegistry();
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
return registry;
}
@Global()
@Module({
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
providers: [
ProviderService,
ProviderCredentialsService,
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionRepository,
DurableSessionService,
ConnectorLeaseRepository,
DenyConnectorLeasePolicy,
{
provide: CONNECTOR_LEASE_POLICY,
useExisting: DenyConnectorLeasePolicy,
},
ConnectorLeaseService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry,
},
RuntimeProviderAuditService,
{
provide: RUNTIME_PROVIDER_AUDIT_SINK,
useExisting: RuntimeProviderAuditService,
},
{
provide: RUNTIME_APPROVAL_VERIFIER,
useExisting: CommandRuntimeApprovalVerifier,
},
RuntimeProviderService,
AgentService,
],
controllers: [
ProvidersController,
SessionsController,
AgentConfigsController,
InteractionController,
RoutingController,
],
exports: [
AgentService,
ProviderService,
ProviderCredentialsService,
RoutingService,
RoutingEngineService,
SkillLoaderService,
DurableSessionService,
RuntimeProviderService,
ConnectorLeaseService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
],
})
export class AgentModule {}
-798
View File
@@ -1,798 +0,0 @@
import {
ForbiddenException,
Inject,
Injectable,
Logger,
Optional,
type OnModuleDestroy,
} from '@nestjs/common';
import {
createAgentSession,
DefaultResourceLoader,
SessionManager,
type AgentSession as PiAgentSession,
type AgentSessionEvent,
type ToolDefinition,
} from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaicstack/brain';
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js';
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
import { EmbeddingService } from '../memory/embedding.service.js';
import { CoordService } from '../coord/coord.service.js';
import { ProviderService } from './provider.service.js';
import { McpClientService } from '../mcp-client/mcp-client.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
import { createBrainTools } from './tools/brain-tools.js';
import { createCoordTools } from './tools/coord-tools.js';
import { createMemoryTools } from './tools/memory-tools.js';
import { createFileTools } from './tools/file-tools.js';
import { createGitTools } from './tools/git-tools.js';
import { createShellTools } from './tools/shell-tools.js';
import { createWebTools } from './tools/web-tools.js';
import { createSearchTools } from './tools/search-tools.js';
import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
import { PreferencesService } from '../preferences/preferences.service.js';
import { SessionGCService } from '../gc/session-gc.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
/** A single message from DB conversation history, used for context injection. */
export interface ConversationHistoryMessage {
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: Date;
/** Validated, URI-referenced channel attachments preserved on session resume. */
attachments?: readonly ChannelAttachmentDto[];
}
export interface AgentSessionOptions {
provider?: string;
modelId?: string;
/**
* Sandbox working directory for the session.
* File, git, and shell tools will be restricted to this directory.
* Falls back to AGENT_FILE_SANDBOX_DIR env var or process.cwd().
*/
sandboxDir?: string;
/**
* Platform-level system prompt for this session.
* Merged with skill prompt additions (platform prompt first, then skills).
* Falls back to AGENT_SYSTEM_PROMPT env var when omitted.
*/
systemPrompt?: string;
/**
* Explicit allowlist of tool names available in this session.
* When set, only listed tools are registered with the agent.
* When omitted for non-admin users, falls back to AGENT_USER_TOOLS env var.
* Admins (isAdmin=true) always receive the full tool set unless explicitly restricted.
*/
allowedTools?: string[];
/** Whether the requesting user has admin privileges. Controls default tool access. */
isAdmin?: boolean;
/**
* DB agent config ID. When provided, loads agent config from DB and merges
* provider, model, systemPrompt, and allowedTools. Explicit call-site options
* take precedence over config values.
*/
agentConfigId?: string;
/** ID of the user who owns this session. Used for preferences and system override lookups. */
userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/**
* Prior conversation messages to inject as context when resuming a session.
* These messages are formatted and prepended to the system prompt so the
* agent is aware of what was discussed in previous sessions.
*/
conversationHistory?: ConversationHistoryMessage[];
}
export interface AgentSession {
id: string;
provider: string;
modelId: string;
piSession: PiAgentSession;
listeners: Set<(event: AgentSessionEvent) => void>;
unsubscribe: () => void;
createdAt: number;
promptCount: number;
channels: Set<string>;
/** System prompt additions injected from enabled prompt-type skills. */
skillPromptAdditions: string[];
/** Resolved sandbox directory for this session. */
sandboxDir: string;
/** Tool names available in this session, or null when all tools are available. */
allowedTools: string[] | null;
/** User ID that owns this session, used for preference lookups. */
userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/** Agent config ID applied to this session, if any (M5-001). */
agentConfigId?: string;
/** Human-readable agent name applied to this session, if any (M5-001). */
agentName?: string;
/** M5-007: per-session metrics. */
metrics: SessionMetrics;
}
@Injectable()
export class AgentService implements OnModuleDestroy {
private readonly logger = new Logger(AgentService.name);
private readonly sessions = new Map<string, AgentSession>();
private readonly creating = new Map<string, Promise<AgentSession>>();
constructor(
@Inject(ProviderService) private readonly providerService: ProviderService,
@Inject(BRAIN) private readonly brain: Brain,
@Inject(MEMORY) private readonly memory: Memory,
@Inject(EmbeddingService) private readonly embeddingService: EmbeddingService,
@Inject(CoordService) private readonly coordService: CoordService,
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
@Optional()
@Inject(SystemOverrideService)
private readonly systemOverride: SystemOverrideService | null,
@Optional()
@Inject(PreferencesService)
private readonly preferencesService: PreferencesService | null,
@Inject(SessionGCService) private readonly gc: SessionGCService,
@Optional()
@Inject(OPERATOR_MEMORY_PLUGIN)
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
) {}
/**
* Build the full set of custom tools scoped to the given sandbox directory and session user.
* Brain/coord/memory/web tools are stateless with respect to cwd; file/git/shell
* tools receive the resolved sandboxDir so they operate within the sandbox.
* Memory tools are bound to sessionUserId so the LLM cannot access another user's data.
*/
private buildToolsForSandbox(
sandboxDir: string,
sessionUserId: string | undefined,
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
): ToolDefinition[] {
return [
...createBrainTools(this.brain),
...createCoordTools(this.coordService),
...createMemoryTools(
this.memory,
this.embeddingService.available ? this.embeddingService : null,
sessionUserId,
this.operatorMemory && sessionScope
? { plugin: this.operatorMemory, scope: sessionScope }
: undefined,
),
...createFileTools(sandboxDir),
...createGitTools(sandboxDir),
...createShellTools(sandboxDir),
...createWebTools(),
...createSearchTools(),
];
}
/**
* Resolve the tool allowlist for a session.
* - Admin users: all tools unless an explicit allowedTools list is passed.
* - Regular users: use allowedTools if provided, otherwise parse AGENT_USER_TOOLS env var.
* Returns null when all tools should be available.
*/
private resolveAllowedTools(isAdmin: boolean, allowedTools?: string[]): string[] | null {
if (allowedTools !== undefined) {
return allowedTools.length === 0 ? [] : allowedTools;
}
if (isAdmin) {
return null; // admins get everything
}
const envTools = process.env['AGENT_USER_TOOLS'];
if (!envTools) {
return null; // no restriction configured
}
return envTools
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
const scope = this.scopeFromOptions(options);
const existing = this.sessions.get(sessionId);
if (existing) {
this.assertSessionScope(existing, scope);
return existing;
}
const inflight = this.creating.get(sessionId);
if (inflight) {
const session = await inflight;
this.assertSessionScope(session, scope);
return session;
}
const promise = this.doCreateSession(sessionId, options).finally(() => {
this.creating.delete(sessionId);
});
this.creating.set(sessionId, promise);
return promise;
}
private async doCreateSession(
sessionId: string,
options?: AgentSessionOptions,
): Promise<AgentSession> {
// Merge DB agent config when agentConfigId is provided (M5-001)
let mergedOptions = options;
let resolvedAgentName: string | undefined;
if (options?.agentConfigId) {
const agentConfig = await this.brain.agents.findById(options.agentConfigId);
if (agentConfig) {
resolvedAgentName = agentConfig.name;
mergedOptions = {
provider: options.provider ?? agentConfig.provider,
modelId: options.modelId ?? agentConfig.model,
systemPrompt: options.systemPrompt ?? agentConfig.systemPrompt ?? undefined,
allowedTools: options.allowedTools ?? agentConfig.allowedTools ?? undefined,
sandboxDir: options.sandboxDir,
isAdmin: options.isAdmin,
agentConfigId: options.agentConfigId,
userId: options.userId,
tenantId: options.tenantId,
conversationHistory: options.conversationHistory,
};
this.logger.log(
`Merged agent config "${agentConfig.name}" (${agentConfig.id}) into session ${sessionId}`,
);
}
}
const model = this.resolveModel(mergedOptions);
const providerName = model?.provider ?? 'default';
const modelId = model?.id ?? 'default';
// Resolve sandbox directory: option > env var > process.cwd()
const sandboxDir =
mergedOptions?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
// Resolve allowed tool set
const allowedTools = this.resolveAllowedTools(
mergedOptions?.isAdmin ?? false,
mergedOptions?.allowedTools,
);
this.logger.log(
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
);
// Load skill tools from the catalog
const { metaTools: skillMetaTools, promptAdditions } =
await this.skillLoaderService.loadForSession();
if (skillMetaTools.length > 0) {
this.logger.log(`Attaching ${skillMetaTools.length} skill tool(s) to session ${sessionId}`);
}
if (promptAdditions.length > 0) {
this.logger.log(
`Injecting ${promptAdditions.length} skill prompt addition(s) into session ${sessionId}`,
);
}
// Build per-session tools scoped to the sandbox directory and authenticated user
const sessionUserId = mergedOptions?.userId;
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
const sandboxTools = this.buildToolsForSandbox(
sandboxDir,
sessionUserId,
sessionUserId && sessionTenantId
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
: undefined,
);
// Combine static tools with dynamically discovered MCP client tools and skill tools
const mcpTools = this.mcpClientService.getToolDefinitions();
let allCustomTools = [...sandboxTools, ...skillMetaTools, ...mcpTools];
if (mcpTools.length > 0) {
this.logger.log(`Attaching ${mcpTools.length} MCP client tool(s) to session ${sessionId}`);
}
// Filter tools by allowlist when a restriction is in effect
if (allowedTools !== null) {
const allowedSet = new Set(allowedTools);
const before = allCustomTools.length;
allCustomTools = allCustomTools.filter((t) => allowedSet.has(t.name));
this.logger.log(
`Tool restriction applied: ${allCustomTools.length}/${before} tools allowed for session ${sessionId}`,
);
}
// Build system prompt: platform prompt + skill additions appended
const platformPrompt =
mergedOptions?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
// Format conversation history for context injection (M1-004 / M1-005)
const historyPromptSection = mergedOptions?.conversationHistory?.length
? this.buildHistoryPromptSection(
mergedOptions.conversationHistory,
model?.contextWindow ?? 8192,
sessionId,
)
: undefined;
const appendParts: string[] = [];
if (promptAdditions.length > 0) appendParts.push(promptAdditions.join('\n\n'));
if (historyPromptSection) appendParts.push(historyPromptSection);
const appendSystemPrompt = appendParts.length > 0 ? appendParts.join('\n\n') : undefined;
// Construct a resource loader that injects the configured system prompt
const resourceLoader = new DefaultResourceLoader({
cwd: sandboxDir,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
systemPrompt: platformPrompt,
appendSystemPrompt: appendSystemPrompt,
});
await resourceLoader.reload();
let piSession: PiAgentSession;
try {
const result = await createAgentSession({
sessionManager: SessionManager.inMemory(),
modelRegistry: this.providerService.getRegistry(),
model: model ?? undefined,
cwd: sandboxDir,
tools: [],
customTools: allCustomTools,
resourceLoader,
});
piSession = result.session;
} catch (err) {
this.logger.error(
`Failed to create agent session for ${sessionId}`,
err instanceof Error ? err.stack : String(err),
);
throw new Error(`Agent session creation failed for ${sessionId}: ${String(err)}`);
}
const listeners = new Set<(event: AgentSessionEvent) => void>();
const unsubscribe = piSession.subscribe((event) => {
for (const listener of listeners) {
try {
listener(event);
} catch (err) {
this.logger.error(`Event listener error in session ${sessionId}`, err);
}
}
});
const session: AgentSession = {
id: sessionId,
provider: providerName,
modelId,
piSession,
listeners,
unsubscribe,
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: promptAdditions,
sandboxDir,
allowedTools,
userId: mergedOptions?.userId,
tenantId: sessionTenantId,
agentConfigId: mergedOptions?.agentConfigId,
agentName: resolvedAgentName,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date().toISOString(),
},
};
this.sessions.set(sessionId, session);
this.logger.log(`Agent session ${sessionId} ready (${providerName}/${modelId})`);
if (resolvedAgentName) {
this.logger.log(
`Agent session ${sessionId} using agent config "${resolvedAgentName}" (M5-001)`,
);
}
return session;
}
/**
* Estimate token count for a string using a rough 4-chars-per-token heuristic.
*/
private estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Build a conversation history section for injection into the system prompt.
* Implements M1-004 (history loading) and M1-005 (context window management).
*
* - Formats messages as a readable conversation transcript.
* - If the full history exceeds 80% of the model's context window, older messages
* are summarized and only the most recent messages are kept verbatim.
* - Summarization is a simple extractive approach (no LLM required).
*/
private buildHistoryPromptSection(
history: ConversationHistoryMessage[],
contextWindow: number,
sessionId: string,
): string {
const TOKEN_BUDGET = Math.floor(contextWindow * 0.8);
const HISTORY_HEADER = '## Conversation History (resumed session)\n\n';
const formatMessage = (msg: ConversationHistoryMessage): string => {
const roleLabel =
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`;
};
const formatted = history.map((msg) => formatMessage(msg));
const fullHistory = formatted.join('\n\n');
const fullTokens = this.estimateTokens(HISTORY_HEADER + fullHistory);
if (fullTokens <= TOKEN_BUDGET) {
this.logger.debug(
`Session ${sessionId}: injecting full history (${history.length} msgs, ~${fullTokens} tokens)`,
);
return HISTORY_HEADER + fullHistory;
}
// History exceeds budget — summarize oldest messages, keep recent verbatim
this.logger.log(
`Session ${sessionId}: history (~${fullTokens} tokens) exceeds ${TOKEN_BUDGET} token budget; summarizing oldest messages`,
);
// Reserve 20% of the budget for the summary prefix, rest for verbatim messages
const SUMMARY_RESERVE = Math.floor(TOKEN_BUDGET * 0.2);
const verbatimBudget = TOKEN_BUDGET - SUMMARY_RESERVE;
let verbatimTokens = 0;
let verbatimCutIndex = history.length;
for (let i = history.length - 1; i >= 0; i--) {
const t = this.estimateTokens(formatted[i]!);
if (verbatimTokens + t > verbatimBudget) break;
verbatimTokens += t;
verbatimCutIndex = i;
}
const summarizedMessages = history.slice(0, verbatimCutIndex);
const verbatimMessages = history.slice(verbatimCutIndex);
let summaryText = '';
if (summarizedMessages.length > 0) {
const topics = summarizedMessages
.filter((m) => m.role === 'user')
.map((m) => m.content.slice(0, 120).replace(/\n/g, ' '))
.join('; ');
summaryText =
`**Previous conversation summary** (${summarizedMessages.length} messages omitted for brevity):\n` +
`Topics discussed: ${topics || '(no user messages in summarized portion)'}`;
}
const verbatimSection = verbatimMessages.map((m) => formatMessage(m)).join('\n\n');
const parts: string[] = [HISTORY_HEADER];
if (summaryText) parts.push(summaryText);
if (verbatimSection) parts.push(verbatimSection);
const result = parts.join('\n\n');
this.logger.log(
`Session ${sessionId}: summarized ${summarizedMessages.length} messages, kept ${verbatimMessages.length} verbatim (~${this.estimateTokens(result)} tokens)`,
);
return result;
}
private attachmentContext(attachments: readonly ChannelAttachmentDto[]): string {
if (attachments.length === 0) return '';
return `\n\n[Untrusted channel attachments]\n${attachments
.map((attachment: ChannelAttachmentDto): string =>
JSON.stringify({
id: attachment.id,
name: attachment.name,
mimeType: attachment.mimeType,
url: attachment.url,
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
}),
)
.join('\n')}`;
}
private resolveModel(options?: AgentSessionOptions) {
if (!options?.provider && !options?.modelId) {
return this.providerService.getDefaultModel() ?? null;
}
if (options.provider && options.modelId) {
const model = this.providerService.findModel(options.provider, options.modelId);
if (!model) {
throw new Error(`Model not found: ${options.provider}/${options.modelId}`);
}
return model;
}
if (options.modelId) {
const available = this.providerService.listAvailableModels();
const match = available.find((m) => m.id === options.modelId);
if (match) {
return this.providerService.findModel(match.provider, match.id) ?? null;
}
}
return this.providerService.getDefaultModel() ?? null;
}
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
const session = this.sessions.get(sessionId);
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
return session;
}
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values())
.filter((s) => this.sessionMatchesScope(s, scope))
.map((s) => this.toSessionInfo(s, now));
}
listAllSessionsForSystem(): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined {
const s = this.sessions.get(sessionId);
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
return this.toSessionInfo(s);
}
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
if (!options.userId) {
throw new ForbiddenException('Session owner scope is required');
}
return {
userId: options.userId,
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
};
}
private tenantIdFor(
userId: string | undefined,
tenantId: string | undefined,
): string | undefined {
return tenantId ?? userId;
}
private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean {
return (
session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId
);
}
private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void {
if (!this.sessionMatchesScope(session, scope)) {
throw new ForbiddenException('Session does not belong to the current owner/tenant scope');
}
}
private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto {
return {
id: session.id,
provider: session.provider,
modelId: session.modelId,
...(session.agentName ? { agentName: session.agentName } : {}),
createdAt: new Date(session.createdAt).toISOString(),
promptCount: session.promptCount,
channels: Array.from(session.channels),
durationMs: now - session.createdAt,
metrics: { ...session.metrics },
};
}
/**
* Record token usage for a session turn (M5-007).
* Accumulates tokens across the session lifetime.
*/
recordTokenUsage(
sessionId: string,
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number },
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.metrics.tokens.input += tokens.input;
session.metrics.tokens.output += tokens.output;
session.metrics.tokens.cacheRead += tokens.cacheRead;
session.metrics.tokens.cacheWrite += tokens.cacheWrite;
session.metrics.tokens.total += tokens.total;
session.metrics.lastActivityAt = new Date().toISOString();
}
/**
* Record a model switch event for a session (M5-007).
*/
recordModelSwitch(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.metrics.modelSwitches += 1;
session.metrics.lastActivityAt = new Date().toISOString();
}
/**
* Increment message count for a session (M5-007).
*/
recordMessage(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.metrics.messageCount += 1;
session.metrics.lastActivityAt = new Date().toISOString();
}
/**
* Update the model tracked on a live session (M5-002).
* This records the model change in the session metadata so subsequent
* session:info emissions reflect the new model. The Pi session itself is
* not reconstructed — the model is used on the next createSession call for
* the same conversationId when the session is torn down or a new one is created.
*/
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
const prev = session.modelId;
session.modelId = modelId;
this.recordModelSwitch(sessionId);
this.logger.log(`Session ${sessionId}: model updated ${prev}${modelId} (M5-002)`);
}
/**
* Apply a new agent config to a live session mid-conversation (M5-003).
* Updates agentName, agentConfigId, and modelId on the session object.
* System prompt and tools take effect when the next session is created for
* this conversationId (they are baked in at session creation time).
*/
applyAgentConfig(
sessionId: string,
agentConfigId: string,
agentName: string,
scope: ActorTenantScope,
modelId?: string,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
session.agentConfigId = agentConfigId;
session.agentName = agentName;
if (modelId) {
this.updateSessionModel(sessionId, modelId, scope);
}
this.logger.log(
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
);
}
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.add(channel);
}
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.delete(channel);
}
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] | undefined,
): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] = [],
): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.promptCount += 1;
// Channel attachments are untrusted URI references. Preserve exact,
// authenticated metadata for the agent without treating it as authority.
const attachmentContext = this.attachmentContext(attachments);
// Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = `${message}${attachmentContext}`;
if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}
}
try {
await session.piSession.prompt(effectiveMessage);
} catch (err) {
this.logger.error(
`Prompt failed for session=${sessionId}, messageLength=${message.length}`,
err instanceof Error ? err.stack : String(err),
);
throw err;
}
}
onEvent(
sessionId: string,
listener: (event: AgentSessionEvent) => void,
scope: ActorTenantScope,
): () => void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.listeners.add(listener);
return () => session.listeners.delete(listener);
}
async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
await this.destroySessionForSystem(sessionId);
}
private async destroySessionForSystem(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.logger.log(`Destroying agent session ${sessionId}`);
try {
session.unsubscribe();
} catch (err) {
this.logger.error(`Failed to unsubscribe session ${sessionId}`, String(err));
}
try {
session.piSession.dispose();
} catch (err) {
this.logger.error(`Failed to dispose piSession for ${sessionId}`, String(err));
}
session.listeners.clear();
session.channels.clear();
this.sessions.delete(sessionId);
// Run GC cleanup for this session (fire and forget, errors are logged)
this.gc.collect(sessionId).catch((err: unknown) => {
this.logger.error(
`GC collect failed for session ${sessionId}`,
err instanceof Error ? err.stack : String(err),
);
});
}
async onModuleDestroy(): Promise<void> {
this.logger.log('Shutting down all agent sessions');
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
const results = await Promise.allSettled(stops);
for (const result of results) {
if (result.status === 'rejected') {
this.logger.error('Session shutdown failure', String(result.reason));
}
}
}
}
@@ -1,341 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import {
connectorLeaseAuditLog,
createPgliteDb,
eq,
runPgliteMigrations,
type DbHandle,
} from '@mosaicstack/db';
import type { ConnectorExecutionContext, FencedConnectorAdapter } from '@mosaicstack/types';
import { DB } from '../database/database.module.js';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
import {
CONNECTOR_LEASE_POLICY,
ConnectorLeaseService,
type ConnectorLeasePolicy,
type ConnectorLeasePolicySubject,
} from './connector-lease.service.js';
const authorize = vi.fn().mockResolvedValue(true);
const policy: ConnectorLeasePolicy = { authorize };
const context = {
actorScope: { userId: 'operator-a', tenantId: 'tenant-a' },
correlationId: 'correlation-acquire',
};
describe('gateway connector lease fencing integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let service: ConnectorLeaseService;
let repository: ConnectorLeaseRepository;
beforeAll(async (): Promise<void> => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-14T17:00:00.000Z'));
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-connector-lease-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
moduleRef = await Test.createTestingModule({
providers: [
ConnectorLeaseRepository,
ConnectorLeaseService,
{ provide: DB, useValue: handle.db },
{ provide: CONNECTOR_LEASE_POLICY, useValue: policy },
],
}).compile();
service = moduleRef.get(ConnectorLeaseService);
repository = moduleRef.get(ConnectorLeaseRepository);
});
afterAll(async (): Promise<void> => {
vi.useRealTimers();
await moduleRef.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
it('derives tenant authority at the gateway and validates a grant before side effects', async (): Promise<void> => {
const lease = await service.acquire(
{
logicalAgentId: 'Mos',
bindingId: 'operator-chat',
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
context,
);
const grant = await service.issueGrant(
{ lease, scopes: ['runtime.send'], ttlMs: 30_000 },
{ ...context, correlationId: 'correlation-grant' },
);
const execute = vi.fn(async (_message: string, leaseContext: ConnectorExecutionContext) => {
return leaseContext.leaseEpoch;
});
const adapter: FencedConnectorAdapter<string, string> = { execute };
await expect(service.executeGrant(grant, 'runtime.send', 'hello', adapter)).resolves.toBe('1');
expect(execute).toHaveBeenCalledOnce();
expect(authorize).toHaveBeenCalledWith(
expect.objectContaining({
action: 'grant.issue',
requestedScopes: ['runtime.send'],
requestedTtlMs: 30_000,
}),
);
expect(execute.mock.calls[0]?.[1]).toMatchObject({
identity: { tenantId: 'tenant-a', logicalAgentId: 'mos' },
bindingId: 'operator-chat',
connectorId: 'pi-worker-a',
});
});
it('normalizes lease-derived policy subjects before authorization', async (): Promise<void> => {
const lease = await service.acquire(
{
logicalAgentId: 'mos',
bindingId: 'operator-chat-policy',
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
{ ...context, correlationId: 'correlation-policy-setup' },
);
const aliasedLease = {
...lease,
identity: { ...lease.identity, logicalAgentId: ' MOS ' },
bindingId: ' Operator-Chat-Policy ',
connectorId: ' PI-Worker-A ',
scopes: [' Runtime.Send '],
leaseEpoch: `00${lease.leaseEpoch}`,
};
await service.heartbeat(aliasedLease, 30_000, {
...context,
correlationId: 'correlation-policy-heartbeat',
});
expect(authorize).toHaveBeenLastCalledWith(
expect.objectContaining({
action: 'lease.heartbeat',
logicalAgentId: 'mos',
bindingId: 'operator-chat-policy',
connectorId: 'pi-worker-a',
requestedScopes: ['runtime.send'],
}),
);
await service.issueGrant(
{ lease: aliasedLease, scopes: [' Runtime.Send '], ttlMs: 1_000 },
{ ...context, correlationId: 'correlation-policy-grant' },
);
expect(authorize).toHaveBeenLastCalledWith(
expect.objectContaining({
action: 'grant.issue',
logicalAgentId: 'mos',
bindingId: 'operator-chat-policy',
connectorId: 'pi-worker-a',
requestedScopes: ['runtime.send'],
}),
);
await service.release(aliasedLease, {
...context,
correlationId: 'correlation-policy-release',
});
expect(authorize).toHaveBeenLastCalledWith(
expect.objectContaining({
action: 'lease.release',
logicalAgentId: 'mos',
bindingId: 'operator-chat-policy',
connectorId: 'pi-worker-a',
requestedScopes: ['runtime.send'],
}),
);
});
it('denies stale, forged, expired, cross-tenant, and cross-binding grants before effects', async (): Promise<void> => {
const bindingId = 'operator-chat-denials';
const current = await service.acquire(
{
logicalAgentId: 'mos',
bindingId,
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
{ ...context, correlationId: 'correlation-denial-setup' },
);
const stale = await service.issueGrant(
{ lease: current, scopes: ['runtime.send'], ttlMs: 30_000 },
{ ...context, correlationId: 'correlation-stale' },
);
await service.takeover(
{
logicalAgentId: 'mos',
bindingId,
connectorId: 'pi-worker-b',
scopes: ['runtime.send'],
ttlMs: 60_000,
expectedEpoch: current.leaseEpoch,
},
{ ...context, correlationId: 'correlation-takeover' },
);
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
await expect(service.executeGrant(stale, 'runtime.send', undefined, adapter)).rejects.toThrow();
const active = await service.current('mos', bindingId, context);
if (!active) throw new Error('active lease fixture is unavailable');
const grant = await service.issueGrant(
{ lease: active, scopes: ['runtime.send'], ttlMs: 1_000 },
{ ...context, correlationId: 'correlation-active' },
);
await expect(
service.executeGrant({ ...grant }, 'runtime.send', undefined, adapter),
).rejects.toThrow();
await expect(
service.executeGrant(
{ ...grant, bindingId: 'other-binding' },
'runtime.send',
undefined,
adapter,
),
).rejects.toThrow();
await expect(
service.issueGrant(
{ lease: active, scopes: ['runtime.send'], ttlMs: 30_000 },
{
actorScope: { userId: 'operator-b', tenantId: 'tenant-b' },
correlationId: 'correlation-cross-tenant',
},
),
).rejects.toThrow();
const crossTenantAudit = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-cross-tenant'));
expect(crossTenantAudit).toHaveLength(1);
expect(crossTenantAudit[0]).toMatchObject({
tenantId: 'tenant-b',
logicalAgentId: 'untrusted',
bindingId: 'untrusted',
connectorId: 'untrusted',
reason: 'policy_denied',
});
vi.setSystemTime(new Date('2026-07-14T17:00:02.000Z'));
await expect(service.executeGrant(grant, 'runtime.send', undefined, adapter)).rejects.toThrow();
expect(adapter.execute).not.toHaveBeenCalled();
});
it('rejects submitted lifecycle scopes that differ from durable authority before policy or mutation', async (): Promise<void> => {
authorize.mockResolvedValue(true);
const heartbeatLease = await service.acquire(
{
logicalAgentId: 'mos',
bindingId: 'operator-chat-heartbeat-scope',
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
{ ...context, correlationId: 'correlation-heartbeat-scope-setup' },
);
const releaseLease = await service.acquire(
{
logicalAgentId: 'mos',
bindingId: 'operator-chat-release-scope',
connectorId: 'pi-worker-a',
scopes: ['runtime.send'],
ttlMs: 60_000,
},
{ ...context, correlationId: 'correlation-release-scope-setup' },
);
const forgedHeartbeat = { ...heartbeatLease, scopes: ['tool.execute'] };
const forgedRelease = { ...releaseLease, scopes: ['tool.execute'] };
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'tool.execute';
});
authorize.mockClear();
await expect(
service.heartbeat(forgedHeartbeat, 30_000, {
...context,
correlationId: 'correlation-heartbeat-scope-forgery',
}),
).rejects.toThrow('Connector authority policy denied');
await expect(
service.release(forgedRelease, {
...context,
correlationId: 'correlation-release-scope-forgery',
}),
).rejects.toThrow('Connector authority policy denied');
expect(authorize).not.toHaveBeenCalled();
const currentHeartbeat = await repository.findCurrent({
identity: heartbeatLease.identity,
bindingId: heartbeatLease.bindingId,
});
const currentRelease = await repository.findCurrent({
identity: releaseLease.identity,
bindingId: releaseLease.bindingId,
});
expect(currentHeartbeat).toMatchObject({
leaseId: heartbeatLease.leaseId,
scopes: ['runtime.send'],
heartbeatAt: heartbeatLease.heartbeatAt,
expiresAt: heartbeatLease.expiresAt,
});
expect(currentRelease).toMatchObject({
leaseId: releaseLease.leaseId,
scopes: ['runtime.send'],
});
expect(currentRelease?.releasedAt).toBeUndefined();
const forgedAudits = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-heartbeat-scope-forgery'));
expect(forgedAudits).toHaveLength(1);
expect(forgedAudits[0]).toMatchObject({
bindingId: heartbeatLease.bindingId,
connectorId: heartbeatLease.connectorId,
event: 'reject',
outcome: 'denied',
reason: 'policy_denied',
});
const forgedReleaseAudits = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-release-scope-forgery'));
expect(forgedReleaseAudits).toHaveLength(1);
expect(forgedReleaseAudits[0]).toMatchObject({
bindingId: releaseLease.bindingId,
connectorId: releaseLease.connectorId,
event: 'reject',
outcome: 'denied',
reason: 'policy_denied',
});
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'runtime.send';
});
await expect(
service.heartbeat(heartbeatLease, 30_000, {
...context,
correlationId: 'correlation-heartbeat-scope-canonical',
}),
).resolves.toMatchObject({ scopes: ['runtime.send'] });
await expect(
service.release(releaseLease, {
...context,
correlationId: 'correlation-release-scope-canonical',
}),
).resolves.toBeUndefined();
});
});
@@ -1,76 +0,0 @@
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
connectorLeaseAuditLog,
createDb,
eq,
logicalAgentConnectorLeases,
type DbHandle,
} from '@mosaicstack/db';
import { ConnectorLeaseCoordinator } from '@mosaicstack/agent';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
const hasPostgres = Boolean(process.env['DATABASE_URL']);
const tenantId = `lease-test-${randomUUID()}`;
const identity = { tenantId, logicalAgentId: 'mos' } as const;
describe.skipIf(!hasPostgres)('ConnectorLeaseRepository real PostgreSQL integration', (): void => {
let handle: DbHandle;
beforeAll((): void => {
handle = createDb(process.env['DATABASE_URL']);
});
afterAll(async (): Promise<void> => {
if (!handle) return;
await handle.db
.delete(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.tenantId, tenantId));
await handle.db
.delete(logicalAgentConnectorLeases)
.where(eq(logicalAgentConnectorLeases.tenantId, tenantId));
await handle.close();
});
it('preserves the exclusive CAS fence across a real pool close/reopen', async (): Promise<void> => {
const command = {
identity,
bindingId: 'operator-chat',
scopes: ['runtime.send'],
ttlMs: 60_000,
} as const;
const firstCoordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
const contenders = await Promise.allSettled([
firstCoordinator.acquire({
...command,
connectorId: 'connector-a',
correlationId: 'postgres-acquire-a',
}),
firstCoordinator.acquire({
...command,
connectorId: 'connector-b',
correlationId: 'postgres-acquire-b',
}),
]);
const acquired = contenders.find((result) => result.status === 'fulfilled');
if (!acquired || acquired.status !== 'fulfilled') throw new Error('no lease contender won');
expect(contenders.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
await handle.close();
handle = createDb(process.env['DATABASE_URL']);
const reopened = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
const persisted = await reopened.current({ identity, bindingId: 'operator-chat' });
expect(persisted).toMatchObject({
leaseId: acquired.value.leaseId,
leaseEpoch: '1',
});
const takeover = await reopened.takeover({
...command,
connectorId: 'connector-c',
correlationId: 'postgres-takeover',
expectedEpoch: acquired.value.leaseEpoch,
});
expect(takeover).toMatchObject({ connectorId: 'connector-c', leaseEpoch: '2' });
});
});
@@ -1,149 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
connectorLeaseAuditLog,
createPgliteDb,
eq,
runPgliteMigrations,
type DbHandle,
} from '@mosaicstack/db';
import { ConnectorLeaseCoordinator, ConnectorLeaseError } from '@mosaicstack/agent';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
function acquireCommand(connectorId: string, correlationId: string) {
return {
identity,
bindingId: 'operator-chat',
connectorId,
scopes: ['runtime.send', 'tool.execute'],
ttlMs: 60_000,
correlationId,
};
}
describe('ConnectorLeaseRepository PostgreSQL semantics', (): void => {
let dataDir: string;
let handle: DbHandle;
let now: Date;
let coordinator: ConnectorLeaseCoordinator;
beforeEach(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-connector-lease-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
now = new Date('2026-07-14T17:00:00.000Z');
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
now: (): Date => now,
});
});
afterEach(async (): Promise<void> => {
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
it('allows only one concurrent contender to acquire a binding', async (): Promise<void> => {
const outcomes = await Promise.allSettled([
coordinator.acquire(acquireCommand('connector-a', 'correlation-a')),
coordinator.acquire(acquireCommand('connector-b', 'correlation-b')),
]);
expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
const rejected = outcomes.find((result) => result.status === 'rejected');
expect(rejected).toMatchObject({
reason: { code: 'lease_held' } satisfies Partial<ConnectorLeaseError>,
});
});
it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
const results = await Promise.allSettled([
coordinator.takeover({
...acquireCommand('connector-b', 'correlation-b'),
expectedEpoch: acquired.leaseEpoch,
}),
coordinator.takeover({
...acquireCommand('connector-c', 'correlation-c'),
expectedEpoch: acquired.leaseEpoch,
}),
]);
const winner = results.find((result) => result.status === 'fulfilled');
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(winner?.status === 'fulfilled' ? winner.value.leaseEpoch : null).toBe('2');
expect(results.find((result) => result.status === 'rejected')).toMatchObject({
reason: { code: 'cas_mismatch' } satisfies Partial<ConnectorLeaseError>,
});
});
it('heartbeats and releases only the current connector epoch', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
now = new Date('2026-07-14T17:00:30.000Z');
const renewed = await coordinator.heartbeat({
lease: acquired,
ttlMs: 120_000,
correlationId: 'correlation-renew',
});
expect(renewed.expiresAt).toBe('2026-07-14T17:02:30.000Z');
await coordinator.release({ lease: renewed, correlationId: 'correlation-release' });
await expect(
coordinator.heartbeat({
lease: renewed,
ttlMs: 120_000,
correlationId: 'correlation-stale',
}),
).rejects.toMatchObject({ code: 'lease_released' } satisfies Partial<ConnectorLeaseError>);
});
it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
await handle.close();
now = new Date('2026-07-14T17:02:00.000Z');
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
now: (): Date => now,
});
await expect(
coordinator.acquire(acquireCommand('connector-b', 'correlation-plain-acquire')),
).rejects.toMatchObject({ code: 'takeover_required' } satisfies Partial<ConnectorLeaseError>);
const recovered = await coordinator.takeover({
...acquireCommand('connector-b', 'correlation-takeover'),
expectedEpoch: acquired.leaseEpoch,
});
expect(recovered).toMatchObject({ connectorId: 'connector-b', leaseEpoch: '2' });
});
it('writes credential-safe lifecycle and rejection audit records', async (): Promise<void> => {
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
await coordinator.heartbeat({
lease: acquired,
ttlMs: 60_000,
correlationId: 'correlation-renew',
});
await expect(
coordinator.acquire(acquireCommand('connector-b', 'correlation-reject')),
).rejects.toBeInstanceOf(ConnectorLeaseError);
const rows = await handle.db
.select()
.from(connectorLeaseAuditLog)
.where(eq(connectorLeaseAuditLog.tenantId, identity.tenantId));
expect(rows.map((row) => row.event)).toEqual(
expect.arrayContaining(['acquire', 'renew', 'reject']),
);
const serialized = JSON.stringify(rows, (_key: string, value: unknown): unknown =>
typeof value === 'bigint' ? value.toString(10) : value,
);
expect(serialized).not.toContain('tool.execute');
expect(serialized).not.toContain('runtime.send');
expect(serialized).not.toMatch(/token|secret|credential/i);
});
});
@@ -1,354 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
and,
connectorLeaseAuditLog,
eq,
gt,
isNull,
logicalAgentConnectorLeases,
sql,
type Db,
} from '@mosaicstack/db';
import { ConnectorLeaseError } from '@mosaicstack/agent';
import type {
ConnectorLease,
ConnectorLeaseAcquireMutation,
ConnectorLeaseAuditEvent,
ConnectorLeaseHeartbeatMutation,
ConnectorLeaseRejectReason,
ConnectorLeaseReleaseMutation,
ConnectorLeaseStore,
ConnectorLeaseTakeoverMutation,
LogicalAgentBinding,
} from '@mosaicstack/types';
import { DB } from '../database/database.module.js';
interface SuccessfulMutation {
readonly ok: true;
readonly lease: ConnectorLease;
}
interface FailedMutation {
readonly ok: false;
readonly reason: ConnectorLeaseRejectReason;
}
type MutationResult = SuccessfulMutation | FailedMutation;
@Injectable()
export class ConnectorLeaseRepository implements ConnectorLeaseStore {
constructor(@Inject(DB) private readonly db: Db) {}
async acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const inserted = await tx
.insert(logicalAgentConnectorLeases)
.values({
leaseId: input.leaseId,
tenantId: input.identity.tenantId,
logicalAgentId: input.identity.logicalAgentId,
bindingId: input.bindingId,
connectorId: input.connectorId,
scopes: [...input.scopes],
leaseEpoch: 1n,
acquiredAt: new Date(input.now),
heartbeatAt: new Date(input.now),
expiresAt: new Date(input.expiresAt),
updatedAt: new Date(input.now),
})
.onConflictDoNothing()
.returning();
const row = inserted[0];
if (row) {
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'acquire'));
return { ok: true, lease };
}
const current = await findRow(tx, input);
if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
const reason: ConnectorLeaseRejectReason =
current && (current.releasedAt || current.expiresAt <= new Date(input.now))
? 'takeover_required'
: 'lease_held';
await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason));
return { ok: false, reason };
},
);
return unwrap(result);
}
async takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const current = await findRow(tx, input);
if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) {
await insertAudit(
tx,
rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'),
);
return { ok: false, reason: 'cas_mismatch' };
}
if (current.expiresAt <= new Date(input.now) && !current.releasedAt) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
const updated = await tx
.update(logicalAgentConnectorLeases)
.set({
leaseId: input.leaseId,
connectorId: input.connectorId,
scopes: [...input.scopes],
leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`,
acquiredAt: new Date(input.now),
heartbeatAt: new Date(input.now),
expiresAt: new Date(input.expiresAt),
releasedAt: null,
updatedAt: new Date(input.now),
})
.where(
and(
bindingPredicate(input),
eq(logicalAgentConnectorLeases.leaseId, current.leaseId),
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)),
),
)
.returning();
const row = updated[0];
if (!row) {
await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch'));
return { ok: false, reason: 'cas_mismatch' };
}
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'takeover'));
return { ok: true, lease };
},
);
return unwrap(result);
}
async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const updated = await tx
.update(logicalAgentConnectorLeases)
.set({
heartbeatAt: new Date(input.now),
expiresAt: new Date(input.expiresAt),
updatedAt: new Date(input.now),
})
.where(
and(
bindingPredicate(input.lease),
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
isNull(logicalAgentConnectorLeases.releasedAt),
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
),
)
.returning();
const row = updated[0];
if (row) {
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'renew'));
return { ok: true, lease };
}
const current = await findRow(tx, input.lease);
const reason = classifyAuthorityFailure(
current ? toLease(current) : null,
input.lease,
input.now,
);
if (reason === 'lease_expired' && current) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
await insertAudit(
tx,
rejectionAudit(
{ ...input.lease, correlationId: input.correlationId, now: input.now },
current ? toLease(current) : null,
reason,
),
);
return { ok: false, reason };
},
);
return unwrap(result);
}
async release(input: ConnectorLeaseReleaseMutation): Promise<void> {
const result: MutationResult = await this.db.transaction(
async (tx): Promise<MutationResult> => {
const updated = await tx
.update(logicalAgentConnectorLeases)
.set({
releasedAt: new Date(input.now),
expiresAt: new Date(input.now),
updatedAt: new Date(input.now),
})
.where(
and(
bindingPredicate(input.lease),
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
isNull(logicalAgentConnectorLeases.releasedAt),
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
),
)
.returning();
const row = updated[0];
if (row) {
const lease = toLease(row);
await insertAudit(tx, lifecycleAudit(input, lease, 'release'));
return { ok: true, lease };
}
const current = await findRow(tx, input.lease);
const reason = classifyAuthorityFailure(
current ? toLease(current) : null,
input.lease,
input.now,
);
if (reason === 'lease_expired' && current) {
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
}
await insertAudit(
tx,
rejectionAudit(
{ ...input.lease, correlationId: input.correlationId, now: input.now },
current ? toLease(current) : null,
reason,
),
);
return { ok: false, reason };
},
);
unwrap(result);
}
async findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
const row = await findRow(this.db, binding);
return row ? toLease(row) : null;
}
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
await insertAudit(this.db, event);
}
}
function unwrap(result: MutationResult): ConnectorLease {
if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason));
return result.lease;
}
function safeErrorMessage(reason: ConnectorLeaseRejectReason): string {
return `Connector lease mutation denied: ${reason}`;
}
function bindingPredicate(binding: LogicalAgentBinding) {
return and(
eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId),
eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId),
eq(logicalAgentConnectorLeases.bindingId, binding.bindingId),
);
}
async function findRow(
db: Pick<Db, 'select'>,
binding: LogicalAgentBinding,
): Promise<typeof logicalAgentConnectorLeases.$inferSelect | null> {
const rows = await db
.select()
.from(logicalAgentConnectorLeases)
.where(bindingPredicate(binding))
.limit(1);
return rows[0] ?? null;
}
function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease {
return Object.freeze({
identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }),
bindingId: row.bindingId,
leaseId: row.leaseId,
connectorId: row.connectorId,
scopes: Object.freeze([...row.scopes]),
leaseEpoch: row.leaseEpoch.toString(10),
acquiredAt: row.acquiredAt.toISOString(),
heartbeatAt: row.heartbeatAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}),
});
}
function classifyAuthorityFailure(
current: ConnectorLease | null,
claimed: ConnectorLease,
now: string,
): ConnectorLeaseRejectReason {
if (!current) return 'lease_missing';
if (current.releasedAt) return 'lease_released';
if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired';
if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch';
return 'connector_mismatch';
}
function lifecycleAudit(
input: { readonly correlationId: string; readonly now: string },
lease: ConnectorLease,
event: Exclude<ConnectorLeaseAuditEvent['event'], 'reject'>,
): ConnectorLeaseAuditEvent {
return {
identity: lease.identity,
bindingId: lease.bindingId,
connectorId: lease.connectorId,
leaseId: lease.leaseId,
leaseEpoch: lease.leaseEpoch,
event,
outcome: 'succeeded',
correlationId: input.correlationId,
occurredAt: input.now,
};
}
function rejectionAudit(
input: {
readonly identity: ConnectorLease['identity'];
readonly bindingId: string;
readonly connectorId: string;
readonly correlationId: string;
readonly now: string;
},
current: ConnectorLease | null,
reason: ConnectorLeaseRejectReason,
): ConnectorLeaseAuditEvent {
return {
identity: input.identity,
bindingId: input.bindingId,
connectorId: input.connectorId,
event: 'reject',
outcome: 'denied',
correlationId: input.correlationId,
occurredAt: input.now,
...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}),
reason,
};
}
async function insertAudit(db: Pick<Db, 'insert'>, event: ConnectorLeaseAuditEvent): Promise<void> {
await db.insert(connectorLeaseAuditLog).values({
tenantId: event.identity.tenantId,
logicalAgentId: event.identity.logicalAgentId,
bindingId: event.bindingId,
connectorId: event.connectorId,
...(event.leaseId ? { leaseId: event.leaseId } : {}),
...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}),
event: event.event,
outcome: event.outcome,
...(event.reason ? { reason: event.reason } : {}),
correlationId: event.correlationId,
occurredAt: new Date(event.occurredAt),
});
}
@@ -1,285 +0,0 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { ConnectorLeaseCoordinator, normalizeConnectorLease } from '@mosaicstack/agent';
import {
normalizeConnectorId,
normalizeConnectorScopes,
normalizeCorrelationId,
normalizeLogicalAgentIdentity,
normalizeLogicalBindingId,
type AcquireConnectorLeaseInput,
type ConnectorExecutionGrant,
type ConnectorLease,
type ConnectorLeaseAuditEvent,
type FencedConnectorAdapter,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
export const CONNECTOR_LEASE_POLICY = Symbol('CONNECTOR_LEASE_POLICY');
export type ConnectorLeasePolicyAction =
| 'lease.acquire'
| 'lease.takeover'
| 'lease.heartbeat'
| 'lease.release'
| 'lease.read'
| 'grant.issue';
export interface ConnectorLeaseRequestContext {
readonly actorScope: ActorTenantScope;
readonly correlationId: string;
}
export interface GatewayConnectorLeaseRequest {
readonly logicalAgentId: string;
readonly bindingId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly ttlMs: number;
}
export interface GatewayConnectorLeaseTakeoverRequest extends GatewayConnectorLeaseRequest {
readonly expectedEpoch: string;
}
export interface GatewayConnectorGrantRequest {
readonly lease: ConnectorLease;
readonly scopes: readonly string[];
readonly ttlMs: number;
}
export interface ConnectorLeasePolicySubject {
readonly action: ConnectorLeasePolicyAction;
readonly actorId: string;
readonly tenantId: string;
readonly logicalAgentId: string;
readonly bindingId: string;
readonly connectorId: string;
readonly requestedScopes: readonly string[];
readonly requestedTtlMs: number | null;
}
export interface ConnectorLeasePolicy {
authorize(subject: ConnectorLeasePolicySubject): Promise<boolean>;
}
/** M1 has no concrete cutover policy: unconfigured production use fails closed. */
@Injectable()
export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy {
async authorize(_subject: ConnectorLeasePolicySubject): Promise<boolean> {
return false;
}
}
/** Gateway-owned policy surface for durable connector authority and fenced effects. */
@Injectable()
export class ConnectorLeaseService {
private readonly coordinator: ConnectorLeaseCoordinator;
constructor(
@Inject(ConnectorLeaseRepository) private readonly repository: ConnectorLeaseRepository,
@Inject(CONNECTOR_LEASE_POLICY) private readonly policy: ConnectorLeasePolicy,
) {
this.coordinator = new ConnectorLeaseCoordinator(repository);
}
async acquire(
request: GatewayConnectorLeaseRequest,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease> {
const command = this.command(request, context);
await this.assertPolicy('lease.acquire', command, context, command.scopes, command.ttlMs);
return this.coordinator.acquire({ ...command, correlationId: this.correlation(context) });
}
async takeover(
request: GatewayConnectorLeaseTakeoverRequest,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease> {
const command = this.command(request, context);
await this.assertPolicy('lease.takeover', command, context, command.scopes, command.ttlMs);
return this.coordinator.takeover({
...command,
expectedEpoch: request.expectedEpoch,
correlationId: this.correlation(context),
});
}
async heartbeat(
lease: ConnectorLease,
ttlMs: number,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease> {
const normalizedLease = normalizeConnectorLease(lease);
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
await this.assertPolicy('lease.heartbeat', durableLease, context, durableLease.scopes, ttlMs);
return this.coordinator.heartbeat({
lease: durableLease,
ttlMs,
correlationId: this.correlation(context),
});
}
async release(lease: ConnectorLease, context: ConnectorLeaseRequestContext): Promise<void> {
const normalizedLease = normalizeConnectorLease(lease);
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
await this.assertPolicy('lease.release', durableLease, context, durableLease.scopes, null);
await this.coordinator.release({
lease: durableLease,
correlationId: this.correlation(context),
});
}
async current(
logicalAgentId: string,
bindingId: string,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease | null> {
const binding = {
identity: normalizeLogicalAgentIdentity({
tenantId: context.actorScope.tenantId,
logicalAgentId,
}),
bindingId: normalizeLogicalBindingId(bindingId),
connectorId: 'gateway',
};
await this.assertPolicy('lease.read', binding, context, [], null);
return this.coordinator.current(binding);
}
async issueGrant(
request: GatewayConnectorGrantRequest,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorExecutionGrant> {
const lease = normalizeConnectorLease(request.lease);
await this.assertTenant(lease, context);
const scopes = normalizeConnectorScopes(request.scopes);
await this.assertPolicy('grant.issue', lease, context, scopes, request.ttlMs);
return this.coordinator.issueGrant({
lease,
scopes,
ttlMs: request.ttlMs,
correlationId: this.correlation(context),
});
}
async executeGrant<TInput, TOutput>(
grant: ConnectorExecutionGrant,
requiredScope: string,
input: TInput,
adapter: FencedConnectorAdapter<TInput, TOutput>,
): Promise<TOutput> {
return this.coordinator.executeGrant(grant, requiredScope, input, adapter);
}
private command(
request: GatewayConnectorLeaseRequest,
context: ConnectorLeaseRequestContext,
): Omit<AcquireConnectorLeaseInput, 'correlationId'> {
return {
identity: normalizeLogicalAgentIdentity({
tenantId: context.actorScope.tenantId,
logicalAgentId: request.logicalAgentId,
}),
bindingId: normalizeLogicalBindingId(request.bindingId),
connectorId: normalizeConnectorId(request.connectorId),
scopes: normalizeConnectorScopes(request.scopes),
ttlMs: request.ttlMs,
};
}
private async assertTenant(
lease: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
context: ConnectorLeaseRequestContext,
): Promise<void> {
if (lease.identity.tenantId !== context.actorScope.tenantId) {
await this.recordPolicyDenial(
{
identity: {
tenantId: context.actorScope.tenantId,
logicalAgentId: 'untrusted',
},
bindingId: 'untrusted',
connectorId: 'untrusted',
},
context,
);
throw new ForbiddenException('Connector authority tenant scope denied');
}
}
private async durableLifecycleLease(
submittedLease: ConnectorLease,
context: ConnectorLeaseRequestContext,
): Promise<ConnectorLease> {
await this.assertTenant(submittedLease, context);
const durableLease = await this.coordinator.current(submittedLease);
if (!durableLease || !hasSameLifecycleAuthority(submittedLease, durableLease)) {
await this.recordPolicyDenial(durableLease ?? submittedLease, context);
throw new ForbiddenException('Connector authority policy denied');
}
return durableLease;
}
private async assertPolicy(
action: ConnectorLeasePolicyAction,
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
context: ConnectorLeaseRequestContext,
requestedScopes: readonly string[],
requestedTtlMs: number | null,
): Promise<void> {
const allowed = await this.policy.authorize({
action,
actorId: context.actorScope.userId,
tenantId: subject.identity.tenantId,
logicalAgentId: subject.identity.logicalAgentId,
bindingId: subject.bindingId,
connectorId: subject.connectorId,
requestedScopes: Object.freeze([...requestedScopes]),
requestedTtlMs,
});
if (!allowed) {
await this.recordPolicyDenial(subject, context);
throw new ForbiddenException('Connector authority policy denied');
}
}
private async recordPolicyDenial(
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
context: ConnectorLeaseRequestContext,
): Promise<void> {
const event: ConnectorLeaseAuditEvent = {
identity: subject.identity,
bindingId: subject.bindingId,
connectorId: subject.connectorId,
event: 'reject',
outcome: 'denied',
reason: 'policy_denied',
correlationId: this.correlation(context),
occurredAt: new Date().toISOString(),
};
await this.repository.recordAudit(event);
}
private correlation(context: ConnectorLeaseRequestContext): string {
return normalizeCorrelationId(context.correlationId);
}
}
function hasSameLifecycleAuthority(
submittedLease: ConnectorLease,
durableLease: ConnectorLease,
): boolean {
return (
submittedLease.identity.tenantId === durableLease.identity.tenantId &&
submittedLease.identity.logicalAgentId === durableLease.identity.logicalAgentId &&
submittedLease.bindingId === durableLease.bindingId &&
submittedLease.leaseId === durableLease.leaseId &&
submittedLease.connectorId === durableLease.connectorId &&
submittedLease.leaseEpoch === durableLease.leaseEpoch &&
submittedLease.scopes.length === durableLease.scopes.length &&
submittedLease.scopes.every((scope: string, index: number): boolean => {
return scope === durableLease.scopes[index];
})
);
}
@@ -1,10 +0,0 @@
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
/** Server-side request for a replay-safe provider message. */
export interface ProviderOutboxDto {
sessionId: string;
idempotencyKey: string;
correlationId: string;
content: string;
context: RuntimeProviderRequestContext;
}
@@ -1,416 +0,0 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova',
sessionId: 'tess-pglite-session',
tenantId: 'tenant-pglite',
ownerId: 'tess-owner',
providerId: 'fleet',
runtimeSessionId: 'nova',
};
describe('DurableSessionRepository', () => {
let dataDir: string | undefined;
let handle: DbHandle;
let previousAuthSecret: string | undefined;
beforeAll(async (): Promise<void> => {
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key';
dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
await seedOwner(handle);
}, 30_000);
beforeEach(async (): Promise<void> => {
await handle.db.execute(sql`DELETE FROM interaction_handoffs`);
await handle.db.execute(sql`DELETE FROM interaction_checkpoints`);
await handle.db.execute(sql`DELETE FROM interaction_inbox`);
await handle.db.execute(sql`DELETE FROM interaction_outbox`);
await handle.db.execute(sql`DELETE FROM interaction_sessions`);
});
afterAll(async (): Promise<void> => {
await handle.close();
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
});
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await beforeRestart.create(IDENTITY);
await beforeRestart.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-before-kill',
correlationId: 'correlation-before-kill',
content: 'resume after a kill',
});
await beforeRestart.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-before-kill',
correlationId: 'correlation-before-kill',
channelId: 'cli',
kind: 'provider.send',
content: 'one response only',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-before-kill',
cursor: 'cursor-before-kill',
summary: 'restart-safe state',
compactionEpoch: 1,
});
await beforeRestart.handoff({
sessionId: IDENTITY.sessionId,
handoffId: 'handoff-before-kill',
destination: 'mos',
correlationId: 'correlation-before-kill',
checkpointId: 'checkpoint-before-kill',
status: 'pending',
});
await beforeRestart.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-after-handoff',
cursor: 'cursor-after-handoff',
summary: 'newer state cannot strand the portable handoff',
compactionEpoch: 2,
});
await handle.close();
handle = createPgliteDb(dataDir!);
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const recovered = await afterRestart.recover(IDENTITY.sessionId);
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
const handled: string[] = [];
const effects: string[] = [];
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
handled.push(entry.idempotencyKey);
});
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
effects.push(entry.idempotencyKey);
});
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
handled.push(entry.idempotencyKey);
});
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
effects.push(entry.idempotencyKey);
});
expect(recovered.identity).toEqual(IDENTITY);
expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' });
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]);
expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' });
expect(handled).toEqual(['inbox-before-kill']);
expect(effects).toEqual(['outbox-before-kill']);
}, 30_000);
it('redacts sensitive durable payloads before persistence', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'redacted-inbox',
correlationId: 'correlation-redaction',
content: 'api_key=super-secret-canary',
});
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'redacted-outbox',
correlationId: 'correlation-redaction',
channelId: 'cli',
kind: 'provider.send',
content: 'email [email protected] api_key=super-secret-canary',
});
await coordinator.checkpoint({
sessionId: IDENTITY.sessionId,
checkpointId: 'redacted-checkpoint',
cursor: 'bearer super-secret-canary',
summary: 'email [email protected]',
compactionEpoch: 0,
});
const snapshot = await coordinator.snapshot(IDENTITY.sessionId);
const [persisted] = await handle.db
.select({ content: interactionInbox.content })
.from(interactionInbox)
.where(eq(interactionInbox.idempotencyKey, 'redacted-inbox'));
expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary');
expect(JSON.stringify(snapshot)).not.toContain('[email protected]');
expect(persisted?.content).not.toContain('super-secret-canary');
expect(persisted?.content).not.toContain('[REDACTED]');
}, 30_000);
it('fails closed when the configured idempotency secret is unavailable', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const secret = process.env['BETTER_AUTH_SECRET'];
delete process.env['BETTER_AUTH_SECRET'];
try {
await expect(
coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'requires-idempotency-secret',
correlationId: 'correlation-secret',
content: 'sensitive payload',
}),
).rejects.toThrow(/required for durable idempotency digests/);
} finally {
if (secret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = secret;
}
}, 30_000);
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const input = {
sessionId: IDENTITY.sessionId,
checkpointId: 'checkpoint-secret-conflict',
cursor: 'api_key=secret-one',
summary: 'bearer secret-one',
compactionEpoch: 1,
};
await coordinator.checkpoint(input);
await expect(
coordinator.checkpoint({
...input,
cursor: 'api_key=secret-two',
summary: 'bearer secret-two',
}),
).rejects.toThrow(/checkpoint identity conflict/);
const [persisted] = await handle.db
.select({
digest: interactionCheckpoints.contentDigest,
cursor: interactionCheckpoints.cursor,
})
.from(interactionCheckpoints)
.where(eq(interactionCheckpoints.checkpointId, input.checkpointId));
expect(persisted?.cursor).not.toContain('secret-one');
expect(persisted?.digest).not.toBe(
createHash('sha256')
.update(JSON.stringify([input.cursor, input.summary]))
.digest('hex'),
);
await coordinator.checkpoint({
...input,
checkpointId: 'checkpoint-delimiter-conflict',
cursor: 'a\u0000b',
summary: 'c',
});
await expect(
coordinator.checkpoint({
...input,
checkpointId: 'checkpoint-delimiter-conflict',
cursor: 'a',
summary: 'b\u0000c',
}),
).rejects.toThrow(/checkpoint identity conflict/);
}, 30_000);
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const inbox = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-secret-conflict',
correlationId: 'correlation-inbox-secret',
content: 'api_key=secret-one',
};
const outbox = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-secret-conflict',
correlationId: 'correlation-outbox-secret',
channelId: 'cli',
kind: 'provider.send',
content: 'api_key=secret-one',
};
await coordinator.receive(inbox);
await coordinator.enqueueOutbox(outbox);
await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow(
/idempotency conflict/,
);
await expect(
coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }),
).rejects.toThrow(/idempotency conflict/);
}, 30_000);
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-conflict',
correlationId: 'correlation-inbox',
content: 'original inbox',
});
await coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'cli',
kind: 'provider.send',
content: 'original outbox',
});
await expect(
coordinator.receive({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'inbox-conflict',
correlationId: 'forged-correlation',
content: 'original inbox',
}),
).rejects.toThrow(/idempotency conflict/);
await expect(
coordinator.enqueueOutbox({
sessionId: IDENTITY.sessionId,
idempotencyKey: 'outbox-conflict',
correlationId: 'correlation-outbox',
channelId: 'forged-channel',
kind: 'provider.send',
content: 'original outbox',
}),
).rejects.toThrow(/idempotency conflict/);
}, 30_000);
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'live-effect',
correlationId: 'correlation-live',
content: 'must not duplicate',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-live',
},
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(input);
expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({
status: 'processing',
});
await service.dispatchProviderOutbox(IDENTITY.sessionId, input);
await expect(
service.recoverProviderSession(IDENTITY.sessionId, {
...input,
context: {
...input.context,
actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' },
},
}),
).rejects.toThrow(/scope or correlation mismatch/);
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }],
});
}, 30_000);
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'mismatch-effect',
correlationId: 'correlation-expected',
content: 'must remain pending',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-expected',
},
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(input);
await expect(
service.dispatchProviderOutbox(IDENTITY.sessionId, {
...input,
correlationId: 'correlation-forged',
context: { ...input.context, correlationId: 'correlation-forged' },
}),
).rejects.toThrow(/scope or correlation mismatch/);
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }],
});
}, 30_000);
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new DurableSessionService(repository, runtimeProviders as never);
const first = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'scoped-effect-one',
correlationId: 'correlation-one',
content: 'first result',
context: {
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
channelId: 'cli',
correlationId: 'correlation-one',
},
};
const second = {
...first,
idempotencyKey: 'scoped-effect-two',
correlationId: 'correlation-two',
content: 'second result',
context: { ...first.context, correlationId: 'correlation-two' },
};
await coordinator.create(IDENTITY);
await service.queueProviderSend(first);
await service.queueProviderSend(second);
await service.dispatchProviderOutbox(IDENTITY.sessionId, first);
expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1);
expect(runtimeProviders.sendMessage).toHaveBeenCalledWith(
IDENTITY.providerId,
IDENTITY.runtimeSessionId,
{ content: 'first result', idempotencyKey: 'scoped-effect-one' },
first.context,
);
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
outbox: [
{ idempotencyKey: 'scoped-effect-one', status: 'delivered' },
{ idempotencyKey: 'scoped-effect-two', status: 'pending' },
],
});
}, 30_000);
});
async function seedOwner(handle: DbHandle): Promise<void> {
await handle.db.execute(sql`
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
VALUES ('tess-owner', 'Tess Owner', '[email protected]', false, now(), now())
`);
}
@@ -1,529 +0,0 @@
import { createHash, createHmac } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
desc,
eq,
interactionCheckpoints,
interactionHandoffs,
interactionInbox,
interactionOutbox,
interactionSessions,
type Db,
} from '@mosaicstack/db';
import { seal, unseal } from '@mosaicstack/auth';
import { redactSensitiveContent } from '@mosaicstack/log';
import type {
DurableCheckpoint,
DurableCheckpointInput,
DurableEnqueueResult,
DurableHandoff,
DurableHandoffInput,
DurableInboxEntry,
DurableInboxInput,
DurableInboxStatus,
DurableOutboxEntry,
DurableOutboxInput,
DurableOutboxStatus,
DurableSessionIdentity,
DurableSessionSnapshot,
DurableSessionStore,
} from '@mosaicstack/agent';
import { DB } from '../database/database.module.js';
@Injectable()
export class DurableSessionRepository implements DurableSessionStore {
constructor(@Inject(DB) private readonly db: Db) {}
async create(identity: DurableSessionIdentity): Promise<void> {
await this.db
.insert(interactionSessions)
.values({
id: identity.sessionId,
agentName: identity.agentName,
tenantId: identity.tenantId,
ownerId: identity.ownerId,
providerId: identity.providerId,
runtimeSessionId: identity.runtimeSessionId,
})
.onConflictDoNothing();
const existing = await this.session(identity.sessionId);
if (!existing || !sameEnrollmentScope(existing, identity)) {
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
}
// A recovered/re-enrolled runtime can receive a new provider session ID;
// the conversation handle and owner scope remain immutable.
if (
existing.providerId !== identity.providerId ||
existing.runtimeSessionId !== identity.runtimeSessionId
) {
await this.db
.update(interactionSessions)
.set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId })
.where(eq(interactionSessions.id, identity.sessionId));
}
}
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
const identity = await this.session(sessionId);
if (!identity) return null;
const [inbox, outbox, checkpoints, handoffs] = await Promise.all([
this.db
.select()
.from(interactionInbox)
.where(eq(interactionInbox.sessionId, sessionId))
.orderBy(asc(interactionInbox.createdAt)),
this.db
.select()
.from(interactionOutbox)
.where(eq(interactionOutbox.sessionId, sessionId))
.orderBy(asc(interactionOutbox.createdAt)),
this.db
.select()
.from(interactionCheckpoints)
.where(eq(interactionCheckpoints.sessionId, sessionId))
.orderBy(
desc(interactionCheckpoints.compactionEpoch),
desc(interactionCheckpoints.createdAt),
)
.limit(1),
this.db
.select()
.from(interactionHandoffs)
.where(eq(interactionHandoffs.sessionId, sessionId))
.orderBy(asc(interactionHandoffs.createdAt)),
]);
const checkpoint = checkpoints[0];
return {
identity,
inbox: inbox.map(toInbox),
outbox: outbox.map(toOutbox),
...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}),
handoffs: handoffs.map(toHandoff),
};
}
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
const digest = contentDigest(input.content);
const record: DurableInboxInput = {
...input,
content: redactSensitiveContent(input.content).content,
};
const inserted = await this.db
.insert(interactionInbox)
.values({
...record,
content: seal(record.content),
contentDigest: digest,
status: 'pending',
})
.onConflictDoNothing()
.returning({ status: interactionInbox.status });
if (inserted[0]) return { accepted: true, status: inserted[0].status };
const existing = await this.db
.select()
.from(interactionInbox)
.where(
and(
eq(interactionInbox.sessionId, input.sessionId),
eq(interactionInbox.idempotencyKey, input.idempotencyKey),
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
const entry = toInbox(existing[0]);
if (
!sameInbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const candidate = await this.db
.select()
.from(interactionInbox)
.where(
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')),
)
.orderBy(asc(interactionInbox.createdAt))
.limit(1);
const entry = candidate[0];
if (!entry) return null;
const claimed = await this.db
.update(interactionInbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending')))
.returning();
if (claimed[0]) return toInbox(claimed[0]);
}
return null;
}
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionInbox)
.set({ status: 'processed', updatedAt: new Date() })
.where(
and(
eq(interactionInbox.sessionId, sessionId),
eq(interactionInbox.idempotencyKey, idempotencyKey),
eq(interactionInbox.status, 'processing'),
),
);
}
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionInbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(
eq(interactionInbox.sessionId, sessionId),
eq(interactionInbox.idempotencyKey, idempotencyKey),
eq(interactionInbox.status, 'processing'),
),
);
}
async enqueueOutbox(
input: DurableOutboxInput,
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
const digest = contentDigest(input.content);
const record: DurableOutboxInput = {
...input,
content: redactSensitiveContent(input.content).content,
};
const inserted = await this.db
.insert(interactionOutbox)
.values({
...record,
content: seal(record.content),
contentDigest: digest,
status: 'pending',
})
.onConflictDoNothing()
.returning({ status: interactionOutbox.status });
if (inserted[0]) return { accepted: true, status: inserted[0].status };
const existing = await this.db
.select()
.from(interactionOutbox)
.where(
and(
eq(interactionOutbox.sessionId, input.sessionId),
eq(interactionOutbox.idempotencyKey, input.idempotencyKey),
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
const entry = toOutbox(existing[0]);
if (
!sameOutbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const candidate = await this.db
.select()
.from(interactionOutbox)
.where(
and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')),
)
.orderBy(asc(interactionOutbox.createdAt))
.limit(1);
const entry = candidate[0];
if (!entry) return null;
const claimed = await this.db
.update(interactionOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending')))
.returning();
if (claimed[0]) return toOutbox(claimed[0]);
}
return null;
}
async claimOutboxByKey(
sessionId: string,
idempotencyKey: string,
): Promise<DurableOutboxEntry | null> {
const claimed = await this.db
.update(interactionOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'pending'),
),
)
.returning();
return claimed[0] ? toOutbox(claimed[0]) : null;
}
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionOutbox)
.set({ status: 'delivered', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'processing'),
),
);
}
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
await this.db
.update(interactionOutbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(
eq(interactionOutbox.sessionId, sessionId),
eq(interactionOutbox.idempotencyKey, idempotencyKey),
eq(interactionOutbox.status, 'processing'),
),
);
}
async checkpoint(input: DurableCheckpointInput): Promise<void> {
// Compute identity before redaction. The persisted digest is keyed so a database
// reader cannot use it as an offline oracle for sensitive cursor/summary values.
const digest = contentDigest(JSON.stringify([input.cursor, input.summary]));
const checkpoint: DurableCheckpointInput = {
...input,
cursor: redactSensitiveContent(input.cursor).content,
summary: redactSensitiveContent(input.summary).content,
};
const inserted = await this.db
.insert(interactionCheckpoints)
.values({
...checkpoint,
contentDigest: digest,
cursor: seal(checkpoint.cursor),
summary: seal(checkpoint.summary),
})
.onConflictDoNothing()
.returning({ checkpointId: interactionCheckpoints.checkpointId });
if (inserted[0]) return;
const existing = await this.db
.select()
.from(interactionCheckpoints)
.where(
and(
eq(interactionCheckpoints.sessionId, input.sessionId),
eq(interactionCheckpoints.checkpointId, input.checkpointId),
),
)
.limit(1);
if (
!existing[0] ||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
) {
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
}
}
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
const checkpoints = await this.db
.select()
.from(interactionCheckpoints)
.where(
and(
eq(interactionCheckpoints.sessionId, sessionId),
eq(interactionCheckpoints.checkpointId, checkpointId),
),
)
.limit(1);
const checkpoint = checkpoints[0];
return checkpoint ? toCheckpoint(checkpoint) : null;
}
async handoff(input: DurableHandoffInput): Promise<void> {
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
if (!checkpoint) {
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
}
const inserted = await this.db
.insert(interactionHandoffs)
.values({ ...input })
.onConflictDoNothing()
.returning({ handoffId: interactionHandoffs.handoffId });
if (inserted[0]) return;
const existing = await this.findHandoff(input.handoffId);
if (!existing || !sameHandoff(existing, input)) {
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
}
}
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
const handoffs = await this.db
.select()
.from(interactionHandoffs)
.where(eq(interactionHandoffs.handoffId, handoffId))
.limit(1);
const handoff = handoffs[0];
return handoff ? toHandoff(handoff) : null;
}
async requeueInFlight(sessionId: string): Promise<void> {
// Inbox handlers are process-local work. A provider outbox claim may have
// reached an external target before a crash, so it is deliberately not
// replayed by generic recovery.
await this.db
.update(interactionInbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')),
);
}
private async session(sessionId: string): Promise<DurableSessionIdentity | null> {
const sessions = await this.db
.select()
.from(interactionSessions)
.where(eq(interactionSessions.id, sessionId))
.limit(1);
const session = sessions[0];
return session
? {
agentName: session.agentName,
sessionId: session.id,
tenantId: session.tenantId,
ownerId: session.ownerId,
providerId: session.providerId,
runtimeSessionId: session.runtimeSessionId,
}
: null;
}
}
function contentDigest(content: string): string {
const secret = process.env['BETTER_AUTH_SECRET'];
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests');
}
return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`;
}
function matchesContentDigest(stored: string, content: string): boolean {
return (
stored === contentDigest(content) ||
stored === createHash('sha256').update(content).digest('hex')
);
}
function matchesCheckpointDigest(stored: string, digest: string): boolean {
// Legacy rows predate any pre-redaction identity and cannot safely prove equality.
// Reject rather than let redaction collapse distinct sensitive checkpoint payloads.
return stored === digest;
}
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
return (
left.agentName === right.agentName &&
left.sessionId === right.sessionId &&
left.tenantId === right.tenantId &&
left.ownerId === right.ownerId
);
}
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.content === right.content
);
}
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
return (
left.sessionId === right.sessionId &&
left.idempotencyKey === right.idempotencyKey &&
left.correlationId === right.correlationId &&
left.channelId === right.channelId &&
left.kind === right.kind &&
left.content === right.content
);
}
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
return (
left.sessionId === right.sessionId &&
left.checkpointId === right.checkpointId &&
left.compactionEpoch === right.compactionEpoch
);
}
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
return (
left.sessionId === right.sessionId &&
left.handoffId === right.handoffId &&
left.destination === right.destination &&
left.correlationId === right.correlationId &&
left.checkpointId === right.checkpointId &&
left.status === right.status
);
}
function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry {
return {
sessionId: row.sessionId,
idempotencyKey: row.idempotencyKey,
correlationId: row.correlationId,
content: unseal(row.content),
status: row.status,
};
}
function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry {
return {
sessionId: row.sessionId,
idempotencyKey: row.idempotencyKey,
correlationId: row.correlationId,
channelId: row.channelId,
kind: row.kind,
content: unseal(row.content),
status: row.status,
};
}
function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint {
return {
sessionId: row.sessionId,
checkpointId: row.checkpointId,
cursor: unseal(row.cursor),
summary: unseal(row.summary),
compactionEpoch: row.compactionEpoch,
};
}
function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff {
return {
sessionId: row.sessionId,
handoffId: row.handoffId,
destination: row.destination,
correlationId: row.correlationId,
checkpointId: row.checkpointId,
status: row.status,
};
}
@@ -1,123 +0,0 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import type { ProviderOutboxDto } from './durable-session.dto.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
* uses composition: raw state methods cannot be injected into channel, CLI, or
* MCP adapters without a server-derived actor/tenant/correlation context.
*/
@Injectable()
export class DurableSessionService {
private readonly coordinator: DurableSessionCoordinator;
constructor(
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
) {
this.coordinator = new DurableSessionCoordinator(repository);
}
/** Enroll a verified runtime session under the stable cross-surface conversation handle. */
async enroll(
identity: DurableSessionIdentity,
context: RuntimeProviderRequestContext,
): Promise<void> {
if (
identity.ownerId !== context.actorScope.userId ||
identity.tenantId !== context.actorScope.tenantId
) {
throw new ForbiddenException('Durable session enrollment scope mismatch');
}
await this.coordinator.create(identity);
}
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(input.sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.enqueueOutbox({
sessionId: input.sessionId,
idempotencyKey: input.idempotencyKey,
correlationId: input.correlationId,
channelId: input.context.channelId,
kind: 'provider.send',
content: input.content,
});
}
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
if (sessionId !== input.sessionId) {
throw new ForbiddenException('Durable outbox session mismatch');
}
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
const pendingEntry = snapshot.outbox.find(
(entry): boolean => entry.idempotencyKey === input.idempotencyKey,
);
if (!pendingEntry) return;
// Validate immutable routing before claiming. A caller with a mismatched
// correlation/channel must not strand a pending external side effect.
this.assertOutboxScope(pendingEntry, input);
await this.coordinator.dispatchOutboxEntry(
sessionId,
input.idempotencyKey,
async (entry): Promise<void> => {
this.assertOutboxScope(entry, input);
await this.runtimeProviders.sendMessage(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
{ content: entry.content, idempotencyKey: entry.idempotencyKey },
input.context,
);
},
);
}
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
sessionId,
content: '',
idempotencyKey: 'read-only',
correlationId: context.correlationId,
context,
});
return snapshot;
}
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.recover(sessionId);
}
private assertOutboxScope(
entry: { kind: string; correlationId: string; channelId: string },
input: ProviderOutboxDto,
): void {
if (
entry.kind !== 'provider.send' ||
entry.correlationId !== input.correlationId ||
entry.channelId !== input.context.channelId
) {
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
}
}
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
if (
input.context.actorScope.userId !== ownerId ||
input.context.actorScope.tenantId !== tenantId ||
input.context.correlationId !== input.correlationId
) {
throw new ForbiddenException('Durable session scope or correlation mismatch');
}
}
}
@@ -1,176 +0,0 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Global, Module } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { HermesRuntimeProvider } from '@mosaicstack/agent';
import { AgentModule } from './agent.module.js';
import { AUTH } from '../auth/auth.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { BRAIN } from '../brain/brain.tokens.js';
import { DB } from '../database/database.module.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
import { GCModule } from '../gc/gc.module.js';
import { LogModule } from '../log/log.module.js';
import { CommandsModule } from '../commands/commands.module.js';
import {
AGENT_RUNTIME_PROVIDER_REGISTRY,
RUNTIME_APPROVAL_VERIFIER,
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
} from './runtime-provider-registry.service.js';
import { DurableSessionService } from './durable-session.service.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
import { RoutingService } from './routing.service.js';
import { RoutingEngineService } from './routing/routing-engine.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' };
@Module({})
class EmptyAgentDependencyModule {}
@Global()
@Module({
providers: [
{
provide: AUTH,
useValue: {
api: {
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
headers.get('cookie') === 'session=trusted'
? { user: authenticatedUser, session: { id: 'session-1' } }
: null,
),
},
},
},
AuthGuard,
{ provide: BRAIN, useValue: {} },
{ provide: DB, useValue: {} },
],
exports: [AUTH, AuthGuard, BRAIN, DB],
})
class AuthenticatedRequestModule {}
/**
* This is deliberately an HTTP test rather than a controller unit test: it
* exercises AgentModule's actual provider factory, Nest DI, and AuthGuard.
*/
describe('Hermes runtime provider reachability', (): void => {
let app: NestFastifyApplication | undefined;
beforeAll(async (): Promise<void> => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const moduleRef = await Test.createTestingModule({
imports: [AuthenticatedRequestModule, AgentModule],
})
.overrideModule(CoordModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(McpClientModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(SkillsModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(GCModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(LogModule)
.useModule(EmptyAgentDependencyModule)
.overrideModule(CommandsModule)
.useModule(EmptyAgentDependencyModule)
.overrideProvider(RuntimeProviderAuditService)
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK)
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
.useValue({ consume: vi.fn().mockResolvedValue(false) })
.overrideProvider(DurableSessionService)
.useValue({})
.overrideProvider(DurableSessionRepository)
.useValue({})
.overrideProvider(AgentService)
.useValue({})
.overrideProvider(ProviderService)
.useValue({})
.overrideProvider(ProviderCredentialsService)
.useValue({})
.overrideProvider(RoutingService)
.useValue({})
.overrideProvider(RoutingEngineService)
.useValue({})
.overrideProvider(SkillLoaderService)
.useValue({})
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async (): Promise<void> => {
await app?.close();
});
it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise<void> => {
if (!app) throw new Error('Nest application did not initialize');
const attachDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/attach',
headers: { 'x-correlation-id': 'correlation-1' },
payload: { mode: 'read' },
});
expect(attachDenied.statusCode).toBe(401);
const sendDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/send',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
payload: {},
});
expect(sendDenied.statusCode).toBe(403);
expect(sendDenied.json()).toMatchObject({
message: 'Content and idempotency key are required',
});
const stopDenied = await app.inject({
method: 'POST',
url: '/api/interaction/Nova/sessions/session-1/stop',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
payload: {},
});
expect(stopDenied.statusCode).toBe(403);
expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' });
});
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
if (!app) throw new Error('Nest application did not initialize');
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider);
const denied = await app.inject({
method: 'GET',
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
headers: { 'x-correlation-id': 'correlation-1' },
});
expect(denied.statusCode).toBe(401);
const response = await app.inject({
method: 'GET',
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
});
@@ -1,46 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
const scope = {
actorId: 'owner-1',
tenantId: 'tenant-1',
channelId: 'cli',
correlationId: 'correlation-1',
};
describe('GatewayHermesRuntimeTransport', () => {
it('preserves a configured path prefix and authenticates the concrete runtime request', async () => {
const fetchFn = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 }));
const transport = new GatewayHermesRuntimeTransport(
'https://runtime.example.test/hermes',
'test-service-token',
fetchFn,
);
await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']);
expect(fetchFn).toHaveBeenCalledWith(
new URL('https://runtime.example.test/hermes/capabilities'),
expect.objectContaining({
headers: expect.objectContaining({
authorization: 'Bearer test-service-token',
'x-mosaic-channel-id': 'cli',
}),
}),
);
});
it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => {
const fetchFn = vi.fn();
const transport = new GatewayHermesRuntimeTransport(
'http://runtime.example.test/hermes',
'test-service-token',
fetchFn,
);
await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS');
expect(fetchFn).not.toHaveBeenCalled();
});
});
@@ -1,121 +0,0 @@
import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent';
import type {
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeMessage,
RuntimeScope,
RuntimeStreamEvent,
} from '@mosaicstack/types';
/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */
export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport {
constructor(
private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(),
private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(),
private readonly fetchFn: typeof fetch = fetch,
) {}
async capabilities(scope: RuntimeScope): Promise<string[]> {
return this.request<string[]>('/capabilities', scope);
}
async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> {
return this.request<{ status: string; detail?: string }>('/health', scope);
}
async sessions(scope: RuntimeScope): Promise<HermesLegacySession[]> {
return this.request<HermesLegacySession[]>('/sessions', scope);
}
async *stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
const params = new URLSearchParams(cursor ? { cursor } : {});
const events = await this.request<RuntimeStreamEvent[]>(
`/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`,
scope,
);
yield* events;
}
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, {
method: 'POST',
body: message,
});
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
return this.request<RuntimeAttachHandle>(
`/sessions/${encodeURIComponent(sessionId)}/attach`,
scope,
{
method: 'POST',
body: { mode },
},
);
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, {
method: 'DELETE',
});
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, {
method: 'POST',
body: { approvalRef },
});
}
private async request<T>(
path: string,
scope: RuntimeScope,
init: { method?: string; body?: unknown } = {},
): Promise<T> {
if (!this.baseUrl || !this.serviceToken) {
throw new Error(
'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport',
);
}
const endpoint = new URL(this.baseUrl);
if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) {
throw new Error('Hermes runtime transport requires HTTPS outside loopback');
}
const response = await this.fetchFn(
new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`),
{
method: init.method ?? 'GET',
headers: {
accept: 'application/json',
authorization: `Bearer ${this.serviceToken}`,
'x-mosaic-actor-id': scope.actorId,
'x-mosaic-tenant-id': scope.tenantId,
'x-mosaic-channel-id': scope.channelId,
'x-correlation-id': scope.correlationId,
...(init.body ? { 'content-type': 'application/json' } : {}),
},
...(init.body ? { body: JSON.stringify(init.body) } : {}),
},
);
if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`);
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
}
function isLoopbackHttp(endpoint: URL): boolean {
return (
endpoint.protocol === 'http:' &&
(endpoint.hostname === 'localhost' ||
endpoint.hostname === '127.0.0.1' ||
endpoint.hostname === '::1')
);
}
@@ -1,263 +0,0 @@
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
import { firstValueFrom } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
RuntimeApprovalDeniedError,
RuntimeProviderService,
} from './runtime-provider-registry.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import { InteractionController } from './interaction.controller.js';
describe('InteractionController', (): void => {
afterEach(() => vi.restoreAllMocks());
it('maps a denied runtime approval to Fastify HTTP 403', () => {
const send = vi.fn();
const status = vi.fn().mockReturnValue({ send });
const response = { status };
const host = { switchToHttp: () => ({ getResponse: () => response }) };
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
expect(status).toHaveBeenCalledWith(403);
expect(send).toHaveBeenCalledWith({
statusCode: 403,
message: 'Runtime termination approval denied',
});
});
it('honors a differently named configured instance without a code change', async () => {
const prior = process.env['MOSAIC_AGENT_NAME'];
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
const controller = new InteractionController(runtime as never, {} as never);
await expect(
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
).resolves.toEqual([]);
await expect(
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
).rejects.toThrow('Interaction agent is not configured');
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prior;
});
it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const registry = createGatewayRuntimeProviderRegistry();
const runtime = new RuntimeProviderService(
registry,
{ record: vi.fn().mockResolvedValue(undefined) },
{ consume: vi.fn().mockResolvedValue(false) },
);
const controller = new InteractionController(runtime, {} as never);
await expect(
controller.transitionalCapabilities(
'Nova',
'runtime.hermes',
{ id: 'owner', tenantId: 'team' },
'corr-1',
),
).resolves.toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
it('rejects a request without the non-simple correlation header', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
'X-Correlation-Id is required',
);
});
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = {
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
};
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.enroll(
'Nova',
'conversation-1',
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
{ id: 'owner', tenantId: 'team' },
'corr-1',
),
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
expect(durable.enroll).toHaveBeenCalledWith(
{
agentName: 'Nova',
sessionId: 'conversation-1',
tenantId: 'team',
ownerId: 'owner',
providerId: 'fleet',
runtimeSessionId: 'runtime-1',
},
expect.objectContaining({ correlationId: 'corr-1' }),
);
});
it('rejects an invalid attach mode before invoking a provider', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
await expect(
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
).rejects.toThrow('Interaction attach mode is invalid');
});
it('resumes a durable session by attaching and streaming its runtime events', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtimeEvent = {
type: 'message.delta' as const,
sessionId: 'runtime-1',
cursor: 'cursor-1',
occurredAt: '2026-07-13T00:00:00.000Z',
content: 'resumed',
};
const runtime = {
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
streamSession: vi.fn(async function* () {
yield runtimeEvent;
}),
};
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
await expect(
firstValueFrom(
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
),
).resolves.toEqual({ data: runtimeEvent });
expect(runtime.attach).toHaveBeenCalledWith(
'fleet',
'runtime-1',
'read',
expect.objectContaining({ correlationId: 'corr-1' }),
);
expect(runtime.streamSession).toHaveBeenCalledWith(
'fleet',
'runtime-1',
undefined,
expect.objectContaining({ correlationId: 'corr-1' }),
);
});
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
resolveSnapshot = resolve;
});
const runtime = { streamSession: vi.fn() };
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
const controller = new InteractionController(runtime as never, durable as never);
const subscription = controller
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
.subscribe();
subscription.unsubscribe();
resolveSnapshot({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(runtime.streamSession).not.toHaveBeenCalled();
});
it.each([
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
[
'session-agent mismatch',
{
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
},
],
])('denies a CLI stop for %s', async (_reason, durable) => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
),
).rejects.toBeDefined();
expect(runtime.terminate).not.toHaveBeenCalled();
});
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = {
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
};
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await expect(
controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
),
).rejects.toThrow('Runtime termination approval denied');
});
it('uses the durable session identity and runtime registry for an approved stop', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
const durable = {
getSnapshot: vi.fn().mockResolvedValue({
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
}),
};
const controller = new InteractionController(runtime as never, durable as never);
await controller.stop(
'Nova',
'durable-1',
{ approvalRef: 'approval-1' },
{ id: 'owner' },
'corr-1',
);
expect(runtime.terminate).toHaveBeenCalledWith(
'fleet',
'runtime-1',
'approval-1',
expect.objectContaining({
correlationId: 'corr-1',
actorScope: { userId: 'owner', tenantId: 'owner' },
}),
);
});
});
@@ -1,293 +0,0 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Sse,
Inject,
Param,
Post,
Query,
UseGuards,
UseFilters,
} from '@nestjs/common';
import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types';
import { Observable } from 'rxjs';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { DurableSessionService } from './durable-session.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Authenticated HTTP boundary for operator interaction clients. Identity is
* selected from deployment configuration, never a client-side command name.
*/
@Controller('api/interaction/:agentName')
@UseGuards(AuthGuard)
@UseFilters(RuntimeApprovalDeniedFilter)
export class InteractionController {
constructor(
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
) {}
@Get('sessions')
async sessions(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.listSessions(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
@Get('transitional-capabilities')
async transitionalCapabilities(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.transitionalCapabilityMatrix(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
@Get('tree')
async tree(
@Param('agentName') agentName: string,
@Query('provider') providerId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
return this.runtime.getSessionTree(
this.requiredProvider(providerId),
this.context(user, correlationId),
);
}
/**
* Bind an existing, authorized runtime session to the stable conversation ID.
* This is the lifecycle boundary where both runtime identifiers are known.
*/
@Post('sessions/:sessionId/enroll')
async enroll(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { providerId?: string; runtimeSessionId?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const providerId = this.requiredProvider(body.providerId ?? '');
const runtimeSessionId = body.runtimeSessionId?.trim();
if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required');
const context = this.context(user, correlationId);
const sessions = await this.runtime.listSessions(providerId, context);
if (!sessions.some((session): boolean => session.id === runtimeSessionId)) {
throw new ForbiddenException('Runtime session is not visible to this actor');
}
await this.durable.enroll(
{
agentName,
sessionId,
tenantId: context.actorScope.tenantId,
ownerId: context.actorScope.userId,
providerId,
runtimeSessionId,
},
context,
);
return { status: 'enrolled', sessionId };
}
@Post('sessions/:sessionId/attach')
async attach(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { mode?: RuntimeAttachMode } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
const mode = body.mode ?? 'read';
if (mode !== 'read' && mode !== 'control') {
throw new ForbiddenException('Interaction attach mode is invalid');
}
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
return this.runtime.attach(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
mode,
context,
);
}
@Sse('sessions/:sessionId/stream')
stream(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Query('cursor') cursor: string | undefined,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Observable<{ data: RuntimeStreamEvent }> {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
return new Observable((subscriber) => {
let iterator: AsyncIterator<RuntimeStreamEvent> | undefined;
let cancelled = false;
void (async (): Promise<void> => {
try {
const snapshot = await this.durable.getSnapshot(sessionId, context);
if (cancelled || subscriber.closed) return;
this.assertSessionAgent(snapshot.identity.agentName, agentName);
iterator = this.runtime
.streamSession(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
cursor?.trim() || undefined,
context,
)
[Symbol.asyncIterator]();
if (cancelled || subscriber.closed) {
await iterator.return?.();
return;
}
while (!cancelled && !subscriber.closed) {
const next = await iterator.next();
if (next.done || cancelled || subscriber.closed) break;
subscriber.next({ data: next.value });
}
if (!subscriber.closed) subscriber.complete();
} catch (error: unknown) {
if (!subscriber.closed) subscriber.error(error);
}
})();
return (): void => {
cancelled = true;
void iterator?.return?.();
};
});
}
@Post('sessions/:sessionId/send')
async send(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { content?: string; idempotencyKey?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
throw new ForbiddenException('Content and idempotency key are required');
}
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
const input = {
sessionId,
content: body.content,
idempotencyKey: body.idempotencyKey,
correlationId: context.correlationId,
context,
};
await this.durable.queueProviderSend(input);
await this.durable.dispatchProviderOutbox(sessionId, input);
return { status: 'queued', sessionId };
}
@Post('sessions/:sessionId/stop')
async stop(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@Body() body: { approvalRef?: string } = {},
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
if (!body.approvalRef?.trim())
throw new ForbiddenException('Exact-action approval is required');
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
await this.runtime.terminate(
snapshot.identity.providerId,
snapshot.identity.runtimeSessionId,
body.approvalRef,
context,
);
return { status: 'stopped', sessionId };
}
@Post('sessions/:sessionId/recover')
async recover(
@Param('agentName') agentName: string,
@Param('sessionId') sessionId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
) {
this.assertConfiguredAgent(agentName);
const context = this.context(user, correlationId);
const snapshot = await this.durable.getSnapshot(sessionId, context);
this.assertSessionAgent(snapshot.identity.agentName, agentName);
await this.durable.recoverProviderSession(sessionId, {
sessionId,
content: '',
idempotencyKey: `recovery:${context.correlationId}`,
correlationId: context.correlationId,
context,
});
return { status: 'recovered', sessionId };
}
private context(
user: AuthenticatedUserLike,
correlationId?: string,
): RuntimeProviderRequestContext {
const requestCorrelationId = correlationId?.trim();
// This non-simple request header is mandatory for mutations. Browser
// cross-origin requests cannot set it without a CORS preflight, and the
// gateway's allowlist rejects untrusted origins before the handler runs.
if (!requestCorrelationId) {
throw new ForbiddenException('X-Correlation-Id is required');
}
return {
actorScope: scopeFromUser(user),
channelId: 'cli',
correlationId: requestCorrelationId,
};
}
private assertConfiguredAgent(agentName: string): void {
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!configured || configured !== agentName) {
throw new ForbiddenException('Interaction agent is not configured for this request');
}
}
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
if (sessionAgentName !== agentName)
throw new ForbiddenException('Interaction session identity mismatch');
}
private requiredProvider(providerId: string): string {
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
return providerId;
}
}
@@ -1,204 +0,0 @@
import type { ModelCapability } from '@mosaicstack/types';
/**
* Comprehensive capability matrix for all target models.
* Cost fields are optional and will be filled in when real pricing data is available.
*/
export const MODEL_CAPABILITIES: ModelCapability[] = [
{
id: 'claude-opus-4-6',
provider: 'anthropic',
displayName: 'Claude Opus 4.6',
tier: 'premium',
contextWindow: 200000,
maxOutputTokens: 32000,
capabilities: {
tools: true,
vision: true,
streaming: true,
reasoning: true,
embedding: false,
},
},
{
id: 'claude-sonnet-4-6',
provider: 'anthropic',
displayName: 'Claude Sonnet 4.6',
tier: 'standard',
contextWindow: 200000,
maxOutputTokens: 16000,
capabilities: {
tools: true,
vision: true,
streaming: true,
reasoning: true,
embedding: false,
},
},
{
id: 'claude-haiku-4-5',
provider: 'anthropic',
displayName: 'Claude Haiku 4.5',
tier: 'cheap',
contextWindow: 200000,
maxOutputTokens: 8192,
capabilities: {
tools: true,
vision: true,
streaming: true,
reasoning: false,
embedding: false,
},
},
{
id: 'codex-gpt-5.4',
provider: 'openai',
displayName: 'Codex gpt-5.4',
tier: 'premium',
contextWindow: 128000,
maxOutputTokens: 16384,
capabilities: {
tools: true,
vision: true,
streaming: true,
reasoning: true,
embedding: false,
},
},
{
id: 'glm-5',
provider: 'zai',
displayName: 'GLM-5',
tier: 'standard',
contextWindow: 128000,
maxOutputTokens: 8192,
capabilities: {
tools: true,
vision: false,
streaming: true,
reasoning: false,
embedding: false,
},
},
{
id: 'llama3.2',
provider: 'ollama',
displayName: 'llama3.2',
tier: 'local',
contextWindow: 128000,
maxOutputTokens: 8192,
capabilities: {
tools: true,
vision: false,
streaming: true,
reasoning: false,
embedding: false,
},
},
{
id: 'codellama',
provider: 'ollama',
displayName: 'codellama',
tier: 'local',
contextWindow: 16000,
maxOutputTokens: 4096,
capabilities: {
tools: true,
vision: false,
streaming: true,
reasoning: false,
embedding: false,
},
},
{
id: 'mistral',
provider: 'ollama',
displayName: 'mistral',
tier: 'local',
contextWindow: 32000,
maxOutputTokens: 8192,
capabilities: {
tools: true,
vision: false,
streaming: true,
reasoning: false,
embedding: false,
},
},
{
id: 'nomic-embed-text',
provider: 'ollama',
displayName: 'nomic-embed-text',
tier: 'local',
contextWindow: 8192,
maxOutputTokens: 0,
capabilities: {
tools: false,
vision: false,
streaming: false,
reasoning: false,
embedding: true,
},
},
{
id: 'mxbai-embed-large',
provider: 'ollama',
displayName: 'mxbai-embed-large',
tier: 'local',
contextWindow: 8192,
maxOutputTokens: 0,
capabilities: {
tools: false,
vision: false,
streaming: false,
reasoning: false,
embedding: true,
},
},
];
/**
* Look up a model by its ID.
* Returns undefined if the model is not found.
*/
export function getModelCapability(modelId: string): ModelCapability | undefined {
return MODEL_CAPABILITIES.find((m) => m.id === modelId);
}
/**
* Find models matching a partial capability filter.
* All provided filter keys must match for a model to be included.
*/
export function findModelsByCapability(
filter: Partial<Pick<ModelCapability, 'tier' | 'provider'>> & {
capabilities?: Partial<ModelCapability['capabilities']>;
},
): ModelCapability[] {
return MODEL_CAPABILITIES.filter((model) => {
if (filter.tier !== undefined && model.tier !== filter.tier) return false;
if (filter.provider !== undefined && model.provider !== filter.provider) return false;
if (filter.capabilities) {
for (const [key, value] of Object.entries(filter.capabilities) as [
keyof ModelCapability['capabilities'],
boolean,
][]) {
if (model.capabilities[key] !== value) return false;
}
}
return true;
});
}
/**
* Get all models for a specific provider.
*/
export function getModelsByProvider(provider: string): ModelCapability[] {
return MODEL_CAPABILITIES.filter((m) => m.provider === provider);
}
/**
* Get the full list of all known models.
*/
export function getAllModels(): ModelCapability[] {
return MODEL_CAPABILITIES;
}

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