diff --git a/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts b/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts index 546e64e..1b20084 100644 --- a/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts +++ b/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts @@ -115,6 +115,38 @@ describe('Hermes runtime provider reachability', (): void => { await app?.close(); }); + it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise => { + 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 => { if (!app) throw new Error('Nest application did not initialize'); const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY); diff --git a/docs/openapi-tess.yaml b/docs/openapi-tess.yaml index c348a69..10ddeb0 100644 --- a/docs/openapi-tess.yaml +++ b/docs/openapi-tess.yaml @@ -124,7 +124,7 @@ paths: { summary: Submit Mos handoff, parameters: [{ $ref: '#/components/parameters/correlation' }], - requestBody: { $ref: '#/components/requestBodies/MosHandoff' }, + requestBody: { $ref: '#/components/requestBodies/Handoff' }, responses: { '200': { description: Receipt } }, }, } @@ -261,7 +261,7 @@ components: }, }, } - MosHandoff: + Handoff: { required: true, content: diff --git a/docs/scratchpads/tess-m4-001-mos-coordination.md b/docs/scratchpads/tess-m4-001-mos-coordination.md index e5ad5b0..c75eb76 100644 --- a/docs/scratchpads/tess-m4-001-mos-coordination.md +++ b/docs/scratchpads/tess-m4-001-mos-coordination.md @@ -18,11 +18,11 @@ Implement a transport-neutral coordination contract allowing a configured intera ## Design checkpoint — 2026-07-12 -Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryMosCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task. +Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryInteractionCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task. ## Progress checkpoint — 2026-07-13 -- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`. +- Implemented `InteractionCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`. - Implemented the gateway `InteractionCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context. - Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation. - Did not modify `apps/gateway/src/commands/command-authorization.service.ts`. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index d268f52..8812182 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -54,7 +54,7 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa ### Mos Coordination Boundary -`@mosaicstack/coord` exposes only the transport-neutral `MosCoordinationPort` +`@mosaicstack/coord` exposes only the transport-neutral `InteractionCoordinationPort` verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant, correlation, and interaction-agent identity from authenticated context plus trusted configuration; callers never provide an orchestration target. It diff --git a/docs/tess/MOS-COORDINATION.md b/docs/tess/MOS-COORDINATION.md index cd47929..6ee7d09 100644 --- a/docs/tess/MOS-COORDINATION.md +++ b/docs/tess/MOS-COORDINATION.md @@ -20,29 +20,29 @@ interface CoordinationScope { readonly requesterAgentId: string; // trusted gateway/configuration data } -interface MosHandoffRequest { +interface HandoffRequest { readonly idempotencyKey: string; readonly summary: string; readonly context?: string; readonly missionId?: string; } -interface MosHandoffReceipt { +interface HandoffReceipt { readonly handoffId: string; readonly targetAgentId: string; readonly status: 'accepted' | 'queued'; readonly correlationId: string; } -interface MosHandoff { +interface Handoff { readonly handoffId: string; readonly targetAgentId: string; - readonly request: MosHandoffRequest; + readonly request: HandoffRequest; readonly scope: CoordinationScope; } -interface MosCoordinationPort { - handoff(handoff: MosHandoff): Promise; +interface InteractionCoordinationPort { + handoff(handoff: Handoff): Promise; observe(handoffId: string, scope: CoordinationScope): Promise; result(handoffId: string, scope: CoordinationScope): Promise; } @@ -65,13 +65,13 @@ identity drift, cross-tenant observe/result lookup, and attempts to observe or receive a result for a handoff outside the originating tenant. The service exposes handoff, observe, and result only, and delegates delivery to an injected adapter. -M4 ships a native in-process `InMemoryMosCoordinationPort` as the concrete, +M4 ships a native in-process `InMemoryInteractionCoordinationPort` as the concrete, deterministic adapter. It preserves the immutable handoff ID, tenant, requester identity, and correlation ID while demonstrating the handoff → observe → result round trip. It is a queue/port adapter, not a Mos-side consumer. A future fleet/tmux adapter is a documented M5 deployment seam and must -implement the same `MosCoordinationPort`; no channel client or interaction +implement the same `InteractionCoordinationPort`; no channel client or interaction runtime calls a transport directly. ## Required tests diff --git a/packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts b/packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts index f2dc36a..6ce6880 100644 --- a/packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts +++ b/packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts @@ -11,7 +11,7 @@ const base = { agentName: 'Nova', correlationId: 'corr-1', sessionId: 'session-1 afterEach(() => vi.unstubAllGlobals()); -/** The TUI must preserve gateway denial type/status rather than treating it as success. */ +/** Unit coverage: the TUI preserves a non-success gateway response in its CLI error. */ describe('interaction gateway error mapping', (): void => { it.each([ { @@ -23,7 +23,7 @@ describe('interaction gateway error mapping', (): void => { }, { name: 'send invalid request', - response: { status: 400, message: 'Content and idempotency key are required' }, + response: { status: 403, message: 'Content and idempotency key are required' }, invoke: (): Promise => sendInteractionMessage(gateway, cookie, { ...base, @@ -31,7 +31,7 @@ describe('interaction gateway error mapping', (): void => { idempotencyKey: 'message-1', }), expected: - 'Failed to send interaction message (400): {"message":"Content and idempotency key are required"}', + 'Failed to send interaction message (403): {"message":"Content and idempotency key are required"}', }, { name: 'stop forbidden',