Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cce40ff8b | ||
|
|
be2d14fb6c | ||
|
|
3667a7a77f | ||
|
|
d400ec5b9d | ||
|
|
b91b702a53 | ||
|
|
585dac7a5d | ||
|
|
326a1a58b5 | ||
|
|
2755f86f7b | ||
|
|
fe2cf19461 | ||
|
|
4fde3f622d | ||
|
|
9de9ffa56b | ||
|
|
cb960237d3 | ||
|
|
5e15431027 | ||
|
|
c16256d48c | ||
|
|
92e790ae9d | ||
|
|
0fdcfa0ff4 | ||
|
|
4e2f9888a0 | ||
|
|
9a92bb64ff | ||
|
|
fe26b37e81 | ||
|
|
378c227cbb | ||
|
|
4522adaa5e | ||
|
|
a42d5e2ee5 | ||
|
|
216cd72226 |
@@ -0,0 +1,94 @@
|
|||||||
|
# T1 report: canonical ungated Claude base and lease overlay
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Replaced `packages/mosaic/framework/runtime/claude/settings.json` with the canonical ungated base. It retains the model, QA hooks, plugins, command allowlist, permissions, and `mcpServers.sequential-thinking`.
|
||||||
|
- Added `packages/mosaic/framework/runtime/claude/lease-overlay.json`. It contains only `hooks` and the six removed lease hook entries.
|
||||||
|
- Added the byte-identical pre-split source fixture at `packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json`.
|
||||||
|
- Added `packages/mosaic/src/runtime/claude-settings-base.spec.ts`.
|
||||||
|
|
||||||
|
`framework-manifest.txt` already declares `runtime/**`, so the new overlay is framework-owned and shipped without a manifest change.
|
||||||
|
|
||||||
|
## Lease-hook enumeration
|
||||||
|
|
||||||
|
The actual template has six lease hook entries, matching fred's refined boundary:
|
||||||
|
|
||||||
|
1. `PreToolUse` matcher `.*`: `mutator-gate.py`
|
||||||
|
2. `Stop`: one combined command containing `receipt-observer-client.py` then `promote-complete.py`
|
||||||
|
3. `UserPromptSubmit` matcher `^/mosaic-promote$`: `promote-begin.py`
|
||||||
|
4. `PreCompact`: `revoke-lease.py --reason pre-compact`
|
||||||
|
5. `SessionStart` matcher `compact`: `revoke-lease.py --reason session-start-compact`
|
||||||
|
6. `SessionStart` matcher `resume|clear`: `revoke-lease.py --reason session-start-rollover --bump-generation`
|
||||||
|
|
||||||
|
There is no delta from the refined six-entry enumeration. The Stop entry contains the receipt-observer and promote-complete commands together, rather than as two separate hook objects.
|
||||||
|
|
||||||
|
## Tests and checks
|
||||||
|
|
||||||
|
`pnpm install --frozen-lockfile` was run first because `node_modules` was absent. It completed successfully.
|
||||||
|
|
||||||
|
Red-first run before artifacts existed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RUN v2.1.9 .../packages/mosaic
|
||||||
|
❯ src/runtime/claude-settings-base.spec.ts (4 tests | 4 failed)
|
||||||
|
× keeps every lease command out of the ungated base
|
||||||
|
→ mutator-gate: expected true to be false
|
||||||
|
× reconstructs the pre-split gated hooks while retaining the canonical MCP correction
|
||||||
|
→ ENOENT: .../lease-overlay.json
|
||||||
|
× ships sequential-thinking in the base
|
||||||
|
→ expected undefined to deeply equal { 'sequential-thinking': ... }
|
||||||
|
× limits the overlay to lease hook entries
|
||||||
|
→ ENOENT: .../lease-overlay.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Final focused acceptance run:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RUN v2.1.9 .../packages/mosaic
|
||||||
|
✓ src/runtime/claude-settings-base.spec.ts (4 tests) 19ms
|
||||||
|
Test Files 1 passed (1)
|
||||||
|
Tests 4 passed (4)
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm --filter @mosaicstack/mosaic lint` passed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
> @mosaicstack/[email protected] lint
|
||||||
|
> eslint src
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm --filter @mosaicstack/mosaic typecheck` failed on pre-existing workspace resolution and unrelated package errors. The new spec no longer appears in the error list. Initial failures include missing `@mosaicstack/{brain,forge,log,macp,memory,queue,storage,quality-rails,db,config,prdy,types}` declarations, followed by existing `fleet-backlog.ts`, `gateway-doctor.ts`, and TUI implicit-`any` errors. Exit status: 2.
|
||||||
|
|
||||||
|
A focused legacy consumer run confirms an existing assumption that `settings.json` itself is gated:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm --filter @mosaicstack/mosaic exec vitest run src/mutator-gate/mutator-gate.acceptance.spec.ts
|
||||||
|
❯ src/mutator-gate/mutator-gate.acceptance.spec.ts (20 tests | 6 failed)
|
||||||
|
× non-dangerous parser residual is denied by the global all-tools hook without a lease
|
||||||
|
→ expected all-tools mutator-gate command in settings.json
|
||||||
|
× Claude and Pi compaction observer wiring is complete and fail-closed
|
||||||
|
→ expected PreCompact/SessionStart revoke-lease hooks in settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The other four failures in that focused run reported `STALE_GENERATION` where the test expected `MUTATOR_UNVERIFIED`, plus one successful-gate assertion. I did not redesign this legacy suite because the task explicitly says to report consumers that assume the base is gated.
|
||||||
|
|
||||||
|
## Consumers found
|
||||||
|
|
||||||
|
Direct `runtime/claude/settings.json` path consumers found by the required repository grep:
|
||||||
|
|
||||||
|
- `packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets`: copies the base to `~/.claude/settings.json`.
|
||||||
|
- `packages/mosaic/src/commands/install-ordering-guard.ts` and `.spec.ts`: documentation and behavior assume the source embeds enforcement hooks.
|
||||||
|
- `packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh`: comments and assertions expect `mutator-gate.py` and `receipt-observer-client.py` in the base.
|
||||||
|
- `packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts`: reads the base and asserts mutator, promotion, and compaction lease wiring.
|
||||||
|
- `packages/mosaic/src/lease-broker/promotion_trigger_unittest.py`: reads the base and asserts promotion wiring.
|
||||||
|
- `packages/mosaic/src/lease-broker/recovery_runtime_unittest.py`: reads the base.
|
||||||
|
- `packages/mosaic/src/runtime/update-checker.ts` and `.spec.ts`: references the path in settings wiring/update checks.
|
||||||
|
- Documentation-only references: `docs/compaction-refresh/probes/p6_constrained_recovery.py`, `docs/plans/agent-reflection-loop-PRD.md`, `docs/tasks/544-agent-reflection-loop.md`, and the framework QA documentation/scripts found by grep.
|
||||||
|
|
||||||
|
I did not change these consumers. The install/link and lease acceptance consumers must be taught to select and compose `lease-overlay.json` when a gated promotion seat is requested. That composition behavior is outside T1.
|
||||||
|
|
||||||
|
## Ambiguity handled
|
||||||
|
|
||||||
|
The exact pre-split template fixture has no `mcpServers` key (SHA-256 `44e74ea1e9d424fffa020ee666402662ac856b88bf6ae7f3b8931eed29dc75a4`). The task simultaneously requires a byte-for-byte pre-split fixture, `mcpServers.sequential-thinking` in the base, and `deep-merge(base, overlay) == original`. Those three conditions cannot all hold because a merge cannot remove the required MCP key.
|
||||||
|
|
||||||
|
The acceptance test preserves the exact fixture and asserts that the normalized merge equals the pre-split template plus the required canonical `mcpServers.sequential-thinking` correction. It verifies all original hook content is reconstructed and the base carries the required MCP. Production three-layer merge semantics remain W-F1 work.
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# REPORT-T2
|
||||||
|
|
||||||
|
Date: 2026-08-13 11:29 CDT
|
||||||
|
Branch: `feat/wf-fleet-t2-launch`
|
||||||
|
Base: `216cd722`
|
||||||
|
Issue: #1209
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
- Added `mosaic fleet launch <name> [--dry-run]` in `packages/mosaic/src/commands/fleet-launch-command.ts` and registered it on the existing fleet command.
|
||||||
|
- Added strict schema-one parsing for the user-owned `~/.mosaic/fleet/agents/<name>/profile.json`:
|
||||||
|
- required `schema` and `harness`
|
||||||
|
- default bundle `primary`
|
||||||
|
- optional `model`, `overlay`, `plugins`, `skills`, and string-valued `env`
|
||||||
|
- unknown-key refusal naming the key
|
||||||
|
- dedicated `SCHEMA_TOO_NEW` code and upgrade guidance
|
||||||
|
- Added the three-layer settings composer. Objects merge recursively, scalars use the higher layer, arrays replace, and `null` deletes a key. The selected agent overlay defaults to no overlay when the profile field is absent.
|
||||||
|
- Writes canonical merged settings to `<agent-home>/settings.json` and the future harvest comparison snapshot to `<agent-dir>/settings.generated.json`.
|
||||||
|
- Resolves `primary` to its named bundle, reads an optional account email, and reports forms such as `primary -> fred_example.com ([email protected])`.
|
||||||
|
- Validates credential targets with `lstat`, rejects symlink credential files, resolves and checks containment under the harness auth root, and refuses a real credential file at the seat-link path as first-auth state.
|
||||||
|
- Installs selected plugin and skill entries as seat-local symlinks, prunes stale symlinks, and refuses real objects instead of deleting them.
|
||||||
|
- Builds a declared seat environment with the harness home variable, `MOSAIC_AGENT_NAME`, and profile environment entries. Mechanical values override conflicting profile entries.
|
||||||
|
- Extended `launch.ts` so `harnessHome()` accepts fleet context and remains the home-resolution seam. The fleet launcher uses the existing runtime preflight, prompt, ledger, lease-gated, and process execution path over a minimal ambient environment.
|
||||||
|
- Added deterministic dry-run output containing source layers, merged settings, output and snapshot paths, resolved bundle, symlink plans, declared environment, and harness argv.
|
||||||
|
- Added 17 focused tests, including the required merge, schema, A3, dry-run snapshot, managed-link, command dry-run, execution-seam, and non-zero failure cases.
|
||||||
|
|
||||||
|
## Reconciliation decisions and contradictions
|
||||||
|
|
||||||
|
### Prominent contradiction: roster registries do not contain the frozen launch schema
|
||||||
|
|
||||||
|
The existing code has two other profile/registry concepts:
|
||||||
|
|
||||||
|
- `fleet-profiles.ts` models system-type YAML roster templates. Its `FleetProfile` has no harness bundle, overlay, plugin, skill, or seat environment fields.
|
||||||
|
- roster-v2 models topology and lifecycle. It requires class, provider, reasoning, tool policy, working directory, lifecycle, and launch-yolo fields that schema-one `profile.json` does not contain.
|
||||||
|
|
||||||
|
Deriving a complete roster-v2 member from the frozen per-agent profile is therefore not possible without inventing values. Launch now reads only the per-agent `profile.json` and does not require roster-v2 or the legacy v1 roster. roster-v2 remains the existing lifecycle/topology registry. No second launch registry was introduced.
|
||||||
|
|
||||||
|
The pre-existing `resolveFleetIdentity()` path requires a legacy roster and a secure tmux helper whenever `MOSAIC_AGENT_NAME` is present during contract composition. For profile-backed launch, `launch.ts` excludes roster identity keys only from the contract-build environment, then exports the declared profile seat identity to the harness process. Legacy root runtime launches retain the existing roster-backed behavior. This is the smallest reconciliation that allows profile-only launch without fabricating roster-v2 fields.
|
||||||
|
|
||||||
|
### Historical whole-store plugin link
|
||||||
|
|
||||||
|
The prototype used a whole `plugins` directory symlink, while this task requires selected entry links and pruning. Launch refuses that historical shape with an explicit migration message. It does not delete or silently convert the whole-store link.
|
||||||
|
|
||||||
|
### Existing `FleetProfile` name
|
||||||
|
|
||||||
|
The system-type YAML `FleetProfile` remains unchanged. The new type is named `FleetAgentLaunchProfile` to keep the concepts separate while treating per-agent `profile.json` as the launch SSOT.
|
||||||
|
|
||||||
|
## Ambiguities and bounded choices
|
||||||
|
|
||||||
|
- The design does not freeze the generated snapshot filename. This implementation uses `settings.generated.json` in the agent directory, beside the hidden harness home.
|
||||||
|
- The design explicitly identifies Claude `.credentials.json` and Pi `auth.json`. Codex and OpenCode use `auth.json` in the filename map, matching their harness-home composition shape, but no real credential launch was performed in this task.
|
||||||
|
- Full interactive harvest-back disposition is not implemented. The task asks to store the generated snapshot for the future diff, and this change does that.
|
||||||
|
- A machine descriptor file and content digests were not added. Dry-run and execution consume one resolved in-memory composition, and dry-run prints that composition.
|
||||||
|
- No real harness process or real operator home was used. Every new filesystem test uses a temporary fixture root.
|
||||||
|
|
||||||
|
## Test run
|
||||||
|
|
||||||
|
Dependency install and build:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ pnpm install --frozen-lockfile
|
||||||
|
Scope: all 28 workspace projects
|
||||||
|
Lockfile is up to date, resolution step is skipped
|
||||||
|
Done in 4.7s using pnpm v10.6.2
|
||||||
|
|
||||||
|
$ pnpm --filter @mosaicstack/mosaic... build
|
||||||
|
Scope: 13 of 28 workspace projects
|
||||||
|
packages/mosaic build: Done
|
||||||
|
```
|
||||||
|
|
||||||
|
Focused and touched integration tests:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-launch-command.spec.ts src/commands/launch.spec.ts src/commands/fleet.spec.ts
|
||||||
|
Test Files 3 passed (3)
|
||||||
|
Tests 256 passed (256)
|
||||||
|
```
|
||||||
|
|
||||||
|
Typecheck and lint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ pnpm --filter @mosaicstack/mosaic typecheck
|
||||||
|
> tsc --noEmit
|
||||||
|
(exit 0)
|
||||||
|
|
||||||
|
$ pnpm exec eslint packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
|
||||||
|
(exit 0)
|
||||||
|
|
||||||
|
$ pnpm exec prettier --check packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
|
||||||
|
Checking formatting...
|
||||||
|
All matched files use Prettier code style!
|
||||||
|
```
|
||||||
|
|
||||||
|
Package-wide Vitest result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ pnpm --filter @mosaicstack/mosaic exec vitest run
|
||||||
|
Test Files 1 failed | 83 passed (84)
|
||||||
|
Tests 4 failed | 1535 passed (1539)
|
||||||
|
```
|
||||||
|
|
||||||
|
All four failures are in `src/mutator-gate/mutator-gate.acceptance.spec.ts`. Three expected `MUTATOR_UNVERIFIED` but received `STALE_GENERATION`; one runtime-gate assertion expected status zero and received status two. An isolated rerun produced the same four failures. I did not confirm whether they predate this branch. The focused launch, fleet, and typecheck runs are green.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# T3 report: `mosaic fleet agent new`
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Added `packages/mosaic/src/fleet/fleet-agent-scaffold.ts`.
|
||||||
|
- Creates user-owned seats at `~/.mosaic/fleet/agents/<name>` (test seam: `fleetDataHome`, environment default: `MOSAIC_DATA_HOME`).
|
||||||
|
- Writes schema-one `profile.json` with default `harness: "claude"`, `bundle: "primary"`, optional `model`, `overlay: "overlay.json"`, and mandatory `env.MOSAIC_AGENT_NAME`.
|
||||||
|
- Writes a positive `SOUL.md` identity and materializes that identity in `.claude/CLAUDE.md` or `.pi/AGENTS.md`.
|
||||||
|
- Writes `overlay.json` as `{}`. Claude homes get `.claude.json` with `hasCompletedOnboarding: true` and `theme: "dark"`. No settings file is composed.
|
||||||
|
- Creates the appropriate credential symlink (`.credentials.json` for Claude, `auth.json` for Pi), allowing an intentional dangling destination and reporting it at the command surface.
|
||||||
|
- Compares every existing object (including link targets as link text), succeeds only byte-identically, and otherwise refuses with the differing paths.
|
||||||
|
- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.ts` and wired `fleet agent new <name> [--harness claude|pi] [--bundle B] [--model M]` in `packages/mosaic/src/commands/fleet.ts`.
|
||||||
|
- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts` with temp-root-only coverage: exact Claude/Pi layouts, literal quote/backtick/`$( )` handling, unsafe names and option failures, idempotence, changed-file refusal, and credential-link comparison.
|
||||||
|
|
||||||
|
## Reconciliation
|
||||||
|
|
||||||
|
`fleet-agent-crud-command.ts` currently registers roster-v2 `get/create/update/delete/plan` directly under `mosaic fleet`; it has no `agent new` command or profile schema. T3 adds an `agent` namespace for the profile-owned user-data scaffold and leaves roster-v2 CRUD unchanged.
|
||||||
|
|
||||||
|
No roster projection is created. Current roster-v2 requires fields that cannot be derived from the new profile (`class`, provider, working directory, reasoning, tool policy, lifecycle), while no current `mosaic fleet launch <name>` consumes these profiles. Writing such a roster entry would create the forbidden second registry and invent semantics. The profile is therefore the sole state created here. When the launcher owns profile-to-roster projection, it must derive it there and emit the required actionable unscaffolded-name message.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ pnpm install --frozen-lockfile
|
||||||
|
Done in 4.1s using pnpm v10.6.2
|
||||||
|
|
||||||
|
$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-agent-scaffold-command.spec.ts
|
||||||
|
✓ src/commands/fleet-agent-scaffold-command.spec.ts (13 tests) 28ms
|
||||||
|
Test Files 1 passed (1)
|
||||||
|
Tests 13 passed (13)
|
||||||
|
|
||||||
|
$ pnpm --filter @mosaicstack/mosaic exec eslint src/fleet/fleet-agent-scaffold.ts src/commands/fleet-agent-scaffold-command.ts src/commands/fleet-agent-scaffold-command.spec.ts src/commands/fleet.ts
|
||||||
|
(exit 0)
|
||||||
|
|
||||||
|
$ pnpm exec prettier --check packages/mosaic/src/fleet/fleet-agent-scaffold.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts packages/mosaic/src/commands/fleet.ts
|
||||||
|
All matched files use Prettier code style!
|
||||||
|
|
||||||
|
$ git diff --check
|
||||||
|
(exit 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm --filter @mosaicstack/mosaic typecheck` remains blocked by pre-existing unresolved workspace package entries (`@mosaicstack/brain`, `@mosaicstack/db`, `@mosaicstack/types`, and others). The typecheck output had no diagnostics naming T3 files. Running the pre-existing CRUD command spec is blocked by the same `@mosaicstack/db` Vite resolution failure through `fleet-backlog.ts`.
|
||||||
|
|
||||||
|
## Skipped ambiguity
|
||||||
|
|
||||||
|
The design asks for a generated harness-home `settings.json` as part of an earlier generic home-template description, but the task explicitly says composed settings are left to launch. T3 creates no `settings.json`; launch composition remains the owner.
|
||||||
@@ -417,7 +417,7 @@ describe('ConversationsController — search endpoint', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
brain = createMockBrain({ searchResults });
|
brain = createMockBrain({ searchResults });
|
||||||
controller = new ConversationsController(brain as never);
|
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns matching messages for a valid search query', async () => {
|
it('returns matching messages for a valid search query', async () => {
|
||||||
@@ -479,7 +479,7 @@ describe('ConversationsController — search endpoint', () => {
|
|||||||
describe('ConversationsController — message CRUD', () => {
|
describe('ConversationsController — message CRUD', () => {
|
||||||
it('listMessages returns 404 when conversation is not owned by user', async () => {
|
it('listMessages returns 404 when conversation is not owned by user', async () => {
|
||||||
const brain = createMockBrain({ conversation: undefined });
|
const brain = createMockBrain({ conversation: undefined });
|
||||||
const controller = new ConversationsController(brain as never);
|
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||||
|
|
||||||
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
|
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -489,7 +489,7 @@ describe('ConversationsController — message CRUD', () => {
|
|||||||
it('listMessages returns the messages for an owned conversation', async () => {
|
it('listMessages returns the messages for an owned conversation', async () => {
|
||||||
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
|
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
|
||||||
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
|
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
|
||||||
const controller = new ConversationsController(brain as never);
|
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||||
|
|
||||||
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
|
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
|
||||||
|
|
||||||
@@ -500,7 +500,7 @@ describe('ConversationsController — message CRUD', () => {
|
|||||||
|
|
||||||
it('addMessage returns the persisted message', async () => {
|
it('addMessage returns the persisted message', async () => {
|
||||||
const brain = createMockBrain({ conversation: makeConversation() });
|
const brain = createMockBrain({ conversation: makeConversation() });
|
||||||
const controller = new ConversationsController(brain as never);
|
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||||
|
|
||||||
const result = await controller.addMessage(
|
const result = await controller.addMessage(
|
||||||
CONV_ID,
|
CONV_ID,
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ function payload(content: string, messageId: string, correlationId: string): Dis
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat runtime router must never be exercised on the Discord approval/stop control paths —
|
||||||
|
* those paths run entirely through the command-authorization, runtime-provider and durable-session
|
||||||
|
* dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService`
|
||||||
|
* slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because
|
||||||
|
* approval/stop never resolve a chat runtime, this fixture is never triggered and the integration
|
||||||
|
* stays a GREEN cross-surface control.
|
||||||
|
*/
|
||||||
|
function failIfUsedChatRuntimeRouter() {
|
||||||
|
return {
|
||||||
|
onModuleInit: () => {
|
||||||
|
throw new Error('chat runtime router must not initialise on the Discord control path');
|
||||||
|
},
|
||||||
|
get active(): never {
|
||||||
|
throw new Error('chat runtime must not be resolved on the Discord approval/stop path');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function authorization(): CommandAuthorizationService {
|
function authorization(): CommandAuthorizationService {
|
||||||
const entries = new Map<string, string>();
|
const entries = new Map<string, string>();
|
||||||
return new CommandAuthorizationService(
|
return new CommandAuthorizationService(
|
||||||
@@ -113,7 +132,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const gateway = new ChatGateway(
|
const gateway = new ChatGateway(
|
||||||
{} as never,
|
failIfUsedChatRuntimeRouter() as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe('Resource ownership checks', () => {
|
|||||||
// The repo enforces ownership via the WHERE clause; it returns undefined when the
|
// The repo enforces ownership via the WHERE clause; it returns undefined when the
|
||||||
// conversation does not belong to the requesting user.
|
// conversation does not belong to the requesting user.
|
||||||
brain.conversations.findById.mockResolvedValue(undefined);
|
brain.conversations.findById.mockResolvedValue(undefined);
|
||||||
const controller = new ConversationsController(brain as never);
|
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||||
|
|
||||||
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Test, type TestingModule } from '@nestjs/testing';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
||||||
@@ -12,10 +14,25 @@ vi.mock('../routing/routing-engine.service.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { SessionsController } from '../sessions.controller.js';
|
import { SessionsController } from '../sessions.controller.js';
|
||||||
|
import { AgentService } from '../agent.service.js';
|
||||||
import { ChatController } from '../../chat/chat.controller.js';
|
import { ChatController } from '../../chat/chat.controller.js';
|
||||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||||
import type { AgentSession } from '../agent.service.js';
|
import type { AgentSession } from '../agent.service.js';
|
||||||
import type { SessionInfoDto } from '../session.dto.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_A = { id: 'user-a', tenantId: 'tenant-a' };
|
||||||
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
||||||
@@ -74,6 +91,12 @@ function makeAgentSession(owner = USER_A): AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing
|
||||||
|
* USER_A's conversation id) is never granted the session. Because every method exists and no method
|
||||||
|
* throws for a wrong shape, production runs to its real ownership decision — the RED never comes from
|
||||||
|
* a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch.
|
||||||
|
*/
|
||||||
function makeScopedAgentService() {
|
function makeScopedAgentService() {
|
||||||
const foreign = makeAgentSession(USER_A);
|
const foreign = makeAgentSession(USER_A);
|
||||||
return {
|
return {
|
||||||
@@ -87,7 +110,7 @@ function makeScopedAgentService() {
|
|||||||
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
||||||
scope?.userId === USER_B.id ? undefined : foreign,
|
scope?.userId === USER_B.id ? undefined : foreign,
|
||||||
),
|
),
|
||||||
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
|
createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')),
|
||||||
onEvent: vi.fn(() => vi.fn()),
|
onEvent: vi.fn(() => vi.fn()),
|
||||||
addChannel: vi.fn(),
|
addChannel: vi.fn(),
|
||||||
removeChannel: vi.fn(),
|
removeChannel: vi.fn(),
|
||||||
@@ -96,6 +119,201 @@ function makeScopedAgentService() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScopedAgentService = ReturnType<typeof makeScopedAgentService>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structurally-complete harness conversation service that throws if any method is invoked.
|
||||||
|
* Fronted behind the legacy runtime's harness slot: the legacy path must never reach it.
|
||||||
|
*/
|
||||||
|
const failIfUsedConversationService = {
|
||||||
|
attach: () => {
|
||||||
|
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||||
|
},
|
||||||
|
detach: () => {
|
||||||
|
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||||
|
},
|
||||||
|
send: () => {
|
||||||
|
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeFrom: async function* () {
|
||||||
|
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||||
|
},
|
||||||
|
} as unknown as HarnessConversationService;
|
||||||
|
|
||||||
|
/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */
|
||||||
|
const boundConversationService = {
|
||||||
|
attach: () => Promise.reject(new Error('unused')),
|
||||||
|
detach: () => Promise.reject(new Error('unused')),
|
||||||
|
send: () => Promise.reject(new Error('unused')),
|
||||||
|
|
||||||
|
subscribeFrom: async function* () {
|
||||||
|
throw new Error('unused');
|
||||||
|
},
|
||||||
|
} as unknown as HarnessConversationService;
|
||||||
|
|
||||||
|
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
for (const id of adapterIds) {
|
||||||
|
registry.register({
|
||||||
|
id,
|
||||||
|
describe: () => Promise.reject(new Error('unused')),
|
||||||
|
catalog: () => Promise.reject(new Error('unused')),
|
||||||
|
create: () => Promise.reject(new Error('unused')),
|
||||||
|
resume: () => Promise.reject(new Error('unused')),
|
||||||
|
} as HarnessAdapter);
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime}
|
||||||
|
* that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to
|
||||||
|
* reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService.
|
||||||
|
* The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a
|
||||||
|
* call landing on it proves the router-delegation redesign is live rather than the old direct path.
|
||||||
|
*/
|
||||||
|
function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter {
|
||||||
|
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||||
|
const harness = new HarnessChatRuntime(failIfUsedConversationService);
|
||||||
|
const router = new ChatRuntimeRouter(
|
||||||
|
new HarnessRegistry(),
|
||||||
|
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
embedded,
|
||||||
|
harness,
|
||||||
|
'legacy',
|
||||||
|
);
|
||||||
|
router.onModuleInit();
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AgentService method names the controller/gateway must NEVER drive on the runtime at the
|
||||||
|
* delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record
|
||||||
|
* one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime
|
||||||
|
* call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text.
|
||||||
|
*/
|
||||||
|
const FORBIDDEN_AGENT_OPS = [
|
||||||
|
'getSession',
|
||||||
|
'createSession',
|
||||||
|
'onEvent',
|
||||||
|
'addChannel',
|
||||||
|
'prompt',
|
||||||
|
'setThinking',
|
||||||
|
'abort',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields
|
||||||
|
* an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at
|
||||||
|
* INVOCATION time and forwards to the real method (bound to the real target, so the router's internal
|
||||||
|
* delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING
|
||||||
|
* properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a
|
||||||
|
* canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim.
|
||||||
|
*
|
||||||
|
* The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router
|
||||||
|
* at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the
|
||||||
|
* production source cannot. This replaces the earlier `source.toContain('<frozen op>')` proof — which
|
||||||
|
* a dead declaration could satisfy while production still executed a shim — with invocation evidence.
|
||||||
|
*/
|
||||||
|
function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter {
|
||||||
|
return new Proxy(target, {
|
||||||
|
get(t, prop) {
|
||||||
|
const value = Reflect.get(t, prop);
|
||||||
|
if (typeof value === 'function' && typeof prop === 'string') {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
calls.push(prop);
|
||||||
|
return (value as (...a: unknown[]) => unknown).apply(t, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
}) as ChatRuntimeRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts).
|
||||||
|
*
|
||||||
|
* BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider
|
||||||
|
* (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production
|
||||||
|
* resolves whichever its constructor declares:
|
||||||
|
* - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the
|
||||||
|
* router (and its embedded fake) is never reached.
|
||||||
|
* - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never
|
||||||
|
* touched (stays at zero) and scope is observed inside the embedded fake behind the router.
|
||||||
|
* The SAME test body reds today and greens later; a method-for-method AgentService shim on the router
|
||||||
|
* records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and
|
||||||
|
* restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed
|
||||||
|
* scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link
|
||||||
|
* makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings.
|
||||||
|
*/
|
||||||
|
function buildRestModule(
|
||||||
|
directAgentService: ScopedAgentService,
|
||||||
|
embeddedAgentService: ScopedAgentService,
|
||||||
|
routerCalls: string[],
|
||||||
|
): Promise<TestingModule> {
|
||||||
|
return (
|
||||||
|
Test.createTestingModule({
|
||||||
|
controllers: [ChatController],
|
||||||
|
providers: [
|
||||||
|
{ provide: AgentService, useValue: directAgentService },
|
||||||
|
{
|
||||||
|
provide: ChatRuntimeRouter,
|
||||||
|
useFactory: () =>
|
||||||
|
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects
|
||||||
|
// AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph
|
||||||
|
// resolves and the test reds on BEHAVIOUR, not on a DI collection error.
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGatewayModule(
|
||||||
|
directAgentService: ScopedAgentService,
|
||||||
|
embeddedAgentService: ScopedAgentService,
|
||||||
|
routerCalls: string[],
|
||||||
|
): Promise<TestingModule> {
|
||||||
|
const brain = {
|
||||||
|
conversations: {
|
||||||
|
// The sender OWNS this durable conversation, so the browser-send admission gate lets the turn
|
||||||
|
// reach the router seam. Foreignness is asserted downstream at the in-memory agent session
|
||||||
|
// (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection
|
||||||
|
// property has its own dedicated coverage.
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ChatGateway,
|
||||||
|
{ provide: AgentService, useValue: directAgentService },
|
||||||
|
{ provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } },
|
||||||
|
{ provide: BRAIN, useValue: brain },
|
||||||
|
{ provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } },
|
||||||
|
{ provide: CommandExecutorService, useValue: { execute: vi.fn() } },
|
||||||
|
{
|
||||||
|
provide: RoutingEngineService,
|
||||||
|
useValue: {
|
||||||
|
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ChatRuntimeRouter,
|
||||||
|
useFactory: () =>
|
||||||
|
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
}
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
||||||
it('requires explicit owner+tenant scope on protected session operations', () => {
|
it('requires explicit owner+tenant scope on protected session operations', () => {
|
||||||
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
||||||
@@ -152,50 +370,66 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => {
|
||||||
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
|
// TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot
|
||||||
const agentService = makeScopedAgentService();
|
// swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived
|
||||||
const controller = new ChatController(agentService as never);
|
// 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 });
|
||||||
|
|
||||||
await expect(
|
// Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN.
|
||||||
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
await expect(
|
||||||
).rejects.toMatchObject({ status: 404 });
|
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
// Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail.
|
||||||
userId: USER_B.id,
|
|
||||||
tenantId: USER_B.tenantId,
|
// 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
|
||||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
// 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', () => {
|
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => {
|
||||||
function makeGateway(agentService = makeScopedAgentService()) {
|
|
||||||
const brain = {
|
|
||||||
conversations: {
|
|
||||||
findById: vi.fn().mockResolvedValue(undefined),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
findMessages: vi.fn().mockResolvedValue([]),
|
|
||||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
|
|
||||||
const commandExecutor = { execute: vi.fn() };
|
|
||||||
const routingEngine = {
|
|
||||||
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
|
||||||
};
|
|
||||||
const gateway = new ChatGateway(
|
|
||||||
agentService as never,
|
|
||||||
{} as never,
|
|
||||||
brain as never,
|
|
||||||
commandRegistry as never,
|
|
||||||
commandExecutor as never,
|
|
||||||
routingEngine as never,
|
|
||||||
);
|
|
||||||
return { gateway, agentService };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSocket() {
|
function makeSocket() {
|
||||||
return {
|
return {
|
||||||
id: 'socket-b',
|
id: 'socket-b',
|
||||||
@@ -206,57 +440,519 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () =>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
|
// TESS test B — WebSocket send/attach.
|
||||||
const { gateway, agentService } = makeGateway();
|
it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => {
|
||||||
const socket = makeSocket();
|
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 gateway.handleMessage(socket as never, {
|
await Promise.resolve(
|
||||||
conversationId: CONVERSATION_ID,
|
gateway.handleMessage(socket as never, {
|
||||||
content: 'attach to foreign session',
|
conversationId: CONVERSATION_ID,
|
||||||
});
|
content: 'attach to foreign session',
|
||||||
|
}),
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
// RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router.
|
||||||
userId: USER_B.id,
|
expect
|
||||||
tenantId: USER_B.tenantId,
|
.soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router')
|
||||||
});
|
.toContain('prepareLegacySocketTurn');
|
||||||
expect(agentService.onEvent).not.toHaveBeenCalled();
|
// RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
|
||||||
expect(agentService.addChannel).not.toHaveBeenCalled();
|
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(agentService.prompt).not.toHaveBeenCalled();
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
expect(harnessConversation.attach).not.toHaveBeenCalled();
|
||||||
'error',
|
expect(harnessConversation.send).not.toHaveBeenCalled();
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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);
|
||||||
|
|
||||||
it('does not mutate thinking level on another owner/tenant session', () => {
|
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream());
|
||||||
const { gateway, agentService } = makeGateway();
|
expect(prepared.ok).toBe(true);
|
||||||
const socket = makeSocket();
|
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
|
||||||
|
const lease = prepared.value;
|
||||||
|
|
||||||
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
const first = await lease.dispatch();
|
||||||
|
expect(first).toEqual({ ok: true, value: undefined });
|
||||||
|
expect(svc.prompt).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
const second = await lease.dispatch();
|
||||||
userId: USER_B.id,
|
expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false });
|
||||||
tenantId: USER_B.tenantId,
|
// Zero additional effect — the second dispatch must not prompt again.
|
||||||
|
expect(svc.prompt).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disposes once; a second dispose is a silent no-op that never re-detaches or destroys the session', async () => {
|
||||||
|
const { svc, unsubscribe, session } = makeLeaseAgentService();
|
||||||
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
|
|
||||||
|
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
|
||||||
|
expect(prepared.ok).toBe(true);
|
||||||
|
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
|
||||||
|
const lease = prepared.value;
|
||||||
|
|
||||||
|
await lease.dispose();
|
||||||
|
await lease.dispose();
|
||||||
|
|
||||||
|
// Listener + channel torn down exactly once across two dispose calls.
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
expect(svc.removeChannel).toHaveBeenCalledTimes(1);
|
||||||
|
// Disposal never terminates the underlying session or process.
|
||||||
|
expect(session.piSession.abort).not.toHaveBeenCalled();
|
||||||
|
expect(session.piSession.dispose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => {
|
||||||
|
const { svc, unsubscribe } = makeLeaseAgentService();
|
||||||
|
svc.addChannel = vi.fn(() => {
|
||||||
|
throw new Error('channel attach failed');
|
||||||
});
|
});
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||||
'error',
|
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
// 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);
|
||||||
|
|
||||||
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
|
const unhandled: unknown[] = [];
|
||||||
const { gateway, agentService } = makeGateway();
|
const onUnhandled = (reason: unknown): void => {
|
||||||
const socket = makeSocket();
|
unhandled.push(reason);
|
||||||
|
};
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
|
||||||
|
content: 'a prompt that never returns',
|
||||||
|
});
|
||||||
|
// No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all
|
||||||
|
// installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs.
|
||||||
|
await vi.advanceTimersByTimeAsync(200_000);
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
|
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
|
||||||
|
// The single idempotent dispose ran on the timeout path: listener detached exactly once.
|
||||||
|
expect(detach).toHaveBeenCalledTimes(1);
|
||||||
|
// Advancing far past the deadline fires nothing more: dispose cleared the timer.
|
||||||
|
vi.advanceTimersByTime(600_000);
|
||||||
|
expect(detach).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
expect(unhandled).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
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 () => {
|
||||||
userId: USER_B.id,
|
const session = makeAgentSession(USER_A);
|
||||||
tenantId: USER_B.tenantId,
|
const detach = vi.fn();
|
||||||
|
let rejectPrompt: (reason: unknown) => void = () => undefined;
|
||||||
|
const prompting = new Promise<void>((_resolve, reject) => {
|
||||||
|
rejectPrompt = reject;
|
||||||
});
|
});
|
||||||
expect(socket.emit).toHaveBeenCalledWith(
|
const svc = {
|
||||||
'error',
|
getSession: vi.fn(() => session),
|
||||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,920 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { Test, type TestingModule } from '@nestjs/testing';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
|
||||||
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
|
import { HarnessModule } from '../harness/harness.module.js';
|
||||||
|
import { ChatModule } from './chat.module.js';
|
||||||
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
|
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||||
|
import {
|
||||||
|
HARNESS_CONVERSATION_SERVICE,
|
||||||
|
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
HARNESS_REGISTRY,
|
||||||
|
type HarnessConversationServiceBinding,
|
||||||
|
} from '../harness/harness.tokens.js';
|
||||||
|
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||||
|
import {
|
||||||
|
ChatRuntimeUnavailableError,
|
||||||
|
ownConversation,
|
||||||
|
type ChatRuntime,
|
||||||
|
type ChatRuntimeMode,
|
||||||
|
type LegacyEmbeddedChatPort,
|
||||||
|
type LegacyRuntimeStream,
|
||||||
|
type LegacySessionPresentation,
|
||||||
|
type LegacySocketTurnLease,
|
||||||
|
type OwnedConversationContext,
|
||||||
|
} from './chat-runtime.js';
|
||||||
|
import { AppModule } from '../app.module.js';
|
||||||
|
import { ProviderService } from '../agent/provider.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one
|
||||||
|
* runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and
|
||||||
|
* never downgrades `pi-rpc` to embedded execution. Red-first: the router is an
|
||||||
|
* unimplemented stub, so every behavioural assertion below fails until Step Three.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const embedded: ChatRuntime = { kind: 'embedded' };
|
||||||
|
const harness: ChatRuntime = { kind: 'harness' };
|
||||||
|
|
||||||
|
/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */
|
||||||
|
const boundConversationService = {
|
||||||
|
attach: () => Promise.reject(new Error('unused')),
|
||||||
|
detach: () => Promise.reject(new Error('unused')),
|
||||||
|
send: () => Promise.reject(new Error('unused')),
|
||||||
|
|
||||||
|
subscribeFrom: async function* () {
|
||||||
|
throw new Error('unused');
|
||||||
|
},
|
||||||
|
} as unknown as HarnessConversationService;
|
||||||
|
|
||||||
|
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
for (const id of adapterIds) {
|
||||||
|
registry.register({
|
||||||
|
id,
|
||||||
|
describe: () => Promise.reject(new Error('unused')),
|
||||||
|
catalog: () => Promise.reject(new Error('unused')),
|
||||||
|
create: () => Promise.reject(new Error('unused')),
|
||||||
|
resume: () => Promise.reject(new Error('unused')),
|
||||||
|
} as HarnessAdapter);
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouter(
|
||||||
|
mode: ChatRuntimeMode,
|
||||||
|
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
|
||||||
|
): ChatRuntimeRouter {
|
||||||
|
return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear down a module that was deliberately driven to a fail-closed init.
|
||||||
|
* `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing
|
||||||
|
* (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed
|
||||||
|
* startup error, this time into teardown. Each caller here has already captured and asserted that
|
||||||
|
* exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise —
|
||||||
|
* swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly.
|
||||||
|
*/
|
||||||
|
async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise<void> {
|
||||||
|
await moduleRef.close().catch((err: unknown) => {
|
||||||
|
if (err instanceof ChatRuntimeUnavailableError) return;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatRuntimeRouter', () => {
|
||||||
|
it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => {
|
||||||
|
const router = buildRouter('pi-rpc', {
|
||||||
|
adapters: ['pi'],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => router.onModuleInit()).not.toThrow();
|
||||||
|
expect(router.active).toBe(harness);
|
||||||
|
expect(router.active.kind).toBe('harness');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => {
|
||||||
|
// Empty registry + unavailable service: legacy must ignore both and still start.
|
||||||
|
const router = buildRouter('legacy', {
|
||||||
|
adapters: [],
|
||||||
|
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => router.onModuleInit()).not.toThrow();
|
||||||
|
expect(router.active).toBe(embedded);
|
||||||
|
expect(router.active.kind).toBe('embedded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed at init when pi-rpc mode has no registered pi adapter', () => {
|
||||||
|
const router = buildRouter('pi-rpc', {
|
||||||
|
adapters: [],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
|
||||||
|
try {
|
||||||
|
router.onModuleInit();
|
||||||
|
expect.unreachable('onModuleInit must throw when the pi adapter is absent');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => {
|
||||||
|
const router = buildRouter('pi-rpc', {
|
||||||
|
adapters: ['pi'],
|
||||||
|
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
router.onModuleInit();
|
||||||
|
expect.unreachable('onModuleInit must throw when the conversation service is unbound');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => {
|
||||||
|
const router = buildRouter('pi-rpc', {
|
||||||
|
adapters: [],
|
||||||
|
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
|
||||||
|
// A failed pi-rpc init must not silently expose the embedded runtime.
|
||||||
|
expect(() => router.active).toThrow();
|
||||||
|
let leaked: ChatRuntime | undefined;
|
||||||
|
try {
|
||||||
|
leaked = router.active;
|
||||||
|
} catch {
|
||||||
|
leaked = undefined;
|
||||||
|
}
|
||||||
|
expect(leaked).not.toBe(embedded);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => {
|
||||||
|
const router = buildRouter('pi-rpc', {
|
||||||
|
adapters: [],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
router.onModuleInit();
|
||||||
|
expect.unreachable('onModuleInit must throw');
|
||||||
|
} catch (err) {
|
||||||
|
const message = (err as ChatRuntimeUnavailableError).message;
|
||||||
|
expect(message).toBe(
|
||||||
|
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
|
||||||
|
);
|
||||||
|
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input).
|
||||||
|
*
|
||||||
|
* The unit suite above constructs the router but never invokes a legacy port operation, so the
|
||||||
|
* six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that
|
||||||
|
* deletes one of them SURVIVES for lack of a test that drives that operation. This group closes
|
||||||
|
* that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a
|
||||||
|
* valid branded {@link OwnedConversationContext} and valid input, against a recording embedded
|
||||||
|
* stub whose method returns a distinguishable `ok:true` success and increments a per-op counter.
|
||||||
|
*
|
||||||
|
* For each operation:
|
||||||
|
* - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }`
|
||||||
|
* result AND that the embedded stub was touched zero times (no effects);
|
||||||
|
* - the paired legacy test proves that same stub method IS reached and returns its distinguishable
|
||||||
|
* success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful,
|
||||||
|
* not vacuously true because the stub could never be called.
|
||||||
|
*
|
||||||
|
* Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED
|
||||||
|
* (the router returns the embedded `ok:true` value and records the call), with every outer guard
|
||||||
|
* and the other five inner guards intact. `next` is untouched; nothing here changes production.
|
||||||
|
*/
|
||||||
|
describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => {
|
||||||
|
const RUNTIME_UNSUPPORTED = {
|
||||||
|
ok: false,
|
||||||
|
code: 'runtime_unsupported',
|
||||||
|
retryable: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PRESENTATION: LegacySessionPresentation = {
|
||||||
|
provider: 'embedded-provider',
|
||||||
|
modelId: 'embedded-model',
|
||||||
|
thinkingLevel: 'low',
|
||||||
|
availableThinkingLevels: ['low', 'high'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream: LegacyRuntimeStream = {
|
||||||
|
channelId: 'websocket:test-socket',
|
||||||
|
onEvent: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = (): OwnedConversationContext =>
|
||||||
|
ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-operation invocation counters with declared keys (not an index signature) so each
|
||||||
|
* `calls.<op>` is definitely `number` under `noUncheckedIndexedAccess`.
|
||||||
|
*/
|
||||||
|
type LegacyPortCallCounts = {
|
||||||
|
completeLegacyRestTurn: number;
|
||||||
|
prepareLegacySocketTurn: number;
|
||||||
|
setLegacyThinking: number;
|
||||||
|
abortLegacyTurn: number;
|
||||||
|
applyLegacyModelOverride: number;
|
||||||
|
readLegacySessionPresentation: number;
|
||||||
|
dispatchVerifiedDiscordIngress: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An embedded port that records every invocation and returns a distinguishable `ok:true`
|
||||||
|
* value per operation. If a router op reaches it (its guard removed), both the recorded call
|
||||||
|
* count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result.
|
||||||
|
*/
|
||||||
|
function recordingEmbeddedPort(): {
|
||||||
|
port: ChatRuntime & LegacyEmbeddedChatPort;
|
||||||
|
calls: LegacyPortCallCounts;
|
||||||
|
} {
|
||||||
|
const calls: LegacyPortCallCounts = {
|
||||||
|
completeLegacyRestTurn: 0,
|
||||||
|
prepareLegacySocketTurn: 0,
|
||||||
|
setLegacyThinking: 0,
|
||||||
|
abortLegacyTurn: 0,
|
||||||
|
applyLegacyModelOverride: 0,
|
||||||
|
readLegacySessionPresentation: 0,
|
||||||
|
dispatchVerifiedDiscordIngress: 0,
|
||||||
|
};
|
||||||
|
const lease: LegacySocketTurnLease = {
|
||||||
|
presentation: PRESENTATION,
|
||||||
|
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
|
||||||
|
dispose: () => Promise.resolve(),
|
||||||
|
};
|
||||||
|
const port: ChatRuntime & LegacyEmbeddedChatPort = {
|
||||||
|
kind: 'embedded',
|
||||||
|
completeLegacyRestTurn: () => {
|
||||||
|
calls.completeLegacyRestTurn += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
value: { text: 'EMBEDDED-REST', presentation: PRESENTATION },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
prepareLegacySocketTurn: () => {
|
||||||
|
calls.prepareLegacySocketTurn += 1;
|
||||||
|
return Promise.resolve({ ok: true, value: lease });
|
||||||
|
},
|
||||||
|
setLegacyThinking: () => {
|
||||||
|
calls.setLegacyThinking += 1;
|
||||||
|
return { ok: true, value: PRESENTATION };
|
||||||
|
},
|
||||||
|
abortLegacyTurn: () => {
|
||||||
|
calls.abortLegacyTurn += 1;
|
||||||
|
return Promise.resolve({ ok: true, value: undefined });
|
||||||
|
},
|
||||||
|
applyLegacyModelOverride: () => {
|
||||||
|
calls.applyLegacyModelOverride += 1;
|
||||||
|
return { ok: true, value: PRESENTATION };
|
||||||
|
},
|
||||||
|
readLegacySessionPresentation: () => {
|
||||||
|
calls.readLegacySessionPresentation += 1;
|
||||||
|
return { ok: true, value: PRESENTATION };
|
||||||
|
},
|
||||||
|
dispatchVerifiedDiscordIngress: () => {
|
||||||
|
calls.dispatchVerifiedDiscordIngress += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
presentation: PRESENTATION,
|
||||||
|
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
|
||||||
|
dispose: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { port, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
|
||||||
|
return new ChatRuntimeRouter(
|
||||||
|
registryWith(['pi']),
|
||||||
|
boundConversationService,
|
||||||
|
port,
|
||||||
|
harness,
|
||||||
|
'pi-rpc',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
|
||||||
|
return new ChatRuntimeRouter(
|
||||||
|
registryWith([]),
|
||||||
|
boundConversationService,
|
||||||
|
port,
|
||||||
|
harness,
|
||||||
|
'legacy',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeLegacyRestTurn ---------------------------------------------------
|
||||||
|
it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.completeLegacyRestTurn).toBe(0);
|
||||||
|
});
|
||||||
|
it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.completeLegacyRestTurn).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// prepareLegacySocketTurn --------------------------------------------------
|
||||||
|
it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await piRouter(port).prepareLegacySocketTurn(
|
||||||
|
ctx(),
|
||||||
|
{ content: 'hello' },
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.prepareLegacySocketTurn).toBe(0);
|
||||||
|
});
|
||||||
|
it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await legacyRouter(port).prepareLegacySocketTurn(
|
||||||
|
ctx(),
|
||||||
|
{ content: 'hello' },
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.prepareLegacySocketTurn).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// setLegacyThinking (sync) -------------------------------------------------
|
||||||
|
it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = piRouter(port).setLegacyThinking(ctx(), 'high');
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.setLegacyThinking).toBe(0);
|
||||||
|
});
|
||||||
|
it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = legacyRouter(port).setLegacyThinking(ctx(), 'high');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.setLegacyThinking).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// abortLegacyTurn ----------------------------------------------------------
|
||||||
|
it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await piRouter(port).abortLegacyTurn(ctx());
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.abortLegacyTurn).toBe(0);
|
||||||
|
});
|
||||||
|
it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = await legacyRouter(port).abortLegacyTurn(ctx());
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.abortLegacyTurn).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// applyLegacyModelOverride (sync) ------------------------------------------
|
||||||
|
it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.applyLegacyModelOverride).toBe(0);
|
||||||
|
});
|
||||||
|
it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.applyLegacyModelOverride).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// readLegacySessionPresentation (sync) -------------------------------------
|
||||||
|
it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = piRouter(port).readLegacySessionPresentation(ctx());
|
||||||
|
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||||
|
expect(calls.readLegacySessionPresentation).toBe(0);
|
||||||
|
});
|
||||||
|
it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const result = legacyRouter(port).readLegacySessionPresentation(ctx());
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.readLegacySessionPresentation).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) ---------
|
||||||
|
it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => {
|
||||||
|
const { port, calls } = recordingEmbeddedPort();
|
||||||
|
const discordCtx = ctx() as unknown as Parameters<
|
||||||
|
ChatRuntimeRouter['dispatchVerifiedDiscordIngress']
|
||||||
|
>[0];
|
||||||
|
const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(calls.dispatchVerifiedDiscordIngress).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Two — group 1 (real Nest module-graph readiness).
|
||||||
|
*
|
||||||
|
* The unit suite above constructs the router directly. This group drives the SAME contract
|
||||||
|
* through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting
|
||||||
|
* idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry`
|
||||||
|
* via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest
|
||||||
|
* lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose
|
||||||
|
* `onModuleInit` throws a generic Error, so:
|
||||||
|
* - readiness cases fail because the graph never comes up (init rejects), and
|
||||||
|
* - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed
|
||||||
|
* `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that
|
||||||
|
* "throws anything" cannot mask these greens.
|
||||||
|
* The router is NOT wired into a production module yet, so it is provided here via a factory
|
||||||
|
* over the real registry token. Importing the real `ChatModule` bare is deliberately avoided:
|
||||||
|
* it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a
|
||||||
|
* collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router.
|
||||||
|
*/
|
||||||
|
describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => {
|
||||||
|
async function bootRouterGraph(
|
||||||
|
mode: ChatRuntimeMode,
|
||||||
|
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
|
||||||
|
) {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [HarnessModule],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ChatRuntimeRouter,
|
||||||
|
useFactory: (registry: HarnessRegistry) =>
|
||||||
|
new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode),
|
||||||
|
inject: [HARNESS_REGISTRY],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
// The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern,
|
||||||
|
// never exercised here); stub it so the graph resolves. The registry is NOT overridden —
|
||||||
|
// group 1 asserts against the genuine production HarnessRegistry.
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
// Resolve the production registry singleton and register the requested adapters ON IT, so
|
||||||
|
// the router (which injects the same singleton) sees them when its lifecycle hook runs.
|
||||||
|
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||||
|
for (const id of opts.adapters) {
|
||||||
|
registry.register({
|
||||||
|
id,
|
||||||
|
describe: () => Promise.reject(new Error('unused')),
|
||||||
|
catalog: () => Promise.reject(new Error('unused')),
|
||||||
|
create: () => Promise.reject(new Error('unused')),
|
||||||
|
resume: () => Promise.reject(new Error('unused')),
|
||||||
|
} as HarnessAdapter);
|
||||||
|
}
|
||||||
|
return moduleRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture an init rejection without letting a resolved init masquerade as success.
|
||||||
|
const initError = (moduleRef: { init(): Promise<unknown> }): Promise<unknown> =>
|
||||||
|
moduleRef.init().then(
|
||||||
|
() => new Error('module init resolved but the contract requires it to reject'),
|
||||||
|
(err: unknown) => err,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('brings the graph up and resolves only the harness runtime in pi-rpc mode (pi adapter + bound service)', async () => {
|
||||||
|
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||||
|
adapters: ['pi'],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await moduleRef.init();
|
||||||
|
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||||
|
expect(router.active).toBe(harness);
|
||||||
|
expect(router.active.kind).toBe('harness');
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brings the graph up in legacy mode over the REAL empty HarnessRegistry and resolves only the embedded runtime', async () => {
|
||||||
|
const moduleRef = await bootRouterGraph('legacy', {
|
||||||
|
adapters: [],
|
||||||
|
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Defense-in-depth: the production module wires the genuine registry, empty by default —
|
||||||
|
// guards against a test-double registry silently satisfying the readiness check.
|
||||||
|
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||||
|
expect(registry).toBeInstanceOf(HarnessRegistry);
|
||||||
|
expect(registry.list()).toHaveLength(0);
|
||||||
|
|
||||||
|
await moduleRef.init();
|
||||||
|
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||||
|
expect(router.active).toBe(embedded);
|
||||||
|
expect(router.active.kind).toBe('embedded');
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed at module init when pi-rpc mode has no registered pi adapter (specific typed error, not a stub throw)', async () => {
|
||||||
|
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||||
|
adapters: [],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const err = await initError(moduleRef);
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
} finally {
|
||||||
|
await closeIgnoringFailedInit(moduleRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed at module init when pi-rpc mode has the unavailable conversation-service sentinel', async () => {
|
||||||
|
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||||
|
adapters: ['pi'],
|
||||||
|
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const err = await initError(moduleRef);
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
} finally {
|
||||||
|
await closeIgnoringFailedInit(moduleRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces only fixed, browser-safe failure text when the graph fails closed (no stub/exception detail)', async () => {
|
||||||
|
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||||
|
adapters: [],
|
||||||
|
service: boundConversationService,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const err = await initError(moduleRef);
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
const message = (err as ChatRuntimeUnavailableError).message;
|
||||||
|
expect(message).toBe(
|
||||||
|
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
|
||||||
|
);
|
||||||
|
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(|not implemented/);
|
||||||
|
} finally {
|
||||||
|
await closeIgnoringFailedInit(moduleRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Two — group 1b (production ChatModule wiring, declaration proof).
|
||||||
|
*
|
||||||
|
* Correction #1 (Scrappy fe3e02) asked for a red that imports the real `ChatModule` and calls
|
||||||
|
* `module.init()`. Investigated and found impractical/masking-prone: `ChatModule` provides
|
||||||
|
* `ChatGateway`, whose 10-argument constructor injects app-global providers (AgentService, AUTH,
|
||||||
|
* BRAIN, RoutingEngineService) plus the Commands/GC/Mcp/Reload subsystems across a forwardRef
|
||||||
|
* cycle. Booting it in isolation is a full-app integration boot — "override only unrelated
|
||||||
|
* dependencies" balloons into faking ~4 subsystems, and `overrideProvider` cannot even grant the
|
||||||
|
* cross-module export-scope visibility ChatGateway needs (probe: `ChatGateway` unresolved at
|
||||||
|
* `CommandExecutorService`). That is exactly the STOP-and-return branch of the directive.
|
||||||
|
*
|
||||||
|
* The faithful, unmaskable cover instead of a fragile boot: read the PRODUCTION `ChatModule`'s own
|
||||||
|
* Nest `@Module` metadata to prove it DECLARES the exclusive router provider and imports the real
|
||||||
|
* `HarnessModule` (the genuine registry source). This inspects the actual module object — not
|
||||||
|
* source text, not a test factory — so nothing can mask it. Group 1 above separately proves the
|
||||||
|
* router RESOLVES against the real, empty `HarnessRegistry` through the Nest lifecycle; the union
|
||||||
|
* of the two covers "the router is wired through ChatModule to the real registry" without the
|
||||||
|
* impractical single-graph boot. RED today (ChatModule provides only ChatGateway and imports only
|
||||||
|
* CommandsModule); GREEN once Step Three registers the router and imports HarnessModule.
|
||||||
|
*/
|
||||||
|
describe('ChatModule production wiring (Task Five, Step Two group 1b — declaration proof)', () => {
|
||||||
|
// Unwrap a forwardRef(() => Module) import to the module it references; pass others through.
|
||||||
|
const resolveImport = (imp: unknown): unknown =>
|
||||||
|
imp &&
|
||||||
|
typeof imp === 'object' &&
|
||||||
|
typeof (imp as { forwardRef?: unknown }).forwardRef === 'function'
|
||||||
|
? (imp as { forwardRef: () => unknown }).forwardRef()
|
||||||
|
: imp;
|
||||||
|
|
||||||
|
// A provider entry is either a class (shorthand) or a { provide, ... } object; take its token.
|
||||||
|
const providerToken = (provider: unknown): unknown =>
|
||||||
|
typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
|
||||||
|
|
||||||
|
it('declares the exclusive ChatRuntimeRouter as a provider on the production ChatModule', () => {
|
||||||
|
const providers: unknown[] = Reflect.getMetadata('providers', ChatModule) ?? [];
|
||||||
|
expect(providers.map(providerToken)).toContain(ChatRuntimeRouter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports the real HarnessModule into the production ChatModule (registry source, not a test double)', () => {
|
||||||
|
const imports: unknown[] = Reflect.getMetadata('imports', ChatModule) ?? [];
|
||||||
|
expect(imports.map(resolveImport)).toContain(HarnessModule);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Two — group 1c (bounded real-`ChatModule` boot).
|
||||||
|
*
|
||||||
|
* Scrappy adjudication d67d2b (option c): boot the ACTUAL production `ChatModule` as the SUT and
|
||||||
|
* assert the exclusive router resolves THROUGH it — the single-graph proof group 1 (router over the
|
||||||
|
* real registry) and group 1b (production-module metadata) each cover only a half of. The heavy,
|
||||||
|
* UNRELATED cycle is the only thing bounded away, per the established isolation pattern in
|
||||||
|
* `apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`:
|
||||||
|
* - `CommandsModule` (drags the Commands <-> Reload <-> Chat forwardRef cycle plus GC/Mcp/queue)
|
||||||
|
* is replaced wholesale with an empty module via `.overrideModule(...).useModule(...)`;
|
||||||
|
* - `ChatGateway` (10-arg constructor, an HTTP/socket concern never exercised here) is replaced
|
||||||
|
* with an inert value;
|
||||||
|
* - the sole legacy-controller dependency, `AgentService`, is supplied by a tiny `@Global()` stub;
|
||||||
|
* - the HTTP-only `AuthGuard` is stubbed.
|
||||||
|
* Nothing about the router, `HarnessModule`, the registry, or the conversation-service binding is
|
||||||
|
* faked in the production-legacy case — those are retrieved from the REAL `ChatModule` graph. Mode
|
||||||
|
* is driven only through the production `CHAT_HARNESS_RUNTIME` env contract (`resolveChatRuntimeMode`).
|
||||||
|
*
|
||||||
|
* Red-first: today `ChatModule` neither imports `HarnessModule` nor provides `ChatRuntimeRouter`, so
|
||||||
|
* the booted graph contains no router/registry/conversation-service tokens. `init()` may resolve
|
||||||
|
* (there is no router lifecycle hook yet to reject), so every case fails on the MISSING actual
|
||||||
|
* router/registry/service wiring — not on unrelated DI, which is bounded away. GREEN at Step Three
|
||||||
|
* once `ChatModule` imports `HarnessModule`, provides the exclusive router, and binds the
|
||||||
|
* conversation-service token (defaulting to the unavailable sentinel).
|
||||||
|
*/
|
||||||
|
describe('ChatModule bounded real boot (Task Five, Step Two group 1c)', () => {
|
||||||
|
// The unrelated heavy cycle, replaced wholesale — not stubbed provider-by-provider.
|
||||||
|
@Module({})
|
||||||
|
class EmptyCommandsModule {}
|
||||||
|
|
||||||
|
// The ONLY genuine legacy dependency of the real ChatController, supplied inertly and globally so
|
||||||
|
// the pre-refactor controller instantiates without dragging AgentModule into the graph.
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [{ provide: AgentService, useValue: {} }],
|
||||||
|
exports: [AgentService],
|
||||||
|
})
|
||||||
|
class LegacyControllerDepsModule {}
|
||||||
|
|
||||||
|
const ORIGINAL_RUNTIME_ENV = process.env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIGINAL_RUNTIME_ENV === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
else process.env['CHAT_HARNESS_RUNTIME'] = ORIGINAL_RUNTIME_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the real ChatModule with only the unrelated cycle bounded away. `mode` is set through the
|
||||||
|
* genuine production env contract before providers instantiate. The optional overrides replace
|
||||||
|
* the registry / conversation-service the router injects, exercising the pi-rpc precondition
|
||||||
|
* branches through the ACTUAL module (they are no-ops today because those tokens are not yet in
|
||||||
|
* the graph — which is exactly why the router-retrieval assertions go red).
|
||||||
|
*/
|
||||||
|
async function bootChatModule(
|
||||||
|
mode: ChatRuntimeMode,
|
||||||
|
overrides: {
|
||||||
|
registryAdapters?: readonly string[];
|
||||||
|
conversationService?: HarnessConversationServiceBinding;
|
||||||
|
} = {},
|
||||||
|
): Promise<TestingModule> {
|
||||||
|
if (mode === 'pi-rpc') process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
else delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
|
||||||
|
let builder = Test.createTestingModule({
|
||||||
|
imports: [LegacyControllerDepsModule, ChatModule],
|
||||||
|
})
|
||||||
|
.overrideModule(CommandsModule)
|
||||||
|
.useModule(EmptyCommandsModule)
|
||||||
|
.overrideProvider(ChatGateway)
|
||||||
|
.useValue({})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true });
|
||||||
|
|
||||||
|
if (overrides.registryAdapters) {
|
||||||
|
builder = builder
|
||||||
|
.overrideProvider(HARNESS_REGISTRY)
|
||||||
|
.useValue(registryWith(overrides.registryAdapters));
|
||||||
|
}
|
||||||
|
if (overrides.conversationService !== undefined) {
|
||||||
|
builder = builder
|
||||||
|
.overrideProvider(HARNESS_CONVERSATION_SERVICE)
|
||||||
|
.useValue(overrides.conversationService);
|
||||||
|
}
|
||||||
|
return builder.compile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture an init rejection without letting a resolved init masquerade as success.
|
||||||
|
const initError = (moduleRef: TestingModule): Promise<unknown> =>
|
||||||
|
moduleRef.init().then(
|
||||||
|
() => new Error('module init resolved but the contract requires it to reject'),
|
||||||
|
(err: unknown) => err,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('legacy mode: the actual router resolves the embedded runtime, the actual registry is empty, and the conversation-service token is the unavailable sentinel', async () => {
|
||||||
|
const moduleRef = await bootChatModule('legacy');
|
||||||
|
try {
|
||||||
|
await moduleRef.init();
|
||||||
|
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||||
|
expect(router.active.kind).toBe('embedded');
|
||||||
|
|
||||||
|
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||||
|
expect(registry).toBeInstanceOf(HarnessRegistry);
|
||||||
|
expect(registry.list()).toHaveLength(0);
|
||||||
|
|
||||||
|
const service = moduleRef.get<HarnessConversationServiceBinding>(
|
||||||
|
HARNESS_CONVERSATION_SERVICE,
|
||||||
|
{
|
||||||
|
strict: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(service).toBe(HARNESS_CONVERSATION_SERVICE_UNAVAILABLE);
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi-rpc mode over the REAL empty registry fails closed at init with the typed adapter-unavailable error', async () => {
|
||||||
|
const moduleRef = await bootChatModule('pi-rpc');
|
||||||
|
try {
|
||||||
|
const err = await initError(moduleRef);
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
} finally {
|
||||||
|
await closeIgnoringFailedInit(moduleRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi-rpc mode with a pi adapter present but the sentinel conversation service fails closed with the typed conversation-service-unavailable error', async () => {
|
||||||
|
const moduleRef = await bootChatModule('pi-rpc', {
|
||||||
|
registryAdapters: ['pi'],
|
||||||
|
conversationService: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const err = await initError(moduleRef);
|
||||||
|
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||||
|
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||||
|
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||||
|
} finally {
|
||||||
|
await closeIgnoringFailedInit(moduleRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pi-rpc mode with a pi adapter and a bound conversation service: the actual router selects the harness runtime', async () => {
|
||||||
|
const moduleRef = await bootChatModule('pi-rpc', {
|
||||||
|
registryAdapters: ['pi'],
|
||||||
|
conversationService: boundConversationService,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await moduleRef.init();
|
||||||
|
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||||
|
expect(router.active.kind).toBe('harness');
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Two — group 2 (WHOLE production `AppModule` boot, legacy end-to-end wiring).
|
||||||
|
*
|
||||||
|
* The groups above bound away the heavy cycle to isolate the router. This group instead boots the
|
||||||
|
* ACTUAL production `AppModule` (the exact graph `main.ts` runs) in the default LEGACY chat-runtime
|
||||||
|
* mode, overriding ONLY the storage/network side-effect adapters so the boot is bounded and offline
|
||||||
|
* — never the chat/router/harness/reload/commands surface under test. The bounded fakes are exactly
|
||||||
|
* the disk/network leaves:
|
||||||
|
* - `ProviderService` (the #1 hang risk: its real `onModuleInit` starts an unref'd health-check
|
||||||
|
* `setInterval` and fetches Ollama over HTTP) → inert no-op instance;
|
||||||
|
* - `DB_HANDLE`/`DB` → a fake Drizzle-shaped handle that satisfies `runPgliteMigrations` (the local
|
||||||
|
* tier's `DatabaseModule.onModuleInit`) AND `DefaultRoutingRulesSeed.onModuleInit` (which reads a
|
||||||
|
* system-rule count — the fake reports rules already present so the seed insert is skipped),
|
||||||
|
* opening no real database;
|
||||||
|
* - `STORAGE_ADAPTER`/`MEMORY`/`MEMORY_ADAPTER`/`AUTH`/`BRAIN`/`LOG_SERVICE` → inert fakes so no
|
||||||
|
* storage/auth/log backend is contacted.
|
||||||
|
* Local tier (the repo's `mosaic.config.json`) already disables BullMQ/Redis and the queue handles;
|
||||||
|
* Discord/Telegram/MCP plugins are env-gated and disarmed by deleting their tokens. Nothing about the
|
||||||
|
* router, `ChatModule`, `HarnessModule`, or `ChatGateway` is faked — those come from the REAL graph.
|
||||||
|
*
|
||||||
|
* The boot+init MUST SUCCEED cleanly (proven by `beforeAll` completing and the ChatGateway test
|
||||||
|
* passing). Red-first: on this branch `ChatRuntimeRouter` is registered in NO module (ChatModule
|
||||||
|
* provides only ChatGateway), so `moduleRef.get(ChatRuntimeRouter)` throws `UnknownElementException`
|
||||||
|
* — a WIRING gap, NOT an init failure. That single retrieval is the intended behavioural red; it
|
||||||
|
* flips green once Step Three registers the exclusive router. The ChatGateway retrieval and its
|
||||||
|
* browser-facing method surface are asserted alongside and pass today, pinning that the boot itself
|
||||||
|
* is healthy so the router failure cannot be mistaken for a mis-shaped fake or an unbounded side
|
||||||
|
* effect.
|
||||||
|
*/
|
||||||
|
describe('AppModule production boot — legacy ChatRuntimeRouter wiring (Task Five, Step Two group 2)', () => {
|
||||||
|
// A Drizzle-shaped fake that satisfies both DB consumers reached during a local-tier init:
|
||||||
|
// • runPgliteMigrations(): reads handle.db.$client.exec + handle.db.execute(SELECT hashes);
|
||||||
|
// exec is a no-op and execute yields an empty ledger, so migration statements no-op through.
|
||||||
|
// • DefaultRoutingRulesSeed.seedDefaultRules(): db.select().from().where() must resolve to a
|
||||||
|
// row set — we report a non-zero system-rule count so the seeding INSERT branch is skipped.
|
||||||
|
const fakeDb = {
|
||||||
|
$client: { exec: async (): Promise<void> => {} },
|
||||||
|
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: () => ({ values: async (): Promise<void> => {} }),
|
||||||
|
};
|
||||||
|
const fakeDbHandle = { db: fakeDb, close: async (): Promise<void> => {} };
|
||||||
|
const fakeStorageAdapter = {
|
||||||
|
name: 'fake',
|
||||||
|
migrate: async (): Promise<void> => {},
|
||||||
|
close: async (): Promise<void> => {},
|
||||||
|
};
|
||||||
|
// Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch.
|
||||||
|
const fakeProviderService = {
|
||||||
|
onModuleInit: async (): Promise<void> => {},
|
||||||
|
onModuleDestroy: (): void => {},
|
||||||
|
getRegistry: () => ({
|
||||||
|
getAvailable: () => [],
|
||||||
|
getAll: () => [],
|
||||||
|
find: () => undefined,
|
||||||
|
}),
|
||||||
|
getDefaultModel: () => undefined,
|
||||||
|
listAvailableModels: () => [],
|
||||||
|
listProviders: () => [],
|
||||||
|
getAdapter: () => undefined,
|
||||||
|
getProvidersHealth: () => [],
|
||||||
|
};
|
||||||
|
const fakeBrain = { conversations: {}, agents: {} };
|
||||||
|
|
||||||
|
const BOOT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
let moduleRef: TestingModule;
|
||||||
|
let envSnapshot: Record<string, string | undefined>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
envSnapshot = { ...process.env };
|
||||||
|
// Env hygiene: disarm the network-facing plugins/adapters and pin the legacy runtime mode.
|
||||||
|
delete process.env['DATABASE_URL'];
|
||||||
|
delete process.env['DISCORD_BOT_TOKEN'];
|
||||||
|
delete process.env['TELEGRAM_BOT_TOKEN'];
|
||||||
|
delete process.env['MCP_SERVERS'];
|
||||||
|
delete process.env['CHAT_HARNESS_RUNTIME']; // resolveChatRuntimeMode → 'legacy'
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] = 'local';
|
||||||
|
|
||||||
|
moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||||
|
// Storage/network side-effect adapters ONLY — never the router/chat/harness surface under test.
|
||||||
|
.overrideProvider('DB_HANDLE')
|
||||||
|
.useValue(fakeDbHandle)
|
||||||
|
.overrideProvider('DB')
|
||||||
|
.useValue(fakeDb)
|
||||||
|
.overrideProvider('STORAGE_ADAPTER')
|
||||||
|
.useValue(fakeStorageAdapter)
|
||||||
|
.overrideProvider('AUTH')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider('BRAIN')
|
||||||
|
.useValue(fakeBrain)
|
||||||
|
.overrideProvider('LOG_SERVICE')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider('MEMORY')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider('MEMORY_ADAPTER')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(ProviderService)
|
||||||
|
.useValue(fakeProviderService)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
// The boot itself MUST succeed cleanly — a rejection here is a bounding failure, not the red.
|
||||||
|
await moduleRef.init();
|
||||||
|
}, BOOT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (moduleRef) await moduleRef.close();
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in envSnapshot)) delete process.env[key];
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(envSnapshot)) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Passes TODAY: the real ChatGateway is provided by the real ChatModule and its browser-facing
|
||||||
|
// surface exists. This pins that the whole-AppModule boot came up healthy, so the router failure
|
||||||
|
// below is unambiguously a wiring gap and not a mis-shaped fake or an unbounded side effect.
|
||||||
|
it('boots the whole AppModule and exposes the real ChatGateway with its browser-facing methods', () => {
|
||||||
|
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||||
|
expect(typeof gateway.broadcastReload).toBe('function');
|
||||||
|
expect(typeof gateway.getModelOverride).toBe('function');
|
||||||
|
expect(typeof gateway.setModelOverride).toBe('function');
|
||||||
|
expect(typeof gateway.broadcastSessionInfo).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
// RED TODAY: ChatRuntimeRouter is registered in no module on this branch, so this retrieval throws
|
||||||
|
// UnknownElementException — the intended red-first wiring failure. GREEN once Step Three registers
|
||||||
|
// the exclusive router in the production graph, where legacy mode resolves the embedded runtime.
|
||||||
|
it('resolves the exclusive ChatRuntimeRouter to the embedded runtime in legacy mode', () => {
|
||||||
|
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||||
|
expect(router.active.kind).toBe('embedded');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||||
|
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||||
|
import {
|
||||||
|
isHarnessConversationServiceAvailable,
|
||||||
|
type HarnessConversationServiceBinding,
|
||||||
|
} from '../harness/harness.tokens.js';
|
||||||
|
import type {
|
||||||
|
ChatRuntime,
|
||||||
|
ChatRuntimeMode,
|
||||||
|
LegacyBrowserMessagePayload,
|
||||||
|
LegacyEmbeddedChatPort,
|
||||||
|
LegacyRuntimeResult,
|
||||||
|
LegacyRuntimeStream,
|
||||||
|
LegacySessionPresentation,
|
||||||
|
LegacySocketTurnLease,
|
||||||
|
OwnedConversationContext,
|
||||||
|
VerifiedDiscordIngressContext,
|
||||||
|
VerifiedDiscordTurnLease,
|
||||||
|
} from './chat-runtime.js';
|
||||||
|
import { ChatRuntimeUnavailableError, resolveChatRuntimeMode } from './chat-runtime.js';
|
||||||
|
|
||||||
|
/** The fixed fail-closed result for a legacy browser operation issued under `pi-rpc`. */
|
||||||
|
const RUNTIME_UNSUPPORTED = {
|
||||||
|
ok: false as const,
|
||||||
|
code: 'runtime_unsupported' as const,
|
||||||
|
retryable: false as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the one live {@link ChatRuntime} for this process and enforces the
|
||||||
|
* `pi-rpc` readiness preconditions at module init — before the gateway accepts
|
||||||
|
* traffic. It never falls back from `pi-rpc` to embedded execution: an unmet
|
||||||
|
* `pi-rpc` precondition is a typed startup failure ({@link ChatRuntimeUnavailableError}),
|
||||||
|
* and until `onModuleInit` selects a runtime, {@link active} throws rather than
|
||||||
|
* exposing any runtime — a failed `pi-rpc` init can never leak the embedded one.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ChatRuntimeRouter implements OnModuleInit, LegacyEmbeddedChatPort {
|
||||||
|
private readonly mode: ChatRuntimeMode;
|
||||||
|
|
||||||
|
/** The single resolved runtime. Undefined until a successful `onModuleInit`. */
|
||||||
|
private resolved: ChatRuntime | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly harnessRegistry: HarnessRegistry,
|
||||||
|
private readonly conversationService: HarnessConversationServiceBinding,
|
||||||
|
private readonly embedded: ChatRuntime,
|
||||||
|
private readonly harness: ChatRuntime,
|
||||||
|
mode: ChatRuntimeMode = resolveChatRuntimeMode(),
|
||||||
|
) {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
if (this.mode === 'legacy') {
|
||||||
|
// Legacy ignores the pi-rpc preconditions entirely and always runs embedded.
|
||||||
|
this.resolved = this.embedded;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pi-rpc: both preconditions are hard startup failures, checked in a fixed order.
|
||||||
|
if (!this.harnessRegistry.has('pi')) {
|
||||||
|
this.resolved = undefined;
|
||||||
|
throw new ChatRuntimeUnavailableError('adapter_unavailable');
|
||||||
|
}
|
||||||
|
if (!isHarnessConversationServiceAvailable(this.conversationService)) {
|
||||||
|
this.resolved = undefined;
|
||||||
|
throw new ChatRuntimeUnavailableError('conversation_service_unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resolved = this.harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
get active(): ChatRuntime {
|
||||||
|
if (this.resolved === undefined) {
|
||||||
|
// Reached only if init has not run or failed closed; never expose a runtime here.
|
||||||
|
throw new Error('The chat runtime is not available: startup did not resolve a runtime.');
|
||||||
|
}
|
||||||
|
return this.resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The process-wide mode, available before {@link onModuleInit}. Production handlers read
|
||||||
|
* this to fail a legacy browser turn closed under `pi-rpc` *before* parsing the payload as
|
||||||
|
* either browser-legacy input or a Discord envelope — never to branch into a fallback.
|
||||||
|
*/
|
||||||
|
get runtimeMode(): ChatRuntimeMode {
|
||||||
|
return this.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The embedded runtime narrowed to its port. Only reached on the legacy path (and for the
|
||||||
|
* verified-Discord op in both modes), where the injected runtime is always a real
|
||||||
|
* `EmbeddedChatRuntime`. The router spec constructs the router with a bare `{ kind }` stub
|
||||||
|
* but never invokes a port op, so this narrowing is never exercised against the stub.
|
||||||
|
*/
|
||||||
|
private get embeddedPort(): LegacyEmbeddedChatPort {
|
||||||
|
return this.embedded as unknown as LegacyEmbeddedChatPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LegacyEmbeddedChatPort: legacy browser operations fail closed under pi-rpc ---
|
||||||
|
|
||||||
|
completeLegacyRestTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: Readonly<{ content: string }>,
|
||||||
|
): Promise<
|
||||||
|
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||||
|
> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||||
|
}
|
||||||
|
return this.embeddedPort.completeLegacyRestTurn(context, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareLegacySocketTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: LegacyBrowserMessagePayload,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||||
|
}
|
||||||
|
return this.embeddedPort.prepareLegacySocketTurn(context, input, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLegacyThinking(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
level: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return RUNTIME_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
return this.embeddedPort.setLegacyThinking(context, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||||
|
}
|
||||||
|
return this.embeddedPort.abortLegacyTurn(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLegacyModelOverride(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
modelId: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return RUNTIME_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
return this.embeddedPort.applyLegacyModelOverride(context, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
readLegacySessionPresentation(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
if (this.mode === 'pi-rpc') {
|
||||||
|
return RUNTIME_UNSUPPORTED;
|
||||||
|
}
|
||||||
|
return this.embeddedPort.readLegacySessionPresentation(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verified Discord ingress bypasses browser mode: it is embedded-only in BOTH modes and
|
||||||
|
* never reaches the harness or routing-engine selection. It is reached only through a
|
||||||
|
* {@link VerifiedDiscordIngressContext}, which exists only after every ingress check.
|
||||||
|
*/
|
||||||
|
dispatchVerifiedDiscordIngress(
|
||||||
|
context: VerifiedDiscordIngressContext,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
|
||||||
|
return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single chat execution strategy resolved by {@link ChatRuntimeRouter}.
|
||||||
|
*
|
||||||
|
* Exactly one runtime is live per process. There is no union that lets a
|
||||||
|
* `pi-rpc` deployment silently fall back to embedded execution: an unmet
|
||||||
|
* `pi-rpc` precondition is a typed startup failure, never a downgrade.
|
||||||
|
*/
|
||||||
|
export type ChatRuntimeMode = 'legacy' | 'pi-rpc';
|
||||||
|
|
||||||
|
export type ChatRuntimeKind = 'embedded' | 'harness';
|
||||||
|
|
||||||
|
/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */
|
||||||
|
export interface ChatRuntime {
|
||||||
|
readonly kind: ChatRuntimeKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */
|
||||||
|
export type ChatRuntimeUnavailableReason =
|
||||||
|
| 'adapter_unavailable'
|
||||||
|
| 'conversation_service_unavailable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised at module init when `pi-rpc` mode is selected but its preconditions are
|
||||||
|
* unmet. Carries only fixed, browser-safe text — never a raw exception message,
|
||||||
|
* stack, or provider detail — and reports the frozen ack code `runtime_unsupported`.
|
||||||
|
*/
|
||||||
|
export class ChatRuntimeUnavailableError extends Error {
|
||||||
|
readonly code = 'runtime_unsupported' as const;
|
||||||
|
readonly reason: ChatRuntimeUnavailableReason;
|
||||||
|
|
||||||
|
constructor(reason: ChatRuntimeUnavailableReason) {
|
||||||
|
super(
|
||||||
|
reason === 'adapter_unavailable'
|
||||||
|
? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.'
|
||||||
|
: 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.',
|
||||||
|
);
|
||||||
|
this.name = 'ChatRuntimeUnavailableError';
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the process-wide chat runtime mode from the environment. Anything other
|
||||||
|
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
|
||||||
|
*/
|
||||||
|
export function resolveChatRuntimeMode(
|
||||||
|
env: Record<string, string | undefined> = process.env,
|
||||||
|
): ChatRuntimeMode {
|
||||||
|
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transitional embedded chat port (Task Five).
|
||||||
|
//
|
||||||
|
// The legacy embedded browser behaviour is moved behind this exact interface so
|
||||||
|
// neither the controller nor the gateway retains AgentService, RoutingEngine,
|
||||||
|
// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime`
|
||||||
|
// implements the port; `ChatRuntimeRouter` exposes the same narrowly named
|
||||||
|
// operations and returns `runtime_unsupported` before touching Embedded for legacy
|
||||||
|
// browser operations when the mode is `pi-rpc`.
|
||||||
|
//
|
||||||
|
// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion,
|
||||||
|
// legacy Socket streaming, P3 harness turns, and verified Discord are distinct
|
||||||
|
// transport/trust capabilities — there is deliberately no generic
|
||||||
|
// `sendConversationTurn` nor an AgentService-shaped mirror on the router.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser
|
||||||
|
* DTOs are never structurally assignable to it. The factory that mints one may be called
|
||||||
|
* only after authentication with `scopeFromUser(...)`, never with payload authority fields.
|
||||||
|
*/
|
||||||
|
declare const ownedConversationContextBrand: unique symbol;
|
||||||
|
|
||||||
|
/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */
|
||||||
|
export interface OwnedConversationContext {
|
||||||
|
readonly [ownedConversationContextBrand]: true;
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned
|
||||||
|
* conversations all collapse to `conversation_unavailable`. Ownership/mode/validation
|
||||||
|
* failures are total results and never throw.
|
||||||
|
*/
|
||||||
|
export type LegacyRuntimeFailure =
|
||||||
|
| { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false }
|
||||||
|
| { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false }
|
||||||
|
| { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false }
|
||||||
|
| {
|
||||||
|
readonly ok: false;
|
||||||
|
readonly code: 'thinking_level_invalid';
|
||||||
|
readonly retryable: false;
|
||||||
|
readonly availableThinkingLevels: readonly string[];
|
||||||
|
}
|
||||||
|
| { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true }
|
||||||
|
| { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false }
|
||||||
|
| { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean }
|
||||||
|
| { readonly ok: false; readonly code: 'timeout'; readonly retryable: true };
|
||||||
|
|
||||||
|
/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */
|
||||||
|
export type LegacyRuntimeResult<T> =
|
||||||
|
| { readonly ok: true; readonly value: T }
|
||||||
|
| LegacyRuntimeFailure;
|
||||||
|
|
||||||
|
/** User-facing session projection. Carries no session object, handle, or credential path. */
|
||||||
|
export interface LegacySessionPresentation {
|
||||||
|
readonly provider: string;
|
||||||
|
readonly modelId: string;
|
||||||
|
readonly thinkingLevel: string;
|
||||||
|
readonly availableThinkingLevels: readonly string[];
|
||||||
|
readonly agentName?: string;
|
||||||
|
readonly routingDecision?: RoutingDecisionInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terminal usage stats, normalized by Embedded from AgentService metrics. */
|
||||||
|
export interface LegacyUsage {
|
||||||
|
readonly provider: string;
|
||||||
|
readonly modelId: string;
|
||||||
|
readonly thinkingLevel: string;
|
||||||
|
readonly tokens: Readonly<{
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
total: number;
|
||||||
|
}>;
|
||||||
|
readonly cost: number;
|
||||||
|
readonly context: Readonly<{ percent: number | null; window: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw
|
||||||
|
* exception, tool arguments, or credential-bearing path — the gateway sees only these.
|
||||||
|
*/
|
||||||
|
export type LegacyRuntimeEvent =
|
||||||
|
| { readonly type: 'started' }
|
||||||
|
| { readonly type: 'text_delta'; readonly text: string }
|
||||||
|
| { readonly type: 'thinking_delta'; readonly text: string }
|
||||||
|
| {
|
||||||
|
readonly type: 'tool_started';
|
||||||
|
readonly toolCallId: string;
|
||||||
|
readonly toolName: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly type: 'tool_finished';
|
||||||
|
readonly toolCallId: string;
|
||||||
|
readonly toolName: string;
|
||||||
|
readonly isError: boolean;
|
||||||
|
}
|
||||||
|
| { readonly type: 'settled'; readonly usage?: LegacyUsage };
|
||||||
|
|
||||||
|
/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */
|
||||||
|
export interface LegacyBrowserMessagePayload {
|
||||||
|
readonly content: string;
|
||||||
|
readonly provider?: string;
|
||||||
|
readonly modelId?: string;
|
||||||
|
readonly agentId?: string;
|
||||||
|
readonly attachments?: readonly ChannelAttachmentDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A prepared-but-not-yet-dispatched legacy socket turn. */
|
||||||
|
export interface LegacySocketTurnLease {
|
||||||
|
readonly presentation: LegacySessionPresentation;
|
||||||
|
/**
|
||||||
|
* Atomically one-shot and scope-rechecking. A second call returns
|
||||||
|
* `turn_already_dispatched` and performs zero prompt/tool effects.
|
||||||
|
*/
|
||||||
|
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
||||||
|
/** Idempotent, non-throwing. Removes listener and channel, including partial setup. */
|
||||||
|
dispose(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token
|
||||||
|
* auth plus signature, allowlist, binding, expected-route, replay, configured-agent,
|
||||||
|
* forced-scope, and attachment-normalization checks.
|
||||||
|
*/
|
||||||
|
declare const verifiedDiscordIngressContextBrand: unique symbol;
|
||||||
|
|
||||||
|
/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */
|
||||||
|
export interface VerifiedDiscordIngressContext {
|
||||||
|
readonly [verifiedDiscordIngressContextBrand]: true;
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
||||||
|
readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>;
|
||||||
|
readonly content: string;
|
||||||
|
readonly attachments?: readonly ChannelAttachmentDto[];
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly discordMessageId: string;
|
||||||
|
readonly discordUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */
|
||||||
|
export interface VerifiedDiscordTurnLease {
|
||||||
|
readonly presentation: LegacySessionPresentation;
|
||||||
|
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
||||||
|
dispose(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-owned egress projection the runtime pushes normalized events into. */
|
||||||
|
export interface LegacyRuntimeStream {
|
||||||
|
/** Server-derived, e.g. `websocket:<socket-id>`. Never client-supplied. */
|
||||||
|
readonly channelId: string;
|
||||||
|
onEvent(event: LegacyRuntimeEvent): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter`
|
||||||
|
* mirrors the operation names and fails closed with `runtime_unsupported` for legacy
|
||||||
|
* browser operations under `pi-rpc`.
|
||||||
|
*/
|
||||||
|
export interface LegacyEmbeddedChatPort {
|
||||||
|
completeLegacyRestTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: Readonly<{ content: string }>,
|
||||||
|
): Promise<
|
||||||
|
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
prepareLegacySocketTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: LegacyBrowserMessagePayload,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>>;
|
||||||
|
|
||||||
|
setLegacyThinking(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
level: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||||
|
|
||||||
|
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>>;
|
||||||
|
|
||||||
|
applyLegacyModelOverride(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
modelId: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||||
|
|
||||||
|
readLegacySessionPresentation(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||||
|
|
||||||
|
dispatchVerifiedDiscordIngress(
|
||||||
|
context: VerifiedDiscordIngressContext,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass
|
||||||
|
* a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied
|
||||||
|
* authority field. The brand is phantom, so this is the only way to obtain the branded type.
|
||||||
|
*/
|
||||||
|
export function ownConversation(
|
||||||
|
conversationId: string,
|
||||||
|
scope: Readonly<{ userId: string; tenantId: string }>,
|
||||||
|
): OwnedConversationContext {
|
||||||
|
return { conversationId, scope } as unknown as OwnedConversationContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every
|
||||||
|
* ingress check (service-token auth, signature, allowlist, binding, expected-route, replay,
|
||||||
|
* configured-agent, forced-scope, attachment normalization) before calling this.
|
||||||
|
*/
|
||||||
|
export function verifyDiscordIngress(
|
||||||
|
fields: Omit<VerifiedDiscordIngressContext, typeof verifiedDiscordIngressContextBrand>,
|
||||||
|
): VerifiedDiscordIngressContext {
|
||||||
|
return { ...fields } as unknown as VerifiedDiscordIngressContext;
|
||||||
|
}
|
||||||
@@ -3,21 +3,20 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
Logger,
|
Logger,
|
||||||
ForbiddenException,
|
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Inject,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatRequestDto } from './chat.dto.js';
|
import { ChatRequestDto } from './chat.dto.js';
|
||||||
|
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||||
|
import { ownConversation } from './chat-runtime.js';
|
||||||
|
import type { LegacyRuntimeFailure } from './chat-runtime.js';
|
||||||
|
|
||||||
interface ChatResponse {
|
interface ChatResponse {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -29,7 +28,7 @@ interface ChatResponse {
|
|||||||
export class ChatController {
|
export class ChatController {
|
||||||
private readonly logger = new Logger(ChatController.name);
|
private readonly logger = new Logger(ChatController.name);
|
||||||
|
|
||||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
constructor(private readonly runtime: ChatRuntimeRouter) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
@@ -40,68 +39,38 @@ export class ChatController {
|
|||||||
const conversationId = body.conversationId ?? uuid();
|
const conversationId = body.conversationId ?? uuid();
|
||||||
const scope = scopeFromUser(user);
|
const scope = scopeFromUser(user);
|
||||||
|
|
||||||
try {
|
|
||||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
|
||||||
if (!agentSession) {
|
|
||||||
agentSession = await this.agentService.createSession(conversationId, {
|
|
||||||
userId: scope.userId,
|
|
||||||
tenantId: scope.tenantId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ForbiddenException) {
|
|
||||||
throw new NotFoundException('Session not found');
|
|
||||||
}
|
|
||||||
this.logger.error(
|
|
||||||
`Session creation failed for conversation=${conversationId}`,
|
|
||||||
err instanceof Error ? err.stack : String(err),
|
|
||||||
);
|
|
||||||
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
|
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
|
||||||
|
|
||||||
let responseText = '';
|
// The one exclusive runtime owns execution. In legacy mode this reaches the embedded runtime;
|
||||||
|
// in pi-rpc it fails closed with `runtime_unsupported` before ever touching embedded execution.
|
||||||
|
const result = await this.runtime.completeLegacyRestTurn(
|
||||||
|
ownConversation(conversationId, scope),
|
||||||
|
{ content: body.content },
|
||||||
|
);
|
||||||
|
|
||||||
const done = new Promise<void>((resolve, reject) => {
|
if (result.ok) {
|
||||||
const timer = setTimeout(() => {
|
return { conversationId, text: result.value.text };
|
||||||
cleanup();
|
|
||||||
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
|
|
||||||
reject(new Error('Agent response timed out'));
|
|
||||||
}, 120_000);
|
|
||||||
|
|
||||||
const cleanup = this.agentService.onEvent(
|
|
||||||
conversationId,
|
|
||||||
(event: AgentSessionEvent) => {
|
|
||||||
if (
|
|
||||||
event.type === 'message_update' &&
|
|
||||||
event.assistantMessageEvent.type === 'text_delta'
|
|
||||||
) {
|
|
||||||
responseText += event.assistantMessageEvent.delta;
|
|
||||||
}
|
|
||||||
if (event.type === 'agent_end') {
|
|
||||||
clearTimeout(timer);
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scope,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.agentService.prompt(conversationId, body.content, scope);
|
|
||||||
await done;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof HttpException) throw err;
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (message.includes('timed out')) {
|
|
||||||
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
|
||||||
}
|
|
||||||
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
|
|
||||||
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { conversationId, text: responseText };
|
throw this.toHttpException(result, conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a total {@link LegacyRuntimeFailure} to the fixed browser-safe HTTP surface. */
|
||||||
|
private toHttpException(failure: LegacyRuntimeFailure, conversationId: string): HttpException {
|
||||||
|
switch (failure.code) {
|
||||||
|
case 'conversation_unavailable':
|
||||||
|
return new NotFoundException('Session not found');
|
||||||
|
case 'request_invalid':
|
||||||
|
case 'thinking_level_invalid':
|
||||||
|
return new HttpException('Invalid chat request', HttpStatus.BAD_REQUEST);
|
||||||
|
case 'timeout':
|
||||||
|
return new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
||||||
|
case 'runtime_unsupported':
|
||||||
|
case 'runtime_unavailable':
|
||||||
|
return new HttpException('Agent runtime unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
|
default:
|
||||||
|
this.logger.error(`Chat turn failed for conversation=${conversationId}: ${failure.code}`);
|
||||||
|
return new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
||||||
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class ChatRequestDto {
|
export class ChatRequestDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -37,3 +46,56 @@ export class ChatSocketMessageDto {
|
|||||||
/** Validated channel attachment references; binary content is not embedded. */
|
/** Validated channel attachment references; binary content is not embedded. */
|
||||||
attachments?: readonly ChannelAttachmentDto[];
|
attachments?: readonly ChannelAttachmentDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, group 2 — the frozen pi-rpc `turn:send` selection triple.
|
||||||
|
*
|
||||||
|
* Each id is a required, non-empty, bounded string. There is no `@IsOptional` and no extra
|
||||||
|
* field: under `forbidNonWhitelisted` an unknown selection key is rejected, and a missing id
|
||||||
|
* fails `@IsString` (undefined is not a string) rather than silently passing.
|
||||||
|
*/
|
||||||
|
export class HarnessTurnSelectionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
harnessId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
providerId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
modelId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, group 2 — the frozen wire contract for a pi-rpc `turn:send`.
|
||||||
|
*
|
||||||
|
* Validated through the production `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`:
|
||||||
|
* a UUID conversation id; `content` trimmed then bounded to 1..10_000 characters (whitespace-only
|
||||||
|
* collapses to empty and fails `@IsNotEmpty`); a nested `selection` object recursed with an
|
||||||
|
* explicit `@Type` (a bare `@ValidateNested` is masked green by class-validator's empty-metadata
|
||||||
|
* `unknownValue`); and a UUID-v4 idempotency key. No `provider`/`modelId`/`attachments` or other
|
||||||
|
* authority field is declared, so `forbidNonWhitelisted` rejects every unknown top-level key.
|
||||||
|
*/
|
||||||
|
export class HarnessTurnSendDto {
|
||||||
|
@IsUUID()
|
||||||
|
conversationId!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(10_000)
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => HarnessTurnSelectionDto)
|
||||||
|
selection!: HarnessTurnSelectionDto;
|
||||||
|
|
||||||
|
@IsUUID('4')
|
||||||
|
idempotencyKey!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,12 +8,31 @@ const payload: SlashCommandPayload = {
|
|||||||
approvalId: 'approval-1',
|
approvalId: 'approval-1',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 5 fence (F, existing control): gateway-owned command authorization/approval must
|
||||||
|
* cause ZERO chat-runtime dispatch. Placed in the gateway's chat-runtime-router slot (the
|
||||||
|
* former direct `AgentService` slot) so any accidental chat-runtime resolution throws
|
||||||
|
* loudly instead of silently passing. Because execute/approval run entirely through the
|
||||||
|
* command executor dependency and never resolve a chat runtime, this fixture is never
|
||||||
|
* triggered and the ingress stays a GREEN control.
|
||||||
|
*/
|
||||||
|
function failIfUsedChatRuntimeRouter() {
|
||||||
|
return {
|
||||||
|
onModuleInit: () => {
|
||||||
|
throw new Error('chat runtime router must not initialise on the command approval path');
|
||||||
|
},
|
||||||
|
get active(): never {
|
||||||
|
throw new Error('chat runtime must not be resolved on the command approval path');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildGateway(commandExecutor: {
|
function buildGateway(commandExecutor: {
|
||||||
execute: ReturnType<typeof vi.fn>;
|
execute: ReturnType<typeof vi.fn>;
|
||||||
createApproval: ReturnType<typeof vi.fn>;
|
createApproval: ReturnType<typeof vi.fn>;
|
||||||
}): ChatGateway {
|
}): ChatGateway {
|
||||||
return new ChatGateway(
|
return new ChatGateway(
|
||||||
{} as never,
|
failIfUsedChatRuntimeRouter() as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -72,3 +91,114 @@ describe('ChatGateway command approval ingress', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so
|
||||||
|
* embedded slash-commands (/model, /agent, and every other non-audited command) are fixed
|
||||||
|
* "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent
|
||||||
|
* fall-through to embedded execution. Only runtime-independent audited system commands
|
||||||
|
* (/reload) pass through as a positive control, and the approval path stays runtime-independent.
|
||||||
|
* The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so
|
||||||
|
* a fence bypass surfaces as a thrown error rather than a silent embedded dispatch.
|
||||||
|
*/
|
||||||
|
function buildPiRpcGateway(commandExecutor: {
|
||||||
|
execute: ReturnType<typeof vi.fn>;
|
||||||
|
createApproval: ReturnType<typeof vi.fn>;
|
||||||
|
}): ChatGateway {
|
||||||
|
const piRpcRouter = {
|
||||||
|
runtimeMode: 'pi-rpc' as const,
|
||||||
|
onModuleInit: () => {
|
||||||
|
throw new Error('chat runtime router must not initialise on the pi-rpc command path');
|
||||||
|
},
|
||||||
|
get active(): never {
|
||||||
|
throw new Error('chat runtime must not be resolved on the pi-rpc command path');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return new ChatGateway(
|
||||||
|
piRpcRouter as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
commandExecutor as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => {
|
||||||
|
const UNSUPPORTED = 'Slash commands are not available on this deployment.';
|
||||||
|
|
||||||
|
it.each(['model', 'agent', 'gc'])(
|
||||||
|
'fails /%s closed before the executor under pi-rpc (execute never called)',
|
||||||
|
async (command): Promise<void> => {
|
||||||
|
const commandExecutor = {
|
||||||
|
execute: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ command, conversationId: 'conversation-1', success: true }),
|
||||||
|
createApproval: vi.fn(),
|
||||||
|
};
|
||||||
|
const gateway = buildPiRpcGateway(commandExecutor);
|
||||||
|
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||||
|
|
||||||
|
await gateway.handleCommandExecute(client as never, {
|
||||||
|
command,
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commandExecutor.execute).toHaveBeenCalledTimes(0);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('command:result', {
|
||||||
|
command,
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
success: false,
|
||||||
|
message: UNSUPPORTED,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise<void> => {
|
||||||
|
const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true };
|
||||||
|
const commandExecutor = {
|
||||||
|
execute: vi.fn().mockResolvedValue(reloadResult),
|
||||||
|
createApproval: vi.fn(),
|
||||||
|
};
|
||||||
|
const gateway = buildPiRpcGateway(commandExecutor);
|
||||||
|
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||||
|
|
||||||
|
await gateway.handleCommandExecute(client as never, {
|
||||||
|
command: 'reload',
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commandExecutor.execute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(commandExecutor.execute).toHaveBeenCalledWith(
|
||||||
|
{ command: 'reload', conversationId: 'conversation-1' },
|
||||||
|
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise<void> => {
|
||||||
|
const commandExecutor = {
|
||||||
|
execute: vi.fn(),
|
||||||
|
createApproval: vi.fn().mockResolvedValue({
|
||||||
|
approvalId: 'approval-1',
|
||||||
|
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const gateway = buildPiRpcGateway(commandExecutor);
|
||||||
|
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||||
|
|
||||||
|
await gateway.handleCommandApproval(client as never, {
|
||||||
|
command: 'gc',
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
|
||||||
|
{ command: 'gc', conversationId: 'conversation-1' },
|
||||||
|
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith(
|
||||||
|
'command:approval',
|
||||||
|
expect.objectContaining({ success: true, approvalId: 'approval-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,59 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
import { CommandsModule } from '../commands/commands.module.js';
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
|
import { HarnessModule } from '../harness/harness.module.js';
|
||||||
|
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||||
|
import {
|
||||||
|
HARNESS_CONVERSATION_SERVICE,
|
||||||
|
HARNESS_REGISTRY,
|
||||||
|
type HarnessConversationServiceBinding,
|
||||||
|
} from '../harness/harness.tokens.js';
|
||||||
|
import type { HarnessConversationService } from '@mosaicstack/types';
|
||||||
import { ChatGateway } from './chat.gateway.js';
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
import { ChatController } from './chat.controller.js';
|
import { ChatController } from './chat.controller.js';
|
||||||
|
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||||
|
import { EmbeddedChatRuntime } from './embedded-chat.runtime.js';
|
||||||
|
import { HarnessChatRuntime } from './harness-chat.runtime.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five wiring. The exclusive {@link ChatRuntimeRouter} is the single chat-execution
|
||||||
|
* authority: the controller and gateway inject only the router, never `AgentService`,
|
||||||
|
* `RoutingEngineService`, or a session/`piSession` handle. The router resolves exactly one
|
||||||
|
* runtime at module init — {@link EmbeddedChatRuntime} in legacy mode, {@link HarnessChatRuntime}
|
||||||
|
* in `pi-rpc` — over the REAL {@link HarnessModule} registry and conversation-service binding.
|
||||||
|
*
|
||||||
|
* The router and the harness runtime are constructed through factories because their
|
||||||
|
* dependencies are interface/union types with no runtime injection token (the registry and
|
||||||
|
* conversation-service arrive via the string tokens exported by `HarnessModule`); the embedded
|
||||||
|
* runtime injects the class-typed `AgentService` and is provided directly.
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => CommandsModule)],
|
imports: [forwardRef(() => CommandsModule), HarnessModule],
|
||||||
controllers: [ChatController],
|
controllers: [ChatController],
|
||||||
providers: [ChatGateway],
|
providers: [
|
||||||
exports: [ChatGateway],
|
ChatGateway,
|
||||||
|
EmbeddedChatRuntime,
|
||||||
|
{
|
||||||
|
provide: HarnessChatRuntime,
|
||||||
|
useFactory: (conversationService: HarnessConversationServiceBinding) =>
|
||||||
|
new HarnessChatRuntime(conversationService as HarnessConversationService),
|
||||||
|
inject: [HARNESS_CONVERSATION_SERVICE],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ChatRuntimeRouter,
|
||||||
|
useFactory: (
|
||||||
|
registry: HarnessRegistry,
|
||||||
|
conversationService: HarnessConversationServiceBinding,
|
||||||
|
embedded: EmbeddedChatRuntime,
|
||||||
|
harness: HarnessChatRuntime,
|
||||||
|
) => new ChatRuntimeRouter(registry, conversationService, embedded, harness),
|
||||||
|
inject: [
|
||||||
|
HARNESS_REGISTRY,
|
||||||
|
HARNESS_CONVERSATION_SERVICE,
|
||||||
|
EmbeddedChatRuntime,
|
||||||
|
HarnessChatRuntime,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [ChatGateway, ChatRuntimeRouter],
|
||||||
})
|
})
|
||||||
export class ChatModule {}
|
export class ChatModule {}
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
|
import { AgentService, type AgentSession } from '../agent/agent.service.js';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
import type {
|
||||||
|
ChatRuntime,
|
||||||
|
LegacyBrowserMessagePayload,
|
||||||
|
LegacyEmbeddedChatPort,
|
||||||
|
LegacyRuntimeEvent,
|
||||||
|
LegacyRuntimeResult,
|
||||||
|
LegacySessionPresentation,
|
||||||
|
LegacySocketTurnLease,
|
||||||
|
LegacyUsage,
|
||||||
|
OwnedConversationContext,
|
||||||
|
VerifiedDiscordIngressContext,
|
||||||
|
VerifiedDiscordTurnLease,
|
||||||
|
LegacyRuntimeStream,
|
||||||
|
} from './chat-runtime.js';
|
||||||
|
|
||||||
|
/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */
|
||||||
|
const REST_TURN_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}.
|
||||||
|
*
|
||||||
|
* It owns the embedded in-process execution path — the `AgentService` stack that the
|
||||||
|
* `ChatController` and `ChatGateway` drove directly before Task Five. Once the
|
||||||
|
* {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser
|
||||||
|
* HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so
|
||||||
|
* neither the controller nor the gateway retains `AgentService`, `piSession`, session,
|
||||||
|
* listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by
|
||||||
|
* `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation
|
||||||
|
* collapses to `conversation_unavailable` and never throws out of the port.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort {
|
||||||
|
readonly kind = 'embedded' as const;
|
||||||
|
private readonly logger = new Logger(EmbeddedChatRuntime.name);
|
||||||
|
|
||||||
|
constructor(readonly agentService: AgentService) {}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Legacy REST completion (op A)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async completeLegacyRestTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: Readonly<{ content: string }>,
|
||||||
|
): Promise<
|
||||||
|
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||||
|
> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const { conversationId } = context;
|
||||||
|
|
||||||
|
const resolved = await this.resolveOrCreate(conversationId, scope, {});
|
||||||
|
if (!resolved.ok) return resolved;
|
||||||
|
|
||||||
|
let responseText = '';
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let detach: (() => void) | undefined;
|
||||||
|
let disposed = false;
|
||||||
|
// One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and
|
||||||
|
// detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt
|
||||||
|
// rejection fires first. Without this, a prompt() rejection surfaced through the catch below
|
||||||
|
// would return while leaving the listener attached (free to consume a later turn's events) and
|
||||||
|
// the 120s timer live (its rejection later going unobserved).
|
||||||
|
const dispose = (): void => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
detach?.();
|
||||||
|
};
|
||||||
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
dispose();
|
||||||
|
reject(new Error('Agent response timed out'));
|
||||||
|
}, REST_TURN_TIMEOUT_MS);
|
||||||
|
|
||||||
|
detach = this.agentService.onEvent(
|
||||||
|
conversationId,
|
||||||
|
(event: AgentSessionEvent) => {
|
||||||
|
if (
|
||||||
|
event.type === 'message_update' &&
|
||||||
|
event.assistantMessageEvent.type === 'text_delta'
|
||||||
|
) {
|
||||||
|
responseText += event.assistantMessageEvent.delta;
|
||||||
|
}
|
||||||
|
if (event.type === 'agent_end') {
|
||||||
|
dispose();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
|
||||||
|
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
|
||||||
|
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
|
||||||
|
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
|
||||||
|
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
|
||||||
|
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
|
||||||
|
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
|
||||||
|
const prompting = this.agentService.prompt(conversationId, input.content, scope);
|
||||||
|
try {
|
||||||
|
await Promise.all([prompting, done]);
|
||||||
|
} catch (err) {
|
||||||
|
dispose();
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes('timed out')) {
|
||||||
|
return { ok: false, code: 'timeout', retryable: true };
|
||||||
|
}
|
||||||
|
this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message);
|
||||||
|
return { ok: false, code: 'operation_failed', retryable: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation;
|
||||||
|
return { ok: true, value: { text: responseText, presentation } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Legacy Socket streaming (op B)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async prepareLegacySocketTurn(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
input: LegacyBrowserMessagePayload,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const { conversationId } = context;
|
||||||
|
|
||||||
|
const resolved = await this.resolveOrCreate(conversationId, scope, {
|
||||||
|
...(input.provider ? { provider: input.provider } : {}),
|
||||||
|
...(input.modelId ? { modelId: input.modelId } : {}),
|
||||||
|
...(input.agentId ? { agentConfigId: input.agentId } : {}),
|
||||||
|
});
|
||||||
|
if (!resolved.ok) return resolved;
|
||||||
|
|
||||||
|
let detach: () => void;
|
||||||
|
try {
|
||||||
|
detach = this.subscribe(conversationId, scope, stream);
|
||||||
|
} catch (err) {
|
||||||
|
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
|
||||||
|
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
|
||||||
|
this.logger.error(
|
||||||
|
`Embedded socket subscription failed for conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: this.buildLease(
|
||||||
|
conversationId,
|
||||||
|
scope,
|
||||||
|
input.content,
|
||||||
|
input.attachments,
|
||||||
|
detach,
|
||||||
|
resolved.presentation,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Thinking level (op C) — synchronous, total
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
setLegacyThinking(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
level: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const session = this.agentService.getSession(context.conversationId, scope);
|
||||||
|
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||||
|
|
||||||
|
const availableThinkingLevels = session.piSession.getAvailableThinkingLevels();
|
||||||
|
if (!(availableThinkingLevels as readonly string[]).includes(level)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'thinking_level_invalid',
|
||||||
|
retryable: false,
|
||||||
|
availableThinkingLevels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
session.piSession.setThinkingLevel(level as never);
|
||||||
|
return { ok: true, value: this.presentationForSession(session) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Abort (op D)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const session = this.agentService.getSession(context.conversationId, scope);
|
||||||
|
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await session.piSession.abort();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Legacy abort failed for conversation=${context.conversationId}`,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return { ok: false, code: 'operation_failed', retryable: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Model override (synchronous, total)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
applyLegacyModelOverride(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
modelId: string,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const session = this.agentService.getSession(context.conversationId, scope);
|
||||||
|
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||||
|
|
||||||
|
this.agentService.updateSessionModel(context.conversationId, modelId, scope);
|
||||||
|
const refreshed = this.agentService.getSession(context.conversationId, scope) ?? session;
|
||||||
|
return { ok: true, value: this.presentationForSession(refreshed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Presentation read (synchronous, total)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
readLegacySessionPresentation(
|
||||||
|
context: OwnedConversationContext,
|
||||||
|
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const session = this.agentService.getSession(context.conversationId, scope);
|
||||||
|
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||||
|
return { ok: true, value: this.presentationForSession(session) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Verified Discord ingress (embedded-only in both modes)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async dispatchVerifiedDiscordIngress(
|
||||||
|
context: VerifiedDiscordIngressContext,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
|
||||||
|
const scope = toScope(context.scope);
|
||||||
|
const { conversationId } = context;
|
||||||
|
|
||||||
|
const resolved = await this.resolveOrCreate(
|
||||||
|
conversationId,
|
||||||
|
scope,
|
||||||
|
{ agentConfigId: context.configuredAgent.agentConfigId },
|
||||||
|
{
|
||||||
|
agentConfigId: context.configuredAgent.agentConfigId,
|
||||||
|
instanceId: context.configuredAgent.instanceId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!resolved.ok) return resolved;
|
||||||
|
|
||||||
|
let detach: () => void;
|
||||||
|
try {
|
||||||
|
detach = this.subscribe(conversationId, scope, stream);
|
||||||
|
} catch (err) {
|
||||||
|
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
|
||||||
|
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
|
||||||
|
this.logger.error(
|
||||||
|
`Embedded Discord subscription failed for conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: this.buildLease(
|
||||||
|
conversationId,
|
||||||
|
scope,
|
||||||
|
context.content,
|
||||||
|
context.attachments,
|
||||||
|
detach,
|
||||||
|
resolved.presentation,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the owned session, creating it on first use. Ownership/scope rejections
|
||||||
|
* (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation
|
||||||
|
* failure surfaces as the retryable `runtime_unavailable`. On success returns the
|
||||||
|
* session presentation so callers avoid a redundant `getSession`.
|
||||||
|
*/
|
||||||
|
private async resolveOrCreate(
|
||||||
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>,
|
||||||
|
expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>,
|
||||||
|
): Promise<
|
||||||
|
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
|
||||||
|
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
|
||||||
|
> {
|
||||||
|
// A verified-Discord turn may only run under a session whose configured identity matches the
|
||||||
|
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
|
||||||
|
// pre-existing session AND a freshly created one: a session carrying a different configured
|
||||||
|
// agent — however it arose — is rejected rather than executed under the verified label, so we
|
||||||
|
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
|
||||||
|
// no expectedAgent and skips the check.
|
||||||
|
const identityMatches = (candidate: AgentSession): boolean =>
|
||||||
|
expectedAgent === undefined ||
|
||||||
|
(candidate.agentConfigId === expectedAgent.agentConfigId &&
|
||||||
|
candidate.agentName === expectedAgent.instanceId);
|
||||||
|
|
||||||
|
let session = this.agentService.getSession(conversationId, scope);
|
||||||
|
if (session && !identityMatches(session)) {
|
||||||
|
// Reused same-scope session minted under a different configured identity — reject with zero
|
||||||
|
// effects rather than dispatch a verified turn onto a foreign agent's session.
|
||||||
|
return CONVERSATION_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
if (!session) {
|
||||||
|
try {
|
||||||
|
session = await this.agentService.createSession(conversationId, {
|
||||||
|
userId: scope.userId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
...extraOptions,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ForbiddenException || err instanceof NotFoundException) {
|
||||||
|
return CONVERSATION_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
this.logger.error(
|
||||||
|
`Embedded session creation failed for conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||||
|
}
|
||||||
|
// The just-created session must ALSO carry the reconciled identity before any effect. A
|
||||||
|
// createSession that returns a session under a different configured agent (misconfiguration
|
||||||
|
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
|
||||||
|
if (!identityMatches(session)) {
|
||||||
|
return CONVERSATION_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, presentation: this.presentationForSession(session) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installs a normalizing event listener that forwards to the server-owned stream. */
|
||||||
|
private subscribe(
|
||||||
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
stream: LegacyRuntimeStream,
|
||||||
|
): () => void {
|
||||||
|
const unsubscribe = this.agentService.onEvent(
|
||||||
|
conversationId,
|
||||||
|
(event: AgentSessionEvent) => {
|
||||||
|
const normalized = this.normalizeEvent(conversationId, scope, event);
|
||||||
|
if (normalized) stream.onEvent(normalized);
|
||||||
|
},
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
this.agentService.addChannel(conversationId, stream.channelId, scope);
|
||||||
|
} catch (err) {
|
||||||
|
// Partial setup: the listener was acquired but the channel attach failed. Roll back
|
||||||
|
// exactly what was acquired (the listener) before the failure escapes, so no leaked
|
||||||
|
// subscription survives; the caller converts the rethrow into a total safe failure.
|
||||||
|
try {
|
||||||
|
unsubscribe();
|
||||||
|
} catch {
|
||||||
|
/* idempotent teardown */
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
unsubscribe();
|
||||||
|
} catch {
|
||||||
|
/* idempotent teardown */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.agentService.removeChannel(conversationId, stream.channelId, scope);
|
||||||
|
} catch {
|
||||||
|
/* idempotent teardown */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds an atomically one-shot, scope-rechecking dispatch lease. */
|
||||||
|
private buildLease(
|
||||||
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
content: string,
|
||||||
|
attachments: VerifiedDiscordIngressContext['attachments'],
|
||||||
|
detach: () => void,
|
||||||
|
presentation: LegacySessionPresentation,
|
||||||
|
): LegacySocketTurnLease & VerifiedDiscordTurnLease {
|
||||||
|
let dispatched = false;
|
||||||
|
let disposed = false;
|
||||||
|
return {
|
||||||
|
presentation,
|
||||||
|
dispatch: async (): Promise<LegacyRuntimeResult<void>> => {
|
||||||
|
if (dispatched) {
|
||||||
|
return { ok: false, code: 'turn_already_dispatched', retryable: false };
|
||||||
|
}
|
||||||
|
dispatched = true;
|
||||||
|
try {
|
||||||
|
await this.agentService.prompt(conversationId, content, scope, attachments);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Legacy dispatch failed for conversation=${conversationId}`,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return { ok: false, code: 'operation_failed', retryable: false };
|
||||||
|
}
|
||||||
|
return { ok: true, value: undefined };
|
||||||
|
},
|
||||||
|
dispose: async (): Promise<void> => {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
detach();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes a raw agent event into the redaction-agnostic transport event, or drops it. */
|
||||||
|
private normalizeEvent(
|
||||||
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
event: AgentSessionEvent,
|
||||||
|
): LegacyRuntimeEvent | undefined {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'agent_start':
|
||||||
|
return { type: 'started' };
|
||||||
|
case 'agent_end':
|
||||||
|
return { type: 'settled', ...this.usageFor(conversationId, scope) };
|
||||||
|
case 'message_update': {
|
||||||
|
const assistant = event.assistantMessageEvent;
|
||||||
|
if (assistant.type === 'text_delta') return { type: 'text_delta', text: assistant.delta };
|
||||||
|
if (assistant.type === 'thinking_delta') {
|
||||||
|
return { type: 'thinking_delta', text: assistant.delta };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
case 'tool_execution_start':
|
||||||
|
return { type: 'tool_started', toolCallId: event.toolCallId, toolName: event.toolName };
|
||||||
|
case 'tool_execution_end':
|
||||||
|
return {
|
||||||
|
type: 'tool_finished',
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
toolName: event.toolName,
|
||||||
|
isError: event.isError,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gathers terminal usage from the Pi session and records it into session metrics.
|
||||||
|
* Embedded owns AgentService metrics; the gateway never touches `piSession` stats.
|
||||||
|
*/
|
||||||
|
private usageFor(conversationId: string, scope: ActorTenantScope): { usage?: LegacyUsage } {
|
||||||
|
const session = this.agentService.getSession(conversationId, scope);
|
||||||
|
const piSession = session?.piSession;
|
||||||
|
const stats = piSession?.getSessionStats();
|
||||||
|
if (!session || !stats) return {};
|
||||||
|
const contextUsage = piSession?.getContextUsage();
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
input: stats.tokens?.input ?? 0,
|
||||||
|
output: stats.tokens?.output ?? 0,
|
||||||
|
cacheRead: stats.tokens?.cacheRead ?? 0,
|
||||||
|
cacheWrite: stats.tokens?.cacheWrite ?? 0,
|
||||||
|
total: stats.tokens?.total ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.agentService.recordTokenUsage(conversationId, { ...tokens });
|
||||||
|
|
||||||
|
return {
|
||||||
|
usage: {
|
||||||
|
provider: session.provider,
|
||||||
|
modelId: session.modelId,
|
||||||
|
thinkingLevel: piSession?.thinkingLevel ?? 'off',
|
||||||
|
tokens,
|
||||||
|
cost: stats.cost ?? 0,
|
||||||
|
context: {
|
||||||
|
percent: contextUsage?.percent ?? null,
|
||||||
|
window: contextUsage?.contextWindow ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Presentation from a live session id, or undefined when no owned session exists. */
|
||||||
|
private presentationFor(
|
||||||
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
): LegacySessionPresentation | undefined {
|
||||||
|
const session = this.agentService.getSession(conversationId, scope);
|
||||||
|
return session ? this.presentationForSession(session) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-facing projection carrying no session handle, credential, or raw stats. */
|
||||||
|
private presentationForSession(session: AgentSession): LegacySessionPresentation {
|
||||||
|
return {
|
||||||
|
provider: session.provider,
|
||||||
|
modelId: session.modelId,
|
||||||
|
thinkingLevel: session.piSession.thinkingLevel,
|
||||||
|
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
||||||
|
...(session.agentName ? { agentName: session.agentName } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared terminal `conversation_unavailable` failure (missing/foreign/lost ownership). */
|
||||||
|
const CONVERSATION_UNAVAILABLE = {
|
||||||
|
ok: false as const,
|
||||||
|
code: 'conversation_unavailable' as const,
|
||||||
|
retryable: false as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Narrows a branded context scope to the `AgentService` actor/tenant scope (identical shape). */
|
||||||
|
function toScope(scope: Readonly<{ userId: string; tenantId: string }>): ActorTenantScope {
|
||||||
|
return { userId: scope.userId, tenantId: scope.tenantId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
AttachConversation,
|
||||||
|
ConversationSnapshot,
|
||||||
|
DetachConversation,
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessConversationService,
|
||||||
|
HarnessEventEnvelope,
|
||||||
|
HarnessSelection,
|
||||||
|
SendHarnessTurn,
|
||||||
|
TurnReceipt,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { HarnessChatRuntime } from './harness-chat.runtime.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step One (harness runtime). Proves the `pi-rpc` runtime executes
|
||||||
|
* exclusively through the {@link HarnessConversationService} RPC boundary and
|
||||||
|
* forwards the caller's exact selection tuple and idempotency key without
|
||||||
|
* substitution. Red-first: the runtime is an unimplemented stub, so every
|
||||||
|
* delegation assertion fails until Step Three.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const context: HarnessActorContext = {
|
||||||
|
actorId: 'actor-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
seatId: 'seat-1',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude-opus-4-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversationId = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const idempotencyKey = '22222222-2222-4222-8222-222222222222';
|
||||||
|
|
||||||
|
const sendInput: SendHarnessTurn & { idempotencyKey: string } = {
|
||||||
|
context,
|
||||||
|
conversationId,
|
||||||
|
selection,
|
||||||
|
turnId: 'turn-abc',
|
||||||
|
correlationId: 'corr-1',
|
||||||
|
content: 'hello',
|
||||||
|
idempotencyKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachInput: AttachConversation & { afterSequence?: number } = {
|
||||||
|
context,
|
||||||
|
conversationId,
|
||||||
|
clientId: 'client-1',
|
||||||
|
selection,
|
||||||
|
afterSequence: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const detachInput: DetachConversation = {
|
||||||
|
context,
|
||||||
|
conversationId,
|
||||||
|
clientId: 'client-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RecordedCalls {
|
||||||
|
attach: (AttachConversation & { afterSequence?: number })[];
|
||||||
|
detach: DetachConversation[];
|
||||||
|
send: (SendHarnessTurn & { idempotencyKey: string })[];
|
||||||
|
subscribeFrom: { conversationId: string; afterSequence: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot: ConversationSnapshot = {
|
||||||
|
session: {
|
||||||
|
conversationId,
|
||||||
|
nativeSessionId: 'native-1',
|
||||||
|
seatId: 'seat-1',
|
||||||
|
selection,
|
||||||
|
state: 'idle',
|
||||||
|
attachedClientIds: ['client-1'],
|
||||||
|
},
|
||||||
|
lastSequence: 0,
|
||||||
|
replay: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function build(): { runtime: HarnessChatRuntime; calls: RecordedCalls } {
|
||||||
|
const calls: RecordedCalls = { attach: [], detach: [], send: [], subscribeFrom: [] };
|
||||||
|
const service: HarnessConversationService = {
|
||||||
|
attach: (input) => {
|
||||||
|
calls.attach.push(input);
|
||||||
|
return Promise.resolve(snapshot);
|
||||||
|
},
|
||||||
|
detach: (input) => {
|
||||||
|
calls.detach.push(input);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
send: (input) => {
|
||||||
|
calls.send.push(input);
|
||||||
|
// The service echoes only the requested tuple; there is no representable substitute.
|
||||||
|
const receipt: TurnReceipt = {
|
||||||
|
conversationId: input.conversationId,
|
||||||
|
turnId: 'turn-server',
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
state: 'accepted',
|
||||||
|
selection: input.selection,
|
||||||
|
};
|
||||||
|
return Promise.resolve(receipt);
|
||||||
|
},
|
||||||
|
subscribeFrom: (id, afterSequence) => {
|
||||||
|
calls.subscribeFrom.push({ conversationId: id, afterSequence });
|
||||||
|
|
||||||
|
return (async function* (): AsyncIterable<HarnessEventEnvelope> {
|
||||||
|
return;
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { runtime: new HarnessChatRuntime(service), calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HarnessChatRuntime', () => {
|
||||||
|
it('is the harness runtime kind and needs only a HarnessConversationService', () => {
|
||||||
|
const { runtime } = build();
|
||||||
|
expect(runtime.kind).toBe('harness');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates send to the conversation service with the exact tuple and idempotency key', async () => {
|
||||||
|
const { runtime, calls } = build();
|
||||||
|
|
||||||
|
const receipt = await runtime.send(sendInput);
|
||||||
|
|
||||||
|
expect(calls.send).toHaveLength(1);
|
||||||
|
const firstSend = calls.send[0]!;
|
||||||
|
expect(firstSend).toEqual(sendInput);
|
||||||
|
expect(firstSend.idempotencyKey).toBe(idempotencyKey);
|
||||||
|
expect(firstSend.selection).toEqual(selection);
|
||||||
|
// The runtime must not substitute an effective tuple onto the receipt.
|
||||||
|
expect(receipt.selection).toEqual(selection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates attach to the conversation service and returns its snapshot', async () => {
|
||||||
|
const { runtime, calls } = build();
|
||||||
|
|
||||||
|
const result = await runtime.attach(attachInput);
|
||||||
|
|
||||||
|
expect(calls.attach).toHaveLength(1);
|
||||||
|
expect(calls.attach[0]).toEqual(attachInput);
|
||||||
|
expect(result).toBe(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates detach to the conversation service', async () => {
|
||||||
|
const { runtime, calls } = build();
|
||||||
|
|
||||||
|
await runtime.detach(detachInput);
|
||||||
|
|
||||||
|
expect(calls.detach).toHaveLength(1);
|
||||||
|
expect(calls.detach[0]).toEqual(detachInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates subscribeFrom to the conversation service journal replay', async () => {
|
||||||
|
const { runtime, calls } = build();
|
||||||
|
|
||||||
|
const iterable = runtime.subscribeFrom(conversationId, 7);
|
||||||
|
// Drain to prove it is the service-backed async iterable, not a fabricated one.
|
||||||
|
const drained: unknown[] = [];
|
||||||
|
for await (const event of iterable) {
|
||||||
|
drained.push(event);
|
||||||
|
}
|
||||||
|
expect(drained).toHaveLength(0);
|
||||||
|
|
||||||
|
expect(calls.subscribeFrom).toHaveLength(1);
|
||||||
|
expect(calls.subscribeFrom[0]).toEqual({ conversationId, afterSequence: 7 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type {
|
||||||
|
AttachConversation,
|
||||||
|
ConversationSnapshot,
|
||||||
|
DetachConversation,
|
||||||
|
HarnessConversationService,
|
||||||
|
HarnessEventEnvelope,
|
||||||
|
SendHarnessTurn,
|
||||||
|
TurnReceipt,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import type { ChatRuntime } from './chat-runtime.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `pi-rpc` chat runtime. It executes browser chat exclusively through the
|
||||||
|
* harness-neutral {@link HarnessConversationService} RPC boundary — it never
|
||||||
|
* touches the embedded `AgentService`/`ProviderService`/`RoutingEngineService`
|
||||||
|
* stack, and it forwards the caller's exact selection tuple and idempotency key
|
||||||
|
* without substitution.
|
||||||
|
*
|
||||||
|
* It owns no state and adds no policy: every method forwards the caller's exact
|
||||||
|
* argument to the injected {@link HarnessConversationService} and returns its
|
||||||
|
* result unchanged, so the requested selection tuple and idempotency key can
|
||||||
|
* never be substituted on the way through.
|
||||||
|
*/
|
||||||
|
export class HarnessChatRuntime implements ChatRuntime {
|
||||||
|
readonly kind = 'harness' as const;
|
||||||
|
|
||||||
|
constructor(private readonly conversations: HarnessConversationService) {}
|
||||||
|
|
||||||
|
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot> {
|
||||||
|
return this.conversations.attach(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
detach(input: DetachConversation): Promise<void> {
|
||||||
|
return this.conversations.detach(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt> {
|
||||||
|
return this.conversations.send(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeFrom(
|
||||||
|
conversationId: string,
|
||||||
|
afterSequence: number,
|
||||||
|
): AsyncIterable<HarnessEventEnvelope> {
|
||||||
|
return this.conversations.subscribeFrom(conversationId, afterSequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ChatRuntimeMode } from '../chat/chat-runtime.js';
|
||||||
|
import { ConversationsController } from './conversations.controller.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 5 harness fence for the conversations REST write path.
|
||||||
|
*
|
||||||
|
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
|
||||||
|
* legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a
|
||||||
|
* fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write.
|
||||||
|
* Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`.
|
||||||
|
*
|
||||||
|
* Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution,
|
||||||
|
* injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from
|
||||||
|
* `process.env` at request time. The two "env is flipped after construction" tests below are the
|
||||||
|
* load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn
|
||||||
|
* RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`.
|
||||||
|
*/
|
||||||
|
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const USER = { id: 'user-1' };
|
||||||
|
|
||||||
|
function sendMessageDto() {
|
||||||
|
return {
|
||||||
|
role: 'user' as const,
|
||||||
|
content: 'hello from the legacy REST write path',
|
||||||
|
metadata: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function brainWithMessageSpy() {
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({
|
||||||
|
id: 'message-1',
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
role: 'user',
|
||||||
|
content: 'hello from the legacy REST write path',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
brain: { conversations: { addMessage } } as never,
|
||||||
|
addMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */
|
||||||
|
function routerFixedTo(mode: ChatRuntimeMode) {
|
||||||
|
return { runtimeMode: mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
let priorMode: string | undefined;
|
||||||
|
|
||||||
|
describe('conversations REST write path — Task 5 harness fence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => {
|
||||||
|
const { brain, addMessage } = brainWithMessageSpy();
|
||||||
|
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
|
||||||
|
).rejects.toMatchObject({ code: 'runtime_unsupported' });
|
||||||
|
|
||||||
|
// Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be
|
||||||
|
// written, so no duplicate message can be produced.
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes through the repository when the router resolved legacy (GREEN control)', async () => {
|
||||||
|
const { brain, addMessage } = brainWithMessageSpy();
|
||||||
|
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
|
||||||
|
|
||||||
|
const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
|
||||||
|
|
||||||
|
expect(addMessage).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
role: 'user',
|
||||||
|
content: 'hello from the legacy REST write path',
|
||||||
|
metadata: undefined,
|
||||||
|
},
|
||||||
|
USER.id,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => {
|
||||||
|
// The runtime mode is fixed at module init. A later env mutation must not reopen the fence:
|
||||||
|
// a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write.
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
|
||||||
|
const { brain, addMessage } = brainWithMessageSpy();
|
||||||
|
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
|
||||||
|
).rejects.toMatchObject({ code: 'runtime_unsupported' });
|
||||||
|
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => {
|
||||||
|
// Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a
|
||||||
|
// request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write.
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
const { brain, addMessage } = brainWithMessageSpy();
|
||||||
|
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
|
||||||
|
|
||||||
|
await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
|
||||||
|
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Inject,
|
Inject,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -19,6 +20,7 @@ import type { Brain } from '@mosaicstack/brain';
|
|||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
|
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
|
||||||
import {
|
import {
|
||||||
CreateConversationDto,
|
CreateConversationDto,
|
||||||
UpdateConversationDto,
|
UpdateConversationDto,
|
||||||
@@ -26,10 +28,41 @@ import {
|
|||||||
SearchMessagesDto,
|
SearchMessagesDto,
|
||||||
} from './conversations.dto.js';
|
} from './conversations.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
|
||||||
|
* legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before
|
||||||
|
* the repository is touched — never a duplicate write. The `code` field is exposed at the top level
|
||||||
|
* so callers can discriminate the refusal while the 503 status carries the browser-safe surface.
|
||||||
|
*/
|
||||||
|
class HarnessRuntimeWriteUnsupportedException extends HttpException {
|
||||||
|
readonly code = 'runtime_unsupported' as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
code: 'runtime_unsupported',
|
||||||
|
message:
|
||||||
|
'Conversation message writes are handled by the harness runtime on this deployment.',
|
||||||
|
},
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('api/conversations')
|
@Controller('api/conversations')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class ConversationsController {
|
export class ConversationsController {
|
||||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
/**
|
||||||
|
* `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3).
|
||||||
|
* The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at
|
||||||
|
* request time — a single source of truth, so the controller cannot disagree with the router
|
||||||
|
* about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode`
|
||||||
|
* so this class depends on nothing else the router exposes.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
|
@Inject(ChatRuntimeRouter) private readonly router: Pick<ChatRuntimeRouter, 'runtimeMode'>,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@CurrentUser() user: { id: string }) {
|
async list(@CurrentUser() user: { id: string }) {
|
||||||
@@ -94,6 +127,13 @@ export class ConversationsController {
|
|||||||
@Body() dto: SendMessageDto,
|
@Body() dto: SendMessageDto,
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
) {
|
) {
|
||||||
|
// Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the
|
||||||
|
// harness path owns persistence there, so a direct write would duplicate the message. The mode
|
||||||
|
// comes from the router's init-time resolution, not a request-time env read.
|
||||||
|
if (this.router.runtimeMode === 'pi-rpc') {
|
||||||
|
throw new HarnessRuntimeWriteUnsupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
const message = await this.brain.conversations.addMessage(
|
const message = await this.brain.conversations.addMessage(
|
||||||
{
|
{
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
import { ConversationsController } from './conversations.controller.js';
|
import { ConversationsController } from './conversations.controller.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports {@link ChatModule} solely to inject its exported {@link ChatRuntimeRouter} into
|
||||||
|
* {@link ConversationsController}, so the REST write fence reads the same init-time runtime mode the
|
||||||
|
* router resolved — one source of truth, no duplicate provider, no global token, no AppModule edit.
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ChatModule],
|
||||||
controllers: [ConversationsController],
|
controllers: [ConversationsController],
|
||||||
})
|
})
|
||||||
export class ConversationsModule {}
|
export class ConversationsModule {}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { HarnessRegistry } from './harness.registry.js';
|
import { HarnessRegistry } from './harness.registry.js';
|
||||||
import { HarnessService } from './harness.service.js';
|
import { HarnessService } from './harness.service.js';
|
||||||
import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js';
|
import {
|
||||||
|
HARNESS_CONVERSATION_SERVICE,
|
||||||
|
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||||
|
HARNESS_REGISTRY,
|
||||||
|
HARNESS_SERVICE,
|
||||||
|
} from './harness.tokens.js';
|
||||||
import { HarnessController } from './harness.controller.js';
|
import { HarnessController } from './harness.controller.js';
|
||||||
import { HarnessSelectionController } from './harness-selection.controller.js';
|
import { HarnessSelectionController } from './harness-selection.controller.js';
|
||||||
import { HarnessSelectionService } from './harness-selection.service.js';
|
import { HarnessSelectionService } from './harness-selection.service.js';
|
||||||
@@ -20,9 +25,13 @@ import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
|
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
|
||||||
{ provide: HARNESS_SERVICE, useClass: HarnessService },
|
{ provide: HARNESS_SERVICE, useClass: HarnessService },
|
||||||
|
// Task Five: bind the conversation-service token to its explicit "not yet bound"
|
||||||
|
// sentinel. The pi-rpc router treats this as a hard, typed startup failure; Task 14
|
||||||
|
// replaces it with a real service. Exported so ChatModule's router can inject it.
|
||||||
|
{ provide: HARNESS_CONVERSATION_SERVICE, useValue: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE },
|
||||||
HarnessSelectionRepository,
|
HarnessSelectionRepository,
|
||||||
HarnessSelectionService,
|
HarnessSelectionService,
|
||||||
],
|
],
|
||||||
exports: [HARNESS_REGISTRY, HARNESS_SERVICE],
|
exports: [HARNESS_REGISTRY, HARNESS_SERVICE, HARNESS_CONVERSATION_SERVICE],
|
||||||
})
|
})
|
||||||
export class HarnessModule {}
|
export class HarnessModule {}
|
||||||
|
|||||||
@@ -4,8 +4,42 @@
|
|||||||
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
|
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
|
||||||
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
|
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
|
||||||
*/
|
*/
|
||||||
|
import type { HarnessConversationService } from '@mosaicstack/types';
|
||||||
|
|
||||||
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
|
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
|
||||||
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
|
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
|
||||||
|
|
||||||
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
|
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
|
||||||
export type HarnessServiceToken = typeof HARNESS_SERVICE;
|
export type HarnessServiceToken = typeof HARNESS_SERVICE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token for the {@link HarnessConversationService} that {@link HarnessChatRuntime}
|
||||||
|
* depends on. Until Task 14 provides a real implementation, `HarnessModule` binds
|
||||||
|
* the {@link HARNESS_CONVERSATION_SERVICE_UNAVAILABLE} sentinel here, and the
|
||||||
|
* `pi-rpc` router treats that sentinel as a hard, typed startup failure.
|
||||||
|
*/
|
||||||
|
export const HARNESS_CONVERSATION_SERVICE = 'HARNESS_CONVERSATION_SERVICE' as const;
|
||||||
|
|
||||||
|
export type HarnessConversationServiceToken = typeof HARNESS_CONVERSATION_SERVICE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit "not yet bound" value for {@link HARNESS_CONVERSATION_SERVICE}. It is a
|
||||||
|
* distinct sentinel — never `null`/`undefined` — so an unbound service is an
|
||||||
|
* intentional, checkable state rather than an accidental nil that could read as
|
||||||
|
* "present". Replaced by a real service in Task 14.
|
||||||
|
*/
|
||||||
|
export const HARNESS_CONVERSATION_SERVICE_UNAVAILABLE: unique symbol = Symbol(
|
||||||
|
'HARNESS_CONVERSATION_SERVICE_UNAVAILABLE',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A binding for {@link HARNESS_CONVERSATION_SERVICE}: a real service or the sentinel. */
|
||||||
|
export type HarnessConversationServiceBinding =
|
||||||
|
| HarnessConversationService
|
||||||
|
| typeof HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
|
||||||
|
|
||||||
|
/** Narrows a binding to a usable service, excluding the unavailable sentinel. */
|
||||||
|
export function isHarnessConversationServiceAvailable(
|
||||||
|
binding: HarnessConversationServiceBinding,
|
||||||
|
): binding is HarnessConversationService {
|
||||||
|
return binding !== HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { RuntimeProviderService } from '../agent/runtime-provider-registry.servi
|
|||||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||||
|
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
|
||||||
|
import { EmbeddedChatRuntime } from '../chat/embedded-chat.runtime.js';
|
||||||
|
import { HarnessChatRuntime } from '../chat/harness-chat.runtime.js';
|
||||||
|
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||||
|
|
||||||
const SERVICE_TOKEN = 'test-service-token';
|
const SERVICE_TOKEN = 'test-service-token';
|
||||||
@@ -25,6 +29,7 @@ const ENV_KEYS = [
|
|||||||
'DISCORD_ALLOWED_USER_IDS',
|
'DISCORD_ALLOWED_USER_IDS',
|
||||||
'MOSAIC_AGENT_NAME',
|
'MOSAIC_AGENT_NAME',
|
||||||
'MOSAIC_AGENT_CONFIG_ID',
|
'MOSAIC_AGENT_CONFIG_ID',
|
||||||
|
'CHAT_HARNESS_RUNTIME',
|
||||||
] as const;
|
] as const;
|
||||||
const savedEnv = new Map<string, string | undefined>();
|
const savedEnv = new Map<string, string | undefined>();
|
||||||
|
|
||||||
@@ -150,6 +155,57 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 5 fence (C): the Discord SEND path runs through the exclusive {@link ChatRuntimeRouter},
|
||||||
|
* constructed here in `pi-rpc` mode with a fully-resolved runtime (`active` = harness). A verified
|
||||||
|
* Discord *service* turn must nonetheless execute on the {@link EmbeddedChatRuntime} — never the
|
||||||
|
* harness, never the routing engine — per the Q1/Q2 adjudication: the router owns a dedicated
|
||||||
|
* verified-ingress dispatch that delegates to embedded regardless of mode, with zero harness
|
||||||
|
* fallback. The gateway is given the router in the former direct-`AgentService` constructor slot.
|
||||||
|
*
|
||||||
|
* RED today: production still reads that slot as a bare `AgentService`, so `this.agentService`
|
||||||
|
* resolves to the router, `getSession(...)` is not a function, the send path throws and is caught
|
||||||
|
* (an `error` is emitted and the handler returns) BEFORE it ever reaches the embedded runtime. The
|
||||||
|
* failure is behavioural wiring — collection, DI, and `onModuleInit` all succeed. GREEN re-routes
|
||||||
|
* the verified Discord dispatch through the router into the embedded runtime, satisfying the
|
||||||
|
* preserved create/prompt assertions without weakening any control. `harnessConversations.append`
|
||||||
|
* proves the harness path is never touched even though the pi-rpc router resolved it as `active`.
|
||||||
|
*
|
||||||
|
* Correction #4 is proved behaviourally, not by naming an accessor: the verified-ingress dispatch
|
||||||
|
* is reachable only from the fully-verified `discordService` branch (the create/prompt tests below)
|
||||||
|
* and never from a browser-emittable socket event (the browser-forgery refusal test).
|
||||||
|
*/
|
||||||
|
function readyPiRpcRegistry(): HarnessRegistry {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
// A registered 'pi' adapter + an available (non-sentinel) conversation service let the pi-rpc
|
||||||
|
// router resolve `active` = harness instead of failing closed at init, so these tests model the
|
||||||
|
// real hostile condition — the harness runtime IS live — rather than a degraded router.
|
||||||
|
registry.register({ id: 'pi' } as never);
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function piRpcRouterFronting(
|
||||||
|
agentService: unknown,
|
||||||
|
harnessConversations: { append: ReturnType<typeof vi.fn> },
|
||||||
|
): ChatRuntimeRouter {
|
||||||
|
const routerConversationServiceTripwire = {
|
||||||
|
append: () => {
|
||||||
|
throw new Error('router conversation service must not be resolved on the Discord path');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||||
|
const harness = new HarnessChatRuntime(harnessConversations as never);
|
||||||
|
const router = new ChatRuntimeRouter(
|
||||||
|
readyPiRpcRegistry(),
|
||||||
|
routerConversationServiceTripwire as never,
|
||||||
|
embedded,
|
||||||
|
harness,
|
||||||
|
'pi-rpc',
|
||||||
|
);
|
||||||
|
router.onModuleInit();
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Discord ingress security', () => {
|
describe('Discord ingress security', () => {
|
||||||
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
||||||
const [binding] = parseDiscordInteractionBindings(
|
const [binding] = parseDiscordInteractionBindings(
|
||||||
@@ -433,6 +489,7 @@ describe('Discord ingress security', () => {
|
|||||||
|
|
||||||
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
|
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
|
||||||
configureDiscordEnv();
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
|
||||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
{
|
{
|
||||||
@@ -489,8 +546,9 @@ describe('Discord ingress security', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const routingEngine = { resolve: vi.fn() };
|
const routingEngine = { resolve: vi.fn() };
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
const gateway = new ChatGateway(
|
const gateway = new ChatGateway(
|
||||||
agentService as never,
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
brain as never,
|
brain as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -531,6 +589,575 @@ describe('Discord ingress security', () => {
|
|||||||
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
|
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
|
||||||
);
|
);
|
||||||
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
||||||
|
// Even though the pi-rpc router resolved the harness as `active`, verified Discord ingress must
|
||||||
|
// never touch it — the create path stays on the embedded runtime.
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches a verified Discord SEND once and drops a byte-identical replay with zero additional dispatch/persist/ack (Task 5 G4)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createSession = vi.fn().mockResolvedValue(session);
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-replay',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const ackCount = (): number =>
|
||||||
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||||
|
|
||||||
|
// One fully-valid signed envelope; the replay reuses the SAME object (same messageId).
|
||||||
|
const envelope = ingressEnvelope('verified once', 'discord-replay-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
});
|
||||||
|
|
||||||
|
// First delivery: the verified-Discord SEND runs the full embedded dispatch exactly once.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
|
||||||
|
// Byte-identical replay: the messageId is already claimed, so resolveDiscordIngress returns
|
||||||
|
// null and the SEND handler bails before dispatch/persist/ack. Every effect stays at exactly one.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
// The harness runtime is never touched on either delivery.
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND that fails the configured service identity consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once and a later duplicate stays fail-closed (Task 5 item 4 — claim ordering)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createSession = vi.fn().mockResolvedValue(session);
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-claim-ordering',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const ackCount = (): number =>
|
||||||
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||||
|
|
||||||
|
// A single fully-valid signed envelope, reused byte-for-byte across all three deliveries.
|
||||||
|
const envelope = ingressEnvelope('verified once with late identity', 'discord-order-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
});
|
||||||
|
|
||||||
|
// (1) Configured service identity is MISSING. The envelope is validly signed and passes the
|
||||||
|
// binding + route checks, but the SEND must refuse at the identity gate BEFORE any claim
|
||||||
|
// or effect. If the claim fires ahead of that gate, this delivery silently burns the
|
||||||
|
// replay claim for `discord-order-001` even though nothing dispatched.
|
||||||
|
delete process.env['DISCORD_SERVICE_USER_ID'];
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(0);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(0);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(0);
|
||||||
|
expect(ackCount()).toBe(0);
|
||||||
|
|
||||||
|
// (2) Identity is now configured; the operator resends the SAME envelope byte-for-byte. Because
|
||||||
|
// step (1) consumed no claim, this corrected retry claims once and runs the full embedded
|
||||||
|
// dispatch exactly once. (Under the pre-fix ordering the claim was already spent in step (1),
|
||||||
|
// so this retry is dropped as a replay and never dispatches — the RED this test drives.)
|
||||||
|
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
|
||||||
|
// (3) A genuine duplicate after a committed turn stays fail-closed: the claim taken in step (2)
|
||||||
|
// blocks it, so every effect remains at exactly one.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND whose configured agent record fails reconciliation consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createSession = vi.fn().mockResolvedValue(session);
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
// The durable agent record does not reconcile on the first delivery (its name no longer matches
|
||||||
|
// the verified binding's instance id), then reconciles cleanly on the corrected retry.
|
||||||
|
const findAgent = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'agent-config-nova', name: 'Renamed-Away' })
|
||||||
|
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: findAgent },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-reconcile',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const ackCount = (): number =>
|
||||||
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||||
|
|
||||||
|
const envelope = ingressEnvelope(
|
||||||
|
'verified once with stale agent record',
|
||||||
|
'discord-reconcile-001',
|
||||||
|
{
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// (1) The configured-agent reconcile runs BEFORE the replay claim. A mismatch refuses the turn
|
||||||
|
// and, crucially, consumes no claim for discord-reconcile-001 — nothing dispatches.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(0);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(0);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(0);
|
||||||
|
expect(ackCount()).toBe(0);
|
||||||
|
|
||||||
|
// (2) The record now reconciles; because step (1) took no claim, this byte-identical retry claims
|
||||||
|
// once and runs the full embedded dispatch exactly once. (Pre-fix, the claim was spent ahead
|
||||||
|
// of the reconcile in step (1), so this retry was dropped as a replay — the RED this drives.)
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
|
||||||
|
// (3) A genuine duplicate after the committed turn stays fail-closed.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND refuses to reuse a same-scope embedded session minted under a different configured identity, with zero prompt/persist/ack (Task 5 finding 3)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// A live session already exists for this conversation/scope, but it was minted under a DIFFERENT
|
||||||
|
// configured agent (Orion). The verified binding reconciles to Nova, so reusing this session would
|
||||||
|
// execute one agent's turn under another agent's verified label — the reuse guard must refuse it.
|
||||||
|
const foreignIdentitySession = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-orion',
|
||||||
|
agentName: 'Orion',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const createSession = vi.fn().mockResolvedValue(foreignIdentitySession);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(foreignIdentitySession),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-identity-swap',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('reuse under a different identity', 'discord-identity-swap-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refused at the embedded reuse guard: no prompt, no persist, no ack — only a typed refusal.
|
||||||
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
expect(client.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
|
||||||
|
);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const createSession = vi.fn().mockResolvedValue(session);
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
// The name matches the verified binding, but the record's own id is a DIFFERENT agent config —
|
||||||
|
// an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery,
|
||||||
|
// then admit the corrected record whose id matches the binding.
|
||||||
|
const findAgent = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' })
|
||||||
|
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: findAgent },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-reconcile-id',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const ackCount = (): number =>
|
||||||
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||||
|
|
||||||
|
const envelope = ingressEnvelope(
|
||||||
|
'verified once with aliased agent id',
|
||||||
|
'discord-reconcile-id-001',
|
||||||
|
{
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the
|
||||||
|
// turn BEFORE the replay claim, so nothing dispatches and the claim stays available.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(0);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(0);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(0);
|
||||||
|
expect(ackCount()).toBe(0);
|
||||||
|
|
||||||
|
// (2) The record now reconciles on both id and name; because step (1) took no claim, this
|
||||||
|
// byte-identical retry claims once and runs the full embedded dispatch exactly once.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
|
||||||
|
// (3) A genuine duplicate after the committed turn stays fail-closed.
|
||||||
|
await gateway.handleMessage(client as never, envelope);
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ackCount()).toBe(1);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => {
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||||
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||||
|
{
|
||||||
|
instanceId: 'Nova',
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
pairedUsers: {
|
||||||
|
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// No live session exists for this scope, so the runtime MINTS one — but createSession returns a
|
||||||
|
// session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova).
|
||||||
|
// The post-create identity recheck must refuse it rather than dispatch one agent's turn under
|
||||||
|
// another agent's verified label. (The existing reuse test covers the getSession path; this
|
||||||
|
// covers the createSession path scrappy flagged as unvalidated.)
|
||||||
|
const mintedForeignSession = {
|
||||||
|
provider: 'configured-provider',
|
||||||
|
modelId: 'configured-model',
|
||||||
|
agentConfigId: 'agent-config-orion',
|
||||||
|
agentName: 'Orion',
|
||||||
|
piSession: {
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const createSession = vi.fn().mockResolvedValue(mintedForeignSession);
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession,
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
removeChannel: vi.fn(),
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
|
conversations: {
|
||||||
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
|
findMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ resolve: vi.fn() } as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'discord-client-postcreate-mismatch',
|
||||||
|
data: { discordService: true },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('mint under a different identity', 'discord-postcreate-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The freshly minted session failed the post-create identity recheck: refused with a typed
|
||||||
|
// error, no prompt, no persist, no ack.
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt).not.toHaveBeenCalled();
|
||||||
|
expect(addMessage).not.toHaveBeenCalled();
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
expect(client.emit).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
|
||||||
|
);
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retains validated persisted attachments in resumed conversation history', async () => {
|
it('retains validated persisted attachments in resumed conversation history', async () => {
|
||||||
@@ -593,11 +1220,16 @@ describe('Discord ingress security', () => {
|
|||||||
|
|
||||||
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
|
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
|
||||||
configureDiscordEnv();
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
const addMessage = vi.fn().mockResolvedValue(undefined);
|
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||||
const session = {
|
const session = {
|
||||||
provider: 'test-provider',
|
provider: 'test-provider',
|
||||||
modelId: 'test-model',
|
modelId: 'test-model',
|
||||||
|
// The reused embedded session carries the SAME reconciled identity as the verified binding,
|
||||||
|
// so the finding-3 session-reuse guard admits it rather than refusing an identity swap.
|
||||||
|
agentConfigId: 'agent-config-nova',
|
||||||
|
agentName: 'Nova',
|
||||||
piSession: {
|
piSession: {
|
||||||
thinkingLevel: 'medium',
|
thinkingLevel: 'medium',
|
||||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||||
@@ -611,6 +1243,7 @@ describe('Discord ingress security', () => {
|
|||||||
prompt,
|
prompt,
|
||||||
};
|
};
|
||||||
const brain = {
|
const brain = {
|
||||||
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||||
conversations: {
|
conversations: {
|
||||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -618,8 +1251,9 @@ describe('Discord ingress security', () => {
|
|||||||
addMessage,
|
addMessage,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
const gateway = new ChatGateway(
|
const gateway = new ChatGateway(
|
||||||
agentService as never,
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
brain as never,
|
brain as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -667,6 +1301,66 @@ describe('Discord ingress security', () => {
|
|||||||
}),
|
}),
|
||||||
'discord-service',
|
'discord-service',
|
||||||
);
|
);
|
||||||
|
// The verified Discord prompt dispatch stays on the embedded runtime; the pi-rpc harness that
|
||||||
|
// the router resolved as `active` is never reached.
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a browser-forged Discord ingress envelope in pi-rpc with a fixed typed refusal and zero dispatch', async () => {
|
||||||
|
// Correction #2 + #4 (behavioural). A browser socket is never `discordService` (that flag is
|
||||||
|
// set only on a valid service-token handshake), so it cannot forge the trusted Discord path by
|
||||||
|
// emitting an envelope-shaped payload. In pi-rpc it must receive a FIXED TYPED refusal
|
||||||
|
// (`runtime_unsupported`, the same typed code the sibling harness-fence uses) and reach neither
|
||||||
|
// the forced Discord service scope, the verified Discord operation, the embedded runtime, nor
|
||||||
|
// the harness. There is no dedicated socket event for verified ingress — the only ingress
|
||||||
|
// surface is the generic `message` handler, and a non-service client is refused there.
|
||||||
|
//
|
||||||
|
// RED today: a non-service client emitting an envelope-shaped payload falls to the browser
|
||||||
|
// branch, fails the chat-message shape check, and is dropped SILENTLY (a warn + return) with no
|
||||||
|
// typed refusal emitted — so the refusal assertion fails. Collection and construction succeed;
|
||||||
|
// the gap is behavioural. GREEN emits the fixed typed refusal before any dispatch.
|
||||||
|
configureDiscordEnv();
|
||||||
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
createSession: vi.fn(),
|
||||||
|
recordMessage: vi.fn(),
|
||||||
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||||
|
addChannel: vi.fn(),
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const harnessConversations = { append: vi.fn() };
|
||||||
|
const routingEngine = { resolve: vi.fn() };
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||||
|
{} as never,
|
||||||
|
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
routingEngine as never,
|
||||||
|
);
|
||||||
|
const client = {
|
||||||
|
id: 'browser-forging-discord',
|
||||||
|
data: { discordService: false },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessage(
|
||||||
|
client as never,
|
||||||
|
ingressEnvelope('forged from a browser', 'browser-forgery-001', {
|
||||||
|
conversationId: 'Nova:discord:channel-001',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const refusal = client.emit.mock.calls.find(
|
||||||
|
([, payload]) => (payload as { code?: string } | undefined)?.code === 'runtime_unsupported',
|
||||||
|
);
|
||||||
|
expect(refusal).toBeDefined();
|
||||||
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||||
|
expect(agentService.createSession).not.toHaveBeenCalled();
|
||||||
|
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||||
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||||
|
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a thread message through its allowed bound parent channel', () => {
|
it('accepts a thread message through its allowed bound parent channel', () => {
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ import type {
|
|||||||
AgentTextPayload,
|
AgentTextPayload,
|
||||||
AgentThinkingPayload,
|
AgentThinkingPayload,
|
||||||
ChatMessagePayload,
|
ChatMessagePayload,
|
||||||
|
ChatSendCapabilityPayload,
|
||||||
|
ChatSendProtocol,
|
||||||
ClientToServerEvents,
|
ClientToServerEvents,
|
||||||
CommandDef,
|
CommandDef,
|
||||||
CommandManifest,
|
CommandManifest,
|
||||||
CommandManifestPayload,
|
CommandManifestPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessTurnAckPayload,
|
||||||
|
HarnessTurnSendPayload,
|
||||||
MessageAckPayload,
|
MessageAckPayload,
|
||||||
RoutingDecisionInfo,
|
RoutingDecisionInfo,
|
||||||
ServerToClientEvents,
|
ServerToClientEvents,
|
||||||
@@ -37,11 +42,16 @@ export type {
|
|||||||
AgentTextPayload,
|
AgentTextPayload,
|
||||||
AgentThinkingPayload,
|
AgentThinkingPayload,
|
||||||
ChatMessagePayload,
|
ChatMessagePayload,
|
||||||
|
ChatSendCapabilityPayload,
|
||||||
|
ChatSendProtocol,
|
||||||
ClientToServerEvents,
|
ClientToServerEvents,
|
||||||
CommandDef,
|
CommandDef,
|
||||||
CommandManifest,
|
CommandManifest,
|
||||||
CommandManifestPayload,
|
CommandManifestPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessTurnAckPayload,
|
||||||
|
HarnessTurnSendPayload,
|
||||||
MessageAckPayload,
|
MessageAckPayload,
|
||||||
RoutingDecisionInfo,
|
RoutingDecisionInfo,
|
||||||
ServerToClientEvents,
|
ServerToClientEvents,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||||
|
import type { HarnessSelection } from '@/lib/types';
|
||||||
import type { HarnessSelectionValue } from './use-harness-selection';
|
import type { HarnessSelectionValue } from './use-harness-selection';
|
||||||
|
|
||||||
interface ComposerProps {
|
interface ComposerProps {
|
||||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
onSend: (input: { content: string; selection: HarnessSelection }) => boolean;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
/** True from local send time through server turn startup/ack and
|
/** True from local send time through server turn startup/ack and
|
||||||
@@ -44,11 +45,17 @@ export function Composer({
|
|||||||
if (busy) return;
|
if (busy) return;
|
||||||
// Send is gated on a validated, persisted catalog tuple — a draft or unset
|
// Send is gated on a validated, persisted catalog tuple — a draft or unset
|
||||||
// selection can never emit, so provider/model never travel as free text.
|
// selection can never emit, so provider/model never travel as free text.
|
||||||
if (!harness.canSend) return;
|
if (!harness.canSend || harness.persistedSelection === null) return;
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
onSend({ content: trimmed, ...harness.projection });
|
// Pass the validated, persisted selection tuple only. The hook derives the
|
||||||
setContent('');
|
// wire projection (legacy `message` provider/model, or `turn:send`) from the
|
||||||
|
// negotiated `chat:send-capability` protocol — never from flat caller input.
|
||||||
|
const selection = harness.persistedSelection;
|
||||||
|
const ok = onSend({ content: trimmed, selection });
|
||||||
|
// Clear the input only when the send was accepted — a refused turn (e.g. a
|
||||||
|
// failed idempotency mint) must retain the user's text so it is not lost.
|
||||||
|
if (ok) setContent('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
|||||||
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
||||||
export interface FakeChatSocket {
|
export interface FakeChatSocket {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
/** Mirrors socket.io-client's `Socket.id`: the connection identity the server
|
||||||
|
* echoes in a `chat:send-capability` payload. The generation-bound send
|
||||||
|
* protocol accepts an advertisement only when `payload.connectionId === id`. */
|
||||||
|
id: string;
|
||||||
connect(): FakeChatSocket;
|
connect(): FakeChatSocket;
|
||||||
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||||
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||||
@@ -51,8 +55,10 @@ export function createFakeChatSocket(): {
|
|||||||
/** Simulates socket.io-client's automatic reconnect of the *same*
|
/** Simulates socket.io-client's automatic reconnect of the *same*
|
||||||
* instance after a transient disconnect: marks the socket connected again
|
* instance after a transient disconnect: marks the socket connected again
|
||||||
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
||||||
* without clearing or replacing any listeners. */
|
* without clearing or replacing any listeners. A real reconnect is assigned
|
||||||
simulateReconnect(): void;
|
* a fresh `Socket.id`; pass `nextId` to model that new connection identity
|
||||||
|
* (defaults to the current id so existing callers are unaffected). */
|
||||||
|
simulateReconnect(nextId?: string): void;
|
||||||
} {
|
} {
|
||||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||||
const emitted: EmittedEvent[] = [];
|
const emitted: EmittedEvent[] = [];
|
||||||
@@ -63,6 +69,7 @@ export function createFakeChatSocket(): {
|
|||||||
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
||||||
const socket = {
|
const socket = {
|
||||||
connected: false,
|
connected: false,
|
||||||
|
id: 'socket-a',
|
||||||
connect: vi.fn(function connect(this: void) {
|
connect: vi.fn(function connect(this: void) {
|
||||||
socket.connected = true;
|
socket.connected = true;
|
||||||
return socket;
|
return socket;
|
||||||
@@ -105,8 +112,9 @@ export function createFakeChatSocket(): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function simulateReconnect(): void {
|
function simulateReconnect(nextId: string = socket.id): void {
|
||||||
socket.connected = true;
|
socket.connected = true;
|
||||||
|
socket.id = nextId;
|
||||||
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||||
(handler as () => void)();
|
(handler as () => void)();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ vi.mock('@/lib/socket', () => ({
|
|||||||
destroySocket: destroySocketMock,
|
destroySocket: destroySocketMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import type { ChatSendProtocol, HarnessSelection } from '@mosaicstack/types';
|
||||||
import { useChatConnection, type ChatConnectionValue } from './use-chat-connection';
|
import { useChatConnection, type ChatConnectionValue } from './use-chat-connection';
|
||||||
|
|
||||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||||
@@ -33,6 +34,126 @@ function Harness(): null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Two (web send path) red-first support. These probe the FUTURE
|
||||||
|
* pi-rpc send contract against the CURRENT implementation, so the desired API is
|
||||||
|
* expressed here as a localized cast — production types stay untouched until Step
|
||||||
|
* Three. The reds fail on behaviour (legacy `message` emitted instead of
|
||||||
|
* `turn:send`; no nested selection; no idempotency key; void return; no
|
||||||
|
* conversation-id gating), never on a missing module or type.
|
||||||
|
*/
|
||||||
|
interface HarnessTurnSendInput {
|
||||||
|
readonly content: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
type HarnessSendMessage = (input: HarnessTurnSendInput) => boolean;
|
||||||
|
|
||||||
|
function harnessSend(): HarnessSendMessage {
|
||||||
|
return latest?.actions.sendMessage as unknown as HarnessSendMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five MAJOR-1 (browser send-protocol negotiation) support. The Gateway
|
||||||
|
* advertises how this connection may send via a server-to-client-only
|
||||||
|
* `chat:send-capability` (already part of the typed `ServerToClientEvents`
|
||||||
|
* contract, so this uses the fake's typed `serverEmit` — no cast); the hook
|
||||||
|
* holds the advertised protocol and routes `sendMessage` through an exhaustive
|
||||||
|
* switch on it, never inferring it from conversation/selection. When no listener
|
||||||
|
* is registered yet (CURRENT impl), the emit is an inert no-op, so the reds
|
||||||
|
* below fail on BEHAVIOUR — the current send path still infers a protocol and
|
||||||
|
* emits regardless of any advertisement — not on a missing module or type.
|
||||||
|
*/
|
||||||
|
function advertiseCapability(protocol: ChatSendProtocol, connectionId: string): void {
|
||||||
|
fake.serverEmit('chat:send-capability', { protocol, connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a controllable `crypto.randomUUID` on the global crypto object and
|
||||||
|
* return a restore fn. Uses defineProperty on the instance so it works whether
|
||||||
|
* or not the native method is configurable (it lives on the prototype, so an own
|
||||||
|
* property simply shadows it).
|
||||||
|
*/
|
||||||
|
function installRandomUUID(fn: () => string): () => void {
|
||||||
|
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
||||||
|
if (!g.crypto) {
|
||||||
|
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
|
||||||
|
}
|
||||||
|
const cryptoObj = g.crypto as { randomUUID?: () => string };
|
||||||
|
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
|
||||||
|
Object.defineProperty(cryptoObj, 'randomUUID', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: fn,
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (original) {
|
||||||
|
Object.defineProperty(cryptoObj, 'randomUUID', original);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(cryptoObj, 'randomUUID');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force `crypto.randomUUID` to read as ABSENT by shadowing it with an own
|
||||||
|
* `undefined` property. The native method lives on `Crypto.prototype`, so a
|
||||||
|
* bare delete of the (non-existent) own property would leave the inherited
|
||||||
|
* method visible — the shadow is what actually makes the call site see no
|
||||||
|
* secure generator. Returns a restore fn.
|
||||||
|
*/
|
||||||
|
function removeRandomUUID(): () => void {
|
||||||
|
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
||||||
|
if (!g.crypto) {
|
||||||
|
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
|
||||||
|
}
|
||||||
|
const cryptoObj = g.crypto as { randomUUID?: () => string };
|
||||||
|
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
|
||||||
|
Object.defineProperty(cryptoObj, 'randomUUID', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (original) {
|
||||||
|
Object.defineProperty(cryptoObj, 'randomUUID', original);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(cryptoObj, 'randomUUID');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five, Step Two group 4/5 support — the FUTURE `turn:ack` receipt surface
|
||||||
|
* and the FUTURE fixed idempotency/rejection notice, expressed as a localized
|
||||||
|
* read-only view over `state`. Production `ChatConnectionState` gains
|
||||||
|
* `turnReceipt` at Step Three; the cast keeps production types untouched until
|
||||||
|
* then, so a success assertion against it fails on BEHAVIOUR (no turn:ack
|
||||||
|
* handler runs), never on a missing module. `error` already exists on state.
|
||||||
|
*/
|
||||||
|
interface HarnessTurnReceiptView {
|
||||||
|
readonly idempotencyKey: string;
|
||||||
|
readonly receiptId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
interface HarnessTurnStateView {
|
||||||
|
readonly turnReceipt: HarnessTurnReceiptView | null | undefined;
|
||||||
|
readonly error: string | null;
|
||||||
|
}
|
||||||
|
function harnessTurnState(): HarnessTurnStateView {
|
||||||
|
return latest?.state as unknown as HarnessTurnStateView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a server `turn:ack` the CURRENT hook has no listener for — a safe no-op
|
||||||
|
* today (the fake iterates an empty handler set), so the group-4 reds fail
|
||||||
|
* because nothing is surfaced, not because this throws. The event name is cast
|
||||||
|
* past the compile-time `ServerToClientEvents` contract exactly as the
|
||||||
|
* `turn:send` client cast is; the typed event map lands at Step Three.
|
||||||
|
*/
|
||||||
|
function serverEmitTurnAck(payload: unknown): void {
|
||||||
|
fake.serverEmitRaw('turn:ack' as unknown as Parameters<typeof fake.serverEmitRaw>[0], payload);
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -67,6 +188,20 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('useChatConnection', () => {
|
describe('useChatConnection', () => {
|
||||||
|
// Task Five MAJOR-1: the send path is PROTOCOL-driven — `sendMessage` routes
|
||||||
|
// only on the negotiated `chat:send-capability`, never on inferred
|
||||||
|
// conversation/selection state. These pre-existing cases exercise the legacy
|
||||||
|
// `message` branch, so the connection is advertised `legacy-message` once here
|
||||||
|
// (server-to-client, for this exact socket id) after the mount registers its
|
||||||
|
// listener. Sub-describes that need the pi turn-runtime reset the generation
|
||||||
|
// and re-advertise `turn-send`; the capability describe resets to the
|
||||||
|
// unadvertised `unavailable` baseline and drives the protocol itself.
|
||||||
|
beforeEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('legacy-message', fake.socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('establishes the active conversation from the first message:ack when message omitted conversationId', async () => {
|
it('establishes the active conversation from the first message:ack when message omitted conversationId', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
@@ -344,7 +479,10 @@ describe('useChatConnection', () => {
|
|||||||
|
|
||||||
it('sendMessage emits optional conversationId/provider/modelId and appends an optimistic user turn', async () => {
|
it('sendMessage emits optional conversationId/provider/modelId and appends an optimistic user turn', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
latest?.actions.sendMessage({ content: 'hello', provider: 'anthropic', modelId: 'claude' });
|
latest?.actions.sendMessage({
|
||||||
|
content: 'hello',
|
||||||
|
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fake.emitted).toContainEqual({
|
expect(fake.emitted).toContainEqual({
|
||||||
@@ -373,6 +511,408 @@ describe('useChatConnection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('turn:send harness routing (Task Five, Step Two red-first)', () => {
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
};
|
||||||
|
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||||
|
|
||||||
|
// The pi turn-runtime routes sends through `turn:send`. Reset the generation
|
||||||
|
// (clearing the outer `legacy-message` advertisement + first-wins lock) and
|
||||||
|
// advertise `turn-send` for this exact connection, so every send below takes
|
||||||
|
// the turn-runtime branch.
|
||||||
|
beforeEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateReconnect();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('turn-send', fake.socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function establishConversation(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('emits a single turn:send with the nested selection tuple and a UUID idempotencyKey — never the legacy message event', async () => {
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
try {
|
||||||
|
await establishConversation();
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'hello', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
||||||
|
expect(sends).toHaveLength(1);
|
||||||
|
expect(sends[0]?.payload).toEqual({
|
||||||
|
conversationId: 'c1',
|
||||||
|
content: 'hello',
|
||||||
|
selection,
|
||||||
|
idempotencyKey: UUID,
|
||||||
|
});
|
||||||
|
// The pi-rpc sender must not fall back to the embedded `message` event.
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates the idempotencyKey with exactly one crypto.randomUUID() call per accepted send', async () => {
|
||||||
|
const gen = vi.fn(() => UUID);
|
||||||
|
const restore = installRandomUUID(gen);
|
||||||
|
try {
|
||||||
|
await establishConversation();
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'first', selection });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'second', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(gen).toHaveBeenCalledTimes(2);
|
||||||
|
const keys = fake.emitted
|
||||||
|
.filter((e) => e.event === 'turn:send')
|
||||||
|
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
|
||||||
|
expect(keys).toEqual([UUID, UUID]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send before an active conversation id exists (no first-send auto-create)', async () => {
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
let returned: boolean | undefined;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
returned = harnessSend()({ content: 'too early', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(returned).toBe(false);
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||||
|
// Nothing optimistically appended when the send is refused.
|
||||||
|
expect(latest?.state.messages.some((m) => m.text === 'too early')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when it emits and false when the send is refused', async () => {
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
let refusedEarly: boolean | undefined;
|
||||||
|
let acceptedAfter: boolean | undefined;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
refusedEarly = harnessSend()({ content: 'early', selection });
|
||||||
|
});
|
||||||
|
await establishConversation();
|
||||||
|
await act(async () => {
|
||||||
|
acceptedAfter = harnessSend()({ content: 'now', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(refusedEarly).toBe(false);
|
||||||
|
expect(acceptedAfter).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when secure UUID generation throws: emits nothing, appends nothing, releases the lock, and a later send succeeds', async () => {
|
||||||
|
await establishConversation();
|
||||||
|
|
||||||
|
const failing = installRandomUUID(() => {
|
||||||
|
throw new Error('secure random unavailable');
|
||||||
|
});
|
||||||
|
let firstReturn: boolean | undefined;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
firstReturn = harnessSend()({ content: 'blocked', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
failing();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(firstReturn).toBe(false);
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||||
|
expect(latest?.state.messages.some((m) => m.text === 'blocked')).toBe(false);
|
||||||
|
|
||||||
|
// The send lock must have been released, so a subsequent valid send works.
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
let secondReturn: boolean | undefined;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
secondReturn = harnessSend()({ content: 'retry', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(secondReturn).toBe(true);
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('turn:ack receipt + rejection contract (Task Five, Step Two group 4)', () => {
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
};
|
||||||
|
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||||
|
|
||||||
|
// turn:ack is the receipt for a `turn:send`, so these establish under the pi
|
||||||
|
// turn-runtime: reset the generation (clearing the outer `legacy-message`
|
||||||
|
// advertisement + lock) and advertise `turn-send` for this connection.
|
||||||
|
beforeEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateReconnect();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('turn-send', fake.socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Establish the conversation and send one accepted turn under a controlled
|
||||||
|
// idempotency key. Returns the crypto restore fn so callers unwind it.
|
||||||
|
async function establishAndSend(): Promise<() => void> {
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'hello', selection });
|
||||||
|
});
|
||||||
|
return restore;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('surfaces a turn:ack receipt echoing the exact idempotencyKey, server receiptId, and requested selection tuple', async () => {
|
||||||
|
const restore = await establishAndSend();
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
serverEmitTurnAck({
|
||||||
|
conversationId: 'c1',
|
||||||
|
idempotencyKey: UUID,
|
||||||
|
receiptId: 'r1',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// RED anchor: no turn:ack handler exists, so nothing is recorded. Green
|
||||||
|
// only when Step Three echoes the exact tuple back into state — never a
|
||||||
|
// substituted or fabricated one.
|
||||||
|
expect(harnessTurnState().turnReceipt).toEqual({
|
||||||
|
idempotencyKey: UUID,
|
||||||
|
receiptId: 'r1',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on a rejected turn:ack surfaces a visible safe notice, never the raw internal error, and fabricates no receipt tuple', async () => {
|
||||||
|
const restore = await establishAndSend();
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
serverEmitTurnAck({
|
||||||
|
conversationId: 'c1',
|
||||||
|
idempotencyKey: UUID,
|
||||||
|
ok: false,
|
||||||
|
code: 'runtime_unsupported',
|
||||||
|
error: 'ADAPTER_BOOM internal stack: pi adapter unavailable at 0xdeadbeef',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// RED anchor: a rejected ack must surface a visible notice; today no
|
||||||
|
// handler runs, so state.error stays null.
|
||||||
|
expect(harnessTurnState().error).toBeTruthy();
|
||||||
|
// The raw internal exception text must never reach the browser surface.
|
||||||
|
expect(harnessTurnState().error ?? '').not.toContain('ADAPTER_BOOM');
|
||||||
|
expect(harnessTurnState().error ?? '').not.toContain('0xdeadbeef');
|
||||||
|
// A rejection must not fabricate a success receipt tuple.
|
||||||
|
expect(harnessTurnState().turnReceipt ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses one fixed safe rejection notice regardless of the internal cause (frozen union, not a passthrough)', async () => {
|
||||||
|
const firstRestore = await establishAndSend();
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
serverEmitTurnAck({
|
||||||
|
conversationId: 'c1',
|
||||||
|
idempotencyKey: UUID,
|
||||||
|
ok: false,
|
||||||
|
code: 'runtime_unsupported',
|
||||||
|
error: 'cause-ALPHA adapter_unavailable',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
firstRestore();
|
||||||
|
}
|
||||||
|
const firstNotice = harnessTurnState().error;
|
||||||
|
|
||||||
|
// A fresh turn on the same conversation, rejected for a DIFFERENT internal
|
||||||
|
// reason, must surface the identical fixed notice.
|
||||||
|
const secondRestore = installRandomUUID(() => UUID);
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'again', selection });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
serverEmitTurnAck({
|
||||||
|
conversationId: 'c1',
|
||||||
|
idempotencyKey: UUID,
|
||||||
|
ok: false,
|
||||||
|
code: 'runtime_unsupported',
|
||||||
|
error: 'cause-BRAVO conversation_service_unavailable',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
secondRestore();
|
||||||
|
}
|
||||||
|
const secondNotice = harnessTurnState().error;
|
||||||
|
|
||||||
|
// RED anchor: both are null today; green requires a single frozen safe
|
||||||
|
// string surfaced for both distinct internal causes.
|
||||||
|
expect(firstNotice).toBeTruthy();
|
||||||
|
expect(secondNotice).toBeTruthy();
|
||||||
|
expect(firstNotice).toBe(secondNotice);
|
||||||
|
expect(firstNotice ?? '').not.toContain('ALPHA');
|
||||||
|
expect(secondNotice ?? '').not.toContain('BRAVO');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('idempotency-key failure semantics (Task Five, Step Two group 5)', () => {
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
};
|
||||||
|
const UUID_A = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const UUID_B = '22222222-2222-4222-9222-222222222222';
|
||||||
|
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
// The idempotency key is minted only on the pi turn-runtime `turn:send`
|
||||||
|
// branch: reset the generation (clearing the outer `legacy-message`
|
||||||
|
// advertisement + lock) and advertise `turn-send` for this connection.
|
||||||
|
beforeEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateReconnect();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('turn-send', fake.socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function establish(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('mints a DISTINCT UUID-v4 idempotencyKey for each of two accepted turns — a key is never reused across turns', async () => {
|
||||||
|
const keys = [UUID_A, UUID_B];
|
||||||
|
let call = 0;
|
||||||
|
const restore = installRandomUUID(() => keys[call++] ?? UUID_A);
|
||||||
|
try {
|
||||||
|
await establish();
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'first', selection });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'second', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = fake.emitted
|
||||||
|
.filter((e) => e.event === 'turn:send')
|
||||||
|
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
|
||||||
|
// RED anchor: current sendMessage emits the legacy `message`, so no
|
||||||
|
// turn:send keys exist at all.
|
||||||
|
expect(sent).toHaveLength(2);
|
||||||
|
expect(sent[0]).toMatch(UUID_V4);
|
||||||
|
expect(sent[1]).toMatch(UUID_V4);
|
||||||
|
expect(sent[0]).not.toBe(sent[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when crypto.randomUUID is ABSENT: surfaces a visible fixed idempotency-unavailable notice, emits nothing, appends nothing, releases the lock synchronously, and a later valid send succeeds', async () => {
|
||||||
|
await establish();
|
||||||
|
|
||||||
|
const restoreCrypto = removeRandomUUID();
|
||||||
|
let firstReturn: boolean | undefined;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
firstReturn = harnessSend()({ content: 'no-secure-random', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreCrypto();
|
||||||
|
}
|
||||||
|
|
||||||
|
// RED anchors: a refused send returns false and surfaces a visible notice.
|
||||||
|
expect(firstReturn).toBe(false);
|
||||||
|
expect(harnessTurnState().error).toBeTruthy();
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||||
|
expect(latest?.state.messages.some((m) => m.text === 'no-secure-random')).toBe(false);
|
||||||
|
|
||||||
|
// The lock released synchronously (no server event needed): a later valid
|
||||||
|
// send goes through.
|
||||||
|
const restore = installRandomUUID(() => UUID_A);
|
||||||
|
let secondReturn: boolean | undefined;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
secondReturn = harnessSend()({ content: 'recovered', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
expect(secondReturn).toBe(true);
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the SAME fixed idempotency-unavailable notice whether randomUUID is absent or throws, never leaking the thrown message', async () => {
|
||||||
|
// Case 1: absent.
|
||||||
|
await establish();
|
||||||
|
const restoreAbsent = removeRandomUUID();
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'absent', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreAbsent();
|
||||||
|
}
|
||||||
|
const absentNotice = harnessTurnState().error;
|
||||||
|
|
||||||
|
// Case 2: throws with a distinctive internal message.
|
||||||
|
const failing = installRandomUUID(() => {
|
||||||
|
throw new Error('SECURE_RANDOM_BOOM entropy pool drained');
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'throws', selection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
failing();
|
||||||
|
}
|
||||||
|
const throwNotice = harnessTurnState().error;
|
||||||
|
|
||||||
|
// RED anchor: both are null today.
|
||||||
|
expect(absentNotice).toBeTruthy();
|
||||||
|
expect(throwNotice).toBeTruthy();
|
||||||
|
expect(absentNotice).toBe(throwNotice);
|
||||||
|
// The thrown internal detail must never reach the browser surface.
|
||||||
|
expect(throwNotice ?? '').not.toContain('SECURE_RANDOM_BOOM');
|
||||||
|
expect(throwNotice ?? '').not.toContain('entropy pool');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('abort emits abort with the active conversationId', async () => {
|
it('abort emits abort with the active conversationId', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
@@ -684,7 +1224,16 @@ describe('useChatConnection', () => {
|
|||||||
expect(latest?.state.approvalRequestPending).toBe(false);
|
expect(latest?.state.approvalRequestPending).toBe(false);
|
||||||
|
|
||||||
// The send lock must also be released — a subsequent sendMessage after
|
// The send lock must also be released — a subsequent sendMessage after
|
||||||
// reconnect must not be permanently blocked by the interrupted turn.
|
// reconnect must not be permanently blocked by the interrupted turn. The
|
||||||
|
// disconnect also voids the negotiated send protocol (MAJOR-1), so model the
|
||||||
|
// reconnect handshake — the socket reconnects and the server re-advertises
|
||||||
|
// how this connection may send — before probing the released lock.
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateReconnect();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('legacy-message', fake.socket.id);
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
latest?.actions.sendMessage({ content: 'after reconnect' });
|
latest?.actions.sendMessage({ content: 'after reconnect' });
|
||||||
});
|
});
|
||||||
@@ -1665,4 +2214,319 @@ describe('useChatConnection', () => {
|
|||||||
}
|
}
|
||||||
expect(destroySocketMock).toHaveBeenCalledOnce();
|
expect(destroySocketMock).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('chat:send-capability protocol negotiation (Task Five MAJOR-1, red-first)', () => {
|
||||||
|
const capSelection: HarnessSelection = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
};
|
||||||
|
const UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||||
|
// The one fixed, safe user-facing notice the hook must surface (code
|
||||||
|
// `send_protocol_unavailable`) when a send is attempted on a connection whose
|
||||||
|
// advertised protocol is `unavailable`/unknown/absent. Contract-frozen string.
|
||||||
|
const UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.';
|
||||||
|
|
||||||
|
// These tests each drive the protocol negotiation themselves, so they must
|
||||||
|
// start from a clean, unadvertised generation. Reconnect resets protocolRef
|
||||||
|
// to `unavailable` and clears the outer `legacy-message` first-wins lock
|
||||||
|
// WITHOUT advertising — no client emit, so `fake.emitted` stays empty and the
|
||||||
|
// "starts unavailable" premise holds.
|
||||||
|
beforeEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateReconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function establishConversation(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectCalls(): number {
|
||||||
|
return (fake.socket.connect as unknown as { mock: { calls: unknown[] } }).mock.calls.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('starts with no advertised protocol: a send is refused, emits nothing, mints no key, and surfaces the fixed unavailable notice', async () => {
|
||||||
|
// No `chat:send-capability` has arrived, so the connection has not been told
|
||||||
|
// it may send at all. The current impl infers "selection + no conversation +
|
||||||
|
// no flat provider/model → return false" but SURFACES NOTHING — the red is
|
||||||
|
// that the fixed `send_protocol_unavailable` notice is never set.
|
||||||
|
let uuidCalls = 0;
|
||||||
|
const restore = installRandomUUID(() => {
|
||||||
|
uuidCalls += 1;
|
||||||
|
return UUID;
|
||||||
|
});
|
||||||
|
let returned: boolean | undefined;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
returned = harnessSend()({ content: 'hi', selection: capSelection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(returned).toBe(false);
|
||||||
|
expect(fake.emitted).toHaveLength(0);
|
||||||
|
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||||
|
// The test's name promises "mints no key": the unavailable branch must not
|
||||||
|
// reach the idempotency mint at all. Without this assertion a defect that
|
||||||
|
// mints a key before refusing survives.
|
||||||
|
expect(uuidCalls).toBe(0);
|
||||||
|
// ...and no user content may be optimistically appended on refusal.
|
||||||
|
expect(latest?.state.messages.some((m) => m.text === 'hi')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy-message advertised overrides conversation-inference: an established conversation still routes the legacy message event, never turn:send', async () => {
|
||||||
|
// Same inputs the inference impl routes to `turn:send` (selection + active
|
||||||
|
// conversation). The advertised protocol is authoritative: it must emit the
|
||||||
|
// legacy `message` event instead. Red: current impl emits turn:send.
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
try {
|
||||||
|
await establishConversation();
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('legacy-message', fake.socket.id);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'hi', selection: capSelection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'turn:send')).toHaveLength(0);
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: { conversationId: 'c1', content: 'hi', provider: 'anthropic', modelId: 'claude' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy-message advertised with no conversation: derives provider/model from the selection tuple and emits one message', async () => {
|
||||||
|
// The flat provider/modelId caller inputs are gone; the legacy branch must
|
||||||
|
// source them from the confirmed persisted selection. Red: current impl
|
||||||
|
// refuses a bare harness send (selection + no flat fields → return false).
|
||||||
|
let returned: boolean | undefined;
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('legacy-message', fake.socket.id);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
returned = harnessSend()({ content: 'first', selection: capSelection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(returned).toBe(true);
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: {
|
||||||
|
conversationId: undefined,
|
||||||
|
content: 'first',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unavailable advertised: refuses even with an active conversation and selection, emits nothing, surfaces the fixed notice', async () => {
|
||||||
|
// Red: current impl ignores the advertisement and emits turn:send.
|
||||||
|
let uuidCalls = 0;
|
||||||
|
const restore = installRandomUUID(() => {
|
||||||
|
uuidCalls += 1;
|
||||||
|
return UUID;
|
||||||
|
});
|
||||||
|
let returned: boolean | undefined;
|
||||||
|
try {
|
||||||
|
await establishConversation();
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('unavailable', fake.socket.id);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
returned = harnessSend()({ content: 'nope', selection: capSelection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(returned).toBe(false);
|
||||||
|
expect(fake.emitted).toHaveLength(0);
|
||||||
|
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||||
|
// Refusal must not optimistically append the user's turn to the transcript
|
||||||
|
// (a distinct leak from the emit): the unavailable branch appends nothing.
|
||||||
|
expect(latest?.state.messages.some((m) => m.text === 'nope')).toBe(false);
|
||||||
|
// ...and must not mint an idempotency key on the refused path.
|
||||||
|
expect(uuidCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an advertisement whose connectionId does not match the socket id: protocol stays unavailable and the send is refused', async () => {
|
||||||
|
// A capability minted for a different (stale/foreign) connection must never
|
||||||
|
// arm this one. Red: current impl has no connection-id gate and emits
|
||||||
|
// turn:send off the inferred path.
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
let returned: boolean | undefined;
|
||||||
|
try {
|
||||||
|
await establishConversation();
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('legacy-message', 'a-different-connection');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
returned = harnessSend()({ content: 'spoof', selection: capSelection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(returned).toBe(false);
|
||||||
|
expect(fake.emitted).toHaveLength(0);
|
||||||
|
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts only the first advertisement for the generation: a later conflicting protocol is ignored', async () => {
|
||||||
|
// legacy-message wins; the subsequent turn-send is a replay/conflict and is
|
||||||
|
// dropped. Red: current impl ignores both and infers turn:send.
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
try {
|
||||||
|
await establishConversation();
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('legacy-message', fake.socket.id);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('turn-send', fake.socket.id);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'hi', selection: capSelection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'turn:send')).toHaveLength(0);
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: { conversationId: 'c1', content: 'hi', provider: 'anthropic', modelId: 'claude' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets to unavailable on disconnect: a later send is refused and never reconnects the socket', async () => {
|
||||||
|
// Disconnect voids the advertised protocol for the generation. The send must
|
||||||
|
// refuse and MUST NOT call socket.connect() to force a reconnection. Red:
|
||||||
|
// current impl keeps the conversation, infers turn:send, and its turn:send
|
||||||
|
// branch calls socket.connect() when the socket is disconnected.
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
let returned: boolean | undefined;
|
||||||
|
let connectsDuringSend = 0;
|
||||||
|
try {
|
||||||
|
await establishConversation();
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('legacy-message', fake.socket.id);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateDisconnect();
|
||||||
|
});
|
||||||
|
const before = connectCalls();
|
||||||
|
await act(async () => {
|
||||||
|
returned = harnessSend()({ content: 'after-drop', selection: capSelection });
|
||||||
|
});
|
||||||
|
connectsDuringSend = connectCalls() - before;
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(returned).toBe(false);
|
||||||
|
expect(fake.emitted).toHaveLength(0);
|
||||||
|
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||||
|
expect(connectsDuringSend).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets on reconnect to a fresh generation: refuses until re-advertised, then honors the new advertisement', async () => {
|
||||||
|
// A reconnect mints a new Socket.id and a new generation; the prior
|
||||||
|
// advertisement (bound to the old id) is stale and must not carry over. The
|
||||||
|
// hook only trusts a fresh advertisement for the new connection. Red:
|
||||||
|
// current impl has no connect listener and keeps inferring turn:send.
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
let refusedAfterReconnect: boolean | undefined;
|
||||||
|
try {
|
||||||
|
await establishConversation();
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('legacy-message', fake.socket.id);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateReconnect('socket-b');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
refusedAfterReconnect = harnessSend()({ content: 'stale', selection: capSelection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(refusedAfterReconnect).toBe(false);
|
||||||
|
expect(fake.emitted).toHaveLength(0);
|
||||||
|
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||||
|
|
||||||
|
// A fresh advertisement for the reconnected id (socket-b) re-arms sending.
|
||||||
|
const restore2 = installRandomUUID(() => UUID);
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
advertiseCapability('legacy-message', 'socket-b');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
harnessSend()({ content: 'welcome-back', selection: capSelection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore2();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: {
|
||||||
|
conversationId: 'c1',
|
||||||
|
content: 'welcome-back',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes on the synchronous protocol ref, not the batched reducer mirror: an advertisement and a send in the SAME tick still route by the just-advertised protocol', async () => {
|
||||||
|
// An advertisement lands and a send is issued within one synchronous tick,
|
||||||
|
// before React commits the reducer's `sendProtocol` mirror. The send is
|
||||||
|
// captured from the pre-advertisement render, so its closed-over reducer
|
||||||
|
// state still reads `sendProtocol === 'unavailable'`; the capability
|
||||||
|
// handler, however, has already set the synchronous `protocolRef` to
|
||||||
|
// `legacy-message`. The hook must route on that ref. Red (against a
|
||||||
|
// stale-mirror routing that reads `state.sendProtocol`): the send reads the
|
||||||
|
// pre-advertisement `unavailable` and refuses instead of emitting `message`.
|
||||||
|
const restore = installRandomUUID(() => UUID);
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
// Bound to the CURRENT (pre-advertisement) render — its closure still
|
||||||
|
// sees the reset `unavailable` mirror even after the advert dispatches.
|
||||||
|
const sendBeforeCommit = harnessSend();
|
||||||
|
advertiseCapability('legacy-message', fake.socket.id);
|
||||||
|
// Same tick, no await: React has not committed the new mirror yet, so
|
||||||
|
// only `protocolRef` reflects `legacy-message`.
|
||||||
|
sendBeforeCommit({ content: 'same-tick', selection: capSelection });
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: {
|
||||||
|
conversationId: undefined,
|
||||||
|
content: 'same-tick',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
asConversationId,
|
asConversationId,
|
||||||
asFiniteNumber,
|
asFiniteNumber,
|
||||||
|
asHarnessSelection,
|
||||||
asString,
|
asString,
|
||||||
asStringArray,
|
asStringArray,
|
||||||
isRecord,
|
isRecord,
|
||||||
@@ -21,10 +22,14 @@ import type {
|
|||||||
AgentStartPayload,
|
AgentStartPayload,
|
||||||
AgentTextPayload,
|
AgentTextPayload,
|
||||||
AgentThinkingPayload,
|
AgentThinkingPayload,
|
||||||
|
ChatSendCapabilityPayload,
|
||||||
|
ChatSendProtocol,
|
||||||
CommandDef,
|
CommandDef,
|
||||||
CommandManifest,
|
CommandManifest,
|
||||||
CommandManifestPayload,
|
CommandManifestPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessTurnAckPayload,
|
||||||
MessageAckPayload,
|
MessageAckPayload,
|
||||||
SessionInfoPayload,
|
SessionInfoPayload,
|
||||||
SessionUsagePayload,
|
SessionUsagePayload,
|
||||||
@@ -130,6 +135,42 @@ const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please tr
|
|||||||
* dropped. */
|
* dropped. */
|
||||||
const APPROVAL_LIMIT_MESSAGE = 'Approval limit reached for this session. This command was not run.';
|
const APPROVAL_LIMIT_MESSAGE = 'Approval limit reached for this session. This command was not run.';
|
||||||
|
|
||||||
|
/** Fixed, browser-safe notice surfaced when the harness runtime rejects a turn
|
||||||
|
* (`turn:ack` with `ok:false`). It is deliberately generic: the raw server
|
||||||
|
* `code`/`message`/`error` can carry adapter internals or entropy-source detail,
|
||||||
|
* so no rejection ever leaks its cause into the UI — every distinct rejection
|
||||||
|
* shows this same string. */
|
||||||
|
const TURN_REJECTED_NOTICE = 'This turn could not be sent. Please try again.';
|
||||||
|
|
||||||
|
/** Fixed, browser-safe notice surfaced when a turn is refused because the
|
||||||
|
* idempotency-key mint failed closed (`crypto.randomUUID` absent or throwing).
|
||||||
|
* Like {@link TURN_REJECTED_NOTICE}, it never carries the thrown message. */
|
||||||
|
const IDEMPOTENCY_UNAVAILABLE_NOTICE = 'This turn could not be sent. Please try again.';
|
||||||
|
|
||||||
|
/** The single fixed, browser-safe notice surfaced (with safe code
|
||||||
|
* `send_protocol_unavailable`) when a send is attempted on a connection whose
|
||||||
|
* negotiated send protocol is `unavailable` — the server never advertised a
|
||||||
|
* usable `chat:send-capability`, advertised `unavailable` (e.g. a pi-rpc runtime
|
||||||
|
* in this slice), or the advertisement was rejected (wrong connection id, replay,
|
||||||
|
* or an unknown protocol). It carries no dynamic detail. */
|
||||||
|
const SEND_PROTOCOL_UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.';
|
||||||
|
|
||||||
|
/** Mints a single idempotency key for one accepted `turn:send`, fail-closed.
|
||||||
|
* Returns a fresh RFC-4122 UUID from `crypto.randomUUID`, or `null` when that
|
||||||
|
* source is absent (not a function) or throws — the caller then refuses the turn
|
||||||
|
* rather than falling back to any non-cryptographic source (Math.random, a
|
||||||
|
* clock, or a counter would all be forgeable/collision-prone). Never throws. */
|
||||||
|
function mintIdempotencyKey(): string | null {
|
||||||
|
try {
|
||||||
|
const c: unknown = globalThis.crypto;
|
||||||
|
if (!isRecord(c) || typeof c.randomUUID !== 'function') return null;
|
||||||
|
const key = (c.randomUUID as () => unknown)();
|
||||||
|
return typeof key === 'string' && key.length > 0 ? key : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** True only for the narrow case a malformed-conversationId `error`/`agent:end`
|
/** True only for the narrow case a malformed-conversationId `error`/`agent:end`
|
||||||
* must be treated as a terminal startup failure: no conversation has ever been
|
* must be treated as a terminal startup failure: no conversation has ever been
|
||||||
* established yet, and a send is still pending one. Once a conversation is
|
* established yet, and a send is still pending one. Once a conversation is
|
||||||
@@ -236,6 +277,14 @@ export interface PendingApproval {
|
|||||||
args?: string;
|
args?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Receipt captured from an accepted harness `turn:ack` — the minimal record proving the
|
||||||
|
* server accepted this exact turn under its minted idempotency key and selection tuple. */
|
||||||
|
export interface HarnessTurnReceipt {
|
||||||
|
idempotencyKey: string;
|
||||||
|
receiptId: string;
|
||||||
|
selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatConnectionState {
|
export interface ChatConnectionState {
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
/** True once a message has been sent while no conversation is active yet, so the
|
/** True once a message has been sent while no conversation is active yet, so the
|
||||||
@@ -268,6 +317,18 @@ export interface ChatConnectionState {
|
|||||||
approvalRequestPending: boolean;
|
approvalRequestPending: boolean;
|
||||||
systemReload: SystemReloadPayload | null;
|
systemReload: SystemReloadPayload | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** How this connection is currently permitted to send, negotiated via the
|
||||||
|
* server-to-client-only `chat:send-capability` advertisement. Starts and resets
|
||||||
|
* to `'unavailable'` on every (re)connect and disconnect — a fresh or dropped
|
||||||
|
* connection has no usable protocol until the server (re-)advertises. This is
|
||||||
|
* the reactive/UI mirror of the synchronous `protocolRef` that `sendMessage`
|
||||||
|
* actually reads; the ref is authoritative because an advertisement and a send
|
||||||
|
* can occur in the same tick before React re-renders. */
|
||||||
|
sendProtocol: ChatSendProtocol;
|
||||||
|
/** Receipt from the most recently accepted harness `turn:ack`, or null before any
|
||||||
|
* turn has been accepted. A rejected turn:ack surfaces via `error` and leaves this
|
||||||
|
* untouched (a prior accepted receipt is not erased by a later rejection). */
|
||||||
|
turnReceipt: HarnessTurnReceipt | null;
|
||||||
messages: ChatTranscriptMessage[];
|
messages: ChatTranscriptMessage[];
|
||||||
/** Monotonically increasing counter used to mint transcript message ids —
|
/** Monotonically increasing counter used to mint transcript message ids —
|
||||||
* never reset while retained messages remain, so ids stay unique across the
|
* never reset while retained messages remain, so ids stay unique across the
|
||||||
@@ -308,7 +369,7 @@ export interface ChatConnectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatConnectionActions {
|
export interface ChatConnectionActions {
|
||||||
sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void;
|
sendMessage: (input: { content: string; selection?: HarnessSelection }) => boolean;
|
||||||
abort: () => void;
|
abort: () => void;
|
||||||
setThinking: (level: string) => void;
|
setThinking: (level: string) => void;
|
||||||
executeCommand: (input: { command: string; args?: string }) => void;
|
executeCommand: (input: { command: string; args?: string }) => void;
|
||||||
@@ -341,6 +402,8 @@ const initialState: ChatConnectionState = {
|
|||||||
approvalRequestPending: false,
|
approvalRequestPending: false,
|
||||||
systemReload: null,
|
systemReload: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
sendProtocol: 'unavailable',
|
||||||
|
turnReceipt: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
messageSeq: 0,
|
messageSeq: 0,
|
||||||
toolSeq: 0,
|
toolSeq: 0,
|
||||||
@@ -361,7 +424,12 @@ type Action =
|
|||||||
| { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload }
|
| { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload }
|
||||||
| { type: 'server/system:reload'; payload: SystemReloadPayload }
|
| { type: 'server/system:reload'; payload: SystemReloadPayload }
|
||||||
| { type: 'server/error'; payload: ErrorPayload }
|
| { type: 'server/error'; payload: ErrorPayload }
|
||||||
|
| { type: 'server/turn:ack'; payload: HarnessTurnAckPayload }
|
||||||
| { type: 'local/send'; content: string }
|
| { type: 'local/send'; content: string }
|
||||||
|
| { type: 'local/capability'; protocol: ChatSendProtocol }
|
||||||
|
| { type: 'local/reset-protocol' }
|
||||||
|
| { type: 'local/send-unavailable' }
|
||||||
|
| { type: 'local/turn-idempotency-unavailable' }
|
||||||
| { type: 'local/approve-request'; command: string; args?: string }
|
| { type: 'local/approve-request'; command: string; args?: string }
|
||||||
| { type: 'local/consume-approval' }
|
| { type: 'local/consume-approval' }
|
||||||
| { type: 'local/approval-saturated' }
|
| { type: 'local/approval-saturated' }
|
||||||
@@ -778,6 +846,30 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'server/turn:ack': {
|
||||||
|
// The harness runtime's turn acknowledgement. The success shape carries a
|
||||||
|
// receipt id + minted idempotencyKey + echoed selection; the failure shape
|
||||||
|
// is discriminated on `ok === false`. Every field is runtime-untrusted (the
|
||||||
|
// top-of-reducer guard already rejected a non-object payload).
|
||||||
|
const record = action.payload as Record<string, unknown>;
|
||||||
|
if (record.ok === false) {
|
||||||
|
// A rejected turn surfaces a FIXED browser-safe notice — never the raw
|
||||||
|
// server `message`/`error`/`code`, which can carry adapter internals — and
|
||||||
|
// does not disturb any previously accepted receipt.
|
||||||
|
return { ...state, error: TURN_REJECTED_NOTICE };
|
||||||
|
}
|
||||||
|
const idempotencyKey = asString(record.idempotencyKey);
|
||||||
|
// The web ack uses `receiptId`; fall back to the frozen contract's `turnId`.
|
||||||
|
const receiptId = asString(record.receiptId) || asString(record.turnId);
|
||||||
|
const selection = asHarnessSelection(record.selection);
|
||||||
|
if (idempotencyKey.length === 0 || receiptId.length === 0 || selection === null) {
|
||||||
|
// A malformed success frame is ignored outright rather than recorded as a
|
||||||
|
// half-populated receipt.
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return { ...state, turnReceipt: { idempotencyKey, receiptId, selection } };
|
||||||
|
}
|
||||||
|
|
||||||
case 'local/send': {
|
case 'local/send': {
|
||||||
const message: ChatTranscriptMessage = {
|
const message: ChatTranscriptMessage = {
|
||||||
// Sourced from the reducer-owned `messageSeq` counter — see the
|
// Sourced from the reducer-owned `messageSeq` counter — see the
|
||||||
@@ -802,6 +894,39 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'local/capability': {
|
||||||
|
// The FIRST valid `chat:send-capability` for this connection generation has
|
||||||
|
// been accepted (connection-id gating + first-wins enforced in the handler);
|
||||||
|
// record how this connection may now send. This is the reactive mirror of
|
||||||
|
// the synchronous `protocolRef` the send path reads.
|
||||||
|
return { ...state, sendProtocol: action.protocol };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'local/reset-protocol': {
|
||||||
|
// A (re)connect or disconnect voids any negotiated protocol: a fresh or
|
||||||
|
// dropped connection has no usable send capability until the server
|
||||||
|
// (re-)advertises. Reset to `unavailable` so no stale advertisement can
|
||||||
|
// authorize a send across a connection boundary.
|
||||||
|
if (state.sendProtocol === 'unavailable') return state;
|
||||||
|
return { ...state, sendProtocol: 'unavailable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'local/send-unavailable': {
|
||||||
|
// A send was attempted while the negotiated protocol is `unavailable`
|
||||||
|
// (never advertised / advertised unavailable / rejected advertisement).
|
||||||
|
// Surface the single FIXED safe notice — nothing was emitted, minted,
|
||||||
|
// appended, or locked.
|
||||||
|
return { ...state, error: SEND_PROTOCOL_UNAVAILABLE_NOTICE };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'local/turn-idempotency-unavailable': {
|
||||||
|
// The idempotency-key mint failed closed (crypto.randomUUID absent or
|
||||||
|
// throwing), so the turn was refused before emit. Surface a FIXED notice —
|
||||||
|
// never the underlying thrown message, which can leak entropy-source
|
||||||
|
// internals.
|
||||||
|
return { ...state, error: IDEMPOTENCY_UNAVAILABLE_NOTICE };
|
||||||
|
}
|
||||||
|
|
||||||
case 'local/disconnect': {
|
case 'local/disconnect': {
|
||||||
// A transient socket disconnect must not leave the UI stuck waiting on
|
// A transient socket disconnect must not leave the UI stuck waiting on
|
||||||
// a turn/approval/send that will never resolve on this connection.
|
// a turn/approval/send that will never resolve on this connection.
|
||||||
@@ -882,6 +1007,20 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
approveLockRef.current = state.approvalRequestPending;
|
approveLockRef.current = state.approvalRequestPending;
|
||||||
}, [state.approvalRequestPending]);
|
}, [state.approvalRequestPending]);
|
||||||
|
|
||||||
|
// Synchronous, generation-bound send protocol. `state.sendProtocol` drives the
|
||||||
|
// reactive UI, but reducer updates are batched/async — a `chat:send-capability`
|
||||||
|
// advertisement and a `sendMessage` can land in the same tick before React
|
||||||
|
// re-renders — so this ref is the source of truth the send path reads. Unlike
|
||||||
|
// sendLockRef/approveLockRef (synchronized FROM the reducer), this ref is
|
||||||
|
// written directly by the socket lifecycle/capability handlers below, which
|
||||||
|
// also dispatch the reducer mirror. It is NOT synchronized from state, because
|
||||||
|
// its whole purpose is to be correct BEFORE the reducer has re-rendered.
|
||||||
|
const protocolRef = useRef<ChatSendProtocol>('unavailable');
|
||||||
|
// True once the first valid advertisement for the CURRENT connection generation
|
||||||
|
// has been accepted; every later advertisement (a conflicting or replayed one)
|
||||||
|
// is ignored until the next (re)connect/disconnect resets the generation.
|
||||||
|
const protocolLockedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
|
|
||||||
@@ -913,7 +1052,45 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
const onError = (payload: ErrorPayload): void => {
|
const onError = (payload: ErrorPayload): void => {
|
||||||
dispatch({ type: 'server/error', payload });
|
dispatch({ type: 'server/error', payload });
|
||||||
};
|
};
|
||||||
|
const onTurnAck = (payload: HarnessTurnAckPayload): void =>
|
||||||
|
dispatch({ type: 'server/turn:ack', payload });
|
||||||
|
|
||||||
|
// Void the negotiated send protocol at every connection-lifecycle boundary.
|
||||||
|
// A fresh or dropped connection has no usable capability until the server
|
||||||
|
// (re-)advertises, so no advertisement bound to a prior connection may carry
|
||||||
|
// across the boundary and authorize a send. Both write the synchronous ref
|
||||||
|
// AND unlock first-wins, then dispatch the reducer mirror.
|
||||||
|
const resetSendProtocol = (): void => {
|
||||||
|
protocolRef.current = 'unavailable';
|
||||||
|
protocolLockedRef.current = false;
|
||||||
|
dispatch({ type: 'local/reset-protocol' });
|
||||||
|
};
|
||||||
|
const onConnect = (): void => {
|
||||||
|
resetSendProtocol();
|
||||||
|
};
|
||||||
|
const onCapability = (payload: ChatSendCapabilityPayload): void => {
|
||||||
|
// Server-to-client-only advertisement of how THIS connection may send.
|
||||||
|
// Accept only the FIRST valid one per generation, and only when it names
|
||||||
|
// this exact connection (`connectionId === socket.id`): a capability minted
|
||||||
|
// for another or stale connection must never arm this one. The payload is
|
||||||
|
// runtime-untrusted despite its compile-time type, so every field is
|
||||||
|
// guard-checked and an unknown protocol is dropped (leaving `unavailable`).
|
||||||
|
if (protocolLockedRef.current) return;
|
||||||
|
if (!isRecord(payload)) return;
|
||||||
|
const { protocol, connectionId } = payload as {
|
||||||
|
protocol?: unknown;
|
||||||
|
connectionId?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof connectionId !== 'string' || connectionId !== socket.id) return;
|
||||||
|
if (protocol !== 'legacy-message' && protocol !== 'turn-send' && protocol !== 'unavailable') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protocolLockedRef.current = true;
|
||||||
|
protocolRef.current = protocol;
|
||||||
|
dispatch({ type: 'local/capability', protocol });
|
||||||
|
};
|
||||||
const onDisconnect = (): void => {
|
const onDisconnect = (): void => {
|
||||||
|
resetSendProtocol();
|
||||||
dispatch({ type: 'local/disconnect' });
|
dispatch({ type: 'local/disconnect' });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -930,6 +1107,11 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
socket.on('command:approval', onCommandApproval);
|
socket.on('command:approval', onCommandApproval);
|
||||||
socket.on('system:reload', onSystemReload);
|
socket.on('system:reload', onSystemReload);
|
||||||
socket.on('error', onError);
|
socket.on('error', onError);
|
||||||
|
socket.on('turn:ack', onTurnAck);
|
||||||
|
// Registered BEFORE connect so the initial post-auth advertisement (and any
|
||||||
|
// reconnect) can never race ahead of its listener.
|
||||||
|
socket.on('connect', onConnect);
|
||||||
|
socket.on('chat:send-capability', onCapability);
|
||||||
socket.on('disconnect', onDisconnect);
|
socket.on('disconnect', onDisconnect);
|
||||||
|
|
||||||
if (!socket.connected) {
|
if (!socket.connected) {
|
||||||
@@ -950,24 +1132,79 @@ export function useChatConnection(): ChatConnectionValue {
|
|||||||
socket.off('command:approval', onCommandApproval);
|
socket.off('command:approval', onCommandApproval);
|
||||||
socket.off('system:reload', onSystemReload);
|
socket.off('system:reload', onSystemReload);
|
||||||
socket.off('error', onError);
|
socket.off('error', onError);
|
||||||
|
socket.off('turn:ack', onTurnAck);
|
||||||
|
socket.off('connect', onConnect);
|
||||||
|
socket.off('chat:send-capability', onCapability);
|
||||||
socket.off('disconnect', onDisconnect);
|
socket.off('disconnect', onDisconnect);
|
||||||
destroySocket();
|
destroySocket();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const actions: ChatConnectionActions = {
|
const actions: ChatConnectionActions = {
|
||||||
sendMessage: ({ content, provider, modelId }) => {
|
sendMessage: ({ content, selection }) => {
|
||||||
if (sendLockRef.current || state.streaming || state.sending) return;
|
// Routing is PROTOCOL-driven, never inferred from conversation/selection/
|
||||||
sendLockRef.current = true;
|
// provider/local mode: the server advertised, once per connection, exactly
|
||||||
const socket = getSocket();
|
// how this connection may send, and that advertisement is authoritative.
|
||||||
if (!socket.connected) socket.connect();
|
// The exhaustive switch maps each protocol to its ONE event; the send path
|
||||||
dispatch({ type: 'local/send', content });
|
// never reconnects the socket (a dropped connection has already reset the
|
||||||
socket.emit('message', {
|
// protocol to `unavailable`, so no emit branch is reachable while offline).
|
||||||
conversationId: state.conversationId ?? undefined,
|
switch (protocolRef.current) {
|
||||||
content,
|
case 'legacy-message': {
|
||||||
provider,
|
// Embedded/legacy runtime: EVERY browser turn — the first (which
|
||||||
modelId,
|
// creates the conversation) and every later one — is the `message`
|
||||||
});
|
// event. provider/model are sourced ONLY from the confirmed persisted
|
||||||
|
// selection tuple, never from separate flat caller inputs.
|
||||||
|
if (sendLockRef.current || state.streaming || state.sending) return false;
|
||||||
|
sendLockRef.current = true;
|
||||||
|
const socket = getSocket();
|
||||||
|
dispatch({ type: 'local/send', content });
|
||||||
|
socket.emit('message', {
|
||||||
|
conversationId: state.conversationId ?? undefined,
|
||||||
|
content,
|
||||||
|
provider: selection?.providerId,
|
||||||
|
modelId: selection?.modelId,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'turn-send': {
|
||||||
|
// Pi turn-runtime: the exclusive `turn:send` contract. Requires an
|
||||||
|
// already-established conversation AND a confirmed persisted selection
|
||||||
|
// tuple; it is lock-independent (no send lock, no optimistic append),
|
||||||
|
// and mints exactly one idempotency key per accepted turn, failing the
|
||||||
|
// turn closed if the mint fails. A premature send (no conversation yet,
|
||||||
|
// or no selection) is refused with no emit and no notice.
|
||||||
|
if (selection == null || state.conversationId === null) return false;
|
||||||
|
const idempotencyKey = mintIdempotencyKey();
|
||||||
|
if (idempotencyKey === null) {
|
||||||
|
dispatch({ type: 'local/turn-idempotency-unavailable' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const socket = getSocket();
|
||||||
|
socket.emit('turn:send', {
|
||||||
|
conversationId: state.conversationId,
|
||||||
|
content,
|
||||||
|
selection,
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'unavailable': {
|
||||||
|
// No usable protocol negotiated for this connection: refuse without
|
||||||
|
// emitting, minting, appending, or acquiring the lock, and surface the
|
||||||
|
// one fixed safe notice (code `send_protocol_unavailable`).
|
||||||
|
dispatch({ type: 'local/send-unavailable' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// Exhaustiveness guard: every ChatSendProtocol member is handled above.
|
||||||
|
// An unknown value can never arm a send — refuse exactly as
|
||||||
|
// `unavailable` rather than falling through to any emit.
|
||||||
|
const _exhaustive: never = protocolRef.current;
|
||||||
|
void _exhaustive;
|
||||||
|
dispatch({ type: 'local/send-unavailable' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
abort: () => {
|
abort: () => {
|
||||||
|
|||||||
@@ -226,7 +226,10 @@ describe('useHarnessSelection', () => {
|
|||||||
modelId: 'gpt-5',
|
modelId: 'gpt-5',
|
||||||
});
|
});
|
||||||
expect(value().canSend).toBe(true);
|
expect(value().canSend).toBe(true);
|
||||||
expect(value().projection).toEqual({ provider: 'openai', modelId: 'gpt-5' });
|
// Task Five: the composer sends the nested `persistedSelection` tuple directly.
|
||||||
|
// The Task-Four compat flat `projection` ({provider, modelId}) is removed — the
|
||||||
|
// harnessId must never be dropped on the way to the wire.
|
||||||
|
expect('projection' in value()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
|
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
|
||||||
@@ -386,7 +389,8 @@ describe('useHarnessSelection', () => {
|
|||||||
providerId: 'anthropic',
|
providerId: 'anthropic',
|
||||||
modelId: 'claude',
|
modelId: 'claude',
|
||||||
});
|
});
|
||||||
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
||||||
|
expect('projection' in value()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
|
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
|
||||||
@@ -420,7 +424,8 @@ describe('useHarnessSelection', () => {
|
|||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
expect(value().canSend).toBe(true);
|
expect(value().canSend).toBe(true);
|
||||||
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
||||||
|
expect('projection' in value()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never requests any /api/providers* endpoint across the whole flow', async () => {
|
it('never requests any /api/providers* endpoint across the whole flow', async () => {
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ export interface HarnessSelectionValue {
|
|||||||
* resolves the composite option identity to the real entry and passes both
|
* resolves the composite option identity to the real entry and passes both
|
||||||
* ids, so a bare model id is never combined with ambient provider state. */
|
* ids, so a bare model id is never combined with ambient provider state. */
|
||||||
selectModel: (providerId: string, modelId: string) => void;
|
selectModel: (providerId: string, modelId: string) => void;
|
||||||
/** The compatibility `{provider, modelId}` projection for the legacy socket
|
|
||||||
* send path — derived ONLY from the validated persisted tuple, never from any
|
|
||||||
* free-text or unpersisted draft. Empty when nothing is sendable. */
|
|
||||||
projection: { provider?: string; modelId?: string };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
||||||
@@ -193,9 +189,6 @@ export function useHarnessSelection(): HarnessSelectionValue {
|
|||||||
!catalogUnavailable &&
|
!catalogUnavailable &&
|
||||||
tuplesEqual(draft, persistedSelection) &&
|
tuplesEqual(draft, persistedSelection) &&
|
||||||
isAvailableInCatalog(persistedSelection, catalog);
|
isAvailableInCatalog(persistedSelection, catalog);
|
||||||
const projection: { provider?: string; modelId?: string } = canSend
|
|
||||||
? { provider: persistedSelection.providerId, modelId: persistedSelection.modelId }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
harnesses,
|
harnesses,
|
||||||
@@ -211,6 +204,5 @@ export function useHarnessSelection(): HarnessSelectionValue {
|
|||||||
selectHarness,
|
selectHarness,
|
||||||
selectProvider,
|
selectProvider,
|
||||||
selectModel,
|
selectModel,
|
||||||
projection,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,58 @@ async function flushAsync(times = 5): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deterministic idempotency key for the Task Five red-first page send test. */
|
||||||
|
const PAGE_UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||||
|
|
||||||
|
/** Install a controllable `crypto.randomUUID` and return a restore fn. Uses
|
||||||
|
* defineProperty on the crypto instance so it works whether or not the native
|
||||||
|
* method is configurable (it lives on the prototype; an own property shadows it). */
|
||||||
|
function installRandomUUID(fn: () => string): () => void {
|
||||||
|
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
||||||
|
if (!g.crypto) {
|
||||||
|
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
|
||||||
|
}
|
||||||
|
const cryptoObj = g.crypto as { randomUUID?: () => string };
|
||||||
|
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
|
||||||
|
Object.defineProperty(cryptoObj, 'randomUUID', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: fn,
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (original) {
|
||||||
|
Object.defineProperty(cryptoObj, 'randomUUID', original);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(cryptoObj, 'randomUUID');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Five MAJOR-1: the send path is PROTOCOL-driven — the browser may send only
|
||||||
|
* as the server advertised, once per connection, over the server-to-client-only
|
||||||
|
* `chat:send-capability`. Model that advertisement for THIS connection id so the
|
||||||
|
* page send tests take the intended branch. `legacy-message` is the default
|
||||||
|
* (advertised in `beforeEach`/`remountWithFetch`); the pi turn-runtime tests
|
||||||
|
* reset the generation and re-advertise `turn-send` via the helper below.
|
||||||
|
*/
|
||||||
|
function advertiseSendCapability(protocol: 'legacy-message' | 'turn-send' | 'unavailable'): void {
|
||||||
|
fake.serverEmit('chat:send-capability', { protocol, connectionId: fake.socket.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the negotiated protocol to a fresh, unlocked generation (clearing the
|
||||||
|
* default `legacy-message` advertisement + first-wins lock), then advertise the
|
||||||
|
* pi turn-runtime `turn:send` protocol for this connection. The per-test override
|
||||||
|
* for the page send tests that route through `turn:send`. */
|
||||||
|
async function advertiseTurnSendGeneration(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
fake.simulateReconnect();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
advertiseSendCapability('turn-send');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||||
let root: Root | null;
|
let root: Root | null;
|
||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
@@ -137,6 +189,12 @@ beforeEach(async () => {
|
|||||||
// Settle the selection hook's mount fetches so the default in-catalog tuple
|
// Settle the selection hook's mount fetches so the default in-catalog tuple
|
||||||
// persists and `canSend` is true for the existing send-path tests.
|
// persists and `canSend` is true for the existing send-path tests.
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
// Model the server's post-auth send-capability advertisement (MAJOR-1). Most
|
||||||
|
// page send tests exercise the legacy `message` branch; the pi turn-runtime
|
||||||
|
// tests override to `turn-send` via advertiseTurnSendGeneration().
|
||||||
|
await act(async () => {
|
||||||
|
advertiseSendCapability('legacy-message');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -159,6 +217,11 @@ async function remountWithFetch(fetchImpl: typeof fetch): Promise<void> {
|
|||||||
root?.render(<ChatPage />);
|
root?.render(<ChatPage />);
|
||||||
});
|
});
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
// Re-advertise on the remounted connection — the prior generation's capability
|
||||||
|
// does not carry across a remount (fresh hook instance, unadvertised protocol).
|
||||||
|
await act(async () => {
|
||||||
|
advertiseSendCapability('legacy-message');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ChatPage', () => {
|
describe('ChatPage', () => {
|
||||||
@@ -571,6 +634,156 @@ describe('ChatPage', () => {
|
|||||||
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits turn:send with the nested persisted selection tuple and a UUID idempotency key (never the legacy message event)', async () => {
|
||||||
|
await advertiseTurnSendGeneration();
|
||||||
|
const restore = installRandomUUID(() => PAGE_UUID);
|
||||||
|
try {
|
||||||
|
// Send is disabled without an active conversation — establish one first.
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'hello there');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
||||||
|
expect(sends).toHaveLength(1);
|
||||||
|
expect(sends[0]?.payload).toEqual({
|
||||||
|
conversationId: 'c1',
|
||||||
|
content: 'hello there',
|
||||||
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||||
|
idempotencyKey: PAGE_UUID,
|
||||||
|
});
|
||||||
|
// The pi-rpc page send must not emit the embedded `message` event, and must
|
||||||
|
// never send a flat {provider, modelId} that drops the harnessId.
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the composer content and emits nothing when the send cannot mint an idempotency key, so the user can retry (composer clears only on success) — Task Five group 5', async () => {
|
||||||
|
await advertiseTurnSendGeneration();
|
||||||
|
const failing = installRandomUUID(() => {
|
||||||
|
throw new Error('secure random unavailable');
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'keep me');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// No wire traffic: neither the harness turn nor the legacy message.
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||||
|
// The composer retained its content — it clears ONLY on a successful send,
|
||||||
|
// so the user can retry without retyping.
|
||||||
|
expect(textarea.value).toBe('keep me');
|
||||||
|
// A visible, safe notice explains why nothing was sent.
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
failing();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the composer after a successful turn:send and never falls back to the legacy message event — Task Five group 5', async () => {
|
||||||
|
await advertiseTurnSendGeneration();
|
||||||
|
const restore = installRandomUUID(() => PAGE_UUID);
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'ship it');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
||||||
|
expect(sends).toHaveLength(1);
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||||
|
// On a successful send the composer clears.
|
||||||
|
expect(textarea.value).toBe('');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the freshly persisted selection as a nested turn:send tuple after the user changes provider/model — never a stale default or flat fields — Task Five group 5', async () => {
|
||||||
|
await advertiseTurnSendGeneration();
|
||||||
|
const restore = installRandomUUID(() => PAGE_UUID);
|
||||||
|
try {
|
||||||
|
// Change the selection away from the mount default and let it persist.
|
||||||
|
const providerSelect = container.querySelector(
|
||||||
|
'select[aria-label="Provider"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(providerSelect, 'anthropic');
|
||||||
|
});
|
||||||
|
const modelSelect = container.querySelector(
|
||||||
|
'select[aria-label="Model"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(modelSelect, 'anthropic:claude');
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'routed');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
||||||
|
expect(sends).toHaveLength(1);
|
||||||
|
// The nested tuple reflects the CURRENTLY persisted selection, not the
|
||||||
|
// mount default {openai, gpt-5}, and never flat provider/model fields.
|
||||||
|
expect(sends[0]?.payload).toEqual({
|
||||||
|
conversationId: 'c1',
|
||||||
|
content: 'routed',
|
||||||
|
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
|
||||||
|
idempotencyKey: PAGE_UUID,
|
||||||
|
});
|
||||||
|
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('disables send until a selection has persisted — no send with an unset selection', async () => {
|
it('disables send until a selection has persisted — no send with an unset selection', async () => {
|
||||||
await remountWithFetch(harnessFetch(null));
|
await remountWithFetch(harnessFetch(null));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# AMD1213-D — transaction and helper trust remediation
|
||||||
|
|
||||||
|
- **Task:** AMD1213-D (issue #1213 amendment; controlling packet `comms/20260813T212447Z__from-scrappy__dc43de.md`)
|
||||||
|
- **Objective:** Address D1–D6 on local `feat/wf-fleet-mvp`, commit-only. Never push or re-author.
|
||||||
|
- **Scope:** Existing C-fence production/tests only. No provider calls.
|
||||||
|
- **Standing constraint:** AMEND/HOLD. Do not push, do not update PR #1213, do not merge, do not
|
||||||
|
re-author. PR #1216 remains independently held for Jason.
|
||||||
|
|
||||||
|
## Where this actually stands (measured 2026-08-15, not inherited from notes)
|
||||||
|
|
||||||
|
Everything below was re-measured against the tree rather than trusted from the previous entries,
|
||||||
|
which understated progress by roughly two defects. Branch head `3667a7a7`.
|
||||||
|
|
||||||
|
| Defect | State | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 transactional rollback | **Confirmed** — controls exist and are strong | see below; one narrow gap (in-memory link restoration unasserted) |
|
||||||
|
| D2 exact managed-link classes | Substantially implemented | classifier at `fleet-launch-command.ts:598-663`: exact resolved credential, direct one-component plugin/skill only, symlink target and ancestor rejected, `realpath` containment, duplicates rejected |
|
||||||
|
| D3 ambient-PATH executable resolution | **Closed** — executables `585dac7a`, environment `3667a7a7` | see below; one deliberate residual (HOME) |
|
||||||
|
| D4 config check/apply safety | Substantially implemented | `secure_dir` ancestor checks, `read_private` with `O_NOFOLLOW` + fstat, apply via mkstemp + fchmod 0600 + fsync + dev/ino re-check before `os.replace`, compatibility path separated |
|
||||||
|
| D5 bounded test seam | **Closed this pass** (commit `b91b702a`) | see below |
|
||||||
|
| D6 validate-by-path then exec-by-path | Substantially implemented | helper runs as a verified snapshot piped to `bash -s`, not executed by pathname; the same binding applied to the runtime in D3 |
|
||||||
|
|
||||||
|
### D1 — confirmed, and a correction to my own survey
|
||||||
|
|
||||||
|
I first reported that eleven `injectFailure` seams existed in production and **zero tests used
|
||||||
|
any of them**, and that D1's rollback path had never been executed. That was wrong. The grep
|
||||||
|
behind it searched for the identifier `injectFailure` in the specs; the specs supply the injector
|
||||||
|
as an inline lambda, so the controls were there and the search could not see them. Method note
|
||||||
|
for the next survey: grep the production seam names, not the parameter name.
|
||||||
|
|
||||||
|
The controls that exist, all in `fleet-launch-command.spec.ts`:
|
||||||
|
|
||||||
|
- **All ten mutation seams**, table-driven — `mkdir-seat`, `prepare-manifest`, `write-settings`,
|
||||||
|
`write-snapshot`, `credential-link`, `prune-link`, `install-link`, `write-manifest`,
|
||||||
|
`close-manifest`, `rename-manifest`. Each asserts byte-for-byte restoration of six artifacts
|
||||||
|
(settings bytes, settings mode, generated snapshot, manifest, credential symlink target, plugin
|
||||||
|
symlink target) plus the absence of the `.tmp` manifest.
|
||||||
|
- **New-seat rollback** — a failure on a seat the transaction itself created leaves no directory
|
||||||
|
and no residue.
|
||||||
|
- **`ROLLBACK_INTEGRITY` escalation** — three parent-substitution attacks (symlink swap, inode
|
||||||
|
replacement, rename away) each produce a typed refusal, leave an external sentinel untouched,
|
||||||
|
and write `.mosaic-fleet-launch-recovery.json`. A replacement of the transaction-created seat
|
||||||
|
is likewise refused rather than deleted.
|
||||||
|
|
||||||
|
That is a real RED→GREEN matrix, not an implementation read as done.
|
||||||
|
|
||||||
|
**Gap, narrow:** nothing asserts the in-memory restoration of `plan.managedLinks.links` to
|
||||||
|
`manifestLinksBefore` — the filesystem is checked, the plan object is not. It matters only if a
|
||||||
|
caller reuses a plan after catching a failure, which nothing currently does, so this is
|
||||||
|
defence-in-depth rather than a live defect. Worth one assertion when D2/D4/D6 are confirmed.
|
||||||
|
|
||||||
|
### D2 — one thing worth recording so it is not "fixed" later
|
||||||
|
|
||||||
|
The alias concern in the packet (`duplicates/normalization aliases`) is closed by strictness, not
|
||||||
|
by normalization. `dirname(link)` is compared literally against the seat root, so `/s/plugins//foo`
|
||||||
|
(`dirname` → `/s/plugins/`), `/s/plugins/./foo` and `/s/plugins/bar/../foo` all fail the comparison
|
||||||
|
and are rejected. Verified by direct measurement of `path.dirname` on each form. Anyone who
|
||||||
|
"improves" this by normalizing the link first would open the alias hole the strict comparison
|
||||||
|
currently closes.
|
||||||
|
|
||||||
|
### D3 — what was wrong and what was done
|
||||||
|
|
||||||
|
The launcher asked `which` whether a runtime was reachable and then spawned the **bare name**,
|
||||||
|
letting the OS resolve it a second time against an ambient PATH at a later moment. Two independent
|
||||||
|
resolutions of an attacker-influenced name with a gap between them.
|
||||||
|
|
||||||
|
Demonstrated against the old code before changing it — a world-writable `codex` shim prepended to
|
||||||
|
PATH:
|
||||||
|
|
||||||
|
```
|
||||||
|
OLD checkRuntime -> PASSED (which found it)
|
||||||
|
OLD execRuntime -> "SHIM EXECUTED — this is not the real runtime"
|
||||||
|
```
|
||||||
|
|
||||||
|
Three exposed call sites, not one: `checkRuntime`'s `which`; `execRuntime` spawning
|
||||||
|
`codex`/`opencode` by name; and `execLeaseGatedRuntime` spawning `python3` by name — the
|
||||||
|
interpreter that starts the lease gate, where a shim replaces the process that enforces every other
|
||||||
|
check. `minimalLaunchEnv` copies ambient PATH straight through.
|
||||||
|
|
||||||
|
Fix: `resolveExecutableFromPath` searches only the PATH the child will actually receive, validates
|
||||||
|
what the search lands on (regular file, executable, not group/other-writable, owned by the
|
||||||
|
launching user or root, no group/world-writable non-sticky directory and no foreign-owned directory
|
||||||
|
on the resolved path), and returns that path pinned to dev/ino. Callers execute the returned path
|
||||||
|
and never the name again. The fleet lease-gate interpreter comes from the root-owned
|
||||||
|
`trustedCapability('python3')`. `checkRuntime` is deliberately kept on the operator path, where
|
||||||
|
"is it reachable from my shell" is the right question.
|
||||||
|
|
||||||
|
**Residuals, stated not engineered around:**
|
||||||
|
|
||||||
|
1. `assertUnchangedSinceValidation` re-confirms dev/ino immediately before spawn. That narrows the
|
||||||
|
validation→exec window; it does not close it. Closing it means exec by held descriptor, which
|
||||||
|
Node cannot do portably. Same accepted boundary already documented for the fleet helper.
|
||||||
|
2. For claude and pi the runtime binary is still re-resolved inside `launch-runtime.py` after the
|
||||||
|
trusted interpreter starts it. **This change does not cover that path.**
|
||||||
|
|
||||||
|
12 tests, one per hole. One was written wrong first and is worth remembering:
|
||||||
|
`mkdirSync(path, { mode: 0o777 })` is masked by the umask to 0o755, so the world-writable-directory
|
||||||
|
case passed while testing nothing. Create at 0o755, then `chmodSync`.
|
||||||
|
|
||||||
|
### D3 environment half — measured, and mostly already true
|
||||||
|
|
||||||
|
Measured before changing anything: the real `fleet launch` route with a shim in place of the
|
||||||
|
runtime binary, the shim dumping its own environment. The subject is therefore what arrives after
|
||||||
|
composition **and** after `launch-runtime.py` adds the lease variables — not the object the
|
||||||
|
launcher builds. Those are different sets.
|
||||||
|
|
||||||
|
The complete child environment for a composed claude seat:
|
||||||
|
|
||||||
|
```
|
||||||
|
PATH HOME USER LOGNAME SHELL TERM COLORTERM TMPDIR XDG_RUNTIME_DIR (inherited allowlist)
|
||||||
|
LANG LC_ALL (fixed, this pass)
|
||||||
|
CLAUDE_CONFIG_DIR MOSAIC_AGENT_NAME <profile env> (declared)
|
||||||
|
MOSAIC_LAUNCH_ID (minted per launch)
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET MOSAIC_LEASE_GENERATION_FILE
|
||||||
|
MOSAIC_LEASE_RUNTIME MOSAIC_LEASE_SESSION_ID
|
||||||
|
MOSAIC_RECEIPT_OBSERVER_SOCKET MOSAIC_RUNTIME_GENERATION (lease gate)
|
||||||
|
```
|
||||||
|
|
||||||
|
Most of the defect was already closed **by construction and untested**. `minimalLaunchEnv` builds
|
||||||
|
from an empty object over a fixed list, so `BASH_ENV`, `ENV`, `PYTHON*`, `NODE_*`, `NPM_CONFIG_*`,
|
||||||
|
`LD_PRELOAD`, `LD_LIBRARY_PATH` and provider credentials never reach the child. All sixteen were
|
||||||
|
planted; none survived, including through the lease gate. The gap was that nothing named the
|
||||||
|
allowlist, and an allowlist no test names is one careless edit away from being a denylist.
|
||||||
|
|
||||||
|
Fixed: **locale was inherited**, so the same seat emitted different message language, collation and
|
||||||
|
number/date formatting depending on who started it. Composed launches now pin `C.UTF-8` — not `C`,
|
||||||
|
which is ASCII and would mangle non-ASCII output. A profile-declared `LANG`/`LC_ALL` still wins,
|
||||||
|
and a test holds that escape hatch open. The operator path is untouched.
|
||||||
|
|
||||||
|
**Residual, deliberate — `HOME` is still the operator's.** The card is right that this is the
|
||||||
|
remaining leak: the runtime gets its own config dir, but anything it shells out to (git, ssh, npm)
|
||||||
|
reads the operator's dotfiles and therefore the operator's credentials. Not changed here, because
|
||||||
|
a seat whose HOME is a bare directory has no gitconfig and no ssh key, so it cannot commit or push
|
||||||
|
— and the fleet MVP's proof is a seat carrying a change to a pushed branch. Moving HOME before the
|
||||||
|
per-agent home is populated improves isolation and breaks the deliverable. **Owner: the
|
||||||
|
harness-homes design**, which is exactly the track that populates a per-agent home with its own
|
||||||
|
auth bundle. Do it there, not here.
|
||||||
|
|
||||||
|
Eight tests, each falsified by inverting the property it defends; every inversion hit only its own
|
||||||
|
test: `BASH_ENV` added to the inherited list → permitted-set + loader-hook killers red (2 failed);
|
||||||
|
locale pin reverted → locale killer red; ambient `MOSAIC_LAUNCH_ID` reused → launch-id killer red;
|
||||||
|
`process.env` recorded into the ledger → ledger-value killer red.
|
||||||
|
|
||||||
|
The permitted-name list in the spec is hand-written, not derived from the launcher. Deriving it
|
||||||
|
would make the test agree with the code by construction and detect nothing.
|
||||||
|
|
||||||
|
### D5 — what remained and what was done
|
||||||
|
|
||||||
|
Most of D5 was already closed: `launchFleetRuntimeForTest` is gone, specs enter through the real
|
||||||
|
`registerFleetLaunchCommand → apply → launchFleetRuntime → launchRuntime` route on a fixture seat,
|
||||||
|
the ledger points at the fixture and **is** asserted, and the seat-seeded/HOME-empty pass plus
|
||||||
|
HOME-seeded/seat-empty fail pair both exist.
|
||||||
|
|
||||||
|
What remained was the dead `recordLaunch?: boolean` context field. Nothing in the package set it;
|
||||||
|
its only effect was to let a caller silently disable recording on the claude branch while codex,
|
||||||
|
opencode and pi recorded unconditionally. Removed.
|
||||||
|
|
||||||
|
## Not part of D1–D6, fixed because it blocked the required evidence
|
||||||
|
|
||||||
|
The amend requires a green full-package Vitest run.
|
||||||
|
`install-ordering-guard.spec.ts > defaults to the real leaseEnforcementActivatable()` made that
|
||||||
|
non-reproducible. `defaultCapabilityProbe` executes `dist/cli.js` out-of-process with a **2000 ms
|
||||||
|
timeout**; in a full run with 86 spec files scheduled at once, one observation beats the timeout and
|
||||||
|
the next does not, so the test's two observations of the same predicate disagree and it fails —
|
||||||
|
reporting machine load as a wiring defect. Passed 3/3 in isolation, failed in three consecutive
|
||||||
|
full runs.
|
||||||
|
|
||||||
|
Ruled out my own change by reverting only the `recordLaunch` edit and re-running: still failed.
|
||||||
|
The guard call is now bracketed by two observations, only an agreeing pair is used as ground truth,
|
||||||
|
a disagreeing pair is retried up to three times, and never holding still is a failure rather than a
|
||||||
|
skip. Falsified by inverting the guard's default to `!leaseEnforcementActivatable()` → red
|
||||||
|
(1 failed / 18 passed), then reverted.
|
||||||
|
|
||||||
|
## Verification state
|
||||||
|
|
||||||
|
- typecheck RC=0.
|
||||||
|
- Full package Vitest, sanitized lease env (`MOSAIC_LEASE_*` + `MOSAIC_RUNTIME_GENERATION`
|
||||||
|
stripped): **87 files / 1627 tests passed, 0 failed**, three consecutive runs plus one against
|
||||||
|
the committed tree, RC=0. That is exactly one file and eight tests above the 86/1619 baseline,
|
||||||
|
so the D3 environment work moved nothing else. eslint RC=0, prettier clean.
|
||||||
|
- Without that sanitization the suite shows 4 failures in `mutator-gate.acceptance.spec.ts`. Those
|
||||||
|
are the known host lease-identity leak into spawned hooks, **not** a product defect — the same
|
||||||
|
spec re-run with only those five variables stripped and no code change is 20/20. The standing fix
|
||||||
|
is the unpushed `fix/lease-test-env-isolation` branch (blocked on the identity blocker below).
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
- **D2/D4/D6 need confirmation, not assumption.** They read as substantially implemented but I
|
||||||
|
have not run the packet's full RED→GREEN control matrix against each seam. D1 is now confirmed
|
||||||
|
(see above), D3 and D5 are closed; these three are the remaining item.
|
||||||
|
- **D3's HOME residual** is routed to harness-homes (see above). It is stated, not engineered
|
||||||
|
around, and it does not belong to this branch.
|
||||||
|
- Required next evidence per the packet: all D1–D6 observed RED→GREEN controls, framework-shell,
|
||||||
|
build/lint/Prettier/bash -n, fresh current-next merge-tree.
|
||||||
|
|
||||||
|
## Blocker not solvable inside this branch
|
||||||
|
|
||||||
|
No `fred` principal exists (`tea login list` has no entry; `MOSAIC_GIT_IDENTITY` never reaches the
|
||||||
|
pane). The only push path on this host is the **retired** mos-dt-0 token. That is why this work is
|
||||||
|
commit-only beyond scrappy's instruction — even after the hold lifts, the truthful authenticated
|
||||||
|
push the packet requires cannot be made under a correct identity yet. Raised with mos-claude and
|
||||||
|
with Jason; awaiting a mint decision.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "resume|clear",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"matcher": "^/mosaic-promote$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||||
|
"timeout": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +1,7 @@
|
|||||||
{
|
{
|
||||||
"model": "opus",
|
"model": "opus",
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PreCompact": [
|
|
||||||
{
|
|
||||||
"matcher": ".*",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"SessionStart": [
|
|
||||||
{
|
|
||||||
"matcher": "compact",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": "resume|clear",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"UserPromptSubmit": [
|
|
||||||
{
|
|
||||||
"matcher": "^/mosaic-promote$",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
|
||||||
"timeout": 15
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
{
|
|
||||||
"matcher": ".*",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
|
||||||
"timeout": 3
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"matcher": "Write|Edit|MultiEdit",
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
@@ -91,11 +38,6 @@
|
|||||||
"Stop": [
|
"Stop": [
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
|
||||||
"timeout": 15
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||||
@@ -315,5 +257,11 @@
|
|||||||
"cpan",
|
"cpan",
|
||||||
"nohup"
|
"nohup"
|
||||||
],
|
],
|
||||||
"enableAllMcpTools": true
|
"enableAllMcpTools": true,
|
||||||
|
"mcpServers": {
|
||||||
|
"sequential-thinking": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
# Fleet launches execute this source through an already-validated absolute bash
|
||||||
|
# capability and pass all interpreter capabilities explicitly. Do not add PATH
|
||||||
|
# lookup here: this helper is intentionally capability-minimal.
|
||||||
MODE="apply"
|
MODE="apply"
|
||||||
RUNTIME="all"
|
RUNTIME="all"
|
||||||
STRICT_CHECK=0
|
STRICT_CHECK=0
|
||||||
|
CLAUDE_CONFIG_DIR=""
|
||||||
|
PYTHON_BIN=""
|
||||||
|
NODE_BIN=""
|
||||||
|
NPX_BIN=""
|
||||||
|
TIMEOUT_BIN=""
|
||||||
PKG="@modelcontextprotocol/server-sequential-thinking"
|
PKG="@modelcontextprotocol/server-sequential-thinking"
|
||||||
|
|
||||||
err() { echo "[mosaic-seq] ERROR: $*" >&2; }
|
err() { echo "[mosaic-seq] ERROR: $*" >&2; }
|
||||||
@@ -13,250 +19,130 @@ log() { echo "[mosaic-seq] $*"; }
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--check)
|
--check) MODE="check"; shift ;;
|
||||||
MODE="check"
|
--runtime) RUNTIME="${2:?--runtime requires a value}"; shift 2 ;;
|
||||||
shift
|
--strict) STRICT_CHECK=1; shift ;;
|
||||||
;;
|
--claude-config-dir) CLAUDE_CONFIG_DIR="${2:?--claude-config-dir requires a value}"; shift 2 ;;
|
||||||
--runtime)
|
--python-bin) PYTHON_BIN="${2:?--python-bin requires a value}"; shift 2 ;;
|
||||||
if [[ $# -lt 2 ]]; then
|
--node-bin) NODE_BIN="${2:?--node-bin requires a value}"; shift 2 ;;
|
||||||
err "--runtime requires a value: claude|codex|opencode|all"
|
--npx-bin) NPX_BIN="${2:?--npx-bin requires a value}"; shift 2 ;;
|
||||||
exit 2
|
--timeout-bin) TIMEOUT_BIN="${2:?--timeout-bin requires a value}"; shift 2 ;;
|
||||||
fi
|
*) err "Unknown argument: $1"; exit 2 ;;
|
||||||
RUNTIME="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--strict)
|
|
||||||
STRICT_CHECK=1
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
err "Unknown argument: $1"
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
case "$RUNTIME" in all|claude|codex|opencode) ;; *) err "Invalid runtime: $RUNTIME"; exit 2;; esac
|
||||||
case "$RUNTIME" in
|
# Explicit fleet-seat operation is capability-minimal. Legacy operator repair
|
||||||
all|claude|codex|opencode) ;;
|
# keeps its documented PATH-based compatibility contract.
|
||||||
*)
|
if [[ -n "$CLAUDE_CONFIG_DIR" || -n "$PYTHON_BIN$NODE_BIN$NPX_BIN$TIMEOUT_BIN" ]]; then
|
||||||
err "Invalid runtime: $RUNTIME (expected claude|codex|opencode|all)"
|
[[ -n "$PYTHON_BIN" && -n "$NODE_BIN" && -n "$NPX_BIN" ]] || { err "Fleet capabilities are required"; exit 2; }
|
||||||
exit 2
|
else
|
||||||
;;
|
PYTHON_BIN=python3
|
||||||
esac
|
NODE_BIN=node
|
||||||
|
NPX_BIN=npx
|
||||||
require_binary() {
|
TIMEOUT_BIN=timeout
|
||||||
local name="$1"
|
fi
|
||||||
if ! command -v "$name" >/dev/null 2>&1; then
|
|
||||||
err "Required binary missing: $name"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_software() {
|
|
||||||
require_binary node
|
|
||||||
require_binary npx
|
|
||||||
}
|
|
||||||
|
|
||||||
warm_package() {
|
warm_package() {
|
||||||
local timeout_sec="${MOSAIC_SEQ_WARM_TIMEOUT_SEC:-15}"
|
local timeout_sec="${MOSAIC_SEQ_WARM_TIMEOUT_SEC:-15}"
|
||||||
if command -v timeout >/dev/null 2>&1; then
|
if [[ -n "$TIMEOUT_BIN" ]]; then "$TIMEOUT_BIN" "$timeout_sec" "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1
|
||||||
timeout "$timeout_sec" npx -y "$PKG" --help >/dev/null 2>&1
|
else "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1; fi
|
||||||
else
|
|
||||||
npx -y "$PKG" --help >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
check_claude_config() {
|
claude_config_python='import json, os, stat, tempfile
|
||||||
python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
p = Path.home() / ".claude" / "settings.json"
|
|
||||||
if not p.exists():
|
|
||||||
raise SystemExit(1)
|
|
||||||
try:
|
|
||||||
data = json.loads(p.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
raise SystemExit(1)
|
|
||||||
mcp = data.get("mcpServers")
|
|
||||||
if not isinstance(mcp, dict):
|
|
||||||
raise SystemExit(1)
|
|
||||||
entry = mcp.get("sequential-thinking")
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
raise SystemExit(1)
|
|
||||||
if entry.get("command") != "npx":
|
|
||||||
raise SystemExit(1)
|
|
||||||
args = entry.get("args")
|
|
||||||
if args != ["-y", "@modelcontextprotocol/server-sequential-thinking"]:
|
|
||||||
raise SystemExit(1)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
apply_claude_config() {
|
def die(): raise SystemExit(1)
|
||||||
python3 - <<'PY'
|
def secure_dir(p):
|
||||||
import json
|
p=Path(p)
|
||||||
from pathlib import Path
|
if not p.is_absolute(): die()
|
||||||
p = Path.home() / ".claude" / "settings.json"
|
# Every parent may be sticky /tmp, but none may be a symlink. The fleet
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
# config root itself must be private and owned by the invoking principal.
|
||||||
if p.exists():
|
for q in [p, *p.parents]:
|
||||||
|
try: s=os.lstat(q)
|
||||||
|
except OSError: die()
|
||||||
|
if stat.S_ISLNK(s.st_mode) or not stat.S_ISDIR(s.st_mode): die()
|
||||||
|
if q != p and s.st_mode & 0o022 and not (s.st_mode & stat.S_ISVTX): die()
|
||||||
|
s=os.lstat(p)
|
||||||
|
if s.st_uid not in (os.geteuid(), 0) or s.st_mode & 0o022: die()
|
||||||
|
return p
|
||||||
|
|
||||||
|
def read_private(p):
|
||||||
|
try: fd=os.open(p, os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||||
|
except OSError: die()
|
||||||
try:
|
try:
|
||||||
data = json.loads(p.read_text(encoding="utf-8"))
|
s=os.fstat(fd)
|
||||||
except Exception:
|
if not stat.S_ISREG(s.st_mode) or s.st_uid not in (os.geteuid(),0) or s.st_mode & 0o077 or s.st_size>1048576: die()
|
||||||
data = {}
|
data=b""
|
||||||
else:
|
while len(data)<=1048576:
|
||||||
data = {}
|
c=os.read(fd,65536)
|
||||||
mcp = data.get("mcpServers")
|
if not c: break
|
||||||
if not isinstance(mcp, dict):
|
data+=c
|
||||||
mcp = {}
|
if len(data)>1048576: die()
|
||||||
mcp["sequential-thinking"] = {
|
return data, (s.st_dev,s.st_ino)
|
||||||
"command": "npx",
|
finally: os.close(fd)
|
||||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
|
||||||
}
|
|
||||||
data["mcpServers"] = mcp
|
|
||||||
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
check_codex_config() {
|
def entry_ok(data):
|
||||||
local cfg="$HOME/.codex/config.toml"
|
try: d=json.loads(data.decode()); e=d.get("mcpServers",{}).get("sequential-thinking",{})
|
||||||
[[ -f "$cfg" ]] || return 1
|
except Exception: return False
|
||||||
grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \
|
return e.get("command")=="npx" and e.get("args")==["-y","@modelcontextprotocol/server-sequential-thinking"]
|
||||||
grep -q '^command = "npx"' "$cfg" && \
|
|
||||||
grep -q '@modelcontextprotocol/server-sequential-thinking' "$cfg"
|
|
||||||
}
|
|
||||||
|
|
||||||
apply_codex_config() {
|
def explicit_check_or_apply(apply):
|
||||||
local cfg="$HOME/.codex/config.toml"
|
root=secure_dir(os.environ["CLAUDE_CONFIG_DIR"]); p=root/".claude.json"
|
||||||
mkdir -p "$(dirname "$cfg")"
|
if not apply: return 0 if entry_ok(read_private(str(p))[0]) else 1
|
||||||
[[ -f "$cfg" ]] || touch "$cfg"
|
old={}; identity=None
|
||||||
|
if os.path.lexists(p):
|
||||||
local tmp
|
raw,identity=read_private(str(p))
|
||||||
tmp="$(mktemp)"
|
try: old=json.loads(raw.decode())
|
||||||
awk '
|
except Exception: old={}
|
||||||
BEGIN { skip = 0 }
|
mcp=old.get("mcpServers") if isinstance(old.get("mcpServers"),dict) else {}
|
||||||
/^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]/ { skip = 1; next }
|
mcp["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; old["mcpServers"]=mcp
|
||||||
skip && /^\[/ { skip = 0 }
|
fd,tmp=tempfile.mkstemp(prefix=".claude.json.",dir=root)
|
||||||
!skip { print }
|
|
||||||
' "$cfg" > "$tmp"
|
|
||||||
mv "$tmp" "$cfg"
|
|
||||||
|
|
||||||
{
|
|
||||||
echo ""
|
|
||||||
echo "[mcp_servers.sequential-thinking]"
|
|
||||||
echo "command = \"npx\""
|
|
||||||
echo "args = [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"]"
|
|
||||||
} >> "$cfg"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_opencode_config() {
|
|
||||||
python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
p = Path.home() / ".config" / "opencode" / "config.json"
|
|
||||||
if not p.exists():
|
|
||||||
raise SystemExit(1)
|
|
||||||
try:
|
|
||||||
data = json.loads(p.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
raise SystemExit(1)
|
|
||||||
mcp = data.get("mcp")
|
|
||||||
if not isinstance(mcp, dict):
|
|
||||||
raise SystemExit(1)
|
|
||||||
entry = mcp.get("sequential-thinking")
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
raise SystemExit(1)
|
|
||||||
if entry.get("type") != "local":
|
|
||||||
raise SystemExit(1)
|
|
||||||
if entry.get("command") != ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"]:
|
|
||||||
raise SystemExit(1)
|
|
||||||
if entry.get("enabled") is not True:
|
|
||||||
raise SystemExit(1)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
apply_opencode_config() {
|
|
||||||
python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
p = Path.home() / ".config" / "opencode" / "config.json"
|
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if p.exists():
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(p.read_text(encoding="utf-8"))
|
os.fchmod(fd,0o600); os.write(fd,(json.dumps(old,indent=2)+"\n").encode()); os.fsync(fd); os.close(fd)
|
||||||
except Exception:
|
try: now=os.lstat(p); current=(now.st_dev,now.st_ino)
|
||||||
data = {}
|
except FileNotFoundError: current=None
|
||||||
else:
|
if current!=identity: die()
|
||||||
data = {}
|
os.replace(tmp,p)
|
||||||
mcp = data.get("mcp")
|
finally:
|
||||||
if not isinstance(mcp, dict):
|
try: os.close(fd)
|
||||||
mcp = {}
|
except OSError: pass
|
||||||
mcp["sequential-thinking"] = {
|
try: os.unlink(tmp)
|
||||||
"type": "local",
|
except FileNotFoundError: pass
|
||||||
"command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"],
|
return 0
|
||||||
"enabled": True
|
|
||||||
}
|
|
||||||
data["mcp"] = mcp
|
|
||||||
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
check_runtime_config() {
|
if os.environ.get("CLAUDE_CONFIG_DIR"):
|
||||||
case "$RUNTIME" in
|
raise SystemExit(explicit_check_or_apply(os.environ.get("SEQ_APPLY")=="1"))
|
||||||
all)
|
# Compatibility path is intentionally not fleet-authoritative.
|
||||||
check_claude_config
|
p=Path.home()/".claude.json"
|
||||||
check_codex_config
|
if not p.exists() and not os.environ.get("SEQ_APPLY")=="1": p=Path.home()/".claude"/"settings.json"
|
||||||
check_opencode_config
|
if os.environ.get("SEQ_APPLY")=="1":
|
||||||
;;
|
try: d=json.loads(p.read_text()) if p.exists() else {}
|
||||||
claude)
|
except Exception: d={}
|
||||||
check_claude_config
|
m=d.get("mcpServers") if isinstance(d.get("mcpServers"),dict) else {}
|
||||||
;;
|
m["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; d["mcpServers"]=m
|
||||||
codex)
|
p.parent.mkdir(parents=True,exist_ok=True); p.write_text(json.dumps(d,indent=2)+"\n")
|
||||||
check_codex_config
|
raise SystemExit(0)
|
||||||
;;
|
try: raise SystemExit(0 if entry_ok(p.read_bytes()) else 1)
|
||||||
opencode)
|
except Exception: raise SystemExit(1)'
|
||||||
check_opencode_config
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
apply_runtime_config() {
|
check_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=0 "$PYTHON_BIN" -c "$claude_config_python"; }
|
||||||
case "$RUNTIME" in
|
apply_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=1 "$PYTHON_BIN" -c "$claude_config_python"; }
|
||||||
all)
|
check_codex_config() { CODEX_CFG="${CODEX_HOME:-$HOME/.codex}/config.toml" "$PYTHON_BIN" -c 'import os,re; from pathlib import Path; s=Path(os.environ["CODEX_CFG"]).read_text(); ok=bool(re.search(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]",s,re.M) and "command = \"npx\"" in s and "@modelcontextprotocol/server-sequential-thinking" in s); raise SystemExit(0 if ok else 1)'; }
|
||||||
apply_claude_config
|
apply_codex_config() { CODEX_CFG="${CODEX_HOME:-$HOME/.codex}/config.toml" "$PYTHON_BIN" -c 'import os,re; from pathlib import Path; p=Path(os.environ["CODEX_CFG"]); p.parent.mkdir(parents=True,exist_ok=True); out=[]; skip=False
|
||||||
apply_codex_config
|
for line in (p.read_text().splitlines() if p.exists() else []):
|
||||||
apply_opencode_config
|
if re.match(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]$",line): skip=True; continue
|
||||||
;;
|
if skip and line.startswith("["): skip=False
|
||||||
claude)
|
if not skip: out.append(line)
|
||||||
apply_claude_config
|
p.write_text("\n".join(out).rstrip()+"\n\n[mcp_servers.sequential-thinking]\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"]\n")'; }
|
||||||
;;
|
check_opencode_config() { XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" "$PYTHON_BIN" -c 'import json,os; from pathlib import Path; p=Path(os.environ["XDG_CONFIG_HOME"])/"opencode/config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home()/".config/opencode/config.json"; d=json.loads(p.read_text()); e=d.get("mcp",{}).get("sequential-thinking"); expected={"type":"local","command":["npx","-y","@modelcontextprotocol/server-sequential-thinking"],"enabled":True}; raise SystemExit(0 if e==expected else 1)' ; }
|
||||||
codex)
|
apply_opencode_config() { XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" "$PYTHON_BIN" -c 'import json,os; from pathlib import Path; p=Path(os.environ["XDG_CONFIG_HOME"])/"opencode/config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home()/".config/opencode/config.json"; p.parent.mkdir(parents=True,exist_ok=True); d=json.loads(p.read_text()) if p.exists() else {}; m=d.get("mcp") if isinstance(d.get("mcp"),dict) else {}; m["sequential-thinking"]={"type":"local","command":["npx","-y","@modelcontextprotocol/server-sequential-thinking"],"enabled":True}; d["mcp"]=m; p.write_text(json.dumps(d,indent=2)+"\n")'; }
|
||||||
apply_codex_config
|
check_runtime_config() { case "$RUNTIME" in all) check_claude_config && check_codex_config && check_opencode_config;; claude) check_claude_config;; codex) check_codex_config;; opencode) check_opencode_config;; esac; }
|
||||||
;;
|
apply_runtime_config() { case "$RUNTIME" in claude) apply_claude_config;; codex) apply_codex_config;; opencode) apply_opencode_config;; all) apply_claude_config && apply_codex_config && apply_opencode_config;; esac; }
|
||||||
opencode)
|
if [[ "$MODE" == check ]]; then
|
||||||
apply_opencode_config
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "$MODE" == "check" ]]; then
|
|
||||||
check_software
|
|
||||||
check_runtime_config
|
check_runtime_config
|
||||||
|
if [[ "$STRICT_CHECK" == 1 || "${MOSAIC_SEQ_CHECK_WARM:-0}" == 1 ]]; then warm_package || { err "sequential-thinking package warm-up failed in strict mode"; exit 1; }; fi
|
||||||
# Runtime launch checks should be local/fast by default.
|
log "sequential-thinking MCP is configured and available (${RUNTIME})"; exit 0
|
||||||
if [[ "$STRICT_CHECK" -eq 1 || "${MOSAIC_SEQ_CHECK_WARM:-0}" == "1" ]]; then
|
|
||||||
if ! warm_package; then
|
|
||||||
err "sequential-thinking package warm-up failed in strict mode"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "sequential-thinking MCP is configured and available (${RUNTIME})"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
check_software
|
|
||||||
if ! warm_package; then
|
|
||||||
err "Unable to warm sequential-thinking package (npx timeout/failure)"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
warm_package || { err "sequential-thinking package warm-up failed"; exit 1; }
|
||||||
apply_runtime_config
|
apply_runtime_config
|
||||||
log "sequential-thinking MCP configured (${RUNTIME})"
|
log "sequential-thinking MCP configured (${RUNTIME})"
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { registerFleetAgentScaffoldCommand } from './fleet-agent-scaffold-command.js';
|
||||||
|
|
||||||
|
let root: string | undefined;
|
||||||
|
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.exitCode = undefined;
|
||||||
|
if (root) await rm(root, { recursive: true, force: true });
|
||||||
|
root = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fleetDataHome(): Promise<string> {
|
||||||
|
root = await mkdtemp(join(tmpdir(), 'mosaic-fleet-agent-new-'));
|
||||||
|
return join(root, '.mosaic');
|
||||||
|
}
|
||||||
|
|
||||||
|
function program(dataHome: string): Command {
|
||||||
|
const result = new Command();
|
||||||
|
result.exitOverride();
|
||||||
|
const fleet = result.command('fleet');
|
||||||
|
const mosaicHome = join(root!, 'installed-mosaic');
|
||||||
|
mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(mosaicHome, 'runtime', 'claude', 'settings.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
'sequential-thinking': {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
registerFleetAgentScaffoldCommand(fleet, {
|
||||||
|
fleetDataHome: dataHome,
|
||||||
|
mosaicHomeFor: () => mosaicHome,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function files(rootDir: string, prefix = ''): Promise<string[]> {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const entry of await readdir(join(rootDir, prefix), { withFileTypes: true })) {
|
||||||
|
const path = join(prefix, entry.name);
|
||||||
|
if (entry.isDirectory()) result.push(...(await files(rootDir, path)));
|
||||||
|
else result.push(path);
|
||||||
|
}
|
||||||
|
return result.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mosaic fleet agent new', (): void => {
|
||||||
|
it('creates the exact authored user-data scaffold under a temp ~/.mosaic root', async (): Promise<void> => {
|
||||||
|
const dataHome = await fleetDataHome();
|
||||||
|
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
||||||
|
const agent = join(dataHome, 'fleet', 'agents', 'mira');
|
||||||
|
|
||||||
|
expect(await files(agent)).toEqual([
|
||||||
|
'.claude/.claude.json',
|
||||||
|
'.claude/.credentials.json',
|
||||||
|
'.claude/.mosaic-managed-links.json',
|
||||||
|
'.claude/CLAUDE.md',
|
||||||
|
'SOUL.md',
|
||||||
|
'overlay.json',
|
||||||
|
'profile.json',
|
||||||
|
]);
|
||||||
|
expect(JSON.parse(await readFile(join(agent, 'profile.json'), 'utf8'))).toEqual({
|
||||||
|
schema: 1,
|
||||||
|
harness: 'claude',
|
||||||
|
bundle: 'primary',
|
||||||
|
overlay: 'overlay.json',
|
||||||
|
env: { MOSAIC_AGENT_NAME: 'mira' },
|
||||||
|
});
|
||||||
|
expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain('## Identity');
|
||||||
|
expect(JSON.parse(await readFile(join(agent, '.claude', '.claude.json'), 'utf8'))).toEqual({
|
||||||
|
hasCompletedOnboarding: true,
|
||||||
|
theme: 'dark',
|
||||||
|
mcpServers: {
|
||||||
|
'sequential-thinking': {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const credentialTarget = join(dataHome, 'auth', 'claude', 'primary', '.credentials.json');
|
||||||
|
expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(credentialTarget);
|
||||||
|
expect(
|
||||||
|
JSON.parse(await readFile(join(agent, '.claude', '.mosaic-managed-links.json'), 'utf8')),
|
||||||
|
).toEqual({
|
||||||
|
links: { [join(agent, '.claude', '.credentials.json')]: credentialTarget },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a Pi home without Claude onboarding state', async (): Promise<void> => {
|
||||||
|
const dataHome = await fleetDataHome();
|
||||||
|
await program(dataHome).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'agent',
|
||||||
|
'new',
|
||||||
|
'pi-seat',
|
||||||
|
'--harness',
|
||||||
|
'pi',
|
||||||
|
]);
|
||||||
|
expect(await files(join(dataHome, 'fleet', 'agents', 'pi-seat'))).toEqual([
|
||||||
|
'.pi/.mosaic-managed-links.json',
|
||||||
|
'.pi/AGENTS.md',
|
||||||
|
'.pi/auth.json',
|
||||||
|
'SOUL.md',
|
||||||
|
'overlay.json',
|
||||||
|
'profile.json',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips quotes, backticks, and shell-looking input literally', async (): Promise<void> => {
|
||||||
|
const dataHome = await fleetDataHome();
|
||||||
|
const name = 'seat"`$(literal)`';
|
||||||
|
const bundle = 'bundle"`$(literal)`';
|
||||||
|
const model = 'model"`$(literal)`';
|
||||||
|
await program(dataHome).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'agent',
|
||||||
|
'new',
|
||||||
|
name,
|
||||||
|
'--harness',
|
||||||
|
'pi',
|
||||||
|
'--bundle',
|
||||||
|
bundle,
|
||||||
|
'--model',
|
||||||
|
model,
|
||||||
|
]);
|
||||||
|
const agent = join(dataHome, 'fleet', 'agents', name);
|
||||||
|
expect(JSON.parse(await readFile(join(agent, 'profile.json'), 'utf8'))).toMatchObject({
|
||||||
|
harness: 'pi',
|
||||||
|
bundle,
|
||||||
|
model,
|
||||||
|
env: { MOSAIC_AGENT_NAME: name },
|
||||||
|
});
|
||||||
|
expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain(`You are ${name},`);
|
||||||
|
expect(await readlink(join(agent, '.pi', 'auth.json'))).toBe(
|
||||||
|
join(dataHome, 'auth', 'pi', bundle, 'auth.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['', '../outside', '/absolute', 'a/b', 'a\\b'])(
|
||||||
|
'rejects unsafe agent name %j with a non-zero outcome',
|
||||||
|
async (name: string): Promise<void> => {
|
||||||
|
const dataHome = await fleetDataHome();
|
||||||
|
const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
try {
|
||||||
|
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', name]);
|
||||||
|
} catch {
|
||||||
|
// Commander rejects a missing positional before the action. That is also
|
||||||
|
// a non-zero CLI failure; all other unsafe names reach the scaffold.
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
if (name !== '')
|
||||||
|
expect(error).toHaveBeenCalledWith(expect.stringContaining('invalid-request'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['--harness', 'codex'],
|
||||||
|
['--bundle', '../outside'],
|
||||||
|
['--model', ''],
|
||||||
|
])(
|
||||||
|
'returns non-zero for invalid %s input',
|
||||||
|
async (option: string, value: string): Promise<void> => {
|
||||||
|
const dataHome = await fleetDataHome();
|
||||||
|
const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
await program(dataHome).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'agent',
|
||||||
|
'new',
|
||||||
|
'mira',
|
||||||
|
option,
|
||||||
|
value,
|
||||||
|
]);
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(error).toHaveBeenCalledWith(expect.stringContaining('invalid-request'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('is idempotent for byte-identical content and refuses a changed user file', async (): Promise<void> => {
|
||||||
|
const dataHome = await fleetDataHome();
|
||||||
|
const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'mira'];
|
||||||
|
await program(dataHome).parseAsync(command);
|
||||||
|
await program(dataHome).parseAsync(command);
|
||||||
|
expect(process.exitCode).toBeUndefined();
|
||||||
|
|
||||||
|
const soul = join(dataHome, 'fleet', 'agents', 'mira', 'SOUL.md');
|
||||||
|
await writeFile(soul, '# user-owned change\n');
|
||||||
|
const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
await program(dataHome).parseAsync(command);
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(error).toHaveBeenCalledWith(expect.stringContaining('SOUL.md'));
|
||||||
|
expect(await readFile(soul, 'utf8')).toBe('# user-owned change\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not follow a managed credential link while comparing existing content', async (): Promise<void> => {
|
||||||
|
const dataHome = await fleetDataHome();
|
||||||
|
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
||||||
|
const credential = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.credentials.json');
|
||||||
|
expect((await lstat(credential)).isSymbolicLink()).toBe(true);
|
||||||
|
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
||||||
|
expect(process.exitCode).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
import { FleetAgentScaffoldError, scaffoldFleetAgent } from '../fleet/fleet-agent-scaffold.js';
|
||||||
|
|
||||||
|
export interface FleetAgentScaffoldCommandDeps {
|
||||||
|
/** Test seam for the user-owned ~/.mosaic root. */
|
||||||
|
readonly fleetDataHome?: string;
|
||||||
|
/** Resolves the active installed Mosaic root that owns the canonical runtime base. */
|
||||||
|
readonly mosaicHomeFor?: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewAgentOptions {
|
||||||
|
readonly harness?: string;
|
||||||
|
readonly bundle?: string;
|
||||||
|
readonly model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers the user-data seat scaffolder, distinct from roster-v2 CRUD. */
|
||||||
|
export function registerFleetAgentScaffoldCommand(
|
||||||
|
fleetCommand: Command,
|
||||||
|
deps: FleetAgentScaffoldCommandDeps = {},
|
||||||
|
): void {
|
||||||
|
const agent = fleetCommand
|
||||||
|
.command('agent')
|
||||||
|
.description('Manage user-owned fleet agent harness homes');
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command('new <name>')
|
||||||
|
.description('Create an additive-or-refuse fleet agent harness home')
|
||||||
|
.option('--harness <harness>', 'Harness: claude or pi', 'claude')
|
||||||
|
.option('--bundle <bundle>', 'Auth bundle selector', 'primary')
|
||||||
|
.option('--model <model>', 'Optional harness-native model')
|
||||||
|
.action(async (name: string, options: NewAgentOptions): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result = await scaffoldFleetAgent({
|
||||||
|
name,
|
||||||
|
harness: options.harness,
|
||||||
|
bundle: options.bundle,
|
||||||
|
model: options.model,
|
||||||
|
...(deps.fleetDataHome === undefined ? {} : { dataHome: deps.fleetDataHome }),
|
||||||
|
...(deps.mosaicHomeFor === undefined ? {} : { mosaicHome: deps.mosaicHomeFor() }),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
result.idempotent
|
||||||
|
? `Fleet agent "${name}" already matches the scaffold.`
|
||||||
|
: `Created fleet agent "${name}" at ${result.agentDir}.`,
|
||||||
|
);
|
||||||
|
if (!result.credentialTargetExists) {
|
||||||
|
console.log(
|
||||||
|
`Notice: credentials link is intentionally dangling until auth bundle "${result.profile['bundle']}" is enrolled: ${result.credentialTarget}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const code = error instanceof FleetAgentScaffoldError ? error.code : 'scaffold-failed';
|
||||||
|
process.stderr.write(`mosaic fleet agent new failed (${code}): ${message}\n`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,7 @@ describe('registerFleetCommand', () => {
|
|||||||
expect(fleet).toBeDefined();
|
expect(fleet).toBeDefined();
|
||||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||||
'add',
|
'add',
|
||||||
|
'agent',
|
||||||
'apply',
|
'apply',
|
||||||
'backlog',
|
'backlog',
|
||||||
'create',
|
'create',
|
||||||
@@ -91,6 +92,7 @@ describe('registerFleetCommand', () => {
|
|||||||
'init',
|
'init',
|
||||||
'install',
|
'install',
|
||||||
'install-systemd',
|
'install-systemd',
|
||||||
|
'launch',
|
||||||
'migrate-v1',
|
'migrate-v1',
|
||||||
'persona',
|
'persona',
|
||||||
'plan',
|
'plan',
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import {
|
|||||||
registerFleetAgentCrudCommands,
|
registerFleetAgentCrudCommands,
|
||||||
type FleetAgentCrudCommandDeps,
|
type FleetAgentCrudCommandDeps,
|
||||||
} from './fleet-agent-crud-command.js';
|
} from './fleet-agent-crud-command.js';
|
||||||
|
import {
|
||||||
|
registerFleetAgentScaffoldCommand,
|
||||||
|
type FleetAgentScaffoldCommandDeps,
|
||||||
|
} from './fleet-agent-scaffold-command.js';
|
||||||
import {
|
import {
|
||||||
registerFleetMigrationCommand,
|
registerFleetMigrationCommand,
|
||||||
type FleetMigrationCommandDeps,
|
type FleetMigrationCommandDeps,
|
||||||
@@ -63,6 +67,7 @@ import { registerFleetBacklogCommand } from './fleet-backlog.js';
|
|||||||
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
||||||
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
||||||
import { registerFleetProvisionCommand } from './fleet-provision.js';
|
import { registerFleetProvisionCommand } from './fleet-provision.js';
|
||||||
|
import { registerFleetLaunchCommand, type FleetLaunchCommandDeps } from './fleet-launch-command.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A function that spawns a command with inherited stdio (TTY passthrough).
|
* A function that spawns a command with inherited stdio (TTY passthrough).
|
||||||
@@ -97,6 +102,10 @@ export interface FleetCommandDeps {
|
|||||||
*/
|
*/
|
||||||
sleepFn?: SleepFn;
|
sleepFn?: SleepFn;
|
||||||
mosaicHome?: string;
|
mosaicHome?: string;
|
||||||
|
/** User-owned fleet/auth/config root. Defaults to ~/.mosaic. */
|
||||||
|
mosaicUserHome?: string;
|
||||||
|
/** Test/embedding seam for the final process-replacing fleet launch. */
|
||||||
|
fleetLauncher?: FleetLaunchCommandDeps['launcher'];
|
||||||
frameworkRoot?: string;
|
frameworkRoot?: string;
|
||||||
/**
|
/**
|
||||||
* Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY.
|
* Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY.
|
||||||
@@ -104,6 +113,8 @@ export interface FleetCommandDeps {
|
|||||||
*/
|
*/
|
||||||
isStdinTTY?: boolean;
|
isStdinTTY?: boolean;
|
||||||
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
||||||
|
/** Test-only user-data root for `fleet agent new` (production: ~/.mosaic). */
|
||||||
|
fleetDataHome?: FleetAgentScaffoldCommandDeps['fleetDataHome'];
|
||||||
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
||||||
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
|
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
|
||||||
}
|
}
|
||||||
@@ -2041,6 +2052,14 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
// fleet/ directory as the roster and heartbeats.
|
// fleet/ directory as the roster and heartbeats.
|
||||||
registerFleetBacklogCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
registerFleetBacklogCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||||
|
|
||||||
|
// User-facing per-agent profile.json is the launch-composition SSOT. It is
|
||||||
|
// intentionally independent of roster-v2, whose lifecycle/topology registry
|
||||||
|
// does not model auth bundles, overlays, plugins, skills, or seat env.
|
||||||
|
registerFleetLaunchCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome, {
|
||||||
|
...(deps.mosaicUserHome === undefined ? {} : { userHome: deps.mosaicUserHome }),
|
||||||
|
...(deps.fleetLauncher === undefined ? {} : { launcher: deps.fleetLauncher }),
|
||||||
|
});
|
||||||
|
|
||||||
// System-type profiles (H2): declarative persona roster + topology, resolved
|
// System-type profiles (H2): declarative persona roster + topology, resolved
|
||||||
// from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag.
|
// from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag.
|
||||||
registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||||
@@ -2054,6 +2073,13 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
// profile. DRY-RUN by default; --write persists under the same --mosaic-home.
|
// profile. DRY-RUN by default; --write persists under the same --mosaic-home.
|
||||||
registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||||
|
|
||||||
|
// `fleet agent new` owns user-data harness homes under ~/.mosaic. The
|
||||||
|
// existing roster-v2 CRUD remains direct fleet control-plane CRUD, so there
|
||||||
|
// is one `agent` namespace but deliberately separate state authorities.
|
||||||
|
registerFleetAgentScaffoldCommand(cmd, {
|
||||||
|
...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }),
|
||||||
|
mosaicHomeFor: () => cmd.opts<{ mosaicHome: string }>().mosaicHome,
|
||||||
|
});
|
||||||
// Roster-v2 desired-state mutations belong directly to the fleet control
|
// Roster-v2 desired-state mutations belong directly to the fleet control
|
||||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
||||||
registerFleetAgentCrudCommands(cmd, deps);
|
registerFleetAgentCrudCommands(cmd, deps);
|
||||||
|
|||||||
@@ -201,10 +201,34 @@ describe('guardClaudeSettingsWiring', () => {
|
|||||||
// red-first). Instead it proves the wiring is genuinely delegated: the
|
// red-first). Instead it proves the wiring is genuinely delegated: the
|
||||||
// no-deps call must agree with an explicit call to the same real
|
// no-deps call must agree with an explicit call to the same real
|
||||||
// predicate, not some other hardcoded value.
|
// predicate, not some other hardcoded value.
|
||||||
const reallyActivatable = leaseEnforcementActivatable();
|
//
|
||||||
const outcome = guardClaudeSettingsWiring(fixtureJson());
|
// The predicate is observed on BOTH sides of the guard call because it is
|
||||||
|
// not deterministic under load. `defaultCapabilityProbe` executes
|
||||||
|
// `dist/cli.js` out-of-process with a 2s timeout; in a full-package run
|
||||||
|
// with every spec file scheduled at once, one observation can beat that
|
||||||
|
// timeout while the next does not. Comparing a single before-observation
|
||||||
|
// against the guard's own internal call therefore failed intermittently
|
||||||
|
// in the full suite while passing in isolation — the test was reporting
|
||||||
|
// machine load as a wiring defect.
|
||||||
|
//
|
||||||
|
// So the guard call is bracketed by two observations and only a pair that
|
||||||
|
// agrees is used as ground truth. A disagreeing pair proves the
|
||||||
|
// environment moved mid-test rather than that the wiring is wrong, and is
|
||||||
|
// retried. This does not weaken the assertion: a delegation failure is
|
||||||
|
// stable and survives every attempt, while load noise does not. Three
|
||||||
|
// attempts that never hold still is itself a failure, so an environment
|
||||||
|
// that is permanently unstable is reported rather than skipped.
|
||||||
|
let before = false;
|
||||||
|
let outcome: ReturnType<typeof guardClaudeSettingsWiring> | undefined;
|
||||||
|
for (let attempt = 0; attempt < 3 && outcome === undefined; attempt += 1) {
|
||||||
|
before = leaseEnforcementActivatable();
|
||||||
|
const candidate = guardClaudeSettingsWiring(fixtureJson());
|
||||||
|
if (leaseEnforcementActivatable() === before) outcome = candidate;
|
||||||
|
}
|
||||||
|
expect(outcome, 'activation probe never held still across three attempts').toBeDefined();
|
||||||
|
if (outcome === undefined) return;
|
||||||
|
|
||||||
if (reallyActivatable) {
|
if (before) {
|
||||||
expect(outcome.exitCode).toBe(0);
|
expect(outcome.exitCode).toBe(0);
|
||||||
expect(outcome.wired).toBe(true);
|
expect(outcome.wired).toBe(true);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* What the launched runtime actually receives in its environment.
|
||||||
|
*
|
||||||
|
* These tests do not inspect `minimalLaunchEnv` and do not use a test seam. They run the real
|
||||||
|
* `fleet launch` route -- register, apply, compose, lease gate, exec -- with a shim standing in
|
||||||
|
* for the runtime binary, and the shim dumps its own environment. So the thing under test is the
|
||||||
|
* environment at the far end of the whole chain, after `launch-runtime.py` has added the lease
|
||||||
|
* variables, rather than the object the launcher believed it was building. The two differ, and
|
||||||
|
* only the first one matters.
|
||||||
|
*
|
||||||
|
* The property being defended: an operator's environment is large, grows over time, and contains
|
||||||
|
* names that make a child execute code before its first instruction (`BASH_ENV`, `PYTHONSTARTUP`,
|
||||||
|
* `NODE_OPTIONS`, `LD_PRELOAD`) as well as credentials for accounts the seat is deliberately not
|
||||||
|
* pegged to. A composed seat must receive a closed set of names, and "closed" is only true if
|
||||||
|
* something measures it.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { registerFleetLaunchCommand } from './fleet-launch-command.js';
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names permitted to reach a composed seat, written out rather than derived from the launcher.
|
||||||
|
*
|
||||||
|
* Deriving it would make the test agree with the code by construction and detect nothing. The
|
||||||
|
* cost of a literal list is that adding a variable means editing this file, which is the point:
|
||||||
|
* a new name in a seat's environment should be a decision someone made, not a side effect.
|
||||||
|
*
|
||||||
|
* PWD, SHLVL and `_` are absent because the shim's own shell sets them after exec; they are
|
||||||
|
* filtered at the measurement site, not permitted here.
|
||||||
|
*/
|
||||||
|
const PERMITTED_CHILD_ENV = new Set([
|
||||||
|
// inherited from the operator by the launcher's allowlist
|
||||||
|
'PATH',
|
||||||
|
'HOME',
|
||||||
|
'USER',
|
||||||
|
'LOGNAME',
|
||||||
|
'SHELL',
|
||||||
|
'TERM',
|
||||||
|
'COLORTERM',
|
||||||
|
'TMPDIR',
|
||||||
|
'XDG_RUNTIME_DIR',
|
||||||
|
// fixed by the launcher
|
||||||
|
'LANG',
|
||||||
|
'LC_ALL',
|
||||||
|
// declared by the seat profile and composition
|
||||||
|
'CLAUDE_CONFIG_DIR',
|
||||||
|
'MOSAIC_AGENT_NAME',
|
||||||
|
'SEAT_FLAG',
|
||||||
|
// minted per launch for ledger correlation
|
||||||
|
'MOSAIC_LAUNCH_ID',
|
||||||
|
// added by the lease gate in launch-runtime.py
|
||||||
|
'MOSAIC_LEASE_BROKER_SOCKET',
|
||||||
|
'MOSAIC_LEASE_GENERATION_FILE',
|
||||||
|
'MOSAIC_LEASE_RUNTIME',
|
||||||
|
'MOSAIC_LEASE_SESSION_ID',
|
||||||
|
'MOSAIC_RECEIPT_OBSERVER_SOCKET',
|
||||||
|
'MOSAIC_RUNTIME_GENERATION',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator environment that must not survive composition.
|
||||||
|
*
|
||||||
|
* Three classes, all real. Loader hooks run attacker-chosen code inside the runtime before it
|
||||||
|
* does anything (`BASH_ENV`/`ENV` for shells, `PYTHON*` for the interpreter that runs the lease
|
||||||
|
* gate, `NODE_*` for the runtime itself, `LD_*` for every dynamically linked binary in the tree).
|
||||||
|
* Package configuration redirects where code is fetched from. Provider credentials belong to the
|
||||||
|
* operator's accounts, and a seat pegged to its own auth bundle that can still read them is not
|
||||||
|
* pegged to anything.
|
||||||
|
*
|
||||||
|
* The values are distinctive so the diagnostics check below can search for them by content.
|
||||||
|
*/
|
||||||
|
const OPERATOR_ONLY_ENV: Record<string, string> = {
|
||||||
|
BASH_ENV: '/poison-a1b2/bash_env.sh',
|
||||||
|
ENV: '/poison-a1b2/env.sh',
|
||||||
|
PYTHONPATH: '/poison-a1b2/pythonpath',
|
||||||
|
PYTHONSTARTUP: '/poison-a1b2/pythonstartup.py',
|
||||||
|
NODE_OPTIONS: '--require /poison-a1b2/preload.js',
|
||||||
|
NODE_PATH: '/poison-a1b2/node_path',
|
||||||
|
NPM_CONFIG_PREFIX: '/poison-a1b2/npm_prefix',
|
||||||
|
NPM_CONFIG_REGISTRY: 'https://poison-a1b2.example.invalid/',
|
||||||
|
LD_PRELOAD: '/poison-a1b2/preload.so',
|
||||||
|
LD_LIBRARY_PATH: '/poison-a1b2/lib',
|
||||||
|
ANTHROPIC_API_KEY: 'poison-a1b2-anthropic-key',
|
||||||
|
OPENAI_API_KEY: 'poison-a1b2-openai-key',
|
||||||
|
GH_TOKEN: 'poison-a1b2-github-token',
|
||||||
|
GITEA_TOKEN: 'poison-a1b2-gitea-token',
|
||||||
|
AWS_SECRET_ACCESS_KEY: 'poison-a1b2-aws-secret',
|
||||||
|
SSH_AUTH_SOCK: '/poison-a1b2/ssh-agent.sock',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Fixture {
|
||||||
|
root: string;
|
||||||
|
systemHome: string;
|
||||||
|
userHome: string;
|
||||||
|
agentDir: string;
|
||||||
|
seatHome: string;
|
||||||
|
bin: string;
|
||||||
|
dump: string;
|
||||||
|
ledger: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(profileEnv: Record<string, string>, extraProfile: Record<string, unknown> = {}) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-child-env-'));
|
||||||
|
roots.push(root);
|
||||||
|
const systemHome = join(root, 'system');
|
||||||
|
const userHome = join(root, 'user');
|
||||||
|
const agentDir = join(userHome, 'fleet', 'agents', 'fred');
|
||||||
|
const seatHome = join(agentDir, '.claude');
|
||||||
|
const namedBundleDir = join(userHome, 'auth', 'claude', 'fred_example.com');
|
||||||
|
const bin = join(root, 'bin');
|
||||||
|
const dump = join(root, 'child-env.txt');
|
||||||
|
|
||||||
|
mkdirSync(join(systemHome, 'runtime', 'claude'), { recursive: true });
|
||||||
|
mkdirSync(join(systemHome, 'tools', '_scripts'), { recursive: true });
|
||||||
|
mkdirSync(seatHome, { recursive: true });
|
||||||
|
mkdirSync(namedBundleDir, { recursive: true });
|
||||||
|
mkdirSync(bin, { recursive: true });
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(agentDir, 'profile.json'),
|
||||||
|
`${JSON.stringify({ schema: 1, harness: 'claude', env: profileEnv, ...extraProfile }, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(join(namedBundleDir, '.credentials.json'), '{}\n', { mode: 0o600 });
|
||||||
|
writeFileSync(
|
||||||
|
join(namedBundleDir, 'account.json'),
|
||||||
|
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
|
||||||
|
);
|
||||||
|
symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir');
|
||||||
|
|
||||||
|
writeFileSync(join(systemHome, 'AGENTS.md'), '# fixture\n');
|
||||||
|
writeFileSync(join(systemHome, 'SOUL.md'), '# fixture\n');
|
||||||
|
const frameworkSettings = readFileSync(
|
||||||
|
join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json'),
|
||||||
|
);
|
||||||
|
writeFileSync(join(systemHome, 'runtime', 'claude', 'settings.json'), frameworkSettings);
|
||||||
|
writeFileSync(
|
||||||
|
join(systemHome, 'runtime', 'claude', 'RUNTIME.md'),
|
||||||
|
readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'RUNTIME.md')),
|
||||||
|
);
|
||||||
|
const helper = join(systemHome, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
||||||
|
writeFileSync(
|
||||||
|
helper,
|
||||||
|
readFileSync(
|
||||||
|
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
||||||
|
),
|
||||||
|
{ mode: 0o700 },
|
||||||
|
);
|
||||||
|
chmodSync(helper, 0o700);
|
||||||
|
writeFileSync(join(seatHome, '.claude.json'), frameworkSettings.toString(), { mode: 0o600 });
|
||||||
|
|
||||||
|
// The shim records its own environment and exits. `claude` is the measurement point; `python3`
|
||||||
|
// is present only so a PATH lookup for it would succeed -- the lease gate deliberately takes the
|
||||||
|
// root-owned interpreter instead, so this copy should never run, and the assertions below do not
|
||||||
|
// depend on which one does.
|
||||||
|
for (const name of ['claude', 'python3']) {
|
||||||
|
const path = join(bin, name);
|
||||||
|
writeFileSync(
|
||||||
|
path,
|
||||||
|
`#!/usr/bin/env bash\nenv > ${JSON.stringify(`${dump}.${name}`)}\nexit 0\n`,
|
||||||
|
{
|
||||||
|
mode: 0o700,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
chmodSync(path, 0o700);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
systemHome,
|
||||||
|
userHome,
|
||||||
|
agentDir,
|
||||||
|
seatHome,
|
||||||
|
bin,
|
||||||
|
dump,
|
||||||
|
ledger: join(systemHome, 'fleet', 'run', 'sessions', 'events.ndjson'),
|
||||||
|
} satisfies Fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the real launch route with a controlled operator environment. */
|
||||||
|
function launch(fx: Fixture, operatorEnv: Record<string, string>): Map<string, string> {
|
||||||
|
const program = new Command().exitOverride();
|
||||||
|
const fleet = program.command('fleet');
|
||||||
|
// The launcher execs and then exits; the fixture runtime returns instead, so the exit is the
|
||||||
|
// normal end of this route rather than a failure.
|
||||||
|
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
|
throw new Error('process.exit called');
|
||||||
|
});
|
||||||
|
const saved = new Map<string, string | undefined>();
|
||||||
|
const set = (name: string, value: string): void => {
|
||||||
|
saved.set(name, process.env[name]);
|
||||||
|
process.env[name] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [name, value] of Object.entries(OPERATOR_ONLY_ENV)) set(name, value);
|
||||||
|
for (const [name, value] of Object.entries(operatorEnv)) set(name, value);
|
||||||
|
saved.set('PATH', process.env['PATH']);
|
||||||
|
process.env['PATH'] = `${fx.bin}:${process.env['PATH'] ?? ''}`;
|
||||||
|
registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome });
|
||||||
|
try {
|
||||||
|
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
|
||||||
|
} catch {
|
||||||
|
// exec replaced by the mocked exit above
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
exit.mockRestore();
|
||||||
|
for (const [name, value] of saved) {
|
||||||
|
if (value === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `${fx.dump}.claude`;
|
||||||
|
if (!existsSync(path)) throw new Error('runtime shim never ran; nothing was measured');
|
||||||
|
const env = new Map<string, string>();
|
||||||
|
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
||||||
|
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line);
|
||||||
|
// Names the shim's own shell sets after exec, not names the launcher passed.
|
||||||
|
if (match && !['PWD', 'SHLVL', '_', 'OLDPWD'].includes(match[1]!))
|
||||||
|
env.set(match[1]!, match[2]!);
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATOR_BASELINE: Record<string, string> = {
|
||||||
|
LANG: 'en_US.UTF-8',
|
||||||
|
LC_ALL: 'en_US.UTF-8',
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
COLORTERM: 'truecolor',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('composed seat child environment', () => {
|
||||||
|
it('hands the runtime no name outside the permitted set', () => {
|
||||||
|
const fx = fixture({ SEAT_FLAG: 'yes' });
|
||||||
|
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
||||||
|
|
||||||
|
const unexpected = [...env.keys()].filter((name) => !PERMITTED_CHILD_ENV.has(name)).sort();
|
||||||
|
expect(
|
||||||
|
unexpected,
|
||||||
|
'a name reached the seat that nobody declared; add it to PERMITTED_CHILD_ENV only if it belongs there',
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops operator loader hooks, package configuration, and provider credentials', () => {
|
||||||
|
const fx = fixture({ SEAT_FLAG: 'yes' });
|
||||||
|
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
||||||
|
|
||||||
|
const survivors = Object.keys(OPERATOR_ONLY_ENV)
|
||||||
|
.filter((name) => env.has(name))
|
||||||
|
.sort();
|
||||||
|
expect(survivors, 'operator-only variables reached the seat').toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the runtime the declared seat values, not the operator equivalents', () => {
|
||||||
|
const fx = fixture({ SEAT_FLAG: 'yes' });
|
||||||
|
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
||||||
|
|
||||||
|
expect(env.get('CLAUDE_CONFIG_DIR')).toBe(fx.seatHome);
|
||||||
|
expect(env.get('MOSAIC_AGENT_NAME')).toBe('fred');
|
||||||
|
expect(env.get('SEAT_FLAG')).toBe('yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fixes the locale instead of inheriting the operator locale', () => {
|
||||||
|
const fx = fixture({ SEAT_FLAG: 'yes' });
|
||||||
|
const env = launch(fx, {
|
||||||
|
LANG: 'de_DE.UTF-8',
|
||||||
|
LC_ALL: 'de_DE.UTF-8',
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
COLORTERM: 'truecolor',
|
||||||
|
HOME: join(fx.root, 'operator-home'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env.get('LANG')).toBe('C.UTF-8');
|
||||||
|
expect(env.get('LC_ALL')).toBe('C.UTF-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a seat that needs a different locale declare one', () => {
|
||||||
|
const fx = fixture({ SEAT_FLAG: 'yes', LANG: 'de_DE.UTF-8', LC_ALL: 'de_DE.UTF-8' });
|
||||||
|
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
||||||
|
|
||||||
|
expect(env.get('LANG')).toBe('de_DE.UTF-8');
|
||||||
|
expect(env.get('LC_ALL')).toBe('de_DE.UTF-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inherits the allowlisted operator values it is supposed to inherit', () => {
|
||||||
|
const fx = fixture({ SEAT_FLAG: 'yes' });
|
||||||
|
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
||||||
|
|
||||||
|
expect(env.get('TERM')).toBe('xterm-256color');
|
||||||
|
expect(env.get('COLORTERM')).toBe('truecolor');
|
||||||
|
expect(env.get('PATH')).toContain(fx.bin);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mints a fresh launch id rather than forwarding the operator session id', () => {
|
||||||
|
const fx = fixture({ SEAT_FLAG: 'yes' });
|
||||||
|
const env = launch(fx, {
|
||||||
|
...OPERATOR_BASELINE,
|
||||||
|
HOME: join(fx.root, 'operator-home'),
|
||||||
|
MOSAIC_LAUNCH_ID: 'operator-session-launch-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
const childId = env.get('MOSAIC_LAUNCH_ID');
|
||||||
|
expect(childId).toBeDefined();
|
||||||
|
expect(childId).not.toBe('operator-session-launch-id');
|
||||||
|
// The id is only useful if the ledger records the same one; correlation is by this value and
|
||||||
|
// never by pid, because exec makes the runtime a different process.
|
||||||
|
expect(readFileSync(fx.ledger, 'utf8')).toContain(`"launch_id":"${childId}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps operator environment values out of the launch ledger', () => {
|
||||||
|
const fx = fixture({ SEAT_FLAG: 'yes' });
|
||||||
|
launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
||||||
|
|
||||||
|
// The ledger records env as present names only, by design. This checks the design holds for
|
||||||
|
// values as well as for the credential file it was written to protect.
|
||||||
|
const ledger = readFileSync(fx.ledger, 'utf8');
|
||||||
|
for (const [name, value] of Object.entries(OPERATOR_ONLY_ENV)) {
|
||||||
|
expect(ledger, `ledger leaked the value of ${name}`).not.toContain(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
symlinkSync,
|
||||||
|
rmSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +19,8 @@ import {
|
|||||||
enumerateSkillDirs,
|
enumerateSkillDirs,
|
||||||
piForceSkillNames,
|
piForceSkillNames,
|
||||||
registerRuntimeLaunchers,
|
registerRuntimeLaunchers,
|
||||||
|
checkSequentialThinking,
|
||||||
|
resolveExecutableFromPath,
|
||||||
type RuntimeLaunchHandler,
|
type RuntimeLaunchHandler,
|
||||||
type ClaudexLaunchHandler,
|
type ClaudexLaunchHandler,
|
||||||
} from './launch.js';
|
} from './launch.js';
|
||||||
@@ -86,6 +99,269 @@ describe('registerRuntimeLaunchers — non-yolo subcommands', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('checkSequentialThinking', () => {
|
||||||
|
it('runs the real fleet launch preflight against the injected seat, not HOME', () => {
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
|
||||||
|
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
||||||
|
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
||||||
|
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
).mcpServers['sequential-thinking'],
|
||||||
|
).toEqual({
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||||
|
});
|
||||||
|
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
||||||
|
checker,
|
||||||
|
);
|
||||||
|
mkdirSync(join(agentDir, '.claude'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(agentDir, '.claude', '.claude.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
'sequential-thinking': {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
vi.stubEnv('HOME', home);
|
||||||
|
expect(() =>
|
||||||
|
checkSequentialThinking('claude', { agentDir, mosaicHome: installed }),
|
||||||
|
).not.toThrow();
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
rmSync(agentDir, { recursive: true, force: true });
|
||||||
|
rmSync(installed, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the real fleet launch preflight when only operator HOME is seeded', () => {
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
|
||||||
|
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
||||||
|
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
||||||
|
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
||||||
|
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
||||||
|
try {
|
||||||
|
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
||||||
|
checker,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(home, '.claude.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
'sequential-thinking': {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubEnv('HOME', home);
|
||||||
|
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
|
||||||
|
'process.exit called',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
exit.mockRestore();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
rmSync(agentDir, { recursive: true, force: true });
|
||||||
|
rmSync(installed, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes with a seeded seat even when operator HOME has no MCP configuration', () => {
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
|
||||||
|
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
||||||
|
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
||||||
|
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
||||||
|
try {
|
||||||
|
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
||||||
|
checker,
|
||||||
|
);
|
||||||
|
mkdirSync(join(agentDir, '.claude'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(agentDir, '.claude', '.claude.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
'sequential-thinking': {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
vi.stubEnv('MOSAIC_HOME', installed);
|
||||||
|
vi.stubEnv('HOME', home);
|
||||||
|
expect(() =>
|
||||||
|
checkSequentialThinking('claude', { agentDir, mosaicHome: installed }),
|
||||||
|
).not.toThrow();
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
rmSync(agentDir, { recursive: true, force: true });
|
||||||
|
rmSync(installed, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs a legacy seat config in place without using operator HOME', () => {
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
|
||||||
|
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
||||||
|
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
||||||
|
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
||||||
|
const bin = join(installed, 'bin');
|
||||||
|
try {
|
||||||
|
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
||||||
|
mkdirSync(join(agentDir, '.claude'), { recursive: true });
|
||||||
|
mkdirSync(bin, { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
||||||
|
checker,
|
||||||
|
);
|
||||||
|
for (const name of ['node', 'npx']) {
|
||||||
|
writeFileSync(join(bin, name), '#!/usr/bin/env bash\nexit 0\n');
|
||||||
|
chmodSync(join(bin, name), 0o755);
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
join(agentDir, '.claude', '.claude.json'),
|
||||||
|
JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` };
|
||||||
|
expect(
|
||||||
|
spawnSync(
|
||||||
|
checker,
|
||||||
|
[
|
||||||
|
'--runtime',
|
||||||
|
'claude',
|
||||||
|
'--claude-config-dir',
|
||||||
|
join(agentDir, '.claude'),
|
||||||
|
'--python-bin',
|
||||||
|
'/usr/bin/python3',
|
||||||
|
'--node-bin',
|
||||||
|
'/usr/bin/node',
|
||||||
|
'--npx-bin',
|
||||||
|
'/usr/bin/npx',
|
||||||
|
'--timeout-bin',
|
||||||
|
'/usr/bin/timeout',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env,
|
||||||
|
},
|
||||||
|
).status,
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
spawnSync(
|
||||||
|
checker,
|
||||||
|
[
|
||||||
|
'--check',
|
||||||
|
'--runtime',
|
||||||
|
'claude',
|
||||||
|
'--claude-config-dir',
|
||||||
|
join(agentDir, '.claude'),
|
||||||
|
'--python-bin',
|
||||||
|
'/usr/bin/python3',
|
||||||
|
'--node-bin',
|
||||||
|
'/usr/bin/node',
|
||||||
|
'--npx-bin',
|
||||||
|
'/usr/bin/npx',
|
||||||
|
'--timeout-bin',
|
||||||
|
'/usr/bin/timeout',
|
||||||
|
],
|
||||||
|
{ env },
|
||||||
|
).status,
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
JSON.parse(readFileSync(join(agentDir, '.claude', '.claude.json'), 'utf8')),
|
||||||
|
).toMatchObject({
|
||||||
|
hasCompletedOnboarding: true,
|
||||||
|
theme: 'dark',
|
||||||
|
mcpServers: { 'sequential-thinking': { command: 'npx' } },
|
||||||
|
});
|
||||||
|
expect(existsSync(join(home, '.claude.json'))).toBe(false);
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
rmSync(agentDir, { recursive: true, force: true });
|
||||||
|
rmSync(installed, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a group-writable installed helper root', () => {
|
||||||
|
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
||||||
|
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
||||||
|
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
||||||
|
try {
|
||||||
|
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
||||||
|
checker,
|
||||||
|
);
|
||||||
|
chmodSync(installed, 0o770);
|
||||||
|
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
|
||||||
|
/not a trusted installed file/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
chmodSync(installed, 0o700);
|
||||||
|
rmSync(agentDir, { recursive: true, force: true });
|
||||||
|
rmSync(installed, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an empty seat even when operator HOME is configured', () => {
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
|
||||||
|
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
||||||
|
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
||||||
|
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
||||||
|
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
||||||
|
try {
|
||||||
|
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
||||||
|
checker,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(home, '.claude.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
'sequential-thinking': {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubEnv('MOSAIC_HOME', installed);
|
||||||
|
vi.stubEnv('HOME', home);
|
||||||
|
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
|
||||||
|
'process.exit called',
|
||||||
|
);
|
||||||
|
expect(exit).toHaveBeenCalledWith(1);
|
||||||
|
} finally {
|
||||||
|
exit.mockRestore();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
rmSync(agentDir, { recursive: true, force: true });
|
||||||
|
rmSync(installed, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildPiSkillArgs', () => {
|
describe('buildPiSkillArgs', () => {
|
||||||
it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
|
it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
|
||||||
expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
|
expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
|
||||||
@@ -349,3 +625,146 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
|
|||||||
expect(mockExit).not.toHaveBeenCalled();
|
expect(mockExit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executable resolution for fleet launches (AMD1213-D, D3/D6).
|
||||||
|
*
|
||||||
|
* The defect these cover: the launcher proved a runtime existed by running ambient
|
||||||
|
* `which`, then spawned the bare name and let the OS resolve it a second time against an
|
||||||
|
* ambient PATH. A directory prepended to PATH satisfied the probe and then supplied the
|
||||||
|
* binary that actually ran, so the check could pass without ever reading seat state.
|
||||||
|
*
|
||||||
|
* Every case below was run against the pre-change resolution first. The shim case is the
|
||||||
|
* one that matters -- under `which` + bare-name spawn it passes, because that is exactly
|
||||||
|
* the behaviour being removed.
|
||||||
|
*/
|
||||||
|
describe('resolveExecutableFromPath', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
const bin = (root: string, name: string, mode = 0o755): string => {
|
||||||
|
const p = join(root, name);
|
||||||
|
writeFileSync(p, '#!/bin/sh\nexit 0\n');
|
||||||
|
chmodSync(p, mode);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'mosaic-exec-resolve-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a safe executable and reports its real path and identity', () => {
|
||||||
|
const safe = join(dir, 'safe');
|
||||||
|
mkdirSync(safe, { mode: 0o755 });
|
||||||
|
const target = bin(safe, 'codex');
|
||||||
|
|
||||||
|
const resolved = resolveExecutableFromPath('codex', safe);
|
||||||
|
|
||||||
|
expect(resolved.path).toBe(target);
|
||||||
|
expect(resolved.ino).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a world-writable binary planted on PATH', () => {
|
||||||
|
// The shim case. `which` reports this happily and a bare-name spawn runs it.
|
||||||
|
const shim = join(dir, 'shim');
|
||||||
|
mkdirSync(shim, { mode: 0o755 });
|
||||||
|
bin(shim, 'codex', 0o777);
|
||||||
|
|
||||||
|
expect(() => resolveExecutableFromPath('codex', shim)).toThrow(/writable by group or other/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a safe binary reached through a world-writable directory', () => {
|
||||||
|
// The binary itself is fine; anyone can swap it for one that is not.
|
||||||
|
const open = join(dir, 'open');
|
||||||
|
mkdirSync(open, { mode: 0o755 });
|
||||||
|
bin(open, 'codex');
|
||||||
|
// chmod after mkdir: the mode argument is masked by the process umask, so a
|
||||||
|
// directory created as 0o777 is really 0o755 and the case tests nothing.
|
||||||
|
chmodSync(open, 0o777);
|
||||||
|
|
||||||
|
expect(() => resolveExecutableFromPath('codex', open)).toThrow(/writable directory/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fall through to a later PATH entry when the first match is unsafe', () => {
|
||||||
|
// Falling through would let a planted unsafe binary silently downgrade the search to
|
||||||
|
// whatever came after it, inverting the precedence PATH exists to express.
|
||||||
|
const shim = join(dir, 'first');
|
||||||
|
const good = join(dir, 'second');
|
||||||
|
mkdirSync(shim, { mode: 0o755 });
|
||||||
|
mkdirSync(good, { mode: 0o755 });
|
||||||
|
bin(shim, 'codex', 0o777);
|
||||||
|
const safeTarget = bin(good, 'codex');
|
||||||
|
|
||||||
|
let resolvedPath: string | undefined;
|
||||||
|
try {
|
||||||
|
resolvedPath = resolveExecutableFromPath('codex', `${shim}:${good}`).path;
|
||||||
|
} catch {
|
||||||
|
resolvedPath = undefined;
|
||||||
|
}
|
||||||
|
expect(resolvedPath).not.toBe(safeTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a relative PATH entry', () => {
|
||||||
|
// A relative entry resolves against the current directory, so what it names depends
|
||||||
|
// on where the launcher happened to be started.
|
||||||
|
expect(() => resolveExecutableFromPath('codex', '.:relative/bin')).toThrow(/not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a symlink and validates the real file behind it', () => {
|
||||||
|
const safe = join(dir, 'real');
|
||||||
|
const linkDir = join(dir, 'links');
|
||||||
|
mkdirSync(safe, { mode: 0o755 });
|
||||||
|
mkdirSync(linkDir, { mode: 0o755 });
|
||||||
|
const target = bin(safe, 'codex-real');
|
||||||
|
symlinkSync(target, join(linkDir, 'codex'));
|
||||||
|
|
||||||
|
expect(resolveExecutableFromPath('codex', linkDir).path).toBe(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a symlink whose real target is unsafe', () => {
|
||||||
|
const open = join(dir, 'openreal');
|
||||||
|
const linkDir = join(dir, 'links2');
|
||||||
|
mkdirSync(open, { mode: 0o755 });
|
||||||
|
mkdirSync(linkDir, { mode: 0o755 });
|
||||||
|
const target = bin(open, 'codex-real', 0o777);
|
||||||
|
symlinkSync(target, join(linkDir, 'codex'));
|
||||||
|
|
||||||
|
expect(() => resolveExecutableFromPath('codex', linkDir)).toThrow(/writable by group or other/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a non-executable file', () => {
|
||||||
|
const safe = join(dir, 'noexec');
|
||||||
|
mkdirSync(safe, { mode: 0o755 });
|
||||||
|
bin(safe, 'codex', 0o644);
|
||||||
|
|
||||||
|
expect(() => resolveExecutableFromPath('codex', safe)).toThrow(/not executable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a directory that merely shares the name', () => {
|
||||||
|
const safe = join(dir, 'dirname');
|
||||||
|
mkdirSync(join(safe, 'codex'), { recursive: true, mode: 0o755 });
|
||||||
|
|
||||||
|
expect(() => resolveExecutableFromPath('codex', safe)).toThrow(/not a regular file/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a name that is a path rather than a bare command', () => {
|
||||||
|
expect(() => resolveExecutableFromPath('../evil', dir)).toThrow(/bare command name/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when no PATH was declared', () => {
|
||||||
|
expect(() => resolveExecutableFromPath('codex', undefined)).toThrow(/no PATH was declared/);
|
||||||
|
expect(() => resolveExecutableFromPath('codex', '')).toThrow(/no PATH was declared/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports not-found rather than resolving something else', () => {
|
||||||
|
const empty = join(dir, 'empty');
|
||||||
|
mkdirSync(empty, { mode: 0o755 });
|
||||||
|
|
||||||
|
expect(() => resolveExecutableFromPath('codex', empty)).toThrow(
|
||||||
|
/not found on the declared PATH/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
@@ -19,14 +20,14 @@ import {
|
|||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { homedir, hostname } from 'node:os';
|
import { homedir, hostname } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname, relative, resolve, sep, delimiter, isAbsolute } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
buildResolvedFleetCommsBlock,
|
buildResolvedFleetCommsBlock,
|
||||||
renderToolsContractStatus,
|
renderToolsContractStatus,
|
||||||
resolveFleetIdentity,
|
resolveFleetIdentity,
|
||||||
} from '../fleet/comms-onboarding.js';
|
} from '../fleet/comms-onboarding.js';
|
||||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
import { assertNoSymlinkAncestors, readRegularFileSecure } from '../fleet/secure-file.js';
|
||||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||||
@@ -35,7 +36,14 @@ import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
|||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||||
|
|
||||||
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||||
|
|
||||||
|
/** Fleet context for the single harness-home resolution seam. */
|
||||||
|
export interface FleetHarnessContext {
|
||||||
|
readonly agentDir: string;
|
||||||
|
/** Active installed Mosaic root for fleet-specific helper resolution. */
|
||||||
|
readonly mosaicHome?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
||||||
claude: 'Claude Code',
|
claude: 'Claude Code',
|
||||||
@@ -64,19 +72,19 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
|
|||||||
opencode: 'XDG_CONFIG_HOME',
|
opencode: 'XDG_CONFIG_HOME',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
|
/** Dedicated runtime home, optionally scoped to a user fleet agent. */
|
||||||
function harnessHome(runtime: RuntimeName): string {
|
export function harnessHome(runtime: RuntimeName, fleet?: FleetHarnessContext): string {
|
||||||
return join(MOSAIC_HOME, `.${runtime}`);
|
return join(fleet?.agentDir ?? MOSAIC_HOME, `.${runtime}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Env overlay pointing a runtime at its mosaic-owned home. The directory is
|
* Env overlay pointing a runtime at its mosaic-owned home. The directory is
|
||||||
* created on demand so a first launch does not fail on a missing path.
|
* created on demand so a first launch does not fail on a missing path.
|
||||||
*/
|
*/
|
||||||
function harnessEnv(runtime: RuntimeName): Record<string, string> {
|
function harnessEnv(runtime: RuntimeName, fleet?: FleetHarnessContext): Record<string, string> {
|
||||||
const key = HARNESS_HOME_ENV[runtime];
|
const key = HARNESS_HOME_ENV[runtime];
|
||||||
if (!key) return {};
|
if (!key) return {};
|
||||||
const home = harnessHome(runtime);
|
const home = harnessHome(runtime, fleet);
|
||||||
mkdirSync(home, { recursive: true });
|
mkdirSync(home, { recursive: true });
|
||||||
return { [key]: home };
|
return { [key]: home };
|
||||||
}
|
}
|
||||||
@@ -132,15 +140,18 @@ function sha256Of(value: string | Buffer): string {
|
|||||||
* so an unexpected digest here is a mechanically detectable red flag rather than
|
* so an unexpected digest here is a mechanically detectable red flag rather than
|
||||||
* a matter of judgement.
|
* a matter of judgement.
|
||||||
*/
|
*/
|
||||||
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
|
function normativeFragmentDigests(
|
||||||
|
runtime: RuntimeName,
|
||||||
|
mosaicHome: string = MOSAIC_HOME,
|
||||||
|
): NormativeFragmentDigest[] {
|
||||||
const candidates: Array<[string, string]> = [
|
const candidates: Array<[string, string]> = [
|
||||||
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
|
['CONSTITUTION.md', join(mosaicHome, 'CONSTITUTION.md')],
|
||||||
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
|
['AGENTS.md', join(mosaicHome, 'AGENTS.md')],
|
||||||
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
|
['SOUL.md', join(mosaicHome, 'SOUL.md')],
|
||||||
['USER.md', join(MOSAIC_HOME, 'USER.md')],
|
['USER.md', join(mosaicHome, 'USER.md')],
|
||||||
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
|
['STANDARDS.md', join(mosaicHome, 'STANDARDS.md')],
|
||||||
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
|
['TOOLS.md', join(mosaicHome, 'TOOLS.md')],
|
||||||
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
|
[`runtime/${runtime}/RUNTIME.md`, join(mosaicHome, 'runtime', runtime, 'RUNTIME.md')],
|
||||||
];
|
];
|
||||||
return candidates.map(([sourceId, path]) => {
|
return candidates.map(([sourceId, path]) => {
|
||||||
try {
|
try {
|
||||||
@@ -162,9 +173,18 @@ function redactArgv(argv: string[]): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
|
function recordLaunch(
|
||||||
|
runtime: RuntimeName,
|
||||||
|
cliArgs: string[],
|
||||||
|
yolo: boolean,
|
||||||
|
fleet?: FleetHarnessContext,
|
||||||
|
launchEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
): void {
|
||||||
try {
|
try {
|
||||||
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
const ledgerDir = fleet?.mosaicHome
|
||||||
|
? join(fleet.mosaicHome, 'fleet', 'run', 'sessions')
|
||||||
|
: LAUNCH_LEDGER_DIR;
|
||||||
|
mkdirSync(ledgerDir, { recursive: true, mode: 0o700 });
|
||||||
// Correlation id for the lease.register half. Set into process.env so it
|
// Correlation id for the lease.register half. Set into process.env so it
|
||||||
// propagates through every `...process.env` / `...baseEnv` spread below.
|
// propagates through every `...process.env` / `...baseEnv` spread below.
|
||||||
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
||||||
@@ -180,17 +200,17 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v
|
|||||||
mode: yolo ? 'yolo' : 'normal',
|
mode: yolo ? 'yolo' : 'normal',
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
cli_version: CLI_VERSION,
|
cli_version: CLI_VERSION,
|
||||||
config_home: harnessHome(runtime),
|
config_home: harnessHome(runtime, fleet),
|
||||||
config_home_isolated: true,
|
config_home_isolated: true,
|
||||||
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
||||||
argv: redactArgv(cliArgs),
|
argv: redactArgv(cliArgs),
|
||||||
normative_fragments: normativeFragmentDigests(runtime),
|
normative_fragments: normativeFragmentDigests(runtime, fleet?.mosaicHome),
|
||||||
// names only — values are never recorded
|
// names only — values are never recorded
|
||||||
mosaic_env_present: Object.keys(process.env)
|
mosaic_env_present: Object.keys(launchEnv)
|
||||||
.filter((k) => k.startsWith('MOSAIC_'))
|
.filter((k) => k.startsWith('MOSAIC_'))
|
||||||
.sort(),
|
.sort(),
|
||||||
};
|
};
|
||||||
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
appendFileSync(join(ledgerDir, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -230,6 +250,125 @@ function checkRuntime(cmd: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An executable located once and pinned by the identity it had when validated. */
|
||||||
|
interface ResolvedExecutable {
|
||||||
|
readonly path: string;
|
||||||
|
readonly dev: number | bigint;
|
||||||
|
readonly ino: number | bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function executableRefusal(name: string, detail: string): Error {
|
||||||
|
return new Error(`refusing to launch '${name}': ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a directory whose contents someone else could swap under us.
|
||||||
|
*
|
||||||
|
* Group- or world-writable is the disqualifier, with the /tmp exception: a sticky
|
||||||
|
* directory is writable by design but only its owner may replace its entries, so it
|
||||||
|
* cannot be used to shadow one.
|
||||||
|
*/
|
||||||
|
function assertSafeAncestry(path: string, name: string, owner: number | undefined): void {
|
||||||
|
let cursor = dirname(path);
|
||||||
|
for (;;) {
|
||||||
|
const info = lstatSync(cursor);
|
||||||
|
if (!info.isDirectory() || info.isSymbolicLink()) {
|
||||||
|
throw executableRefusal(name, `path component is not a real directory: ${cursor}`);
|
||||||
|
}
|
||||||
|
if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) {
|
||||||
|
throw executableRefusal(name, `writable directory on the resolved path: ${cursor}`);
|
||||||
|
}
|
||||||
|
if (owner !== undefined && info.uid !== owner && info.uid !== 0) {
|
||||||
|
throw executableRefusal(
|
||||||
|
name,
|
||||||
|
`directory on the resolved path has a foreign owner: ${cursor}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parent = dirname(cursor);
|
||||||
|
if (parent === cursor) return;
|
||||||
|
cursor = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find one executable named `name`, searching only `searchPath`, and validate the object
|
||||||
|
* that search lands on.
|
||||||
|
*
|
||||||
|
* This exists because `which` answered a different question than the one the launcher
|
||||||
|
* needed. `which` reported that *something* by that name was reachable; the launcher then
|
||||||
|
* spawned the bare name and let the OS resolve it a second time, against an ambient PATH,
|
||||||
|
* at a later moment. Two independent resolutions of an attacker-influenced name, with a
|
||||||
|
* gap in between, is not a check -- a directory prepended to PATH satisfied the probe and
|
||||||
|
* then supplied the thing that actually ran. Resolving once here and executing the exact
|
||||||
|
* path returned is the whole point; callers must not go back to the name.
|
||||||
|
*
|
||||||
|
* Rules worth stating because each one is a hole if dropped:
|
||||||
|
*
|
||||||
|
* * A relative PATH entry is skipped. It resolves against the current directory, so
|
||||||
|
* what it names depends on where the launcher happened to be started.
|
||||||
|
* * The FIRST name match decides the outcome, and an unsafe first match is a refusal
|
||||||
|
* rather than a reason to keep looking. Falling through to a later entry would let a
|
||||||
|
* planted unsafe binary silently downgrade the search to whatever came after it,
|
||||||
|
* which inverts the precedence PATH is supposed to express.
|
||||||
|
* * A symlink is followed, and the real file it lands on is what gets validated and
|
||||||
|
* executed. Validating the link and executing the name would repeat the original bug
|
||||||
|
* one level down.
|
||||||
|
*/
|
||||||
|
export function resolveExecutableFromPath(
|
||||||
|
name: string,
|
||||||
|
searchPath: string | undefined,
|
||||||
|
): ResolvedExecutable {
|
||||||
|
if (name.includes('/')) {
|
||||||
|
throw executableRefusal(name, 'expected a bare command name, not a path');
|
||||||
|
}
|
||||||
|
if (searchPath === undefined || searchPath === '') {
|
||||||
|
throw executableRefusal(name, 'no PATH was declared for the launch');
|
||||||
|
}
|
||||||
|
const owner = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
||||||
|
|
||||||
|
for (const entry of searchPath.split(delimiter)) {
|
||||||
|
if (entry === '' || !isAbsolute(entry)) continue;
|
||||||
|
const candidate = join(entry, name);
|
||||||
|
if (!existsSync(candidate)) continue;
|
||||||
|
|
||||||
|
// First match wins, for good or ill. Everything below either returns or throws.
|
||||||
|
const real = realpathSync(candidate);
|
||||||
|
const info = lstatSync(real);
|
||||||
|
if (!info.isFile()) {
|
||||||
|
throw executableRefusal(name, `${real} is not a regular file`);
|
||||||
|
}
|
||||||
|
if ((info.mode & 0o111) === 0) {
|
||||||
|
throw executableRefusal(name, `${real} is not executable`);
|
||||||
|
}
|
||||||
|
if ((info.mode & 0o022) !== 0) {
|
||||||
|
throw executableRefusal(name, `${real} is writable by group or other`);
|
||||||
|
}
|
||||||
|
if (owner !== undefined && info.uid !== owner && info.uid !== 0) {
|
||||||
|
throw executableRefusal(name, `${real} is owned by neither the launching user nor root`);
|
||||||
|
}
|
||||||
|
assertSafeAncestry(real, name, owner);
|
||||||
|
return { path: real, dev: info.dev, ino: info.ino };
|
||||||
|
}
|
||||||
|
throw executableRefusal(name, `not found on the declared PATH`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-confirm, immediately before spawning, that the path still names the object that was
|
||||||
|
* validated.
|
||||||
|
*
|
||||||
|
* This narrows the window between validation and exec; it does not close it. Closing it
|
||||||
|
* would mean executing a held descriptor, and there is no portable way to exec by
|
||||||
|
* descriptor from Node. The residual is a same-UID replacement landing inside the
|
||||||
|
* remaining window, which is the same accepted boundary already documented for the fleet
|
||||||
|
* helper. Stated rather than engineered around, so nobody reads this as a proof.
|
||||||
|
*/
|
||||||
|
function assertUnchangedSinceValidation(executable: ResolvedExecutable, name: string): void {
|
||||||
|
const now = lstatSync(executable.path);
|
||||||
|
if (now.dev !== executable.dev || now.ino !== executable.ino) {
|
||||||
|
throw executableRefusal(name, `${executable.path} was replaced after it was validated`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function checkSoul(): void {
|
function checkSoul(): void {
|
||||||
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
||||||
if (!existsSync(soulPath)) {
|
if (!existsSync(soulPath)) {
|
||||||
@@ -262,9 +401,9 @@ interface SettingsAudit {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function auditClaudeSettings(): SettingsAudit {
|
function auditClaudeSettings(fleet?: FleetHarnessContext): SettingsAudit {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const settingsPath = join(harnessHome('claude'), 'settings.json');
|
const settingsPath = join(harnessHome('claude', fleet), 'settings.json');
|
||||||
const settings = readJson(settingsPath);
|
const settings = readJson(settingsPath);
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
@@ -332,13 +471,141 @@ function printSettingsWarnings(audit: SettingsAudit): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkSequentialThinking(runtime: string): void {
|
interface TrustedCapability {
|
||||||
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
readonly path: string;
|
||||||
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
readonly content: Buffer;
|
||||||
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
|
readonly dev: number | bigint;
|
||||||
|
readonly ino: number | bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fleet helper accepts capabilities only from root-owned /usr/bin. */
|
||||||
|
function trustedCapability(name: string): TrustedCapability {
|
||||||
|
const candidate = join('/usr/bin', name);
|
||||||
|
let path: string;
|
||||||
|
try {
|
||||||
|
path = realpathSync(candidate);
|
||||||
|
if (!path.startsWith('/usr/')) throw new Error('resolved outside /usr');
|
||||||
|
const snapshot = readRegularFileSecure(path, {
|
||||||
|
root: '/',
|
||||||
|
executable: true,
|
||||||
|
maxBytes: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
const info = lstatSync(path);
|
||||||
|
if ((info.mode & 0o022) !== 0 || info.uid !== 0)
|
||||||
|
throw new Error('unsafe capability owner or mode');
|
||||||
|
return { path, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new Error(
|
||||||
|
`required trusted fleet capability is unavailable: ${name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustedFleetHelper(mosaicHome: string): TrustedCapability {
|
||||||
|
const root = resolve(mosaicHome);
|
||||||
|
const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
||||||
|
try {
|
||||||
|
assertNoSymlinkAncestors(checker);
|
||||||
|
const owner = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
||||||
|
let cursor = root;
|
||||||
|
for (const component of relative(root, checker).split(sep).filter(Boolean)) {
|
||||||
|
const info = lstatSync(cursor);
|
||||||
|
if (
|
||||||
|
!info.isDirectory() ||
|
||||||
|
info.isSymbolicLink() ||
|
||||||
|
(info.mode & 0o022) !== 0 ||
|
||||||
|
(owner !== undefined && info.uid !== owner && info.uid !== 0)
|
||||||
|
) {
|
||||||
|
throw new Error('helper directory has unsafe type, owner, or permissions');
|
||||||
|
}
|
||||||
|
cursor = join(cursor, component);
|
||||||
|
}
|
||||||
|
const helperInfo = lstatSync(checker);
|
||||||
|
if (
|
||||||
|
!helperInfo.isFile() ||
|
||||||
|
helperInfo.isSymbolicLink() ||
|
||||||
|
(helperInfo.mode & 0o022) !== 0 ||
|
||||||
|
(helperInfo.mode & 0o111) === 0 ||
|
||||||
|
(owner !== undefined && helperInfo.uid !== owner && helperInfo.uid !== 0)
|
||||||
|
) {
|
||||||
|
throw new Error('helper has unsafe type, owner, or permissions');
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new Error(
|
||||||
|
`fleet sequential-thinking helper is not a trusted installed file under ${root}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const snapshot = readRegularFileSecure(checker, {
|
||||||
|
root,
|
||||||
|
executable: true,
|
||||||
|
maxBytes: 1024 * 1024,
|
||||||
|
});
|
||||||
|
return { path: checker, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarnessContext): void {
|
||||||
|
// Fleet launch must use the active --mosaic-home installation. Non-fleet
|
||||||
|
// launches retain the package/deployed helper resolver.
|
||||||
|
if (!fleet?.mosaicHome) {
|
||||||
|
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||||
|
if (!existsSync(checker)) return;
|
||||||
|
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
|
||||||
|
if (result.status !== 0) process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const helper = trustedFleetHelper(fleet.mosaicHome);
|
||||||
|
const bash = trustedCapability('bash');
|
||||||
|
const python = trustedCapability('python3');
|
||||||
|
const node = trustedCapability('node');
|
||||||
|
const npx = trustedCapability('npx');
|
||||||
|
const timeout = trustedCapability('timeout');
|
||||||
|
const fleetClaudeConfig =
|
||||||
|
runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined;
|
||||||
|
const fleetCodexHome = runtime === 'codex' && fleet ? harnessHome('codex', fleet) : undefined;
|
||||||
|
const fleetOpenCodeHome =
|
||||||
|
runtime === 'opencode' && fleet ? harnessHome('opencode', fleet) : undefined;
|
||||||
|
const result = spawnSync(
|
||||||
|
bash.path,
|
||||||
|
[
|
||||||
|
'-s',
|
||||||
|
'--',
|
||||||
|
'--check',
|
||||||
|
'--runtime',
|
||||||
|
runtime,
|
||||||
|
'--python-bin',
|
||||||
|
python.path,
|
||||||
|
'--node-bin',
|
||||||
|
node.path,
|
||||||
|
'--npx-bin',
|
||||||
|
npx.path,
|
||||||
|
'--timeout-bin',
|
||||||
|
timeout.path,
|
||||||
|
...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
input: helper.content,
|
||||||
|
stdio: ['pipe', 'ignore', 'ignore'],
|
||||||
|
env: {
|
||||||
|
HOME: fleetClaudeConfig ?? join(fleet.agentDir, '.mosaic-seq-home'),
|
||||||
|
LANG: 'C.UTF-8',
|
||||||
|
...(process.env['MOSAIC_SEQ_CHECK_WARM'] === undefined
|
||||||
|
? {}
|
||||||
|
: { MOSAIC_SEQ_CHECK_WARM: process.env['MOSAIC_SEQ_CHECK_WARM'] }),
|
||||||
|
...(process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] === undefined
|
||||||
|
? {}
|
||||||
|
: { MOSAIC_SEQ_WARM_TIMEOUT_SEC: process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] }),
|
||||||
|
...(fleetCodexHome === undefined ? {} : { CODEX_HOME: fleetCodexHome }),
|
||||||
|
...(fleetOpenCodeHome === undefined ? {} : { XDG_CONFIG_HOME: fleetOpenCodeHome }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
|
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
|
||||||
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`);
|
const repairArgs =
|
||||||
|
fleetClaudeConfig === undefined
|
||||||
|
? ''
|
||||||
|
: ` --claude-config-dir ${fleetClaudeConfig} --python-bin ${python.path} --node-bin ${node.path} --npx-bin ${npx.path} --timeout-bin ${timeout.path}`;
|
||||||
|
console.error(`[mosaic] Fix: ${helper.path} --runtime ${runtime}${repairArgs}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -483,7 +750,11 @@ function buildPrdBlock(): string {
|
|||||||
* `mosaicHome` is parameterized for testability; production callers use the
|
* `mosaicHome` is parameterized for testability; production callers use the
|
||||||
* module-level default.
|
* module-level default.
|
||||||
*/
|
*/
|
||||||
export function composeContract(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
|
export function composeContract(
|
||||||
|
runtime: RuntimeName,
|
||||||
|
mosaicHome: string = MOSAIC_HOME,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string {
|
||||||
const runtimeContractPaths: Record<RuntimeName, string> = {
|
const runtimeContractPaths: Record<RuntimeName, string> = {
|
||||||
claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
|
claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
|
||||||
codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
|
codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
|
||||||
@@ -540,13 +811,13 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
|
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
const fleetIdentity = resolveFleetIdentity(mosaicHome, env['MOSAIC_AGENT_NAME']);
|
||||||
if (!fleetIdentity.ok) {
|
if (!fleetIdentity.ok) {
|
||||||
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
|
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
|
||||||
}
|
}
|
||||||
const canonicalMember = fleetIdentity.identity?.member;
|
const canonicalMember = fleetIdentity.identity?.member;
|
||||||
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
|
if (canonicalMember && env['MOSAIC_AGENT_CLASS']?.trim()) {
|
||||||
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
|
const ambientClass = canonicalizeRoleClass(env['MOSAIC_AGENT_CLASS']).canonicalClass;
|
||||||
if (ambientClass !== canonicalMember.className) {
|
if (ambientClass !== canonicalMember.className) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
|
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
|
||||||
@@ -583,13 +854,13 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
// Fleet launches derive every identity projection from the one canonical roster
|
// Fleet launches derive every identity projection from the one canonical roster
|
||||||
// member resolved above. Non-fleet launches retain the legacy ambient persona
|
// member resolved above. Non-fleet launches retain the legacy ambient persona
|
||||||
// and tool-policy behavior.
|
// and tool-policy behavior.
|
||||||
const personaClass = canonicalMember?.className ?? process.env['MOSAIC_AGENT_CLASS'];
|
const personaClass = canonicalMember?.className ?? env['MOSAIC_AGENT_CLASS'];
|
||||||
const persona = readPersonaContractBlock(mosaicHome, personaClass);
|
const persona = readPersonaContractBlock(mosaicHome, personaClass);
|
||||||
if (persona) parts.push('\n\n' + persona);
|
if (persona) parts.push('\n\n' + persona);
|
||||||
|
|
||||||
const toolPolicyName = canonicalMember
|
const toolPolicyName = canonicalMember
|
||||||
? canonicalMember.toolPolicy
|
? canonicalMember.toolPolicy
|
||||||
: process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
: env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||||
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
|
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
|
||||||
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
||||||
|
|
||||||
@@ -613,8 +884,8 @@ function readFleetToolPolicyBlock(policy: string | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
||||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
function buildRuntimePrompt(runtime: RuntimeName, env: NodeJS.ProcessEnv = process.env): string {
|
||||||
return composeContract(runtime);
|
return composeContract(runtime, MOSAIC_HOME, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Session lock ────────────────────────────────────────────────────────────
|
// ─── Session lock ────────────────────────────────────────────────────────────
|
||||||
@@ -695,8 +966,12 @@ function checkResumableSession(): void {
|
|||||||
|
|
||||||
// ─── Write config for runtimes that read from fixed paths ────────────────────
|
// ─── Write config for runtimes that read from fixed paths ────────────────────
|
||||||
|
|
||||||
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
|
function ensureRuntimeConfig(
|
||||||
const prompt = buildRuntimePrompt(runtime);
|
runtime: RuntimeName,
|
||||||
|
destPath: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): void {
|
||||||
|
const prompt = buildRuntimePrompt(runtime, env);
|
||||||
mkdirSync(dirname(destPath), { recursive: true });
|
mkdirSync(dirname(destPath), { recursive: true });
|
||||||
const existing = readOptional(destPath);
|
const existing = readOptional(destPath);
|
||||||
if (existing !== prompt) {
|
if (existing !== prompt) {
|
||||||
@@ -889,34 +1164,156 @@ function getMissionPrompt(): string {
|
|||||||
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
|
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never {
|
interface RuntimeLaunchContext {
|
||||||
checkMosaicHome();
|
readonly mosaicHome?: string;
|
||||||
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
readonly fleet?: FleetHarnessContext;
|
||||||
checkSoul();
|
readonly declaredEnv?: Readonly<Record<string, string>>;
|
||||||
checkRuntime(runtime);
|
/** Test seam: bypass only final runtime binary discovery. */
|
||||||
|
readonly runtimeCheck?: (runtime: RuntimeName) => void;
|
||||||
|
/** Test seam: receives the fully composed final runtime invocation. */
|
||||||
|
readonly finalExecutor?: (runtime: RuntimeName, args: string[], env: NodeJS.ProcessEnv) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale for a composed launch.
|
||||||
|
*
|
||||||
|
* A seat that inherits the operator's locale behaves differently depending on who happened to
|
||||||
|
* start it: locale selects message language, collation, and number and date formatting, so the
|
||||||
|
* same runtime doing the same work emits different text. That is a reproducibility problem for
|
||||||
|
* the seat and a correctness problem for anything parsing what it prints.
|
||||||
|
*
|
||||||
|
* C.UTF-8 rather than C: both are unambiguous, but plain C is ASCII and would mangle non-ASCII
|
||||||
|
* output, so pinning it would trade one defect for another. A seat that genuinely needs a
|
||||||
|
* different locale declares LANG or LC_ALL in its profile, and the declared value wins.
|
||||||
|
*/
|
||||||
|
const COMPOSED_LAUNCH_LOCALE = 'C.UTF-8';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The environment a composed (fleet) launch hands its child.
|
||||||
|
*
|
||||||
|
* Built from an empty object rather than by subtracting from `process.env`, so the set of names
|
||||||
|
* that reach the child is a closed list that has to be edited deliberately. An allowlist fails
|
||||||
|
* safe as the operator's environment grows; a denylist silently passes every variable nobody
|
||||||
|
* thought of, which is where `BASH_ENV`, `PYTHONSTARTUP`, `NODE_OPTIONS` and `LD_PRELOAD` live --
|
||||||
|
* names that execute attacker-chosen code inside a process that was otherwise fully validated.
|
||||||
|
*
|
||||||
|
* Locale is fixed rather than inherited (above). Everything else here is inherited because the
|
||||||
|
* child needs the operator's actual value: PATH is resolved and validated separately before use,
|
||||||
|
* and HOME remains the operator's -- see the residual recorded in the AMD1213-D scratchpad.
|
||||||
|
*/
|
||||||
|
function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
LANG: COMPOSED_LAUNCH_LOCALE,
|
||||||
|
LC_ALL: COMPOSED_LAUNCH_LOCALE,
|
||||||
|
};
|
||||||
|
for (const name of [
|
||||||
|
'PATH',
|
||||||
|
'HOME',
|
||||||
|
'USER',
|
||||||
|
'LOGNAME',
|
||||||
|
'SHELL',
|
||||||
|
'TERM',
|
||||||
|
'COLORTERM',
|
||||||
|
'TMPDIR',
|
||||||
|
'XDG_RUNTIME_DIR',
|
||||||
|
]) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value !== undefined) env[name] = value;
|
||||||
|
}
|
||||||
|
return { ...env, ...declared };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PATH the launched child will actually receive.
|
||||||
|
*
|
||||||
|
* Resolution has to consult this exact value and not `process.env.PATH`. If the declared
|
||||||
|
* environment overrides PATH, validating against the launcher's own PATH would check one
|
||||||
|
* set of directories and hand the child a different set -- a check answering a question
|
||||||
|
* nobody asked.
|
||||||
|
*/
|
||||||
|
function launchSearchPath(
|
||||||
|
declared: Readonly<Record<string, string>> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
return declared?.['PATH'] ?? process.env['PATH'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchRuntime(
|
||||||
|
runtime: RuntimeName,
|
||||||
|
args: string[],
|
||||||
|
yolo: boolean,
|
||||||
|
context: RuntimeLaunchContext = {},
|
||||||
|
): never {
|
||||||
|
const mosaicHome = context.mosaicHome ?? MOSAIC_HOME;
|
||||||
|
if (context.mosaicHome === undefined) {
|
||||||
|
checkMosaicHome();
|
||||||
|
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
||||||
|
checkSoul();
|
||||||
|
} else {
|
||||||
|
if (!existsSync(mosaicHome)) throw new Error(`Mosaic home not found: ${mosaicHome}`);
|
||||||
|
checkFile(join(mosaicHome, 'AGENTS.md'), 'AGENTS.md');
|
||||||
|
if (!existsSync(join(mosaicHome, 'SOUL.md'))) {
|
||||||
|
throw new Error(`SOUL.md not found: ${mosaicHome}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A fleet launch resolves and validates the runtime binary here, once, and reuses that
|
||||||
|
// exact object below. `checkRuntime`'s ambient `which` stays on the operator path only:
|
||||||
|
// it proves reachability from the operator's own shell, which is the right question
|
||||||
|
// there and the wrong one for a seat. Kept in the same position in the sequence so a
|
||||||
|
// missing runtime still fails before the session lock is written.
|
||||||
|
let resolvedRuntime: ResolvedExecutable | undefined;
|
||||||
|
if (context.runtimeCheck) {
|
||||||
|
context.runtimeCheck(runtime);
|
||||||
|
} else if (context.fleet) {
|
||||||
|
try {
|
||||||
|
resolvedRuntime = resolveExecutableFromPath(runtime, launchSearchPath(context.declaredEnv));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(`[mosaic] ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checkRuntime(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
// Pi doesn't need sequential-thinking (has native thinking levels)
|
// Pi doesn't need sequential-thinking (has native thinking levels)
|
||||||
if (runtime !== 'pi') {
|
if (runtime !== 'pi') {
|
||||||
checkSequentialThinking(runtime);
|
checkSequentialThinking(runtime, context.fleet);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkResumableSession();
|
checkResumableSession();
|
||||||
|
|
||||||
const missionPrompt = getMissionPrompt();
|
const missionPrompt = context.mosaicHome === undefined ? getMissionPrompt() : '';
|
||||||
const hasMissionNoArgs = missionPrompt && args.length === 0;
|
const hasMissionNoArgs = missionPrompt && args.length === 0;
|
||||||
const label = RUNTIME_LABELS[runtime];
|
const label = RUNTIME_LABELS[runtime];
|
||||||
const modeStr = yolo ? ' in YOLO mode' : '';
|
const modeStr = yolo ? ' in YOLO mode' : '';
|
||||||
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
|
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
|
||||||
|
|
||||||
writeSessionLock(runtime);
|
writeSessionLock(runtime);
|
||||||
|
const launchEnv =
|
||||||
|
context.declaredEnv === undefined ? process.env : minimalLaunchEnv(context.declaredEnv);
|
||||||
|
// A per-agent profile is the launch SSOT and intentionally does not require a
|
||||||
|
// second roster registry. Keep roster-v1 identity composition for legacy
|
||||||
|
// launches, but remove its identity keys from the contract-build environment
|
||||||
|
// for a profile-backed seat. The declared identity is still exported to the
|
||||||
|
// harness process below.
|
||||||
|
const contractEnv =
|
||||||
|
context.declaredEnv === undefined
|
||||||
|
? launchEnv
|
||||||
|
: Object.fromEntries(
|
||||||
|
Object.entries(launchEnv).filter(
|
||||||
|
([name]) =>
|
||||||
|
name !== 'MOSAIC_AGENT_NAME' &&
|
||||||
|
name !== 'MOSAIC_AGENT_CLASS' &&
|
||||||
|
name !== 'MOSAIC_AGENT_TOOL_POLICY',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
switch (runtime) {
|
switch (runtime) {
|
||||||
case 'claude': {
|
case 'claude': {
|
||||||
// Audit Claude Code settings and warn about missing hooks/plugins
|
// Audit Claude Code settings and warn about missing hooks/plugins
|
||||||
const settingsAudit = auditClaudeSettings();
|
const settingsAudit = auditClaudeSettings(context.fleet);
|
||||||
printSettingsWarnings(settingsAudit);
|
printSettingsWarnings(settingsAudit);
|
||||||
|
|
||||||
const prompt = buildRuntimePrompt('claude');
|
const prompt = composeContract('claude', mosaicHome, contractEnv);
|
||||||
const cliArgs: string[] = [];
|
const cliArgs: string[] = [];
|
||||||
cliArgs.push('--append-system-prompt', prompt);
|
cliArgs.push('--append-system-prompt', prompt);
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
@@ -925,13 +1322,24 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('claude', cliArgs, yolo);
|
recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
|
||||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
if (process.env['MOSAIC_LAUNCH_ID']) {
|
||||||
|
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
|
||||||
|
}
|
||||||
|
if (context.finalExecutor) {
|
||||||
|
context.finalExecutor('claude', cliArgs, launchEnv);
|
||||||
|
} else {
|
||||||
|
execLeaseGatedRuntime('claude', cliArgs, launchEnv, yolo, context.fleet);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'codex': {
|
case 'codex': {
|
||||||
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md'));
|
ensureRuntimeConfig(
|
||||||
|
'codex',
|
||||||
|
join(harnessHome('codex', context.fleet), 'instructions.md'),
|
||||||
|
contractEnv,
|
||||||
|
);
|
||||||
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
cliArgs.push(missionPrompt);
|
cliArgs.push(missionPrompt);
|
||||||
@@ -939,22 +1347,38 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('codex', cliArgs, yolo);
|
recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv);
|
||||||
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
execRuntime(resolvedRuntime ?? 'codex', cliArgs, {
|
||||||
|
...launchEnv,
|
||||||
|
...harnessEnv('codex', context.fleet),
|
||||||
|
...(process.env['MOSAIC_LAUNCH_ID']
|
||||||
|
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'opencode': {
|
case 'opencode': {
|
||||||
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
||||||
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
ensureRuntimeConfig(
|
||||||
|
'opencode',
|
||||||
|
join(harnessHome('opencode', context.fleet), 'opencode', 'AGENTS.md'),
|
||||||
|
contractEnv,
|
||||||
|
);
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||||
recordLaunch('opencode', args, yolo);
|
recordLaunch('opencode', args, yolo, context.fleet, launchEnv);
|
||||||
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
execRuntime(resolvedRuntime ?? 'opencode', args, {
|
||||||
|
...launchEnv,
|
||||||
|
...harnessEnv('opencode', context.fleet),
|
||||||
|
...(process.env['MOSAIC_LAUNCH_ID']
|
||||||
|
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'pi': {
|
case 'pi': {
|
||||||
const prompt = buildRuntimePrompt('pi');
|
const prompt = composeContract('pi', mosaicHome, contractEnv);
|
||||||
const cliArgs = ['--append-system-prompt', prompt];
|
const cliArgs = ['--append-system-prompt', prompt];
|
||||||
cliArgs.push(...buildPiSkillArgs(args));
|
cliArgs.push(...buildPiSkillArgs(args));
|
||||||
cliArgs.push(...discoverPiExtension());
|
cliArgs.push(...discoverPiExtension());
|
||||||
@@ -964,8 +1388,11 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('pi', cliArgs, yolo);
|
recordLaunch('pi', cliArgs, yolo, context.fleet, launchEnv);
|
||||||
execLeaseGatedRuntime('pi', cliArgs);
|
if (process.env['MOSAIC_LAUNCH_ID']) {
|
||||||
|
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
|
||||||
|
}
|
||||||
|
execLeaseGatedRuntime('pi', cliArgs, launchEnv, false, context.fleet);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -993,32 +1420,71 @@ function execLeaseGatedRuntime(
|
|||||||
args: string[],
|
args: string[],
|
||||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||||
dangerous = false,
|
dangerous = false,
|
||||||
|
fleet?: FleetHarnessContext,
|
||||||
): void {
|
): void {
|
||||||
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
|
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
|
||||||
const dangerousArgs = dangerous ? ['--dangerous'] : [];
|
const dangerousArgs = dangerous ? ['--dangerous'] : [];
|
||||||
|
// The interpreter that starts the lease gate must not itself come off an ambient PATH:
|
||||||
|
// a shim here does not bypass one check, it replaces the process that enforces all of
|
||||||
|
// them. On the fleet path take the same root-owned capability the helper already
|
||||||
|
// requires. The operator path keeps name resolution, as it does everywhere else.
|
||||||
|
const interpreter = fleet ? trustedCapability('python3') : 'python3';
|
||||||
execRuntime(
|
execRuntime(
|
||||||
'python3',
|
interpreter,
|
||||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||||
{
|
{
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
...harnessEnv(runtime),
|
...harnessEnv(runtime, fleet),
|
||||||
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||||
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** exec into the runtime, replacing the current process. */
|
/** Fleet entry point reusing the normative runtime launch and exec path. */
|
||||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
export function launchFleetRuntime(
|
||||||
|
runtime: RuntimeName,
|
||||||
|
args: string[],
|
||||||
|
declaredEnv: Readonly<Record<string, string>>,
|
||||||
|
fleet: FleetHarnessContext,
|
||||||
|
): never {
|
||||||
|
return launchRuntime(runtime, args, false, {
|
||||||
|
mosaicHome: fleet.mosaicHome,
|
||||||
|
fleet,
|
||||||
|
declaredEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* exec into the runtime, replacing the current process.
|
||||||
|
*
|
||||||
|
* `cmd` is either a bare name -- the operator path, where the OS resolves it against the
|
||||||
|
* caller's own PATH -- or an already-resolved executable, which is what every fleet
|
||||||
|
* launch passes. In the resolved case the exact validated path is spawned and its
|
||||||
|
* identity is re-confirmed first, so the thing that was checked is the thing that runs.
|
||||||
|
*/
|
||||||
|
function execRuntime(
|
||||||
|
cmd: string | ResolvedExecutable,
|
||||||
|
args: string[],
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): void {
|
||||||
|
const label = typeof cmd === 'string' ? cmd : cmd.path;
|
||||||
try {
|
try {
|
||||||
|
let target: string;
|
||||||
|
if (typeof cmd === 'string') {
|
||||||
|
target = cmd;
|
||||||
|
} else {
|
||||||
|
assertUnchangedSinceValidation(cmd, cmd.path);
|
||||||
|
target = cmd.path;
|
||||||
|
}
|
||||||
// Use execFileSync with inherited stdio to replace the process
|
// Use execFileSync with inherited stdio to replace the process
|
||||||
const result = spawnSync(cmd, args, {
|
const result = spawnSync(target, args, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
process.exit(result.status ?? 0);
|
process.exit(result.status ?? 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
|
console.error(`[mosaic] Failed to launch ${label}:`, err instanceof Error ? err.message : err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export type FleetAgentHarness = 'claude' | 'pi';
|
||||||
|
|
||||||
|
export interface FleetAgentScaffoldOptions {
|
||||||
|
readonly dataHome?: string;
|
||||||
|
/** Active installed Mosaic root; supplies the canonical authored runtime base. */
|
||||||
|
readonly mosaicHome?: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly harness?: string;
|
||||||
|
readonly bundle?: string;
|
||||||
|
readonly model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetAgentScaffoldResult {
|
||||||
|
readonly agentDir: string;
|
||||||
|
readonly profile: Readonly<Record<string, unknown>>;
|
||||||
|
readonly idempotent: boolean;
|
||||||
|
readonly credentialTarget: string;
|
||||||
|
readonly credentialTargetExists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetAgentScaffoldError extends Error {
|
||||||
|
readonly code: 'invalid-request' | 'agent-exists-different';
|
||||||
|
|
||||||
|
constructor(code: FleetAgentScaffoldError['code'], message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FleetAgentScaffoldError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-owned data root, deliberately distinct from the update-owned mosaic home. */
|
||||||
|
export function defaultFleetDataHome(): string {
|
||||||
|
return process.env['MOSAIC_DATA_HOME'] ?? join(homedir(), '.mosaic');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialize one fleet seat from authored, deterministic template content.
|
||||||
|
* Settings composition intentionally does not happen here: launch owns the
|
||||||
|
* three-layer settings merge and writes the generated settings.json then.
|
||||||
|
*/
|
||||||
|
export async function scaffoldFleetAgent(
|
||||||
|
options: FleetAgentScaffoldOptions,
|
||||||
|
): Promise<FleetAgentScaffoldResult> {
|
||||||
|
const name = requireSafeName(options.name);
|
||||||
|
const harness = requireHarness(options.harness ?? 'claude');
|
||||||
|
const bundle = requireBundle(options.bundle ?? 'primary');
|
||||||
|
const model = optionalNonEmpty(options.model, '--model');
|
||||||
|
const dataHome = resolve(options.dataHome ?? defaultFleetDataHome());
|
||||||
|
const mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic'));
|
||||||
|
const agentDir = join(dataHome, 'fleet', 'agents', name);
|
||||||
|
const homeName = harness === 'claude' ? '.claude' : '.pi';
|
||||||
|
const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json';
|
||||||
|
const credentialTarget = join(dataHome, 'auth', harness, bundle, credentialName);
|
||||||
|
const profile: Record<string, unknown> = {
|
||||||
|
schema: 1,
|
||||||
|
harness,
|
||||||
|
bundle,
|
||||||
|
overlay: 'overlay.json',
|
||||||
|
...(model === undefined ? {} : { model }),
|
||||||
|
env: { MOSAIC_AGENT_NAME: name },
|
||||||
|
};
|
||||||
|
const credentialLink = join(agentDir, homeName, credentialName);
|
||||||
|
const entries: [string, ExpectedFile][] = [
|
||||||
|
['profile.json', { type: 'file', content: json(profile) }],
|
||||||
|
['SOUL.md', { type: 'file', content: soul(name) }],
|
||||||
|
['overlay.json', { type: 'file', content: '{}\n' }],
|
||||||
|
[
|
||||||
|
join(homeName, harness === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'),
|
||||||
|
{ type: 'file', content: identityBootstrap(name) },
|
||||||
|
],
|
||||||
|
[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }],
|
||||||
|
[
|
||||||
|
join(homeName, '.mosaic-managed-links.json'),
|
||||||
|
{ type: 'file', content: json({ links: { [credentialLink]: credentialTarget } }) },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
if (harness === 'claude') {
|
||||||
|
entries.push([
|
||||||
|
join(homeName, '.claude.json'),
|
||||||
|
{ type: 'file', content: json(onboardingState(mosaicHome)) },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const files = new Map<string, ExpectedFile>(entries);
|
||||||
|
|
||||||
|
const differences = await findDifferences(agentDir, files);
|
||||||
|
if (differences.length > 0) {
|
||||||
|
throw new FleetAgentScaffoldError(
|
||||||
|
'agent-exists-different',
|
||||||
|
`Agent "${name}" already exists with different user-owned file(s): ${differences.join(', ')}. Refusing to overwrite.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotent = await pathExists(agentDir);
|
||||||
|
if (!idempotent) {
|
||||||
|
for (const [file, expected] of files) {
|
||||||
|
const path = join(agentDir, file);
|
||||||
|
await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
|
||||||
|
if (expected.type === 'file') {
|
||||||
|
await writeFile(path, expected.content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||||
|
} else {
|
||||||
|
// A dangling link is intentional before enrollment. It makes absent auth
|
||||||
|
// visible at launch instead of silently selecting another account.
|
||||||
|
await symlink(expected.target, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentDir,
|
||||||
|
profile,
|
||||||
|
idempotent,
|
||||||
|
credentialTarget,
|
||||||
|
credentialTargetExists: await pathExists(credentialTarget),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpectedFile =
|
||||||
|
| { readonly type: 'file'; readonly content: string }
|
||||||
|
| { readonly type: 'symlink'; readonly target: string };
|
||||||
|
|
||||||
|
async function findDifferences(
|
||||||
|
agentDir: string,
|
||||||
|
expected: ReadonlyMap<string, ExpectedFile>,
|
||||||
|
): Promise<string[]> {
|
||||||
|
let root;
|
||||||
|
try {
|
||||||
|
root = await lstat(agentDir);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isMissing(error)) return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!root.isDirectory() || root.isSymbolicLink()) return ['.'];
|
||||||
|
|
||||||
|
const actual = await listRelativeEntries(agentDir);
|
||||||
|
const expectedDirs = new Set<string>();
|
||||||
|
for (const path of expected.keys()) {
|
||||||
|
const parent = relative('.', join(path, '..'));
|
||||||
|
if (parent !== '') expectedDirs.add(parent);
|
||||||
|
}
|
||||||
|
const paths = new Set([
|
||||||
|
...expected.keys(),
|
||||||
|
...actual.filter((path: string): boolean => !expectedDirs.has(path)),
|
||||||
|
]);
|
||||||
|
const differences: string[] = [];
|
||||||
|
for (const path of [...paths].sort()) {
|
||||||
|
const required = expected.get(path);
|
||||||
|
if (!required) {
|
||||||
|
differences.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const info = await lstat(join(agentDir, path));
|
||||||
|
if (required.type === 'file') {
|
||||||
|
if (
|
||||||
|
!info.isFile() ||
|
||||||
|
info.isSymbolicLink() ||
|
||||||
|
(await readFile(join(agentDir, path), 'utf8')) !== required.content
|
||||||
|
) {
|
||||||
|
differences.push(path);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
!info.isSymbolicLink() ||
|
||||||
|
(await readlink(join(agentDir, path))) !== required.target
|
||||||
|
) {
|
||||||
|
differences.push(path);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isMissing(error)) differences.push(path);
|
||||||
|
else throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return differences;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRelativeEntries(root: string, prefix = ''): Promise<string[]> {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
|
||||||
|
const path = join(prefix, entry.name);
|
||||||
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||||
|
result.push(path, ...(await listRelativeEntries(root, path)));
|
||||||
|
} else {
|
||||||
|
result.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSafeName(value: string): string {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
value.length === 0 ||
|
||||||
|
value === '.' ||
|
||||||
|
value === '..' ||
|
||||||
|
value.includes('/') ||
|
||||||
|
value.includes('\\') ||
|
||||||
|
value.includes('\0') ||
|
||||||
|
isAbsolute(value)
|
||||||
|
) {
|
||||||
|
throw new FleetAgentScaffoldError(
|
||||||
|
'invalid-request',
|
||||||
|
'Agent name must be one non-empty path component (not absolute or traversal).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireHarness(value: string): FleetAgentHarness {
|
||||||
|
if (value === 'claude' || value === 'pi') return value;
|
||||||
|
throw new FleetAgentScaffoldError('invalid-request', '--harness must be claude or pi.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBundle(value: string): string {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
value.length === 0 ||
|
||||||
|
value === '.' ||
|
||||||
|
value === '..' ||
|
||||||
|
value.includes('/') ||
|
||||||
|
value.includes('\\') ||
|
||||||
|
value.includes('\0') ||
|
||||||
|
isAbsolute(value)
|
||||||
|
) {
|
||||||
|
throw new FleetAgentScaffoldError(
|
||||||
|
'invalid-request',
|
||||||
|
'--bundle must be one non-empty auth-bundle path component.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNonEmpty(value: string | undefined, option: string): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value.length === 0 || value.includes('\0')) {
|
||||||
|
throw new FleetAgentScaffoldError('invalid-request', `${option} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onboardingState(mosaicHome: string): Record<string, unknown> {
|
||||||
|
const settingsPath = join(mosaicHome, 'runtime', 'claude', 'settings.json');
|
||||||
|
let authored: unknown;
|
||||||
|
try {
|
||||||
|
authored = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new FleetAgentScaffoldError(
|
||||||
|
'invalid-request',
|
||||||
|
`canonical Claude settings are unavailable or invalid at ${settingsPath}: ${detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof authored !== 'object' ||
|
||||||
|
authored === null ||
|
||||||
|
Array.isArray(authored) ||
|
||||||
|
!('mcpServers' in authored) ||
|
||||||
|
typeof authored.mcpServers !== 'object' ||
|
||||||
|
authored.mcpServers === null ||
|
||||||
|
Array.isArray(authored.mcpServers)
|
||||||
|
) {
|
||||||
|
throw new FleetAgentScaffoldError(
|
||||||
|
'invalid-request',
|
||||||
|
`canonical Claude settings lack an mcpServers object: ${settingsPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: authored.mcpServers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function soul(name: string): string {
|
||||||
|
return `# SOUL\n\n## Identity\n\nYou are ${name}, a Mosaic fleet agent seat.\n\nRole: _Describe this seat's role._\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity is materialized by value so restricted harness modes never need to read SOUL.md. */
|
||||||
|
function identityBootstrap(name: string): string {
|
||||||
|
return `# Mosaic Fleet Agent Identity\n\nYou are ${name}, a Mosaic fleet agent seat.\n\nYour mechanical identity is ${name} (MOSAIC_AGENT_NAME). Keep this identity when working in repositories with other personas.\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(value: unknown): string {
|
||||||
|
return `${JSON.stringify(value, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await lstat(path);
|
||||||
|
return true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isMissing(error)) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissing(error: unknown): boolean {
|
||||||
|
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guardrail kept explicit for callers that construct paths from untrusted text. */
|
||||||
|
export function isContainedInFleetDataHome(dataHome: string, path: string): boolean {
|
||||||
|
const rel = relative(resolve(dataHome), resolve(path));
|
||||||
|
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ COMPLETE_PATH = TOOLS / "promote-complete.py"
|
|||||||
OBSERVER_CLIENT_PATH = TOOLS / "receipt-observer-client.py"
|
OBSERVER_CLIENT_PATH = TOOLS / "receipt-observer-client.py"
|
||||||
RECEIPT_CHALLENGE_PATH = TOOLS / "receipt_challenge.py"
|
RECEIPT_CHALLENGE_PATH = TOOLS / "receipt_challenge.py"
|
||||||
CLAUDE_SETTINGS = FRAMEWORK / "runtime/claude/settings.json"
|
CLAUDE_SETTINGS = FRAMEWORK / "runtime/claude/settings.json"
|
||||||
|
CLAUDE_LEASE_OVERLAY = FRAMEWORK / "runtime/claude/lease-overlay.json"
|
||||||
CLAUDE_COMMAND = FRAMEWORK / "runtime/claude/commands/mosaic-promote.md"
|
CLAUDE_COMMAND = FRAMEWORK / "runtime/claude/commands/mosaic-promote.md"
|
||||||
SESSION_ID = "a" * 64
|
SESSION_ID = "a" * 64
|
||||||
CHALLENGE = "b" * 64
|
CHALLENGE = "b" * 64
|
||||||
@@ -569,8 +570,15 @@ class PromotionCompleteTest(PromotionHookFixture):
|
|||||||
|
|
||||||
class PromotionTemplateWiringTest(unittest.TestCase):
|
class PromotionTemplateWiringTest(unittest.TestCase):
|
||||||
def test_gated_claude_template_wires_begin_and_ordered_stop_chain(self) -> None:
|
def test_gated_claude_template_wires_begin_and_ordered_stop_chain(self) -> None:
|
||||||
settings = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8"))
|
base = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8"))
|
||||||
hooks = settings["hooks"]
|
overlay = json.loads(CLAUDE_LEASE_OVERLAY.read_text(encoding="utf-8"))
|
||||||
|
# A launched seat composes base + lease overlay; hook event arrays
|
||||||
|
# concatenate with base entries first, so wiring is asserted against
|
||||||
|
# the composed view rather than either file alone.
|
||||||
|
hooks: dict[str, list] = {}
|
||||||
|
for layer in (base["hooks"], overlay["hooks"]):
|
||||||
|
for event, groups in layer.items():
|
||||||
|
hooks.setdefault(event, []).extend(groups)
|
||||||
submit_commands = [
|
submit_commands = [
|
||||||
hook["command"]
|
hook["command"]
|
||||||
for group in hooks["UserPromptSubmit"]
|
for group in hooks["UserPromptSubmit"]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|||||||
import { afterEach, describe, expect, test } from 'vitest';
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
|
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
|
||||||
|
import { deepMergeSettings } from '../commands/fleet-launch-command.js';
|
||||||
import {
|
import {
|
||||||
observeAndPromoteReceiptChallenge,
|
observeAndPromoteReceiptChallenge,
|
||||||
requestBrokerReply,
|
requestBrokerReply,
|
||||||
@@ -42,6 +43,19 @@ const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py')
|
|||||||
const revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py');
|
const revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py');
|
||||||
const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md');
|
const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md');
|
||||||
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
|
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
|
||||||
|
const claudeLeaseOverlayPath = join(frameworkRoot, 'runtime/claude/lease-overlay.json');
|
||||||
|
|
||||||
|
// The gated seat contract is the COMPOSITION of the ungated base and the
|
||||||
|
// lease overlay (gap-7 split); assertions about lease wiring must read that
|
||||||
|
// composed view, produced by the same merge the launcher uses.
|
||||||
|
async function readGatedClaudeSettings(): Promise<Record<string, unknown>> {
|
||||||
|
const base = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as Record<string, unknown>;
|
||||||
|
const overlay = JSON.parse(await readFile(claudeLeaseOverlayPath, 'utf8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
return deepMergeSettings(base, overlay);
|
||||||
|
}
|
||||||
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
|
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
|
||||||
const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts');
|
const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts');
|
||||||
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
|
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
|
||||||
@@ -364,7 +378,7 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
expect(parserResult.status).toBe(0);
|
expect(parserResult.status).toBe(0);
|
||||||
expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 });
|
expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 });
|
||||||
|
|
||||||
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
const settings = (await readGatedClaudeSettings()) as unknown as {
|
||||||
hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> };
|
hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> };
|
||||||
};
|
};
|
||||||
const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*');
|
const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*');
|
||||||
@@ -660,7 +674,7 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Claude and Pi compaction observer wiring is complete and fail-closed', async () => {
|
test('Claude and Pi compaction observer wiring is complete and fail-closed', async () => {
|
||||||
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
const settings = (await readGatedClaudeSettings()) as unknown as {
|
||||||
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
|
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
|
||||||
};
|
};
|
||||||
expect(
|
expect(
|
||||||
@@ -956,7 +970,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_prese
|
|||||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||||
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0);
|
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0);
|
||||||
|
|
||||||
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
const settings = (await readGatedClaudeSettings()) as unknown as {
|
||||||
hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> };
|
hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> };
|
||||||
};
|
};
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||||
|
type JsonObject = { [key: string]: Json };
|
||||||
|
|
||||||
|
const frameworkRoot = fileURLToPath(new URL('../../framework/', import.meta.url));
|
||||||
|
const basePath = `${frameworkRoot}runtime/claude/settings.json`;
|
||||||
|
const overlayPath = `${frameworkRoot}runtime/claude/lease-overlay.json`;
|
||||||
|
const gatedFixturePath = fileURLToPath(
|
||||||
|
new URL('./fixtures/claude-settings.gated.pre-split.json', import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
function readJson(path: string): JsonObject {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf8')) as JsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is JsonObject {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production composition uses universal last-layer-wins array replacement. The
|
||||||
|
// lease overlay therefore carries complete affected event arrays, including the
|
||||||
|
// two QA carry-forward entries needed to avoid dropping non-lease hooks.
|
||||||
|
function deepMerge(base: Json, overlay: Json): Json {
|
||||||
|
if (Array.isArray(base) && Array.isArray(overlay)) return overlay;
|
||||||
|
if (isObject(base) && isObject(overlay)) {
|
||||||
|
const merged: JsonObject = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(overlay)) {
|
||||||
|
merged[key] = key in merged ? deepMerge(merged[key]!, value) : value;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(value: Json): Json {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map(normalize)
|
||||||
|
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
||||||
|
}
|
||||||
|
if (isObject(value)) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, nested]) => [key, normalize(nested)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hookCommands(settings: JsonObject): string[] {
|
||||||
|
const hooks = settings['hooks'];
|
||||||
|
if (!isObject(hooks)) return [];
|
||||||
|
|
||||||
|
return Object.values(hooks).flatMap((event) => {
|
||||||
|
if (!Array.isArray(event)) return [];
|
||||||
|
return event.flatMap((entry) => {
|
||||||
|
if (!isObject(entry) || !Array.isArray(entry['hooks'])) return [];
|
||||||
|
return entry['hooks'].flatMap((hook) =>
|
||||||
|
isObject(hook) && typeof hook['command'] === 'string' ? [hook['command']] : [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequentialThinking: JsonObject = {
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('canonical Claude base and lease-promotion overlay', () => {
|
||||||
|
it('keeps every lease command out of the ungated base', () => {
|
||||||
|
const base = readJson(basePath);
|
||||||
|
const commands = hookCommands(base);
|
||||||
|
|
||||||
|
for (const marker of ['mutator-gate', 'receipt-observer', 'promote-', 'revoke-lease']) {
|
||||||
|
expect(
|
||||||
|
commands.some((command) => command.includes(marker)),
|
||||||
|
marker,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconstructs the pre-split gated hooks while retaining the canonical MCP correction', () => {
|
||||||
|
const base = readJson(basePath);
|
||||||
|
const overlay = readJson(overlayPath);
|
||||||
|
const preSplit = readJson(gatedFixturePath);
|
||||||
|
const expected: JsonObject = {
|
||||||
|
...preSplit,
|
||||||
|
hooks: {
|
||||||
|
...(preSplit['hooks'] as JsonObject),
|
||||||
|
Stop: [
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh',
|
||||||
|
timeout: 15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command:
|
||||||
|
'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status',
|
||||||
|
timeout: 15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
mcpServers: { 'sequential-thinking': sequentialThinking },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(normalize(deepMerge(base, overlay))).toEqual(normalize(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ships sequential-thinking in the base', () => {
|
||||||
|
const base = readJson(basePath);
|
||||||
|
expect(base['mcpServers']).toEqual({ 'sequential-thinking': sequentialThinking });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries six lease commands plus exactly two deliberate QA carry-forward commands', () => {
|
||||||
|
const overlay = readJson(overlayPath);
|
||||||
|
expect(Object.keys(overlay)).toEqual(['hooks']);
|
||||||
|
|
||||||
|
const commands = hookCommands(overlay);
|
||||||
|
const lease = commands.filter((command) =>
|
||||||
|
/mutator-gate|receipt-observer|promote-|revoke-lease/.test(command),
|
||||||
|
);
|
||||||
|
const qa = commands.filter((command) => /prevent-memory-write|reflect-stop/.test(command));
|
||||||
|
expect(lease).toHaveLength(6);
|
||||||
|
expect(qa).toHaveLength(2);
|
||||||
|
expect(commands).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['prevent-memory-write', 'reflect-stop'])(
|
||||||
|
'fails lossless reconstruction if QA carry-forward %s is removed',
|
||||||
|
(marker) => {
|
||||||
|
const base = readJson(basePath);
|
||||||
|
const overlay = readJson(overlayPath);
|
||||||
|
const expected = {
|
||||||
|
...readJson(gatedFixturePath),
|
||||||
|
mcpServers: { 'sequential-thinking': sequentialThinking },
|
||||||
|
};
|
||||||
|
const hooks = overlay['hooks'] as JsonObject;
|
||||||
|
const mutated: JsonObject = {
|
||||||
|
hooks: Object.fromEntries(
|
||||||
|
Object.entries(hooks).map(([event, entries]) => [
|
||||||
|
event,
|
||||||
|
Array.isArray(entries)
|
||||||
|
? entries.filter((entry) => !JSON.stringify(entry).includes(marker))
|
||||||
|
: entries,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
expect(normalize(deepMerge(base, mutated))).not.toEqual(normalize(expected));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
{
|
||||||
|
"model": "opus",
|
||||||
|
"hooks": {
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "resume|clear",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"matcher": "^/mosaic-promote$",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||||
|
"timeout": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|MultiEdit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/qa/qa-hook-stdin.sh",
|
||||||
|
"timeout": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Edit|MultiEdit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/qa/typecheck-hook.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||||
|
"timeout": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"enabledPlugins": {
|
||||||
|
"frontend-design@claude-plugins-official": true,
|
||||||
|
"feature-dev@claude-plugins-official": true,
|
||||||
|
"code-review@claude-plugins-official": true,
|
||||||
|
"pr-review-toolkit@claude-plugins-official": true
|
||||||
|
},
|
||||||
|
"skipDangerousModePermissionPrompt": true,
|
||||||
|
"allowedCommands": [
|
||||||
|
"npm",
|
||||||
|
"npm install",
|
||||||
|
"npm run",
|
||||||
|
"npm test",
|
||||||
|
"npm build",
|
||||||
|
"npm start",
|
||||||
|
"npm run dev",
|
||||||
|
"npm run build",
|
||||||
|
"npm run lint",
|
||||||
|
"npm run typecheck",
|
||||||
|
"npm run test:ci",
|
||||||
|
"npm run test:e2e",
|
||||||
|
"npm run test:unit",
|
||||||
|
"npm run test:integration",
|
||||||
|
"npm run test:cov",
|
||||||
|
"npm run test:security",
|
||||||
|
"npm run security:scan",
|
||||||
|
"npm run security:audit",
|
||||||
|
"npm run performance:benchmark",
|
||||||
|
"npm run build:dev",
|
||||||
|
"npm run build:prod",
|
||||||
|
"npm run test",
|
||||||
|
"npm run test:watch",
|
||||||
|
"npm run migrate",
|
||||||
|
"npm run migrate:rollback",
|
||||||
|
"npm run db:seed",
|
||||||
|
"npm run db:reset",
|
||||||
|
"node",
|
||||||
|
"yarn",
|
||||||
|
"pnpm",
|
||||||
|
"npx",
|
||||||
|
"npx tsc",
|
||||||
|
"npx eslint",
|
||||||
|
"npx prettier",
|
||||||
|
"npx jest",
|
||||||
|
"npx vitest",
|
||||||
|
"git",
|
||||||
|
"git add",
|
||||||
|
"git commit",
|
||||||
|
"git push",
|
||||||
|
"git pull",
|
||||||
|
"git status",
|
||||||
|
"git diff",
|
||||||
|
"git log",
|
||||||
|
"git branch",
|
||||||
|
"git checkout",
|
||||||
|
"git merge",
|
||||||
|
"git init",
|
||||||
|
"git remote",
|
||||||
|
"git fetch",
|
||||||
|
"git reset",
|
||||||
|
"git rebase",
|
||||||
|
"git stash",
|
||||||
|
"git tag",
|
||||||
|
"git show",
|
||||||
|
"git config",
|
||||||
|
"gh",
|
||||||
|
"gh issue",
|
||||||
|
"gh pr",
|
||||||
|
"gh repo",
|
||||||
|
"gh api",
|
||||||
|
"docker",
|
||||||
|
"docker build",
|
||||||
|
"docker run",
|
||||||
|
"docker ps",
|
||||||
|
"docker logs",
|
||||||
|
"docker exec",
|
||||||
|
"docker stop",
|
||||||
|
"docker start",
|
||||||
|
"docker pull",
|
||||||
|
"docker push",
|
||||||
|
"docker-compose",
|
||||||
|
"docker-compose up",
|
||||||
|
"docker-compose down",
|
||||||
|
"docker-compose build",
|
||||||
|
"docker-compose logs",
|
||||||
|
"docker-compose ps",
|
||||||
|
"docker-compose exec",
|
||||||
|
"kubectl",
|
||||||
|
"kubectl get",
|
||||||
|
"kubectl describe",
|
||||||
|
"kubectl logs",
|
||||||
|
"kubectl apply",
|
||||||
|
"kubectl delete",
|
||||||
|
"kubectl port-forward",
|
||||||
|
"mkdir",
|
||||||
|
"touch",
|
||||||
|
"chmod",
|
||||||
|
"chown",
|
||||||
|
"ls",
|
||||||
|
"cd",
|
||||||
|
"pwd",
|
||||||
|
"cp",
|
||||||
|
"mv",
|
||||||
|
"rm",
|
||||||
|
"cat",
|
||||||
|
"echo",
|
||||||
|
"head",
|
||||||
|
"tail",
|
||||||
|
"grep",
|
||||||
|
"grep -E",
|
||||||
|
"grep -r",
|
||||||
|
"find",
|
||||||
|
"find -name",
|
||||||
|
"find -type",
|
||||||
|
"find -path",
|
||||||
|
"find -exec",
|
||||||
|
"find . -type f",
|
||||||
|
"find . -type d",
|
||||||
|
"wc",
|
||||||
|
"sort",
|
||||||
|
"uniq",
|
||||||
|
"curl",
|
||||||
|
"wget",
|
||||||
|
"ping",
|
||||||
|
"netstat",
|
||||||
|
"ss",
|
||||||
|
"lsof",
|
||||||
|
"psql",
|
||||||
|
"pg_dump",
|
||||||
|
"pg_restore",
|
||||||
|
"sqlite3",
|
||||||
|
"jest",
|
||||||
|
"vitest",
|
||||||
|
"playwright",
|
||||||
|
"cypress",
|
||||||
|
"artillery",
|
||||||
|
"lighthouse",
|
||||||
|
"tsc",
|
||||||
|
"eslint",
|
||||||
|
"prettier",
|
||||||
|
"snyk",
|
||||||
|
"semgrep",
|
||||||
|
"tar",
|
||||||
|
"gzip",
|
||||||
|
"unzip",
|
||||||
|
"zip",
|
||||||
|
"which",
|
||||||
|
"whoami",
|
||||||
|
"id",
|
||||||
|
"env",
|
||||||
|
"export",
|
||||||
|
"source",
|
||||||
|
"sleep",
|
||||||
|
"date",
|
||||||
|
"uptime",
|
||||||
|
"df",
|
||||||
|
"du",
|
||||||
|
"free",
|
||||||
|
"top",
|
||||||
|
"htop",
|
||||||
|
"ps",
|
||||||
|
"tree",
|
||||||
|
"jq",
|
||||||
|
"sed",
|
||||||
|
"awk",
|
||||||
|
"xargs",
|
||||||
|
"tee",
|
||||||
|
"test",
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"basename",
|
||||||
|
"dirname",
|
||||||
|
"realpath",
|
||||||
|
"readlink",
|
||||||
|
"stat",
|
||||||
|
"file",
|
||||||
|
"make",
|
||||||
|
"cmake",
|
||||||
|
"gcc",
|
||||||
|
"g++",
|
||||||
|
"clang",
|
||||||
|
"python",
|
||||||
|
"python3",
|
||||||
|
"pip",
|
||||||
|
"pip3",
|
||||||
|
"pip install",
|
||||||
|
"poetry",
|
||||||
|
"pipenv",
|
||||||
|
"go",
|
||||||
|
"go build",
|
||||||
|
"go test",
|
||||||
|
"go run",
|
||||||
|
"go mod",
|
||||||
|
"cargo",
|
||||||
|
"rustc",
|
||||||
|
"ruby",
|
||||||
|
"gem",
|
||||||
|
"bundle",
|
||||||
|
"rake",
|
||||||
|
"java",
|
||||||
|
"javac",
|
||||||
|
"mvn",
|
||||||
|
"gradle",
|
||||||
|
"dotnet",
|
||||||
|
"msbuild",
|
||||||
|
"php",
|
||||||
|
"composer",
|
||||||
|
"perl",
|
||||||
|
"cpan",
|
||||||
|
"nohup"
|
||||||
|
],
|
||||||
|
"enableAllMcpTools": true
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
SlashCommandResultPayload,
|
SlashCommandResultPayload,
|
||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
} from '../commands/index.js';
|
} from '../commands/index.js';
|
||||||
|
import type { HarnessErrorCode, HarnessSelection, HarnessTurnState } from '../harness/index.js';
|
||||||
|
|
||||||
export interface MessageAckPayload {
|
export interface MessageAckPayload {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -107,8 +108,70 @@ export interface AbortPayload {
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frozen P3 `turn:send` wire contract (Task Five; reused unchanged by Tasks 15 and 16).
|
||||||
|
* Accepts no attachments or authority-bearing fields in Slice Zero. Gateway validation
|
||||||
|
* requires a UUID conversation id, non-empty bounded content, a nested selection with exactly
|
||||||
|
* `harnessId`/`providerId`/`modelId` (each 1..255 chars), and a UUID-v4 idempotency key; it
|
||||||
|
* rejects unknown fields, top-level `provider`/`modelId`, malformed nesting, and empty values
|
||||||
|
* before any runtime dispatch.
|
||||||
|
*/
|
||||||
|
export interface HarnessTurnSendPayload {
|
||||||
|
readonly conversationId: string; // UUID; required before send
|
||||||
|
readonly content: string; // trimmed, 1..10_000 characters
|
||||||
|
readonly selection: HarnessSelection; // nested; exactly three ids
|
||||||
|
readonly idempotencyKey: string; // browser-generated UUID v4
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frozen `turn:ack` wire contract. Success echoes the accepted idempotency key and the
|
||||||
|
* exact requested selection tuple; failure carries only fixed/safe text and never a
|
||||||
|
* substituted effective selection or raw exception text.
|
||||||
|
*/
|
||||||
|
export type HarnessTurnAckPayload =
|
||||||
|
| {
|
||||||
|
readonly ok: true;
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly idempotencyKey: string;
|
||||||
|
readonly turnId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly state: HarnessTurnState;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly ok: false;
|
||||||
|
readonly conversationId?: string;
|
||||||
|
readonly idempotencyKey?: string;
|
||||||
|
readonly code: HarnessErrorCode | 'request_invalid' | 'runtime_unsupported';
|
||||||
|
readonly message: string; // fixed/safe text only
|
||||||
|
readonly retryable: boolean;
|
||||||
|
readonly correlationId: string;
|
||||||
|
/** Present only when a complete tuple was validated; always the requested tuple. */
|
||||||
|
readonly selection?: HarnessSelection;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frozen browser send-protocol advertisement (Task Five; server → client only).
|
||||||
|
*
|
||||||
|
* A conversation id or a harness selection never proves that the connected Gateway actually
|
||||||
|
* handles a given wire event, so after BetterAuth authenticates a browser Socket connection the
|
||||||
|
* Gateway advertises — exactly once, targeted to that socket — which send event the client may
|
||||||
|
* use. `legacy-message` in legacy mode, `unavailable` in `pi-rpc` (including test-ready Pi
|
||||||
|
* graphs); Task Five never advertises `turn-send` (its authenticated handler lands in Task 15).
|
||||||
|
* Capability is routing information, never authorization: every server handler still enforces
|
||||||
|
* authentication, ownership, DTO, mode, and runtime checks.
|
||||||
|
*/
|
||||||
|
export type ChatSendProtocol = 'legacy-message' | 'turn-send' | 'unavailable';
|
||||||
|
|
||||||
|
export interface ChatSendCapabilityPayload {
|
||||||
|
readonly protocol: ChatSendProtocol;
|
||||||
|
/** Exact Socket.IO id for the authenticated browser connection this advertisement is bound to. */
|
||||||
|
readonly connectionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Socket.IO typed event map: server → client */
|
/** Socket.IO typed event map: server → client */
|
||||||
export interface ServerToClientEvents {
|
export interface ServerToClientEvents {
|
||||||
|
'chat:send-capability': (payload: ChatSendCapabilityPayload) => void;
|
||||||
'message:ack': (payload: MessageAckPayload) => void;
|
'message:ack': (payload: MessageAckPayload) => void;
|
||||||
'agent:start': (payload: AgentStartPayload) => void;
|
'agent:start': (payload: AgentStartPayload) => void;
|
||||||
'agent:end': (payload: AgentEndPayload) => void;
|
'agent:end': (payload: AgentEndPayload) => void;
|
||||||
@@ -121,12 +184,14 @@ export interface ServerToClientEvents {
|
|||||||
'command:result': (payload: SlashCommandResultPayload) => void;
|
'command:result': (payload: SlashCommandResultPayload) => void;
|
||||||
'command:approval': (payload: SlashCommandApprovalResultPayload) => void;
|
'command:approval': (payload: SlashCommandApprovalResultPayload) => void;
|
||||||
'system:reload': (payload: SystemReloadPayload) => void;
|
'system:reload': (payload: SystemReloadPayload) => void;
|
||||||
|
'turn:ack': (payload: HarnessTurnAckPayload) => void;
|
||||||
error: (payload: ErrorPayload) => void;
|
error: (payload: ErrorPayload) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Socket.IO typed event map: client → server */
|
/** Socket.IO typed event map: client → server */
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
message: (data: ChatMessagePayload) => void;
|
message: (data: ChatMessagePayload) => void;
|
||||||
|
'turn:send': (data: HarnessTurnSendPayload) => void;
|
||||||
'set:thinking': (data: SetThinkingPayload) => void;
|
'set:thinking': (data: SetThinkingPayload) => void;
|
||||||
'command:execute': (data: SlashCommandPayload) => void;
|
'command:execute': (data: SlashCommandPayload) => void;
|
||||||
'command:approve': (data: SlashCommandPayload) => void;
|
'command:approve': (data: SlashCommandPayload) => void;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export type {
|
|||||||
AbortPayload,
|
AbortPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
ChatMessagePayload,
|
ChatMessagePayload,
|
||||||
|
HarnessTurnSendPayload,
|
||||||
|
HarnessTurnAckPayload,
|
||||||
|
ChatSendProtocol,
|
||||||
|
ChatSendCapabilityPayload,
|
||||||
ServerToClientEvents,
|
ServerToClientEvents,
|
||||||
ClientToServerEvents,
|
ClientToServerEvents,
|
||||||
} from './events.js';
|
} from './events.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user