Adds a configured-name-driven interaction CLI (chat, status, sessions, tree, attach, send, stop, health, recover) and authenticated gateway boundary. Session mutations derive actor scope server-side, use durable identity/recovery, route through the provider registry, and require exact-action approval for stop.
Name-as-config: command surface is mosaic interaction; the instance is supplied by --agent or MOSAIC_AGENT_NAME. No instance-specific command literal is hardcoded; tests cover Nova.
Verification: fresh node_modules install; pnpm turbo run typecheck lint test --force; pnpm typecheck; pnpm lint; pnpm format:check; pnpm test.
Refs #709
Task: TESS-M3-002 / TESS-CLI-001
Adds a configured-name-driven interaction CLI (chat, status, sessions, tree, attach, send, stop, health, recover) and authenticated gateway boundary. Session mutations derive actor scope server-side, use durable identity/recovery, route through the provider registry, and require exact-action approval for stop.
Name-as-config: command surface is `mosaic interaction`; the instance is supplied by --agent or MOSAIC_AGENT_NAME. No instance-specific command literal is hardcoded; tests cover Nova.
Verification: fresh node_modules install; pnpm turbo run typecheck lint test --force; pnpm typecheck; pnpm lint; pnpm format:check; pnpm test.
Reviewer: [W-jarvis:reviewer-sonnet] (independent, non-author of LANE diskhygiene-terra)
Head reviewed: de74e46a0640c44c6b7294c24669496980761c92 (confirmed via API immediately before review and unchanged throughout)
1. Subcommand surface — VERIFIED
All nine required verbs are registered and each does real work against the gateway API (no stubs):
interaction.ts:187recover <sessionId> → recoverInteractionSession
Test packages/mosaic/src/commands/interaction.test.ts:6-21 asserts exactly these 9 subcommands are registered.
Non-blocking nit: interaction.ts:120-143 builds const send = … then immediately does void send; purely to silence an unused-var lint rule — harmless but a little odd; a plain statement without the binding would read cleaner.
2. Reuse of merged M1/M2 (no re-invention) — VERIFIED
recover → apps/gateway/src/agent/interaction.controller.ts:137-156 calls TessDurableSessionService.recoverProviderSession, which calls the pre-existing DurableSessionCoordinator.recover (tess-durable-session.service.ts:79-83) — the M2 durable coordinator, not a new mechanism.
attach → interaction.controller.ts:63-85 obtains identity via durable.getSnapshot (new thin wrapper added in this PR at tess-durable-session.service.ts:67-77, itself built from the pre-existing coordinator.snapshot + assertScope) then calls RuntimeProviderService.attach, the same registry execute() path used by every other runtime verb (runtime-provider-registry.service.ts:216-231).
send/stop → send (interaction.controller.ts:87-112) uses queueProviderSend + dispatchProviderOutbox (pre-existing M2 outbox/idempotency path, tess-durable-session.service.ts:26-63), which internally calls RuntimeProviderService.sendMessage. stop (interaction.controller.ts:114-135) calls RuntimeProviderService.terminate, which internally consume()s a durable exact-action approval (runtime-provider-registry.service.ts:251-276) before ever reaching the provider — a hard 403 (ForbiddenException('Exact-action approval is required'), interaction.controller.ts:123-124) if --approval is missing. No ad-hoc bypass found; send is not gated by approval, which is consistent with the pre-existing registry design (only terminate consumes an approval token) and is not something this PR changed.
Actor-scope/session-ownership binding (TESS-SEC-003): every mutating/read handler calls assertConfiguredAgent then durable.getSnapshot/assertSessionAgent before touching the runtime (interaction.controller.ts:42,56,71,78,95,101,122,127,144,147), and getSnapshot/queueProviderSend/dispatchProviderOutbox all funnel through the existing assertScope check comparing context.actorScope to the durable session's ownerId/tenantId (tess-durable-session.service.ts:88-96).
3. Credential-safe status/health — VERIFIED
status (interaction.ts:64-69) hits the pre-existing /api/providers/status endpoint, unmodified by this PR (apps/gateway/src/agent/providers.controller.ts:38-48), which already reduces provider errors to a stable errorCode and exposes effectivePolicy via getEffectivePolicyStatus() — the M2-001 credential-safe pattern, reused rather than reinvented.
health (interaction.ts:73-77) hits the pre-existing global /health/ready (apps/gateway/src/health/health.controller.ts:11-14), which returns only {status:'ready'} — no config/credential surface by construction.
Grepped all new/changed files (interaction.ts, interaction.test.ts, gateway-api.ts additions, interaction.controller.ts) for secret/token/credential literals — none found; only session cookies are forwarded as opaque headers, never logged or printed.
4. NAME-AS-CONFIG invariant — VERIFIED
packages/mosaic/src/commands/interaction.ts:21-25 (configuredAgentName) derives the instance identity solely from --agent or MOSAIC_AGENT_NAME; it throws if neither is set. No literal tess command or identity appears anywhere in the new CLI/controller code.
Server-side enforcement is symmetric: apps/gateway/src/agent/interaction.controller.ts:176-181 (assertConfiguredAgent) rejects any request whose URL :agentName doesn't match the server's own MOSAIC_AGENT_NAME env var — this is what prevents a client from spoofing a different configured identity, not a hardcoded name check.
Explicit test proving a differently-named instance works with zero code change: apps/gateway/src/agent/interaction.controller.test.ts:7-22 sets MOSAIC_AGENT_NAME='Nova', calls controller.sessions('Nova', …) and gets a real result, then calls controller.sessions('Other', …) and gets rejected — proving the exact generic/config-driven behavior required, using a non-tess instance name.
Grep audit: grep -in tess packages/mosaic/src/commands/interaction.ts packages/mosaic/src/commands/interaction.test.ts packages/mosaic/src/tui/gateway-api.ts → zero hits. In apps/gateway/src/agent/interaction.controller.ts the only hits are the imported class name TessDurableSessionService (a pre-existing M2 service/class name, not an identity/command literal or config default — the controller never compares agentName to 'tess' or any hardcoded string). No FAIL condition found.
5. Quality gate — VERIFIED
Ran directly in an isolated worktree at the reviewed head (after building the workspace's transitive TS project deps, which is an environment/build-order prerequisite unrelated to this PR's content):
pnpm --filter @mosaicstack/gateway exec vitest run → 572 passed, 11 skipped (integration tests requiring live services), 0 failed (47 files), including the new src/agent/interaction.controller.test.ts (4/4 passed).
pnpm --filter @mosaicstack/mosaic exec vitest run → 640 passed, 0 failed (47 files), including the new src/commands/interaction.test.ts (1/1 passed).
tsc --noEmit clean for both @mosaicstack/gateway and @mosaicstack/mosaic.
eslint clean on all touched/added files in both packages.
CI (ci/woodpecker/pr/ci) state at head de74e46a… = success (https://ci.mosaicstack.dev/repos/47/pipeline/1735/1), confirmed via the Gitea status API immediately before posting this verdict, head unchanged since review start.
chat and send are functionally identical today (both call sendInteractionMessage); fine per the required subcommand list, but if they're meant to diverge later (e.g. chat adding conversational framing) it's worth a code comment noting the intentional duplication.
VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer-sonnet] head de74e46a06
## Independent review — PR #731 `feat(tess): add generic interaction CLI`
Reviewer: [W-jarvis:reviewer-sonnet] (independent, non-author of LANE diskhygiene-terra)
Head reviewed: `de74e46a0640c44c6b7294c24669496980761c92` (confirmed via API immediately before review and unchanged throughout)
### 1. Subcommand surface — VERIFIED
All nine required verbs are registered and each does real work against the gateway API (no stubs):
- `packages/mosaic/src/commands/interaction.ts:64` `status` → `fetchInteractionStatus`
- `interaction.ts:73` `health` → `fetchInteractionHealth`
- `interaction.ts:80` `sessions <provider>` → `fetchInteractionSessions`
- `interaction.ts:94` `tree <provider>` → `fetchInteractionTree`
- `interaction.ts:106` `attach <sessionId>` → `attachInteractionSession`
- `interaction.ts:121` `send <sessionId> <message>` → `sendInteractionMessage`
- `interaction.ts:146` `chat <sessionId> <message>` → `sendInteractionMessage` (conversational alias of `send`, same real dispatch path — not a stub)
- `interaction.ts:170` `stop <sessionId>` → `stopInteractionSession`, `--approval` required
- `interaction.ts:187` `recover <sessionId>` → `recoverInteractionSession`
Test `packages/mosaic/src/commands/interaction.test.ts:6-21` asserts exactly these 9 subcommands are registered.
Non-blocking nit: `interaction.ts:120-143` builds `const send = …` then immediately does `void send;` purely to silence an unused-var lint rule — harmless but a little odd; a plain statement without the binding would read cleaner.
### 2. Reuse of merged M1/M2 (no re-invention) — VERIFIED
- `recover` → `apps/gateway/src/agent/interaction.controller.ts:137-156` calls `TessDurableSessionService.recoverProviderSession`, which calls the pre-existing `DurableSessionCoordinator.recover` (`tess-durable-session.service.ts:79-83`) — the M2 durable coordinator, not a new mechanism.
- `attach` → `interaction.controller.ts:63-85` obtains identity via `durable.getSnapshot` (new thin wrapper added in this PR at `tess-durable-session.service.ts:67-77`, itself built from the pre-existing `coordinator.snapshot` + `assertScope`) then calls `RuntimeProviderService.attach`, the same registry `execute()` path used by every other runtime verb (`runtime-provider-registry.service.ts:216-231`).
- `send`/`stop` → `send` (`interaction.controller.ts:87-112`) uses `queueProviderSend` + `dispatchProviderOutbox` (pre-existing M2 outbox/idempotency path, `tess-durable-session.service.ts:26-63`), which internally calls `RuntimeProviderService.sendMessage`. `stop` (`interaction.controller.ts:114-135`) calls `RuntimeProviderService.terminate`, which internally `consume()`s a durable exact-action approval (`runtime-provider-registry.service.ts:251-276`) before ever reaching the provider — a hard 403 (`ForbiddenException('Exact-action approval is required')`, `interaction.controller.ts:123-124`) if `--approval` is missing. No ad-hoc bypass found; `send` is not gated by approval, which is consistent with the pre-existing registry design (only `terminate` consumes an approval token) and is not something this PR changed.
- Actor-scope/session-ownership binding (TESS-SEC-003): every mutating/read handler calls `assertConfiguredAgent` then `durable.getSnapshot`/`assertSessionAgent` before touching the runtime (`interaction.controller.ts:42,56,71,78,95,101,122,127,144,147`), and `getSnapshot`/`queueProviderSend`/`dispatchProviderOutbox` all funnel through the existing `assertScope` check comparing `context.actorScope` to the durable session's `ownerId`/`tenantId` (`tess-durable-session.service.ts:88-96`).
### 3. Credential-safe status/health — VERIFIED
- `status` (`interaction.ts:64-69`) hits the pre-existing `/api/providers/status` endpoint, unmodified by this PR (`apps/gateway/src/agent/providers.controller.ts:38-48`), which already reduces provider errors to a stable `errorCode` and exposes `effectivePolicy` via `getEffectivePolicyStatus()` — the M2-001 credential-safe pattern, reused rather than reinvented.
- `health` (`interaction.ts:73-77`) hits the pre-existing global `/health/ready` (`apps/gateway/src/health/health.controller.ts:11-14`), which returns only `{status:'ready'}` — no config/credential surface by construction.
- Grepped all new/changed files (`interaction.ts`, `interaction.test.ts`, `gateway-api.ts` additions, `interaction.controller.ts`) for secret/token/credential literals — none found; only session cookies are forwarded as opaque headers, never logged or printed.
### 4. NAME-AS-CONFIG invariant — VERIFIED
- `packages/mosaic/src/commands/interaction.ts:21-25` (`configuredAgentName`) derives the instance identity solely from `--agent` or `MOSAIC_AGENT_NAME`; it throws if neither is set. No literal `tess` command or identity appears anywhere in the new CLI/controller code.
- Server-side enforcement is symmetric: `apps/gateway/src/agent/interaction.controller.ts:176-181` (`assertConfiguredAgent`) rejects any request whose URL `:agentName` doesn't match the server's own `MOSAIC_AGENT_NAME` env var — this is what prevents a client from spoofing a different configured identity, not a hardcoded name check.
- Explicit test proving a differently-named instance works with **zero code change**: `apps/gateway/src/agent/interaction.controller.test.ts:7-22` sets `MOSAIC_AGENT_NAME='Nova'`, calls `controller.sessions('Nova', …)` and gets a real result, then calls `controller.sessions('Other', …)` and gets rejected — proving the exact generic/config-driven behavior required, using a non-`tess` instance name.
- Grep audit: `grep -in tess packages/mosaic/src/commands/interaction.ts packages/mosaic/src/commands/interaction.test.ts packages/mosaic/src/tui/gateway-api.ts` → zero hits. In `apps/gateway/src/agent/interaction.controller.ts` the only hits are the imported class name `TessDurableSessionService` (a pre-existing M2 service/class name, not an identity/command literal or config default — the controller never compares `agentName` to `'tess'` or any hardcoded string). No FAIL condition found.
### 5. Quality gate — VERIFIED
Ran directly in an isolated worktree at the reviewed head (after building the workspace's transitive TS project deps, which is an environment/build-order prerequisite unrelated to this PR's content):
- `pnpm --filter @mosaicstack/gateway exec vitest run` → **572 passed, 11 skipped (integration tests requiring live services), 0 failed** (47 files), including the new `src/agent/interaction.controller.test.ts` (4/4 passed).
- `pnpm --filter @mosaicstack/mosaic exec vitest run` → **640 passed, 0 failed** (47 files), including the new `src/commands/interaction.test.ts` (1/1 passed).
- `tsc --noEmit` clean for both `@mosaicstack/gateway` and `@mosaicstack/mosaic`.
- `eslint` clean on all touched/added files in both packages.
- CI (`ci/woodpecker/pr/ci`) state at head `de74e46a…` = **success** (https://ci.mosaicstack.dev/repos/47/pipeline/1735/1), confirmed via the Gitea status API immediately before posting this verdict, head unchanged since review start.
### Blocking findings
None.
### Non-blocking follow-ups (do not gate this PR)
- `interaction.ts:143` (`void send;`) — cosmetic; consider dropping the unused intermediate binding.
- `chat` and `send` are functionally identical today (both call `sendInteractionMessage`); fine per the required subcommand list, but if they're meant to diverge later (e.g. `chat` adding conversational framing) it's worth a code comment noting the intentional duplication.
VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer-sonnet] head de74e46a0640c44c6b7294c24669496980761c92
VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer] head de74e46a06
Evidence verified at exact head de74e46a0640c44c6b7294c24669496980761c92:
Woodpecker PR pipeline 1735 is terminal green on this commit; steps ci-postgres, install, sanitization, typecheck, lint, format, and test succeeded.
Generic mosaic interaction command registers required verbs: chat/status/sessions/tree/attach/send/stop/health/recover; instance identity is data via --agent or MOSAIC_AGENT_NAME.
Gateway interaction boundary derives actor/tenant scope from @CurrentUser()/scopeFromUser, requires X-Correlation-Id, validates configured agent identity, and checks durable session identity before attach/send/stop/recover.
Send/recover use durable session service; attach/sessions/tree/stop route through the runtime provider registry; stop requires an approval ref and delegates to the existing exact-action runtime approval path.
Status/health surfaces use credential-safe endpoints; diff contains no live credential material.
No merge performed by reviewer.
Marker: ROR-731-APPROVE-de74e46a
VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer] head de74e46a0640c44c6b7294c24669496980761c92
Evidence verified at exact head `de74e46a0640c44c6b7294c24669496980761c92`:
- Woodpecker PR pipeline `1735` is terminal green on this commit; steps `ci-postgres`, `install`, `sanitization`, `typecheck`, `lint`, `format`, and `test` succeeded.
- Generic `mosaic interaction` command registers required verbs: chat/status/sessions/tree/attach/send/stop/health/recover; instance identity is data via `--agent` or `MOSAIC_AGENT_NAME`.
- Gateway interaction boundary derives actor/tenant scope from `@CurrentUser()`/`scopeFromUser`, requires `X-Correlation-Id`, validates configured agent identity, and checks durable session identity before attach/send/stop/recover.
- Send/recover use durable session service; attach/sessions/tree/stop route through the runtime provider registry; stop requires an approval ref and delegates to the existing exact-action runtime approval path.
- Status/health surfaces use credential-safe endpoints; diff contains no live credential material.
No merge performed by reviewer.
Marker: ROR-731-APPROVE-de74e46a
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Refs #709
Task: TESS-M3-002 / TESS-CLI-001
Adds a configured-name-driven interaction CLI (chat, status, sessions, tree, attach, send, stop, health, recover) and authenticated gateway boundary. Session mutations derive actor scope server-side, use durable identity/recovery, route through the provider registry, and require exact-action approval for stop.
Name-as-config: command surface is
mosaic interaction; the instance is supplied by --agent or MOSAIC_AGENT_NAME. No instance-specific command literal is hardcoded; tests cover Nova.Verification: fresh node_modules install; pnpm turbo run typecheck lint test --force; pnpm typecheck; pnpm lint; pnpm format:check; pnpm test.
Independent review — PR #731
feat(tess): add generic interaction CLIReviewer: [W-jarvis:reviewer-sonnet] (independent, non-author of LANE diskhygiene-terra)
Head reviewed:
de74e46a0640c44c6b7294c24669496980761c92(confirmed via API immediately before review and unchanged throughout)1. Subcommand surface — VERIFIED
All nine required verbs are registered and each does real work against the gateway API (no stubs):
packages/mosaic/src/commands/interaction.ts:64status→fetchInteractionStatusinteraction.ts:73health→fetchInteractionHealthinteraction.ts:80sessions <provider>→fetchInteractionSessionsinteraction.ts:94tree <provider>→fetchInteractionTreeinteraction.ts:106attach <sessionId>→attachInteractionSessioninteraction.ts:121send <sessionId> <message>→sendInteractionMessageinteraction.ts:146chat <sessionId> <message>→sendInteractionMessage(conversational alias ofsend, same real dispatch path — not a stub)interaction.ts:170stop <sessionId>→stopInteractionSession,--approvalrequiredinteraction.ts:187recover <sessionId>→recoverInteractionSessionTest
packages/mosaic/src/commands/interaction.test.ts:6-21asserts exactly these 9 subcommands are registered.Non-blocking nit:
interaction.ts:120-143buildsconst send = …then immediately doesvoid send;purely to silence an unused-var lint rule — harmless but a little odd; a plain statement without the binding would read cleaner.2. Reuse of merged M1/M2 (no re-invention) — VERIFIED
recover→apps/gateway/src/agent/interaction.controller.ts:137-156callsTessDurableSessionService.recoverProviderSession, which calls the pre-existingDurableSessionCoordinator.recover(tess-durable-session.service.ts:79-83) — the M2 durable coordinator, not a new mechanism.attach→interaction.controller.ts:63-85obtains identity viadurable.getSnapshot(new thin wrapper added in this PR attess-durable-session.service.ts:67-77, itself built from the pre-existingcoordinator.snapshot+assertScope) then callsRuntimeProviderService.attach, the same registryexecute()path used by every other runtime verb (runtime-provider-registry.service.ts:216-231).send/stop→send(interaction.controller.ts:87-112) usesqueueProviderSend+dispatchProviderOutbox(pre-existing M2 outbox/idempotency path,tess-durable-session.service.ts:26-63), which internally callsRuntimeProviderService.sendMessage.stop(interaction.controller.ts:114-135) callsRuntimeProviderService.terminate, which internallyconsume()s a durable exact-action approval (runtime-provider-registry.service.ts:251-276) before ever reaching the provider — a hard 403 (ForbiddenException('Exact-action approval is required'),interaction.controller.ts:123-124) if--approvalis missing. No ad-hoc bypass found;sendis not gated by approval, which is consistent with the pre-existing registry design (onlyterminateconsumes an approval token) and is not something this PR changed.assertConfiguredAgentthendurable.getSnapshot/assertSessionAgentbefore touching the runtime (interaction.controller.ts:42,56,71,78,95,101,122,127,144,147), andgetSnapshot/queueProviderSend/dispatchProviderOutboxall funnel through the existingassertScopecheck comparingcontext.actorScopeto the durable session'sownerId/tenantId(tess-durable-session.service.ts:88-96).3. Credential-safe status/health — VERIFIED
status(interaction.ts:64-69) hits the pre-existing/api/providers/statusendpoint, unmodified by this PR (apps/gateway/src/agent/providers.controller.ts:38-48), which already reduces provider errors to a stableerrorCodeand exposeseffectivePolicyviagetEffectivePolicyStatus()— the M2-001 credential-safe pattern, reused rather than reinvented.health(interaction.ts:73-77) hits the pre-existing global/health/ready(apps/gateway/src/health/health.controller.ts:11-14), which returns only{status:'ready'}— no config/credential surface by construction.interaction.ts,interaction.test.ts,gateway-api.tsadditions,interaction.controller.ts) for secret/token/credential literals — none found; only session cookies are forwarded as opaque headers, never logged or printed.4. NAME-AS-CONFIG invariant — VERIFIED
packages/mosaic/src/commands/interaction.ts:21-25(configuredAgentName) derives the instance identity solely from--agentorMOSAIC_AGENT_NAME; it throws if neither is set. No literaltesscommand or identity appears anywhere in the new CLI/controller code.apps/gateway/src/agent/interaction.controller.ts:176-181(assertConfiguredAgent) rejects any request whose URL:agentNamedoesn't match the server's ownMOSAIC_AGENT_NAMEenv var — this is what prevents a client from spoofing a different configured identity, not a hardcoded name check.apps/gateway/src/agent/interaction.controller.test.ts:7-22setsMOSAIC_AGENT_NAME='Nova', callscontroller.sessions('Nova', …)and gets a real result, then callscontroller.sessions('Other', …)and gets rejected — proving the exact generic/config-driven behavior required, using a non-tessinstance name.grep -in tess packages/mosaic/src/commands/interaction.ts packages/mosaic/src/commands/interaction.test.ts packages/mosaic/src/tui/gateway-api.ts→ zero hits. Inapps/gateway/src/agent/interaction.controller.tsthe only hits are the imported class nameTessDurableSessionService(a pre-existing M2 service/class name, not an identity/command literal or config default — the controller never comparesagentNameto'tess'or any hardcoded string). No FAIL condition found.5. Quality gate — VERIFIED
Ran directly in an isolated worktree at the reviewed head (after building the workspace's transitive TS project deps, which is an environment/build-order prerequisite unrelated to this PR's content):
pnpm --filter @mosaicstack/gateway exec vitest run→ 572 passed, 11 skipped (integration tests requiring live services), 0 failed (47 files), including the newsrc/agent/interaction.controller.test.ts(4/4 passed).pnpm --filter @mosaicstack/mosaic exec vitest run→ 640 passed, 0 failed (47 files), including the newsrc/commands/interaction.test.ts(1/1 passed).tsc --noEmitclean for both@mosaicstack/gatewayand@mosaicstack/mosaic.eslintclean on all touched/added files in both packages.ci/woodpecker/pr/ci) state at headde74e46a…= success (https://ci.mosaicstack.dev/repos/47/pipeline/1735/1), confirmed via the Gitea status API immediately before posting this verdict, head unchanged since review start.Blocking findings
None.
Non-blocking follow-ups (do not gate this PR)
interaction.ts:143(void send;) — cosmetic; consider dropping the unused intermediate binding.chatandsendare functionally identical today (both callsendInteractionMessage); fine per the required subcommand list, but if they're meant to diverge later (e.g.chatadding conversational framing) it's worth a code comment noting the intentional duplication.VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer-sonnet] head
de74e46a06VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer] head
de74e46a06Evidence verified at exact head
de74e46a0640c44c6b7294c24669496980761c92:1735is terminal green on this commit; stepsci-postgres,install,sanitization,typecheck,lint,format, andtestsucceeded.mosaic interactioncommand registers required verbs: chat/status/sessions/tree/attach/send/stop/health/recover; instance identity is data via--agentorMOSAIC_AGENT_NAME.@CurrentUser()/scopeFromUser, requiresX-Correlation-Id, validates configured agent identity, and checks durable session identity before attach/send/stop/recover.No merge performed by reviewer.
Marker: ROR-731-APPROVE-de74e46a