Compare commits

..
Author SHA1 Message Date
Jason WoltjeandClaude Fable 5 b2b3c95451 ci: provision Pi runtime 0.84.1 in the test step (Invariant R)
ci/woodpecker/pr/ci Pipeline failed
invariant_r_unittest.py (landing with the lease-remediation stack, PR
#1109) hard-requires an installed `pi` binary pinned to the measured
version: it boots Pi's real tool registry and proves the broker's
read-only carve-out resolves to real, unshadowed builtins. Absent
runtime fails loud by design — so CI must provide it.

Install @earendil-works/[email protected].1 (the canonical Pi;
@mariozechner/* is embedded-legacy) at step level in the test step.
Step-level rather than baked into Dockerfile.ci because ci-image
publishes are currently blocked on registry UNAUTHORIZED; baking it in
is the follow-up once registry auth is fixed, at which point this line
degrades to a fast no-op guard like the openssl line above it.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-11 18:18:42 -05:00
116 changed files with 1118 additions and 22397 deletions
-94
View File
@@ -1,94 +0,0 @@
# 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
View File
@@ -1,102 +0,0 @@
# 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.
-46
View File
@@ -1,46 +0,0 @@
# 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 });
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
controller = new ConversationsController(brain as never);
});
it('returns matching messages for a valid search query', async () => {
@@ -479,7 +479,7 @@ describe('ConversationsController — search endpoint', () => {
describe('ConversationsController — message CRUD', () => {
it('listMessages returns 404 when conversation is not owned by user', async () => {
const brain = createMockBrain({ conversation: undefined });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
NotFoundException,
@@ -489,7 +489,7 @@ describe('ConversationsController — message CRUD', () => {
it('listMessages returns the messages for an owned conversation', async () => {
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
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 () => {
const brain = createMockBrain({ conversation: makeConversation() });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
const result = await controller.addMessage(
CONV_ID,
@@ -35,25 +35,6 @@ 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 {
const entries = new Map<string, string>();
return new CommandAuthorizationService(
@@ -132,7 +113,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
},
);
const gateway = new ChatGateway(
failIfUsedChatRuntimeRouter() 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
// conversation does not belong to the requesting user.
brain.conversations.findById.mockResolvedValue(undefined);
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
NotFoundException,
@@ -1,8 +1,6 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
@@ -14,25 +12,10 @@ vi.mock('../routing/routing-engine.service.js', () => ({
}));
import { SessionsController } from '../sessions.controller.js';
import { AgentService } from '../agent.service.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AuthGuard } from '../../auth/auth.guard.js';
import { AUTH } from '../../auth/auth.tokens.js';
import { BRAIN } from '../../brain/brain.tokens.js';
import { CommandRegistryService } from '../../commands/command-registry.service.js';
import { CommandExecutorService } from '../../commands/command-executor.service.js';
import { RoutingEngineService } from '../routing/routing-engine.service.js';
import { ChatRuntimeRouter } from '../../chat/chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../../chat/embedded-chat.runtime.js';
import { ownConversation } from '../../chat/chat-runtime.js';
import type { LegacyRuntimeStream } from '../../chat/chat-runtime.js';
import { HarnessChatRuntime } from '../../chat/harness-chat.runtime.js';
import { HarnessRegistry } from '../../harness/harness.registry.js';
import { HARNESS_CONVERSATION_SERVICE_UNAVAILABLE } from '../../harness/harness.tokens.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
@@ -91,12 +74,6 @@ 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() {
const foreign = makeAgentSession(USER_A);
return {
@@ -110,7 +87,7 @@ function makeScopedAgentService() {
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')),
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
@@ -119,201 +96,6 @@ 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', () => {
it('requires explicit owner+tenant scope on protected session operations', () => {
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
@@ -370,66 +152,50 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => {
// TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot
// swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived
// scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path.
it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency
const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation
const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam
const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls);
try {
const controller = moduleRef.get(ChatController, { strict: false });
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
const agentService = makeScopedAgentService();
const controller = new ChatController(agentService as never);
// Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN.
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toBeDefined();
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toMatchObject({ status: 404 });
// Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail.
// RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router.
// Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the
// controller @Inject(AgentService) and never calls the router). GREEN once it drives the op.
expect
.soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router')
.toContain('completeLegacyRestTurn');
// RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on
// the router. An AgentService-shaped router shim records one of these → RED, defeating the shim
// on invocation evidence (not source text). A dead named method added alongside the shim does not
// help: it is never invoked, so it never enters routerCalls while a forbidden op still does.
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today;
// restoring the direct injection keeps it failing).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the
// router (fails today; the router path is never taken).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Zero foreign mutation on either path (holds today and at GREEN).
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
// Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the
// controller no longer declares the direct embedded AgentService dependency. A negative source
// check cannot be satisfied by dead text — it only fails when the injection is present.
const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect.soft(controllerSource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.prompt).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => {
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
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() {
return {
id: 'socket-b',
@@ -440,519 +206,57 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router
};
}
// TESS test B — WebSocket send/attach.
it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
}),
).catch(() => undefined);
// RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router.
expect
.soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router')
.toContain('prepareLegacySocketTurn');
// RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN).
expect.soft(directAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(directAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
// Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency.
const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
expect.soft(gatewaySource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
});
// TESS test C — WebSocket set:thinking.
it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleSetThinking(socket as never, {
conversationId: CONVERSATION_ID,
level: 'high',
}),
).catch(() => undefined);
// RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router.
expect
.soft(routerCalls, 'gateway must invoke setLegacyThinking on the router')
.toContain('setLegacyThinking');
// RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test D — WebSocket abort.
it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }),
).catch(() => undefined);
// RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router.
expect
.soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router')
.toContain('abortLegacyTurn');
// RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal.
it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => {
// pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and
// must be refused with a fixed typed code, touching neither the embedded AgentService nor the
// harness conversation service.
const agentService = makeScopedAgentService();
const embedded = new EmbeddedChatRuntime(agentService as never);
const harnessConversation = {
attach: vi.fn(),
detach: vi.fn(),
send: vi.fn(),
subscribeFrom: vi.fn(),
};
const harness = new HarnessChatRuntime(harnessConversation as never);
const router = new ChatRuntimeRouter(
registryWith(['pi']),
boundConversationService,
embedded,
harness,
'pi-rpc',
);
router.onModuleInit();
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const gateway = new ChatGateway(
router as never,
{} as never,
brain as never,
{ getManifest: vi.fn().mockReturnValue([]) } as never,
{ execute: vi.fn() } as never,
{ resolve: vi.fn() } as never,
);
const socket = {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'route me',
}),
).catch(() => undefined);
await gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
});
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.onEvent).not.toHaveBeenCalled();
expect(agentService.addChannel).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ code: 'runtime_unsupported' }),
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
expect(agentService.getSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(harnessConversation.attach).not.toHaveBeenCalled();
expect(harnessConversation.send).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5).
// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService
// fake (every touched method exists, so a RED can only come from behavior, never a
// `getSession is not a function` TypeError). Ownership context is minted through the
// real `ownConversation` factory — the only sanctioned way to reach a port op.
// ---------------------------------------------------------------------------
const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId };
const CONVERSATION_UNAVAILABLE_RESULT = {
ok: false,
code: 'conversation_unavailable',
retryable: false,
} as const;
/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */
function makeStream(): LegacyRuntimeStream {
return { channelId: 'websocket:test-1', onEvent: vi.fn() };
}
/**
* getSession → undefined (session missing), createSession → rejects with `err`. Exercises the
* `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves
* the turn short-circuited before any dispatch.
*/
function makeCollapsingAgentService(err: Error) {
return {
getSession: vi.fn(() => undefined),
createSession: vi.fn().mockRejectedValue(err),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
}
/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */
function makeLeaseAgentService() {
const session = makeAgentSession(USER_A);
const unsubscribe = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => unsubscribe),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
return { svc, unsubscribe, session };
}
/**
* getSession → a live owned session (REST resolveOrCreate succeeds), onEvent returns a `detach`
* spy, and `prompt` REJECTS with a non-timeout error. Drives the REST-turn catch path so the single
* idempotent teardown must clear the 120s timeout and detach the listener exactly once.
*/
function makeRejectingPromptAgentService() {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockRejectedValue(new Error('agent backend exploded')),
recordTokenUsage: vi.fn(),
};
return { svc, detach };
}
describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner'));
const runtime = new EmbeddedChatRuntime(svc as never);
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new NotFoundException('no such conversation'));
const runtime = new EmbeddedChatRuntime(svc as never);
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' });
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => {
const foreign = new EmbeddedChatRuntime(
makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never,
);
const missing = new EmbeddedChatRuntime(
makeCollapsingAgentService(new NotFoundException('no such conversation')) as never,
);
const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' });
const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' });
expect(foreignResult).toEqual(missingResult);
expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
});
});
describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => {
const { svc } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
const first = await lease.dispatch();
expect(first).toEqual({ ok: true, value: undefined });
expect(svc.prompt).toHaveBeenCalledTimes(1);
const second = await lease.dispatch();
expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false });
// Zero additional effect — the second dispatch must not prompt again.
expect(svc.prompt).toHaveBeenCalledTimes(1);
});
it('disposes once; a second dispose is a silent no-op that never re-detaches or destroys the session', async () => {
const { svc, unsubscribe, session } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
await lease.dispose();
await lease.dispose();
// Listener + channel torn down exactly once across two dispose calls.
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(svc.removeChannel).toHaveBeenCalledTimes(1);
// Disposal never terminates the underlying session or process.
expect(session.piSession.abort).not.toHaveBeenCalled();
expect(session.piSession.dispose).not.toHaveBeenCalled();
});
it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => {
const { svc, unsubscribe } = makeLeaseAgentService();
svc.addChannel = vi.fn(() => {
throw new Error('channel attach failed');
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
const runtime = new EmbeddedChatRuntime(svc as never);
// Must NOT throw out of the port — a partial setup collapses to a total safe failure.
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(false);
// Exactly what was acquired (the event listener) is rolled back.
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => {
const { svc, detach } = makeRejectingPromptAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
// A rejected `done` promise firing after completeLegacyRestTurn has already returned would
// surface as an unhandledRejection — the leak this test fences. Capture any that escape.
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const result = await runtime.completeLegacyRestTurn(ctx, {
content: 'trigger a backend failure',
});
// The rejection collapses to a total safe failure (not a timeout) — never throws out of the port.
expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false });
// The single idempotent dispose ran in the catch: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second
// detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
// Let any scheduled rejection surface on a real macrotask, then confirm none did.
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
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 { gateway, agentService } = makeGateway();
const socket = makeSocket();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'a prompt that never returns',
});
// No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all
// installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
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);
});
it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
let rejectPrompt: (reason: unknown) => void = () => undefined;
const prompting = new Promise<void>((_resolve, reject) => {
rejectPrompt = reject;
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn(() => prompting),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'prompt settles after the deadline',
});
// The timeout wins the race while prompt is still pending.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
expect(detach).toHaveBeenCalledTimes(1);
// The prompt now rejects LATE — after the turn already returned its timeout result. Because
// Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late
// rejection is already observed and must not escape as an unhandledRejection.
rejectPrompt(new Error('late backend failure'));
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
});
-2
View File
@@ -21,7 +21,6 @@ import { AdminModule } from './admin/admin.module.js';
import { CommandsModule } from './commands/commands.module.js';
import { PreferencesModule } from './preferences/preferences.module.js';
import { GCModule } from './gc/gc.module.js';
import { HarnessModule } from './harness/harness.module.js';
import { ReloadModule } from './reload/reload.module.js';
import { WorkspaceModule } from './workspace/workspace.module.js';
import { QueueModule } from './queue/queue.module.js';
@@ -61,7 +60,6 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
PreferencesModule,
CommandsModule,
GCModule,
HarnessModule,
QueueModule,
ReloadModule,
WorkspaceModule,
File diff suppressed because it is too large Load Diff
@@ -1,920 +0,0 @@
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');
});
});
@@ -1,173 +0,0 @@
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);
}
}
-273
View File
@@ -1,273 +0,0 @@
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;
}
+63 -32
View File
@@ -3,20 +3,21 @@ import {
Post,
Body,
Logger,
ForbiddenException,
HttpException,
HttpStatus,
NotFoundException,
Inject,
UseGuards,
} from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { Throttle } from '@nestjs/throttler';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { v4 as uuid } from 'uuid';
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 {
conversationId: string;
@@ -28,7 +29,7 @@ interface ChatResponse {
export class ChatController {
private readonly logger = new Logger(ChatController.name);
constructor(private readonly runtime: ChatRuntimeRouter) {}
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Post()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@@ -39,38 +40,68 @@ export class ChatController {
const conversationId = body.conversationId ?? uuid();
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}`);
// 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 },
);
let responseText = '';
if (result.ok) {
return { conversationId, text: result.value.text };
const done = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
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);
}
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);
}
return { conversationId, text: responseText };
}
}
+1 -63
View File
@@ -1,14 +1,5 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { Transform, Type } from 'class-transformer';
import {
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto {
@IsOptional()
@@ -46,56 +37,3 @@ export class ChatSocketMessageDto {
/** Validated channel attachment references; binary content is not embedded. */
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,31 +8,12 @@ const payload: SlashCommandPayload = {
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: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
return new ChatGateway(
failIfUsedChatRuntimeRouter() as never,
{} as never,
{} as never,
{} as never,
{} as never,
@@ -91,114 +72,3 @@ 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
+3 -50
View File
@@ -1,59 +1,12 @@
import { forwardRef, Module } from '@nestjs/common';
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 { 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({
imports: [forwardRef(() => CommandsModule), HarnessModule],
imports: [forwardRef(() => CommandsModule)],
controllers: [ChatController],
providers: [
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],
providers: [ChatGateway],
exports: [ChatGateway],
})
export class ChatModule {}
@@ -1,532 +0,0 @@
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 };
}
@@ -1,170 +0,0 @@
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 });
});
});
@@ -1,47 +0,0 @@
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);
}
}
@@ -1,116 +0,0 @@
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,7 +6,6 @@ import {
ForbiddenException,
Get,
HttpCode,
HttpException,
HttpStatus,
Inject,
NotFoundException,
@@ -20,7 +19,6 @@ import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
import {
CreateConversationDto,
UpdateConversationDto,
@@ -28,41 +26,10 @@ import {
SearchMessagesDto,
} 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')
@UseGuards(AuthGuard)
export class ConversationsController {
/**
* `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'>,
) {}
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list(@CurrentUser() user: { id: string }) {
@@ -127,13 +94,6 @@ export class ConversationsController {
@Body() dto: SendMessageDto,
@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(
{
conversationId: id,
@@ -1,14 +1,7 @@
import { Module } from '@nestjs/common';
import { ChatModule } from '../chat/chat.module.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({
imports: [ChatModule],
controllers: [ConversationsController],
})
export class ConversationsModule {}
@@ -1,19 +0,0 @@
import 'reflect-metadata';
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { CoordModule } from './coord.module.js';
import { InteractionCoordinationService } from './interaction-coordination.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
describe('CoordModule DI (compiled-metadata boot)', () => {
it('resolves InteractionCoordinationService through Nest DI', async () => {
const moduleRef = await Test.createTestingModule({ imports: [CoordModule] })
.overrideGuard(AuthGuard)
.useValue({ canActivate: (): boolean => true })
.compile();
expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf(
InteractionCoordinationService,
);
await moduleRef.close();
});
});
@@ -1,4 +1,4 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import {
InteractionCoordinationClient,
type CoordinationObservation,
@@ -13,7 +13,6 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY');
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
const MAX_TRACKED_HANDOFFS = 1_000;
@@ -61,8 +60,6 @@ export class InteractionCoordinationService {
constructor(
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
@Optional()
@Inject(HANDOFF_ID_FACTORY)
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {}
@@ -1,164 +0,0 @@
import 'reflect-metadata';
import {
type CanActivate,
type ExecutionContext,
type INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { HarnessRegistry } from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import { HarnessSelectionRepository } from './harness-selection.repository.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
// Import the REAL module (not a hand-listed controllers+mocks list) so an
// unresolved provider fails at app.init() — the #1145-class DI-boot guard.
import { HarnessModule } from './harness.module.js';
// A known-available tuple from the fake adapter's default catalog.
const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' };
// A tuple whose provider/model are not in any catalog.
const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' };
// A tuple that is known in the catalog but flagged unavailable.
const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' };
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
requestContext.user = { id: 'user-1' };
return true;
},
};
function registryWithFake(): HarnessRegistry {
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
return registry;
}
describe('Harness selection HTTP surface', () => {
let app: INestApplication;
let repository: HarnessSelectionRepository;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [HarnessModule],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.overrideProvider(HARNESS_REGISTRY)
.useValue(registryWithFake())
.compile();
// Real in-memory repository from the module graph — proves the module wired it.
repository = moduleRef.get(HarnessSelectionRepository);
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
beforeEach(() => {
// Reset owner-scoped state between tests via the public API surface.
repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID);
});
afterAll(async () => {
await app.close();
});
it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => {
const response = await request(app.getHttpServer())
.get('/api/chat/preferences/selection')
.query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' });
expect(response.status).toBe(200);
// The returned selection is user-1's (guard-derived scope), not the query's.
expect(response.body.selection).toEqual(VALID);
});
it('PUT with a valid structured tuple persists and round-trips via GET', async () => {
const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' };
const put = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(next)
.set('Content-Type', 'application/json');
expect(put.status).toBe(200);
expect(put.body.selection).toEqual(next);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.status).toBe(200);
expect(get.body.selection).toEqual(next);
});
it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send({ selection: 'gpt-4o' })
.set('Content-Type', 'application/json');
expect(response.status).toBe(400);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
});
it.each([
['seatId', { ...VALID, seatId: 'attacker-seat' }],
['tenantId', { ...VALID, tenantId: 'attacker-tenant' }],
['userId', { ...VALID, userId: 'attacker' }],
['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }],
['executable', { ...VALID, executable: '/usr/bin/evil' }],
['home', { ...VALID, home: '/home/attacker' }],
['cwd', { ...VALID, cwd: '/tmp/attacker' }],
])(
'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection',
async (_name, body) => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(body)
.set('Content-Type', 'application/json');
expect(response.status).toBe(400);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
},
);
it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(UNKNOWN)
.set('Content-Type', 'application/json');
expect(response.status).toBe(422);
expect(response.body.code).toBe('selection_invalid');
// Echoed back unchanged: no first-row / first-provider substitution.
expect(response.body.selection).toEqual(UNKNOWN);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
});
it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(UNAVAILABLE)
.set('Content-Type', 'application/json');
expect(response.status).toBe(422);
expect(response.body.code).toBe('model_unavailable');
expect(response.body.selection).toEqual(UNAVAILABLE);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
});
});
@@ -1,46 +0,0 @@
import { Body, Controller, Get, HttpException, HttpStatus, Put, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { HarnessOperationError } from './harness.registry.js';
import { HarnessSelectionService } from './harness-selection.service.js';
import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js';
/**
* Chat-preferences selection surface. The scope is ALWAYS derived on the server
* from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and
* query string can never name another user, tenant, or seat. A typed selection
* failure (unknown tuple `selection_invalid`, known-but-unavailable
* `model_unavailable`) is returned as 422 with the requested tuple echoed back
* unchanged, and never mutates the stored selection.
*/
@Controller('api/chat/preferences/selection')
@UseGuards(AuthGuard)
export class HarnessSelectionController {
constructor(private readonly selection: HarnessSelectionService) {}
@Get()
get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto {
return { selection: this.selection.getSelection(scopeFromUser(user)) };
}
@Put()
async put(
@CurrentUser() user: AuthenticatedUserLike,
@Body() dto: HarnessSelectionInputDto,
): Promise<SelectionResponseDto> {
try {
const stored = await this.selection.setSelection(scopeFromUser(user), {
harnessId: dto.harnessId,
providerId: dto.providerId,
modelId: dto.modelId,
});
return { selection: stored };
} catch (error) {
if (error instanceof HarnessOperationError) {
throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY);
}
throw error;
}
}
}
@@ -1,90 +0,0 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { HarnessSelection } from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
import {
HarnessAdapterUnavailableError,
HarnessRegistry,
operationError,
} from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import { readContextFromScope } from './harness.dto.js';
import { HarnessSelectionRepository } from './harness-selection.repository.js';
/**
* Selection logic for the Slice-Zero chat-preferences surface. It validates the
* requested harness/provider/model tuple against the live catalog with NO
* fallback substitution, then persists it owner-scoped. The stored selection is
* only ever mutated when the tuple is valid AND available.
*/
@Injectable()
export class HarnessSelectionService {
constructor(
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
private readonly repository: HarnessSelectionRepository,
) {}
getSelection(scope: ActorTenantScope): HarnessSelection | null {
return this.repository.get(scope);
}
async setSelection(
scope: ActorTenantScope,
selection: HarnessSelection,
): Promise<HarnessSelection> {
// Throws HarnessOperationError (selection_invalid / model_unavailable) with the
// requested tuple echoed back unchanged. The store is untouched on any throw.
await this.assertSelectionAvailable(scope, selection);
return this.repository.set(scope, selection);
}
private async assertSelectionAvailable(
scope: ActorTenantScope,
selection: HarnessSelection,
): Promise<void> {
const correlationId = randomUUID();
let adapter;
try {
adapter = this.registry.get(selection.harnessId);
} catch (error) {
if (error instanceof HarnessAdapterUnavailableError) {
// An unknown harness makes the whole tuple invalid — no fallback adapter.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
correlationId,
);
}
throw error;
}
const catalog = await adapter.catalog(readContextFromScope(scope));
const entry = catalog.models.find(
(candidate) =>
candidate.harnessId === selection.harnessId &&
candidate.providerId === selection.providerId &&
candidate.modelId === selection.modelId,
);
if (!entry) {
// No first-row / first-provider fallback: reject the requested tuple unchanged.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
correlationId,
);
}
if (entry.availability === 'unavailable') {
throw operationError(
'model_unavailable',
'The requested model is currently unavailable.',
selection,
correlationId,
true,
);
}
}
}
@@ -1,138 +0,0 @@
import 'reflect-metadata';
import {
type CanActivate,
type ExecutionContext,
type INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { HarnessRegistry } from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
// The real module under test — importing it (not a hand-listed controllers/mocks
// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard).
import { HarnessModule } from './harness.module.js';
// Fields that must NEVER surface on a browser-facing catalog/list response.
const FORBIDDEN_KEYS = [
'executable',
'executablePath',
'home',
'homeDir',
'cwd',
'workingDir',
'workingDirectory',
'nativeSessionPath',
'sessionPath',
'env',
'secret',
'secrets',
'token',
'apiKey',
];
function assertNoForbiddenLeak(payload: unknown): void {
const serialized = JSON.stringify(payload).toLowerCase();
for (const key of FORBIDDEN_KEYS) {
expect(serialized).not.toContain(key.toLowerCase());
}
}
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
requestContext.user = { id: 'user-1' };
return true;
},
};
function registryWithFake(): HarnessRegistry {
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
return registry;
}
describe('Harness catalog HTTP surface', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [HarnessModule],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.overrideProvider(HARNESS_REGISTRY)
.useValue(registryWithFake())
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app.close();
});
it('boots the real HarnessModule so all providers resolve at app.init()', () => {
// If HarnessModule failed to resolve a provider, beforeAll's app.init() would
// have thrown and this suite would never reach here.
expect(app).toBeDefined();
});
it('GET /api/harnesses returns 200 with safe fields only', async () => {
const response = await request(app.getHttpServer()).get('/api/harnesses');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
const summary = response.body[0];
expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']);
expect(summary.id).toBe('fake');
expect(typeof summary.displayName).toBe('string');
expect(Array.isArray(summary.capabilities)).toBe(true);
assertNoForbiddenLeak(response.body);
});
it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => {
const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog');
expect(response.status).toBe(200);
expect(response.body.harnessId).toBe('fake');
expect(typeof response.body.version).toBe('string');
expect(typeof response.body.fingerprint).toBe('string');
expect(Array.isArray(response.body.models)).toBe(true);
expect(response.body.models.length).toBeGreaterThan(0);
const entry = response.body.models[0];
// Whitelisted catalog-entry fields only (no executables/paths/secrets).
expect(Object.keys(entry).sort()).toEqual(
[
'authState',
'availability',
'displayName',
'harnessId',
'inputTypes',
'modelId',
'providerId',
'reasoningCapability',
].sort(),
);
assertNoForbiddenLeak(response.body);
});
it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => {
const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog');
expect(response.status).toBe(404);
expect(response.body.code).toBe('adapter_unavailable');
// A fallback catalog would carry a models array; a typed error must not.
expect(response.body.models).toBeUndefined();
});
});
@@ -1,65 +0,0 @@
import {
Controller,
Get,
HttpException,
HttpStatus,
Inject,
Param,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import {
readContextFromScope,
toHarnessSummary,
toSafeCatalog,
type HarnessCatalogDto,
type HarnessSummaryDto,
} from './harness.dto.js';
/**
* Generic harness catalog surface. It exposes only harness-neutral, browser-safe
* fields (identity, capabilities, provider/model catalog) never executables,
* native paths, home/cwd, env, or secrets. There is NO provider-probe route here;
* `/api/providers` and `POST /api/providers/test` are intentionally out of scope.
*/
@Controller('api/harnesses')
@UseGuards(AuthGuard)
export class HarnessController {
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
@Get()
async list(@CurrentUser() user: AuthenticatedUserLike): Promise<HarnessSummaryDto[]> {
const context = readContextFromScope(scopeFromUser(user));
const summaries: HarnessSummaryDto[] = [];
for (const adapter of this.registry.list()) {
summaries.push(toHarnessSummary(await adapter.describe(context)));
}
return summaries;
}
@Get(':harnessId/catalog')
async catalog(
@CurrentUser() user: AuthenticatedUserLike,
@Param('harnessId') harnessId: string,
): Promise<HarnessCatalogDto> {
const context = readContextFromScope(scopeFromUser(user));
let adapter;
try {
adapter = this.registry.get(harnessId);
} catch (error) {
if (error instanceof HarnessAdapterUnavailableError) {
// Typed failure — NEVER a fallback catalog for an unknown harness id.
throw new HttpException(
{ code: error.code, message: error.message, harnessId },
HttpStatus.NOT_FOUND,
);
}
throw error;
}
return toSafeCatalog(await adapter.catalog(context));
}
}
-116
View File
@@ -1,116 +0,0 @@
import { randomUUID } from 'node:crypto';
import { IsNotEmpty, IsString } from 'class-validator';
import type {
HarnessActorContext,
HarnessAuthState,
HarnessCapability,
HarnessCatalog,
HarnessCatalogEntry,
HarnessDescriptor,
HarnessInputType,
HarnessModelAvailability,
HarnessSelection,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
/**
* Structured selection tuple accepted on `PUT /api/chat/preferences/selection`.
*
* The body is a STRUCTURED tuple (harness + provider + model), never a free-text
* model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`
* any extra property including smuggled server-authority fields such as
* `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd`
* is rejected with 400. There is deliberately no field through which a caller can
* name a scope; scope is derived on the server from the authenticated session.
*/
export class HarnessSelectionInputDto {
@IsString()
@IsNotEmpty()
harnessId!: string;
@IsString()
@IsNotEmpty()
providerId!: string;
@IsString()
@IsNotEmpty()
modelId!: string;
}
/** Browser-safe harness summary — identity and capabilities only. */
export interface HarnessSummaryDto {
readonly id: string;
readonly displayName: string;
readonly capabilities: readonly HarnessCapability[];
}
/** Browser-safe catalog entry — no executables, paths, secrets, or env. */
export interface HarnessCatalogEntryDto {
readonly harnessId: string;
readonly providerId: string;
readonly modelId: string;
readonly displayName: string;
readonly reasoningCapability: boolean;
readonly inputTypes: readonly HarnessInputType[];
readonly authState: HarnessAuthState;
readonly availability: HarnessModelAvailability;
}
/** Browser-safe catalog envelope. */
export interface HarnessCatalogDto {
readonly harnessId: string;
readonly version: string;
readonly fingerprint: string;
readonly models: readonly HarnessCatalogEntryDto[];
}
/** Response envelope for the caller's current selection (null when unset). */
export interface SelectionResponseDto {
readonly selection: HarnessSelection | null;
}
/**
* Derive a server-trusted {@link HarnessActorContext} for read operations from the
* session-derived {@link ActorTenantScope}. All authority originates on the server;
* nothing here is caller-supplied. A fresh correlation id is minted per call.
*/
export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext {
return {
actorId: scope.userId,
tenantId: scope.tenantId,
seatId: scope.userId,
correlationId: randomUUID(),
};
}
/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */
export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto {
return {
id: descriptor.id,
displayName: descriptor.displayName,
capabilities: [...descriptor.capabilities],
};
}
/** Project a catalog onto the browser-safe shape (whitelist by construction). */
export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto {
return {
harnessId: catalog.harnessId,
version: catalog.version,
fingerprint: catalog.fingerprint,
models: catalog.models.map(toSafeCatalogEntry),
};
}
function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto {
return {
harnessId: entry.harnessId,
providerId: entry.providerId,
modelId: entry.modelId,
displayName: entry.displayName,
reasoningCapability: entry.reasoningCapability,
inputTypes: [...entry.inputTypes],
authState: entry.authState,
availability: entry.availability,
};
}
@@ -1,37 +0,0 @@
import { Module } from '@nestjs/common';
import { HarnessRegistry } from './harness.registry.js';
import { HarnessService } from './harness.service.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
HARNESS_REGISTRY,
HARNESS_SERVICE,
} from './harness.tokens.js';
import { HarnessController } from './harness.controller.js';
import { HarnessSelectionController } from './harness-selection.controller.js';
import { HarnessSelectionService } from './harness-selection.service.js';
import { HarnessSelectionRepository } from './harness-selection.repository.js';
/**
* Wires the harness-neutral registry/service (Task Two) together with the
* Slice-Zero catalog and selection HTTP surfaces (Task Three).
*
* The registry is provided empty here; real harness adapters are registered in a
* later task. Because the controllers/services resolve their collaborators through
* this real module graph, an unresolved provider fails loudly at `app.init()`.
*/
@Module({
controllers: [HarnessController, HarnessSelectionController],
providers: [
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
{ provide: HARNESS_SERVICE, useClass: HarnessService },
// 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,
HarnessSelectionService,
],
exports: [HARNESS_REGISTRY, HARNESS_SERVICE, HARNESS_CONVERSATION_SERVICE],
})
export class HarnessModule {}
@@ -1,69 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
HarnessAdapterUnavailableError,
HarnessRegistrationError,
HarnessRegistry,
} from './harness.registry.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
describe('HarnessRegistry', () => {
it('registers and looks up an adapter by harness id', () => {
const registry = new HarnessRegistry();
const adapter = new FakeHarnessAdapter({ id: 'fake' });
registry.register(adapter);
expect(registry.get('fake')).toBe(adapter);
expect(registry.has('fake')).toBe(true);
expect(registry.list().map((entry) => entry.id)).toEqual(['fake']);
});
it('rejects a blank adapter id', () => {
const registry = new HarnessRegistry();
let error: unknown;
try {
registry.register(new FakeHarnessAdapter({ id: ' ' }));
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessRegistrationError);
expect((error as HarnessRegistrationError).reason).toBe('blank_id');
expect(registry.list()).toEqual([]);
});
it('rejects a duplicate adapter id', () => {
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
let error: unknown;
try {
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessRegistrationError);
expect((error as HarnessRegistrationError).reason).toBe('duplicate_id');
expect((error as HarnessRegistrationError).harnessId).toBe('fake');
// The original registration is untouched.
expect(registry.list()).toHaveLength(1);
});
it('returns adapter_unavailable for an unknown harness id', () => {
const registry = new HarnessRegistry();
let error: unknown;
try {
registry.get('missing');
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessAdapterUnavailableError);
expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable');
expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing');
expect(registry.has('missing')).toBe(false);
});
});
@@ -1,100 +0,0 @@
import { Injectable } from '@nestjs/common';
import type {
HarnessAdapter,
HarnessErrorCode,
HarnessErrorDto,
HarnessSelection,
} from '@mosaicstack/types';
/**
* A typed harness operation failure that carries a fully-formed, browser-safe
* {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested
* tuple there is no field through which a substituted "effective" selection
* could ever be reported.
*/
export class HarnessOperationError extends Error {
readonly code: HarnessErrorCode;
readonly dto: HarnessErrorDto;
constructor(dto: HarnessErrorDto) {
super(dto.message);
this.name = 'HarnessOperationError';
this.code = dto.code;
this.dto = dto;
}
}
/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */
export function operationError(
code: HarnessErrorCode,
message: string,
selection: HarnessSelection,
correlationId: string,
retryable = false,
): HarnessOperationError {
return new HarnessOperationError({ code, message, retryable, correlationId, selection });
}
/** Raised when an unknown harness id is looked up. Discriminated by `code`. */
export class HarnessAdapterUnavailableError extends Error {
readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode;
constructor(readonly harnessId: string) {
super(`No harness adapter is registered for id "${harnessId}".`);
this.name = 'HarnessAdapterUnavailableError';
}
}
export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id';
/** Raised when an adapter cannot be registered (blank or duplicate id). */
export class HarnessRegistrationError extends Error {
constructor(
readonly reason: HarnessRegistrationFailure,
readonly harnessId: string,
) {
super(
reason === 'blank_id'
? 'A harness adapter id must be a non-empty string.'
: `A harness adapter is already registered for id "${harnessId}".`,
);
this.name = 'HarnessRegistrationError';
}
}
/**
* Harness-neutral adapter registry. Adapters are keyed by their harness id.
* Registration rejects blank and duplicate ids; lookup of an unknown id fails
* with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`).
*/
@Injectable()
export class HarnessRegistry {
private readonly adapters = new Map<string, HarnessAdapter>();
register(adapter: HarnessAdapter): void {
const id = adapter.id;
if (typeof id !== 'string' || id.trim().length === 0) {
throw new HarnessRegistrationError('blank_id', id ?? '');
}
if (this.adapters.has(id)) {
throw new HarnessRegistrationError('duplicate_id', id);
}
this.adapters.set(id, adapter);
}
get(harnessId: string): HarnessAdapter {
const adapter = this.adapters.get(harnessId);
if (!adapter) {
throw new HarnessAdapterUnavailableError(harnessId);
}
return adapter;
}
has(harnessId: string): boolean {
return this.adapters.has(harnessId);
}
list(): readonly HarnessAdapter[] {
return [...this.adapters.values()];
}
}
@@ -1,227 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
import {
HarnessScopeViolationError,
HarnessService,
type TrustedGatewayScope,
} from './harness.service.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
const SCOPE: TrustedGatewayScope = {
actorId: 'actor-trusted',
tenantId: 'tenant-trusted',
seatId: 'seat-trusted',
correlationId: 'correlation-trusted',
};
const READ_CONTEXT: HarnessActorContext = {
actorId: SCOPE.actorId,
tenantId: SCOPE.tenantId,
seatId: SCOPE.seatId,
correlationId: SCOPE.correlationId,
};
function setup(capabilities?: readonly HarnessCapability[]) {
const registry = new HarnessRegistry();
const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
registry.register(adapter);
const service = new HarnessService(registry);
return { registry, adapter, service };
}
async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
const catalog = await adapter.catalog(READ_CONTEXT);
const entry = catalog.models.find((model) => model.availability === 'available');
if (!entry) {
throw new Error('fixture requires an available model');
}
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
}
describe('HarnessService', () => {
it('derives the actor context from trusted scope on create', async () => {
const { service, adapter } = setup();
const selection = await availableSelection(adapter);
const snapshot = await service.createSession(SCOPE, {
conversationId: 'conversation-1',
selection,
});
expect(snapshot.seatId).toBe(SCOPE.seatId);
expect(snapshot.state).toBe('idle');
expect(snapshot.selection).toEqual(selection);
expect(snapshot.nativeSessionId).toBeTruthy();
});
it('rejects server-authority fields supplied by an external caller', async () => {
const { service, adapter } = setup();
const selection = await availableSelection(adapter);
const hostile = {
conversationId: 'conversation-1',
selection,
seatId: 'attacker-seat',
executablePath: '/usr/bin/evil',
home: '/home/attacker',
cwd: '/tmp/attacker',
nativeSessionPath: '/var/native/attacker.jsonl',
} as unknown as Parameters<HarnessService['createSession']>[1];
let error: unknown;
try {
await service.createSession(SCOPE, hostile);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessScopeViolationError);
expect((error as HarnessScopeViolationError).field).toBe('seatId');
});
it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
const { service } = setup();
const selection: HarnessSelection = {
harnessId: 'ghost-harness',
providerId: 'p',
modelId: 'm',
};
let error: unknown;
try {
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('adapter_unavailable');
expect(dto.selection).toEqual(selection);
expect(dto.correlationId).toBe(SCOPE.correlationId);
});
it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
const { service } = setup();
const selection: HarnessSelection = {
harnessId: 'fake',
providerId: 'ghost-provider',
modelId: 'ghost-model',
};
let error: unknown;
try {
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('selection_invalid');
expect(dto.selection).toEqual(selection);
});
it('returns model_unavailable without falling back for a known unavailable model', async () => {
const { service, adapter } = setup();
const catalog = await adapter.catalog(READ_CONTEXT);
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
expect(unavailable).toBeDefined();
const selection: HarnessSelection = {
harnessId: unavailable!.harnessId,
providerId: unavailable!.providerId,
modelId: unavailable!.modelId,
};
let error: unknown;
try {
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('model_unavailable');
// No substitution: the DTO tuple is exactly what was requested.
expect(dto.selection).toEqual(selection);
});
it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
const { service, adapter } = setup();
const selection = await availableSelection(adapter);
const created = await service.createSession(SCOPE, {
conversationId: 'conversation-create',
selection,
});
expect(created.state).toBe('idle');
expect(created.processId).toBeTruthy();
expect(created.attachedClientIds).toEqual([]);
const resumed = await service.resumeSession(SCOPE, {
conversationId: 'conversation-resume',
nativeSessionId: 'native-preexisting-123',
selection,
});
// Resume binds the supplied native session; create mints a fresh one.
expect(resumed.nativeSessionId).toBe('native-preexisting-123');
expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);
await service.attach(SCOPE, {
conversationId: 'conversation-create',
clientId: 'browser-1',
});
const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
expect(afterAttach.attachedClientIds).toEqual(['browser-1']);
const afterDetach = await service.detach(SCOPE, {
conversationId: 'conversation-create',
clientId: 'browser-1',
});
// Detach removes the browser attachment only; the process stays alive.
expect(afterDetach.attachedClientIds).toEqual([]);
expect(afterDetach.state).toBe('idle');
expect(afterDetach.processId).toBeTruthy();
const afterEvict = await service.evict(SCOPE, {
conversationId: 'conversation-create',
reason: 'idle_timeout',
});
// Evict stops the process but retains the resumable native session.
expect(afterEvict.state).toBe('evicted');
expect(afterEvict.processId).toBeUndefined();
expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);
const afterEnd = await service.end(SCOPE, {
conversationId: 'conversation-create',
reason: 'session_ended',
});
// End destructively terminates the native session.
expect(afterEnd.state).toBe('ended');
});
it('fails typed when an unsupported capability is exercised', async () => {
const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
(capability) => capability !== 'extensionUi',
);
const { service, adapter } = setup(withoutExtensionUi);
const selection = await availableSelection(adapter);
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
let error: unknown;
try {
await service.respondInteraction(SCOPE, {
conversationId: 'conversation-1',
response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
});
});
-285
View File
@@ -1,285 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
HarnessActorContext,
HarnessAdapter,
HarnessCatalog,
HarnessCloseReason,
HarnessInteractionResponse,
HarnessSelection,
HarnessSessionHandle,
HarnessSessionSnapshot,
} from '@mosaicstack/types';
import {
HarnessAdapterUnavailableError,
HarnessRegistry,
operationError,
} from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
/**
* Trusted, server-derived authority. In production this is produced by the
* Gateway from the authenticated session never from a browser/caller DTO.
*/
export interface TrustedGatewayScope {
readonly actorId: string;
readonly tenantId: string;
readonly seatId: string;
readonly correlationId: string;
}
/** Server-authority fields that must never arrive from an external request DTO. */
const FORBIDDEN_REQUEST_FIELDS = [
'actorId',
'tenantId',
'correlationId',
'seatId',
'seat',
'executable',
'executablePath',
'home',
'homeDir',
'cwd',
'workingDir',
'workingDirectory',
'nativeSessionPath',
'sessionPath',
] as const;
/** Raised when an external request DTO smuggles a server-authority field. */
export class HarnessScopeViolationError extends Error {
constructor(readonly field: string) {
super(`External request supplied server-authority field "${field}".`);
this.name = 'HarnessScopeViolationError';
}
}
export interface CreateHarnessSessionRequest {
readonly conversationId: string;
readonly selection: HarnessSelection;
}
export interface ResumeHarnessSessionRequest {
readonly conversationId: string;
readonly nativeSessionId: string;
readonly selection: HarnessSelection;
}
export interface AttachClientRequest {
readonly conversationId: string;
readonly clientId: string;
}
export interface DetachClientRequest {
readonly conversationId: string;
readonly clientId: string;
}
export interface EvictSessionRequest {
readonly conversationId: string;
readonly reason: HarnessCloseReason;
}
export interface EndSessionRequest {
readonly conversationId: string;
readonly reason: HarnessCloseReason;
}
export interface RespondInteractionRequest {
readonly conversationId: string;
readonly response: HarnessInteractionResponse;
}
interface ActiveSession {
readonly harnessId: string;
readonly handle: HarnessSessionHandle;
readonly correlationId: string;
}
/**
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
* from trusted Gateway scope, validates the selected provider/model tuple with
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
* lifecycle operations.
*/
@Injectable()
export class HarnessService {
private readonly sessions = new Map<string, ActiveSession>();
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
async createSession(
scope: TrustedGatewayScope,
request: CreateHarnessSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const { conversationId, selection } = request;
const adapter = this.resolveAdapter(scope, selection);
const context = deriveActorContext(scope);
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
const handle = await adapter.create({ context, conversationId, selection });
this.sessions.set(conversationId, {
harnessId: selection.harnessId,
handle,
correlationId: scope.correlationId,
});
return handle.snapshot();
}
async resumeSession(
scope: TrustedGatewayScope,
request: ResumeHarnessSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const { conversationId, nativeSessionId, selection } = request;
const adapter = this.resolveAdapter(scope, selection);
const context = deriveActorContext(scope);
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
this.sessions.set(conversationId, {
harnessId: selection.harnessId,
handle,
correlationId: scope.correlationId,
});
return handle.snapshot();
}
async attach(
scope: TrustedGatewayScope,
request: AttachClientRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.attach({ clientId: request.clientId });
return handle.snapshot();
}
async detach(
scope: TrustedGatewayScope,
request: DetachClientRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.detach(request.clientId);
return handle.snapshot();
}
async evict(
scope: TrustedGatewayScope,
request: EvictSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.evictProcess(request.reason);
return handle.snapshot();
}
async end(
scope: TrustedGatewayScope,
request: EndSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.endSession(request.reason);
const snapshot = await handle.snapshot();
this.sessions.delete(request.conversationId);
return snapshot;
}
async respondInteraction(
scope: TrustedGatewayScope,
request: RespondInteractionRequest,
): Promise<void> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.respondInteraction(request.response);
}
async snapshot(
scope: TrustedGatewayScope,
conversationId: string,
): Promise<HarnessSessionSnapshot> {
const handle = this.requireHandle(scope, conversationId);
return handle.snapshot();
}
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
try {
return this.registry.get(selection.harnessId);
} catch (error) {
if (error instanceof HarnessAdapterUnavailableError) {
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
}
throw error;
}
}
private async assertSelectionAvailable(
scope: TrustedGatewayScope,
catalogPromise: Promise<HarnessCatalog>,
selection: HarnessSelection,
): Promise<void> {
const catalog = await catalogPromise;
const entry = catalog.models.find(
(candidate) =>
candidate.harnessId === selection.harnessId &&
candidate.providerId === selection.providerId &&
candidate.modelId === selection.modelId,
);
if (!entry) {
// No first-row fallback: reject the requested tuple unchanged.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
scope.correlationId,
);
}
if (entry.availability === 'unavailable') {
throw operationError(
'model_unavailable',
'The requested model is currently unavailable.',
selection,
scope.correlationId,
true,
);
}
}
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
const active = this.sessions.get(conversationId);
if (!active) {
throw operationError(
'session_not_found',
`No active harness session for conversation "${conversationId}".`,
{ harnessId: '', providerId: '', modelId: '' },
scope.correlationId,
);
}
return active.handle;
}
}
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
return {
actorId: scope.actorId,
tenantId: scope.tenantId,
seatId: scope.seatId,
correlationId: scope.correlationId,
};
}
/** Reject any request object that carries a server-authority field. */
function assertTrustedRequest(request: object): void {
for (const field of FORBIDDEN_REQUEST_FIELDS) {
if (Object.prototype.hasOwnProperty.call(request, field)) {
throw new HarnessScopeViolationError(field);
}
}
}
// Re-export the typed operation error so callers importing from the service
// have the discriminated failure type without reaching into the registry.
export { HarnessOperationError } from './harness.registry.js';
@@ -1,45 +0,0 @@
/**
* Nest dependency-injection tokens for the harness-neutral registry and service.
*
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
*/
import type { HarnessConversationService } from '@mosaicstack/types';
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
export type HarnessServiceToken = typeof HARNESS_SERVICE;
/**
* 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;
}
@@ -1,107 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types';
import { HarnessOperationError } from '../harness.registry.js';
import { FakeHarnessAdapter } from './fake-harness.adapter.js';
import { runHarnessAdapterContract } from './harness-adapter.contract.js';
const CONTEXT: HarnessActorContext = {
actorId: 'actor-1',
tenantId: 'tenant-1',
seatId: 'seat-1',
correlationId: 'correlation-1',
};
// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter.
runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' }));
describe('FakeHarnessAdapter no-substitution', () => {
it('never substitutes the first catalog row when a bogus selection is requested', async () => {
const adapter = new FakeHarnessAdapter({ id: 'fake' });
const catalog = await adapter.catalog(CONTEXT);
const firstRow = catalog.models[0];
if (!firstRow) {
throw new Error('fixture requires a catalog model');
}
const available = catalog.models.find(
(entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId,
);
expect(available).toBeDefined();
const selected: HarnessSelection = {
harnessId: available!.harnessId,
providerId: available!.providerId,
modelId: available!.modelId,
};
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conversation-1',
selection: selected,
});
const bogus: HarnessSelection = {
harnessId: 'fake',
providerId: 'ghost-provider',
modelId: 'ghost-model',
};
let error: unknown;
try {
await handle.setModel(bogus);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('selection_invalid');
// The DTO echoes the exact requested tuple, unchanged.
expect(dto.selection).toEqual(bogus);
// No substitution to the first catalog row.
expect(dto.selection).not.toEqual({
harnessId: firstRow.harnessId,
providerId: firstRow.providerId,
modelId: firstRow.modelId,
});
// The active selection is untouched by the rejected request.
expect((await handle.snapshot()).selection).toEqual(selected);
});
it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => {
const adapter = new FakeHarnessAdapter({ id: 'fake' });
const catalog = await adapter.catalog(CONTEXT);
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
const available = catalog.models.find((entry) => entry.availability === 'available');
expect(unavailable).toBeDefined();
expect(available).toBeDefined();
const startingSelection: HarnessSelection = {
harnessId: available!.harnessId,
providerId: available!.providerId,
modelId: available!.modelId,
};
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conversation-2',
selection: startingSelection,
});
const requested: HarnessSelection = {
harnessId: unavailable!.harnessId,
providerId: unavailable!.providerId,
modelId: unavailable!.modelId,
};
let error: unknown;
try {
await handle.setModel(requested);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('model_unavailable');
expect(dto.selection).toEqual(requested);
expect((await handle.snapshot()).selection).toEqual(startingSelection);
});
});
@@ -1,248 +0,0 @@
import type {
AttachClient,
CreateHarnessSession,
HarnessAdapter,
HarnessActorContext,
HarnessCapability,
HarnessCatalog,
HarnessCatalogEntry,
HarnessCloseReason,
HarnessDescriptor,
HarnessEvent,
HarnessInteractionResponse,
HarnessPrompt,
HarnessPromptReceipt,
HarnessSelection,
HarnessSessionHandle,
HarnessSessionSnapshot,
HarnessSessionState,
ResumeHarnessSession,
} from '@mosaicstack/types';
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
import { operationError } from '../harness.registry.js';
export interface FakeHarnessAdapterOptions {
readonly id: string;
readonly capabilities?: readonly HarnessCapability[];
readonly catalog?: readonly HarnessCatalogEntry[];
}
const FAKE_PROVIDER = 'fake-openai';
function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] {
return [
{
harnessId,
providerId: FAKE_PROVIDER,
modelId: 'fake-mini',
displayName: 'Fake Mini',
reasoningCapability: false,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
{
harnessId,
providerId: FAKE_PROVIDER,
modelId: 'fake-pro',
displayName: 'Fake Pro',
reasoningCapability: true,
inputTypes: ['text', 'image'],
authState: 'ready',
availability: 'available',
},
{
harnessId,
providerId: FAKE_PROVIDER,
modelId: 'fake-legacy',
displayName: 'Fake Legacy',
reasoningCapability: false,
inputTypes: ['text'],
authState: 'unavailable',
availability: 'unavailable',
},
];
}
function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean {
return (
entry.harnessId === selection.harnessId &&
entry.providerId === selection.providerId &&
entry.modelId === selection.modelId
);
}
/**
* In-memory harness session handle used by the fake adapter and by the shared
* conformance suite. It enforces the two invariants the real adapters must also
* honor: model selection is validated against the catalog and is NEVER
* substituted, and unsupported capabilities fail with a typed error.
*/
export class FakeHarnessSessionHandle implements HarnessSessionHandle {
private state: HarnessSessionState = 'idle';
private processId: string | undefined;
private readonly attachedClientIds = new Set<string>();
private readonly listeners = new Set<(event: HarnessEvent) => void>();
constructor(
private readonly conversationId: string,
private readonly nativeSessionId: string,
private readonly seatId: string,
private selection: HarnessSelection,
private readonly correlationId: string,
private readonly capabilities: readonly HarnessCapability[],
private readonly catalog: readonly HarnessCatalogEntry[],
) {
this.processId = `process-${nativeSessionId}`;
}
async snapshot(): Promise<HarnessSessionSnapshot> {
return {
conversationId: this.conversationId,
nativeSessionId: this.nativeSessionId,
processId: this.processId,
seatId: this.seatId,
selection: this.selection,
state: this.state,
attachedClientIds: [...this.attachedClientIds],
};
}
async attach(input: AttachClient): Promise<void> {
this.attachedClientIds.add(input.clientId);
}
async detach(clientId: string): Promise<void> {
// Removes the browser attachment only; the process and native session persist.
this.attachedClientIds.delete(clientId);
}
async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt> {
return {
conversationId: this.conversationId,
turnId: input.turnId,
correlationId: input.correlationId,
state: 'accepted',
selection: this.selection,
};
}
async setModel(selection: HarnessSelection): Promise<HarnessSelection> {
const entry = this.catalog.find((candidate) => matches(candidate, selection));
if (!entry) {
// No fallback to the first catalog row: reject with the requested tuple, unchanged.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
this.correlationId,
);
}
if (entry.availability === 'unavailable') {
throw operationError(
'model_unavailable',
'The requested model is currently unavailable.',
selection,
this.correlationId,
true,
);
}
this.selection = selection;
return this.selection;
}
async abort(_turnId: string): Promise<void> {
// No active turn machinery in the fake; abort is a no-op acknowledgement.
}
async respondInteraction(_input: HarnessInteractionResponse): Promise<void> {
if (!this.capabilities.includes('extensionUi')) {
throw operationError(
'interaction_unsupported',
'This harness does not support interactive responses.',
this.selection,
this.correlationId,
);
}
}
events(listener: (event: HarnessEvent) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async evictProcess(_reason: HarnessCloseReason): Promise<void> {
// Stop the process but keep the resumable native session.
this.processId = undefined;
this.state = 'evicted';
}
async endSession(_reason: HarnessCloseReason): Promise<void> {
// Destructively end the native session.
this.processId = undefined;
this.state = 'ended';
}
}
/**
* Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh
* native session id on `create` and binds the supplied one on `resume`, so the
* two paths are observably distinct.
*/
export class FakeHarnessAdapter implements HarnessAdapter {
readonly id: string;
private readonly capabilities: readonly HarnessCapability[];
private readonly catalogEntries: readonly HarnessCatalogEntry[];
private createdCount = 0;
constructor(options: FakeHarnessAdapterOptions) {
this.id = options.id;
this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES];
this.catalogEntries = options.catalog ?? defaultCatalog(options.id);
}
async describe(_context: HarnessActorContext): Promise<HarnessDescriptor> {
return {
id: this.id,
displayName: `Fake harness (${this.id})`,
capabilities: this.capabilities,
};
}
async catalog(_context: HarnessActorContext): Promise<HarnessCatalog> {
return {
harnessId: this.id,
version: '1.0.0',
fingerprint: `fake-${this.id}-${this.catalogEntries.length}`,
models: this.catalogEntries,
};
}
async create(input: CreateHarnessSession): Promise<HarnessSessionHandle> {
this.createdCount += 1;
const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`;
return new FakeHarnessSessionHandle(
input.conversationId,
nativeSessionId,
input.context.seatId,
input.selection,
input.context.correlationId,
this.capabilities,
this.catalogEntries,
);
}
async resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle> {
return new FakeHarnessSessionHandle(
input.conversationId,
input.nativeSessionId,
input.context.seatId,
input.selection,
input.context.correlationId,
this.capabilities,
this.catalogEntries,
);
}
}
@@ -1,157 +0,0 @@
import { describe, expect, it } from 'vitest';
import type {
HarnessActorContext,
HarnessAdapter,
HarnessCatalogEntry,
HarnessSelection,
} from '@mosaicstack/types';
import { HarnessOperationError } from '../harness.registry.js';
const CONTEXT: HarnessActorContext = {
actorId: 'contract-actor',
tenantId: 'contract-tenant',
seatId: 'contract-seat',
correlationId: 'contract-correlation',
};
function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
}
function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
if (!entry) {
throw new Error('contract fixture requires at least one catalog model');
}
return entry;
}
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
try {
await run();
return undefined;
} catch (caught) {
return caught;
}
}
/**
* Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
* runs it against the fake adapter; Task 13 re-runs the identical suite against
* the native Pi adapter so both share one behavioral contract.
*/
export function runHarnessAdapterContract(
label: string,
createAdapter: () => HarnessAdapter,
): void {
describe(`harness adapter contract: ${label}`, () => {
it('mints a fresh native session on create and binds the supplied one on resume', async () => {
const adapter = createAdapter();
const catalog = await adapter.catalog(CONTEXT);
const selection = toSelection(pickAvailable(catalog.models));
const created = await (
await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
).snapshot();
const resumed = await (
await adapter.resume({
context: CONTEXT,
conversationId: 'conv-resume',
nativeSessionId: 'native-supplied-1',
selection,
})
).snapshot();
expect(created.nativeSessionId).toBeTruthy();
expect(resumed.nativeSessionId).toBe('native-supplied-1');
expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
expect(created.seatId).toBe(CONTEXT.seatId);
});
it('gives detach, evict, and end distinct effects (not aliases)', async () => {
const adapter = createAdapter();
const catalog = await adapter.catalog(CONTEXT);
const selection = toSelection(pickAvailable(catalog.models));
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conv-lifecycle',
selection,
});
await handle.attach({ clientId: 'browser-1' });
await handle.detach('browser-1');
const afterDetach = await handle.snapshot();
expect(afterDetach.attachedClientIds).toEqual([]);
expect(afterDetach.state).not.toBe('evicted');
expect(afterDetach.state).not.toBe('ended');
await handle.evictProcess('idle_timeout');
const afterEvict = await handle.snapshot();
expect(afterEvict.state).toBe('evicted');
// The native session survives eviction (resumable); the process does not.
expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
expect(afterEvict.processId).toBeUndefined();
await handle.endSession('session_ended');
const afterEnd = await handle.snapshot();
expect(afterEnd.state).toBe('ended');
// End is not an alias of evict.
expect(afterEnd.state).not.toBe(afterEvict.state);
});
it('never substitutes the first catalog row for an unknown selection', async () => {
const adapter = createAdapter();
const catalog = await adapter.catalog(CONTEXT);
const firstRow = catalog.models[0];
if (!firstRow) {
throw new Error('contract fixture requires a catalog model');
}
const start = toSelection(pickAvailable(catalog.models));
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conv-nosub',
selection: start,
});
const bogus: HarnessSelection = {
harnessId: adapter.id,
providerId: 'contract-ghost-provider',
modelId: 'contract-ghost-model',
};
const error = await captureError(() => handle.setModel(bogus));
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('selection_invalid');
expect(dto.selection).toEqual(bogus);
expect(dto.selection).not.toEqual(toSelection(firstRow));
expect((await handle.snapshot()).selection).toEqual(start);
});
it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
const adapter = createAdapter();
const descriptor = await adapter.describe(CONTEXT);
const catalog = await adapter.catalog(CONTEXT);
const selection = toSelection(pickAvailable(catalog.models));
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conv-interaction',
selection,
});
const response = {
requestId: 'interaction-1',
type: 'confirm',
accepted: true,
} as const;
if (descriptor.capabilities.includes('extensionUi')) {
await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
} else {
const error = await captureError(() => handle.respondInteraction(response));
expect(error).toBeInstanceOf(HarnessOperationError);
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
}
});
});
}
@@ -12,10 +12,6 @@ import { RuntimeProviderService } from '../agent/runtime-provider-registry.servi
import { ChatGateway } from '../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.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';
const SERVICE_TOKEN = 'test-service-token';
@@ -29,7 +25,6 @@ const ENV_KEYS = [
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CONFIG_ID',
'CHAT_HARNESS_RUNTIME',
] as const;
const savedEnv = new Map<string, string | undefined>();
@@ -155,57 +150,6 @@ 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', () => {
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
const [binding] = parseDiscordInteractionBindings(
@@ -489,7 +433,6 @@ describe('Discord ingress security', () => {
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
@@ -546,9 +489,8 @@ describe('Discord ingress security', () => {
},
};
const routingEngine = { resolve: vi.fn() };
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
agentService as never,
{} as never,
brain as never,
{} as never,
@@ -589,575 +531,6 @@ describe('Discord ingress security', () => {
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
);
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 () => {
@@ -1220,16 +593,11 @@ describe('Discord ingress security', () => {
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const prompt = vi.fn().mockResolvedValue(undefined);
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const addMessage = vi.fn().mockResolvedValue(undefined);
const session = {
provider: 'test-provider',
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: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
@@ -1243,7 +611,6 @@ describe('Discord ingress security', () => {
prompt,
};
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
create: vi.fn().mockResolvedValue(undefined),
@@ -1251,9 +618,8 @@ describe('Discord ingress security', () => {
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
agentService as never,
{} as never,
brain as never,
{} as never,
@@ -1301,66 +667,6 @@ describe('Discord ingress security', () => {
}),
'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', () => {
-10
View File
@@ -10,16 +10,11 @@ import type {
AgentTextPayload,
AgentThinkingPayload,
ChatMessagePayload,
ChatSendCapabilityPayload,
ChatSendProtocol,
ClientToServerEvents,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
HarnessTurnSendPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
@@ -42,16 +37,11 @@ export type {
AgentTextPayload,
AgentThinkingPayload,
ChatMessagePayload,
ChatSendCapabilityPayload,
ChatSendProtocol,
ClientToServerEvents,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
HarnessTurnSendPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
-39
View File
@@ -1,42 +1,3 @@
import type {
HarnessAuthState,
HarnessModelAvailability,
HarnessSelection,
} from '@mosaicstack/types';
// The exact harness/provider/model tuple and its closed enum companions are the
// shared domain types — re-exported here so web consumers (and the runtime
// guards) import one shape, never a divergent local redefinition.
export type { HarnessSelection, HarnessAuthState, HarnessModelAvailability };
/** Harness summary row from `GET /api/harnesses` (the `HarnessSummaryDto`). The
* harness id is kept distinct from any provider id they are never merged. */
export interface HarnessSummary {
id: string;
displayName: string;
capabilities: string[];
}
/** One selectable model in a harness catalog. Extends the `{harnessId,
* providerId, modelId}` tuple with the display/availability metadata the UI
* needs; `inputTypes` is kept as a plain `string[]` on the client boundary
* because it arrives from untrusted JSON and is only ever displayed. */
export interface HarnessCatalogEntry extends HarnessSelection {
displayName: string;
reasoningCapability: boolean;
inputTypes: string[];
authState: HarnessAuthState;
availability: HarnessModelAvailability;
}
/** Harness-scoped catalog from `GET /api/harnesses/:harnessId/catalog`. */
export interface HarnessCatalog {
harnessId: string;
version: string;
fingerprint: string;
models: HarnessCatalogEntry[];
}
/** Conversation returned by the gateway API. */
export interface Conversation {
id: string;
-195
View File
@@ -1,195 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
fetchCatalog,
fetchHarnesses,
fetchPersistedSelection,
persistSelection,
} from './chat-api';
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function stubFetch(): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
/** Every URL the client actually requested, across all calls. */
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map((call) => String(call[0]));
}
describe('chat-api', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(
json([
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
]),
);
const harnesses = await fetchHarnesses();
expect(fetchMock).toHaveBeenCalledOnce();
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses');
expect(harnesses).toEqual([
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
]);
});
it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(
json({
harnessId: 'pi',
version: '2026-08-11',
fingerprint: 'abc123',
models: [
{
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
displayName: 'GPT-5',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
],
}),
);
const result = await fetchCatalog('pi');
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog');
expect(result.ok).toBe(true);
if (!result.ok) throw new Error('expected ok catalog');
expect(result.catalog.harnessId).toBe('pi');
expect(result.catalog.models).toHaveLength(1);
expect(result.catalog.models[0]).toMatchObject({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
availability: 'available',
});
});
it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(
json(
{
code: 'adapter_unavailable',
message: 'raw gateway detail that must not leak verbatim',
harnessId: 'attacker-echo',
extra: { hostile: 'blob' },
},
404,
),
);
const result = await fetchCatalog('ghost');
expect(result.ok).toBe(false);
if (result.ok) throw new Error('expected unavailable result');
expect(result.code).toBe('catalog_unavailable');
// harnessId comes from the request, never the (untrusted) response body.
expect(result.harnessId).toBe('ghost');
expect(typeof result.message).toBe('string');
// The raw response body is never rendered/returned verbatim.
expect(JSON.stringify(result)).not.toContain('hostile');
expect(JSON.stringify(result)).not.toContain('attacker-echo');
});
it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
);
await expect(fetchPersistedSelection()).resolves.toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection');
fetchMock.mockResolvedValueOnce(json({ selection: null }));
await expect(fetchPersistedSelection()).resolves.toBeNull();
});
it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
);
const result = await persistSelection({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
expect(result.ok).toBe(true);
const call = fetchMock.mock.calls[0];
expect(String(call?.[0])).toBe('/api/chat/preferences/selection');
const init = call?.[1] as RequestInit;
expect(String(init.method).toUpperCase()).toBe('PUT');
// The body is exactly the structured tuple — harness/provider/model kept distinct.
expect(JSON.parse(String(init.body))).toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
});
it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(
json(
{
code: 'model_unavailable',
message: 'raw detail that must not leak',
selection: { harnessId: 'x', providerId: 'y', modelId: 'z' },
},
422,
),
);
const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' };
const result = await persistSelection(requested);
expect(result.ok).toBe(false);
if (result.ok) throw new Error('expected failed persist');
expect(['selection_invalid', 'model_unavailable']).toContain(result.code);
// The requested tuple is preserved unchanged — not replaced by the body's echo.
expect(result.requested).toEqual(requested);
expect(JSON.stringify(result)).not.toContain('raw detail');
});
it('never requests any /api/providers* endpoint', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(json([]));
await fetchHarnesses();
fetchMock.mockResolvedValue(
json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }),
);
await fetchCatalog('pi');
fetchMock.mockResolvedValue(json({ selection: null }));
await fetchPersistedSelection();
for (const url of requestedUrls(fetchMock)) {
expect(url).not.toContain('/api/providers');
}
});
});
-132
View File
@@ -1,132 +0,0 @@
/**
* Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection
* UI depends on. Every response body is untrusted and is normalized through the
* runtime guards before it reaches state a 404 (catalog) and a 422 (selection)
* are mapped to typed, body-free error results so a raw gateway body is never
* rendered, and the caller's requested tuple is preserved verbatim on failure.
*
* This module talks ONLY to the harness/chat-preferences endpoints. It never
* calls `/api/providers*` provider identity lives inside the harness catalog.
*/
import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards';
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
/** A catalog fetch either yields the typed catalog or a typed unavailability
* never a thrown raw body. */
export type CatalogResult =
| { ok: true; catalog: HarnessCatalog }
| { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string };
export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable';
/** A persist either confirms the stored tuple or reports a typed domain failure
* that echoes back the exact tuple the caller requested. */
export type SelectionPersistResult =
| { ok: true; selection: HarnessSelection }
| { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection };
/** A safe, generic message for an unavailable catalog the raw 404 body is
* never surfaced. */
const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.';
/** A safe, generic message for a rejected selection. The untrusted 422 body's
* own `message` is deliberately NEVER surfaced only this fixed copy so a
* raw gateway detail can never leak into the UI. Only the closed `code` enum is
* read from the body. */
const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.';
async function readJson(response: Response): Promise<unknown> {
return response.json().catch(() => null);
}
function safeSelectionCode(body: unknown): SelectionErrorCode {
if (typeof body === 'object' && body !== null && 'code' in body) {
const code = (body as { code: unknown }).code;
if (code === 'selection_invalid' || code === 'model_unavailable') return code;
}
// Default to the more conservative "invalid" classification for anything
// unrecognized rather than guessing "model_unavailable".
return 'selection_invalid';
}
/** `GET /api/harnesses` the list of harness summaries. A non-OK response
* normalizes to an empty list (the UI then has no harness to select). */
export async function fetchHarnesses(): Promise<HarnessSummary[]> {
const response = await fetch('/api/harnesses', {
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!response.ok) return [];
return asHarnessSummaries(await readJson(response));
}
/** `GET /api/harnesses/:harnessId/catalog` the harness-scoped catalog. A 404
* (or any non-OK) becomes a typed `catalog_unavailable` result rather than a
* fallback catalog or a rendered raw body. */
export async function fetchCatalog(harnessId: string): Promise<CatalogResult> {
const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, {
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return {
ok: false,
code: 'catalog_unavailable',
// Scoped to the requested harness id, never the untrusted body's echo.
harnessId,
message: CATALOG_UNAVAILABLE_MESSAGE,
};
}
return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) };
}
/** `GET /api/chat/preferences/selection` the persisted tuple, or null when
* unset or malformed. */
export async function fetchPersistedSelection(): Promise<HarnessSelection | null> {
const response = await fetch('/api/chat/preferences/selection', {
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!response.ok) return null;
const body = await readJson(response);
if (typeof body !== 'object' || body === null) return null;
return asHarnessSelection((body as { selection?: unknown }).selection);
}
/** `PUT /api/chat/preferences/selection` with the structured tuple as the body.
* On success returns the confirmed selection; on a typed domain failure (422)
* or validation error, returns a typed result carrying the EXACT requested
* tuple never the body's echo and never the raw body text. */
export async function persistSelection(
selection: HarnessSelection,
): Promise<SelectionPersistResult> {
const requested: HarnessSelection = {
harnessId: selection.harnessId,
providerId: selection.providerId,
modelId: selection.modelId,
};
const response = await fetch('/api/chat/preferences/selection', {
method: 'PUT',
credentials: 'include',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(requested),
});
if (!response.ok) {
const body = await readJson(response);
return {
ok: false,
code: safeSelectionCode(body),
// Fixed copy only — the untrusted body's message is never surfaced.
message: SELECTION_REJECTED_MESSAGE,
requested,
};
}
const body = await readJson(response);
const confirmed =
typeof body === 'object' && body !== null
? asHarnessSelection((body as { selection?: unknown }).selection)
: null;
// A malformed 2xx body is treated as a confirmation of exactly what we sent —
// the server accepted the tuple, so the requested tuple is the source of truth.
return { ok: true, selection: confirmed ?? requested };
}
+20 -103
View File
@@ -1,9 +1,7 @@
import { useState, type KeyboardEvent, type ReactElement } from 'react';
import type { HarnessSelection } from '@/lib/types';
import type { HarnessSelectionValue } from './use-harness-selection';
interface ComposerProps {
onSend: (input: { content: string; selection: HarnessSelection }) => boolean;
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
onStop: () => void;
streaming: boolean;
/** True from local send time through server turn startup/ack and
@@ -11,23 +9,6 @@ interface ComposerProps {
* pre-ack window where a second send could otherwise slip through. */
sending: boolean;
hasConversation: boolean;
/** Structured harness/provider/model selection state. The composer never
* accepts free-text provider/model every sendable tuple is a validated,
* persisted catalog entry, and the send projection is derived from it. */
harness: HarnessSelectionValue;
}
/** The distinct provider ids present in the current catalog, in first-seen
* order the provider select is catalog-derived, never a hardcoded list. */
function providerOptions(harness: HarnessSelectionValue): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const model of harness.catalog?.models ?? []) {
if (seen.has(model.providerId)) continue;
seen.add(model.providerId);
out.push(model.providerId);
}
return out;
}
export function Composer({
@@ -36,26 +17,22 @@ export function Composer({
streaming,
sending,
hasConversation,
harness,
}: ComposerProps): ReactElement {
const [content, setContent] = useState('');
const [provider, setProvider] = useState('');
const [modelId, setModelId] = useState('');
const busy = streaming || sending;
function submit(): void {
if (busy) return;
// 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.
if (!harness.canSend || harness.persistedSelection === null) return;
const trimmed = content.trim();
if (!trimmed) return;
// Pass the validated, persisted selection tuple only. The hook derives the
// 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('');
onSend({
content: trimmed,
provider: provider.trim() || undefined,
modelId: modelId.trim() || undefined,
});
setContent('');
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
@@ -65,19 +42,6 @@ export function Composer({
}
}
// Scope the model options to the intentionally selected provider. With no
// provider chosen (`providerId === ''`) nothing matches, so the model select
// offers only the placeholder — never a cross-provider row.
const models = (harness.catalog?.models ?? []).filter(
(model) => model.providerId === harness.providerId,
);
// A collision-safe composite option identity covering the full provider+model
// tuple. The controlled select mirrors the same identity so the exact catalog
// row highlights (a bare modelId would collide across providers).
const modelOptionValue = (model: { providerId: string; modelId: string }): string =>
`${model.providerId}:${model.modelId}`;
const selectedModelValue = harness.modelId ? `${harness.providerId}:${harness.modelId}` : '';
return (
<form
onSubmit={(event) => {
@@ -87,68 +51,21 @@ export function Composer({
className="flex flex-col gap-2 border-t p-4"
>
<div className="flex flex-wrap gap-2">
<select
aria-label="Harness"
value={harness.harnessId}
onChange={(event) => harness.selectHarness(event.target.value)}
className="rounded border px-2 py-1 text-xs"
>
<option value="">Select a harness</option>
{harness.harnesses.map((item) => (
<option key={item.id} value={item.id}>
{item.displayName}
</option>
))}
</select>
<select
<input
aria-label="Provider"
value={harness.providerId}
onChange={(event) => harness.selectProvider(event.target.value)}
disabled={harness.catalogUnavailable || providerOptions(harness).length === 0}
value={provider}
onChange={(event) => setProvider(event.target.value)}
placeholder="Provider (optional)"
className="rounded border px-2 py-1 text-xs"
>
<option value="">Select a provider</option>
{providerOptions(harness).map((providerId) => (
<option key={providerId} value={providerId}>
{providerId}
</option>
))}
</select>
<select
/>
<input
aria-label="Model"
value={selectedModelValue}
onChange={(event) => {
// Resolve the composite option identity back to the exact catalog
// row and persist that row's own provider+model — never a bare id.
const selected = models.find((model) => modelOptionValue(model) === event.target.value);
if (selected) harness.selectModel(selected.providerId, selected.modelId);
}}
disabled={harness.catalogUnavailable || models.length === 0}
value={modelId}
onChange={(event) => setModelId(event.target.value)}
placeholder="Model (optional)"
className="rounded border px-2 py-1 text-xs"
>
<option value="">Select a model</option>
{models.map((model) => (
<option key={modelOptionValue(model)} value={modelOptionValue(model)}>
{model.displayName}
</option>
))}
</select>
/>
</div>
{harness.catalogUnavailable ? (
<p role="status" className="text-xs opacity-70">
This harness catalog is currently unavailable.
</p>
) : null}
{harness.isStale ? (
<p role="status" className="text-xs opacity-70">
The saved model is no longer available pick another to continue.
</p>
) : null}
{harness.persistError ? (
<p role="alert" className="text-xs">
{harness.persistError.message}
</p>
) : null}
<div className="flex items-end gap-2">
<textarea
aria-label="Message"
@@ -161,7 +78,7 @@ export function Composer({
/>
<button
type="submit"
disabled={!content.trim() || busy || !harness.canSend}
disabled={!content.trim() || busy}
className="rounded px-3 py-2 text-sm font-medium"
>
Send
-101
View File
@@ -5,14 +5,6 @@
* a non-array, `.toFixed` on a non-number) or render an object as a React
* child.
*/
import type {
HarnessAuthState,
HarnessCatalog,
HarnessCatalogEntry,
HarnessModelAvailability,
HarnessSelection,
HarnessSummary,
} from '@/lib/types';
export function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
@@ -46,99 +38,6 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
/**
* The HTTP harness/catalog/selection JSON bodies are as untrusted as the socket
* payloads above a misbehaving or compromised gateway can send anything. The
* guards below normalize those bodies into the typed client shapes without ever
* rendering a raw body, so a 404/422/malformed response can never inject an
* object into React or a non-tuple into the selection state.
*/
/** Normalizes an untrusted `authState` to the closed set, defaulting to the
* safest value (`unavailable`) for anything unrecognized. */
export function asHarnessAuthState(value: unknown): HarnessAuthState {
return value === 'ready' || value === 'auth_required' || value === 'unavailable'
? value
: 'unavailable';
}
/** Normalizes an untrusted `availability` to the closed set, defaulting to
* `unavailable` so a malformed row can never present as sendable. */
export function asHarnessAvailability(value: unknown): HarnessModelAvailability {
return value === 'available' ? 'available' : 'unavailable';
}
/** A tuple is valid only when all three ids are non-empty strings a partial
* or malformed selection is rejected (null) rather than half-adopted. */
export function asHarnessSelection(value: unknown): HarnessSelection | null {
if (!isRecord(value)) return null;
const harnessId = value.harnessId;
const providerId = value.providerId;
const modelId = value.modelId;
if (
typeof harnessId !== 'string' ||
typeof providerId !== 'string' ||
typeof modelId !== 'string' ||
harnessId.length === 0 ||
providerId.length === 0 ||
modelId.length === 0
) {
return null;
}
return { harnessId, providerId, modelId };
}
/** Normalizes an untrusted array into typed harness summaries, dropping any row
* without a usable id. */
export function asHarnessSummaries(value: unknown): HarnessSummary[] {
if (!Array.isArray(value)) return [];
const out: HarnessSummary[] = [];
for (const item of value) {
if (!isRecord(item)) continue;
const id = asString(item.id);
if (id.length === 0) continue;
out.push({
id,
displayName: asNonEmptyString(item.displayName, id),
capabilities: asStringArray(item.capabilities),
});
}
return out;
}
function asHarnessCatalogEntry(value: unknown): HarnessCatalogEntry | null {
const selection = asHarnessSelection(value);
if (selection === null || !isRecord(value)) return null;
return {
...selection,
displayName: asNonEmptyString(value.displayName, selection.modelId),
reasoningCapability: value.reasoningCapability === true,
inputTypes: asStringArray(value.inputTypes),
authState: asHarnessAuthState(value.authState),
availability: asHarnessAvailability(value.availability),
};
}
/** Normalizes an untrusted catalog body into the typed client catalog. The
* caller supplies `harnessId` (from the request path) so the returned catalog
* is scoped to the harness that was actually requested, never a body-echoed id.
* Malformed model rows are dropped rather than invalidating the whole catalog. */
export function asHarnessCatalog(value: unknown, harnessId: string): HarnessCatalog {
const record = isRecord(value) ? value : {};
const rawModels = Array.isArray(record.models) ? record.models : [];
const models: HarnessCatalogEntry[] = [];
for (const row of rawModels) {
const entry = asHarnessCatalogEntry(row);
if (entry !== null) models.push(entry);
}
return {
harnessId,
version: asString(record.version),
fingerprint: asString(record.fingerprint),
models,
};
}
/** The single point of truth for what counts as a valid conversation ID
* anywhere a scoped server event may adopt one into state a non-empty
* string, nothing else. Every site that establishes or compares
@@ -14,10 +14,6 @@ export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
export interface FakeChatSocket {
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;
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
@@ -55,10 +51,8 @@ export function createFakeChatSocket(): {
/** Simulates socket.io-client's automatic reconnect of the *same*
* instance after a transient disconnect: marks the socket connected again
* and fires any handler(s) registered via `socket.on('connect', ...)`,
* without clearing or replacing any listeners. A real reconnect is assigned
* 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;
* without clearing or replacing any listeners. */
simulateReconnect(): void;
} {
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
const emitted: EmittedEvent[] = [];
@@ -69,7 +63,6 @@ export function createFakeChatSocket(): {
// type-checked against ServerToClientEvents/ClientToServerEvents.
const socket = {
connected: false,
id: 'socket-a',
connect: vi.fn(function connect(this: void) {
socket.connected = true;
return socket;
@@ -112,9 +105,8 @@ export function createFakeChatSocket(): {
}
}
function simulateReconnect(nextId: string = socket.id): void {
function simulateReconnect(): void {
socket.connected = true;
socket.id = nextId;
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
for (const handler of listeners.get(lifecycleKey) ?? []) {
(handler as () => void)();
@@ -21,7 +21,6 @@ vi.mock('@/lib/socket', () => ({
destroySocket: destroySocketMock,
}));
import type { ChatSendProtocol, HarnessSelection } from '@mosaicstack/types';
import { useChatConnection, type ChatConnectionValue } from './use-chat-connection';
let fake: ReturnType<typeof createFakeChatSocket>;
@@ -34,126 +33,6 @@ function Harness(): 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(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
@@ -188,20 +67,6 @@ afterEach(async () => {
});
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 () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
@@ -479,10 +344,7 @@ describe('useChatConnection', () => {
it('sendMessage emits optional conversationId/provider/modelId and appends an optimistic user turn', async () => {
await act(async () => {
latest?.actions.sendMessage({
content: 'hello',
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
});
latest?.actions.sendMessage({ content: 'hello', provider: 'anthropic', modelId: 'claude' });
});
expect(fake.emitted).toContainEqual({
@@ -511,408 +373,6 @@ 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 () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
@@ -1224,16 +684,7 @@ describe('useChatConnection', () => {
expect(latest?.state.approvalRequestPending).toBe(false);
// The send lock must also be released — a subsequent sendMessage after
// 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);
});
// reconnect must not be permanently blocked by the interrupted turn.
await act(async () => {
latest?.actions.sendMessage({ content: 'after reconnect' });
});
@@ -2214,319 +1665,4 @@ describe('useChatConnection', () => {
}
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);
});
});
});
+13 -250
View File
@@ -12,7 +12,6 @@ import {
import {
asConversationId,
asFiniteNumber,
asHarnessSelection,
asString,
asStringArray,
isRecord,
@@ -22,14 +21,10 @@ import type {
AgentStartPayload,
AgentTextPayload,
AgentThinkingPayload,
ChatSendCapabilityPayload,
ChatSendProtocol,
CommandDef,
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
MessageAckPayload,
SessionInfoPayload,
SessionUsagePayload,
@@ -135,42 +130,6 @@ const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please tr
* dropped. */
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`
* 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
@@ -277,14 +236,6 @@ export interface PendingApproval {
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 {
conversationId: string | null;
/** True once a message has been sent while no conversation is active yet, so the
@@ -317,18 +268,6 @@ export interface ChatConnectionState {
approvalRequestPending: boolean;
systemReload: SystemReloadPayload | 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[];
/** Monotonically increasing counter used to mint transcript message ids
* never reset while retained messages remain, so ids stay unique across the
@@ -369,7 +308,7 @@ export interface ChatConnectionState {
}
export interface ChatConnectionActions {
sendMessage: (input: { content: string; selection?: HarnessSelection }) => boolean;
sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void;
abort: () => void;
setThinking: (level: string) => void;
executeCommand: (input: { command: string; args?: string }) => void;
@@ -402,8 +341,6 @@ const initialState: ChatConnectionState = {
approvalRequestPending: false,
systemReload: null,
error: null,
sendProtocol: 'unavailable',
turnReceipt: null,
messages: [],
messageSeq: 0,
toolSeq: 0,
@@ -424,12 +361,7 @@ type Action =
| { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload }
| { type: 'server/system:reload'; payload: SystemReloadPayload }
| { type: 'server/error'; payload: ErrorPayload }
| { type: 'server/turn:ack'; payload: HarnessTurnAckPayload }
| { 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/consume-approval' }
| { type: 'local/approval-saturated' }
@@ -846,30 +778,6 @@ 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': {
const message: ChatTranscriptMessage = {
// Sourced from the reducer-owned `messageSeq` counter — see the
@@ -894,39 +802,6 @@ 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': {
// A transient socket disconnect must not leave the UI stuck waiting on
// a turn/approval/send that will never resolve on this connection.
@@ -1007,20 +882,6 @@ export function useChatConnection(): ChatConnectionValue {
approveLockRef.current = 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(() => {
const socket = getSocket();
@@ -1052,45 +913,7 @@ export function useChatConnection(): ChatConnectionValue {
const onError = (payload: ErrorPayload): void => {
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 => {
resetSendProtocol();
dispatch({ type: 'local/disconnect' });
};
@@ -1107,11 +930,6 @@ export function useChatConnection(): ChatConnectionValue {
socket.on('command:approval', onCommandApproval);
socket.on('system:reload', onSystemReload);
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);
if (!socket.connected) {
@@ -1132,79 +950,24 @@ export function useChatConnection(): ChatConnectionValue {
socket.off('command:approval', onCommandApproval);
socket.off('system:reload', onSystemReload);
socket.off('error', onError);
socket.off('turn:ack', onTurnAck);
socket.off('connect', onConnect);
socket.off('chat:send-capability', onCapability);
socket.off('disconnect', onDisconnect);
destroySocket();
};
}, []);
const actions: ChatConnectionActions = {
sendMessage: ({ content, selection }) => {
// Routing is PROTOCOL-driven, never inferred from conversation/selection/
// provider/local mode: the server advertised, once per connection, exactly
// how this connection may send, and that advertisement is authoritative.
// The exhaustive switch maps each protocol to its ONE event; the send path
// never reconnects the socket (a dropped connection has already reset the
// protocol to `unavailable`, so no emit branch is reachable while offline).
switch (protocolRef.current) {
case 'legacy-message': {
// Embedded/legacy runtime: EVERY browser turn — the first (which
// 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;
}
}
sendMessage: ({ content, provider, modelId }) => {
if (sendLockRef.current || state.streaming || state.sending) return;
sendLockRef.current = true;
const socket = getSocket();
if (!socket.connected) socket.connect();
dispatch({ type: 'local/send', content });
socket.emit('message', {
conversationId: state.conversationId ?? undefined,
content,
provider,
modelId,
});
},
abort: () => {
@@ -1,450 +0,0 @@
import { act, type ReactElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { useHarnessSelection, type HarnessSelectionValue } from './use-harness-selection';
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
interface Scenario {
harnesses?: unknown;
catalog?: { body: unknown; status?: number };
selection?: unknown;
/** When set, the PUT resolves only when this is called (for race tests). */
deferPut?: boolean;
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function defer<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
let putBodies: unknown[] = [];
let putDeferred: Deferred<Response> | null = null;
function installFetch(scenario: Scenario): ReturnType<typeof vi.fn> {
putBodies = [];
putDeferred = scenario.deferPut ? defer<Response>() : null;
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
const method = String(init?.method ?? 'GET').toUpperCase();
if (url === '/api/harnesses') return json(scenario.harnesses ?? []);
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
const spec = scenario.catalog ?? {
body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] },
};
return json(spec.body, spec.status ?? 200);
}
if (url === '/api/chat/preferences/selection' && method === 'GET') {
return json({ selection: scenario.selection ?? null });
}
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
putBodies.push(JSON.parse(String(init?.body)));
const ok = json({ selection: JSON.parse(String(init?.body)) });
if (putDeferred) return putDeferred.promise;
return ok;
}
return new Response('not found', { status: 404 });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
let latest: HarnessSelectionValue | null = null;
function Probe(): ReactElement | null {
latest = useHarnessSelection();
return null;
}
let root: Root | null;
let container: HTMLElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
beforeEach(() => {
latest = null;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
vi.unstubAllGlobals();
});
async function mount(): Promise<void> {
await act(async () => {
root?.render(<Probe />);
});
await flush();
}
async function flush(times = 5): Promise<void> {
for (let i = 0; i < times; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
function value(): HarnessSelectionValue {
if (!latest) throw new Error('hook value not captured');
return latest;
}
const PI_CATALOG = {
harnessId: 'pi',
version: '2026-08-11',
fingerprint: 'fp',
models: [
{
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
displayName: 'GPT-5',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
{
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
displayName: 'Claude',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
],
};
describe('useHarnessSelection', () => {
it('loads harnesses and, once a harness is chosen, the model options come only from its catalog', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: null,
});
await mount();
expect(value().harnesses).toEqual([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
expect(value().catalog).toBeNull();
await act(async () => {
value().selectHarness('pi');
});
await flush();
expect(value().catalog?.harnessId).toBe('pi');
expect(value().catalog?.models.map((m) => m.modelId)).toEqual(['gpt-5', 'claude']);
});
it('does not auto-select any catalog row when there is no persisted selection (no first-row fallback)', async () => {
const fetchMock = installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: null,
});
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
expect(value().modelId).toBe('');
expect(value().persistedSelection).toBeNull();
expect(value().canSend).toBe(false);
// Nothing was persisted — no PUT fired for an unset selection.
const putCalls = fetchMock.mock.calls.filter(
(c) => String((c[1] as RequestInit)?.method).toUpperCase() === 'PUT',
);
expect(putCalls).toHaveLength(0);
});
it('persists the structured tuple and only enables send AFTER the PUT resolves (no race ahead of persistence)', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: null,
deferPut: true,
});
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
await act(async () => {
value().selectProvider('openai');
});
await act(async () => {
value().selectModel('openai', 'gpt-5');
});
await flush();
// PUT is in flight (deferred) — send MUST NOT be enabled yet.
expect(value().canSend).toBe(false);
await act(async () => {
putDeferred?.resolve(
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
);
});
await flush();
expect(putBodies).toContainEqual({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' });
expect(value().persistedSelection).toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
expect(value().canSend).toBe(true);
// 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 () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'retired-model' },
});
await mount();
// The persisted tuple is displayed even though its model is gone from the catalog.
expect(value().persistedSelection).toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'retired-model',
});
expect(value().modelId).toBe('retired-model');
expect(value().isStale).toBe(true);
// A stale model is not a valid catalog option, so send stays disabled.
expect(value().canSend).toBe(false);
});
it('disables send for an empty catalog (no viable model) and never fabricates one', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] } },
selection: null,
});
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
expect(value().catalog?.models ?? []).toHaveLength(0);
expect(value().canSend).toBe(false);
});
it('marks the catalog unavailable and disables send when the catalog request 404s', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: {
body: { code: 'adapter_unavailable', message: 'x', harnessId: 'pi' },
status: 404,
},
selection: null,
});
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
expect(value().catalogUnavailable).toBe(true);
expect(value().canSend).toBe(false);
});
it('on a 422 persist, keeps the requested tuple visible, surfaces a typed error, and leaves send disabled', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: {
body: {
...PI_CATALOG,
models: [{ ...PI_CATALOG.models[0], availability: 'unavailable' }],
},
},
selection: null,
});
// Override PUT to 422.
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
const method = String(init?.method ?? 'GET').toUpperCase();
if (url === '/api/harnesses')
return json([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
if (url.endsWith('/catalog')) return json(PI_CATALOG);
if (url === '/api/chat/preferences/selection' && method === 'GET')
return json({ selection: null });
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
return json(
{
code: 'model_unavailable',
message: 'nope',
selection: { harnessId: 'a', providerId: 'b', modelId: 'c' },
},
422,
);
}
return new Response('nf', { status: 404 });
});
vi.stubGlobal('fetch', fetchMock);
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
await act(async () => {
value().selectProvider('openai');
});
await act(async () => {
value().selectModel('openai', 'gpt-5');
});
await flush();
expect(value().modelId).toBe('gpt-5');
expect(value().persistError?.code).toBe('model_unavailable');
expect(value().persistError?.requested).toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
expect(value().persistedSelection).toBeNull();
expect(value().canSend).toBe(false);
});
it('invalidates the model on a provider change and keeps send disabled until the new tuple persists', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: null,
});
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
await act(async () => {
value().selectProvider('openai');
});
await act(async () => {
value().selectModel('openai', 'gpt-5');
});
await flush();
// A valid provider-A tuple has persisted.
expect(value().canSend).toBe(true);
expect(value().persistedSelection).toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
// Switching provider clears the model that no longer belongs to it.
await act(async () => {
value().selectProvider('anthropic');
});
expect(value().modelId).toBe('');
expect(value().canSend).toBe(false);
// Send stays disabled until the new exact provider-B tuple persists.
await act(async () => {
value().selectModel('anthropic', 'claude');
});
await flush();
expect(value().canSend).toBe(true);
expect(value().persistedSelection).toEqual({
harnessId: 'pi',
providerId: '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 () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
deferPut: true,
});
await mount();
// The persisted, in-catalog tuple is sendable after mount (no PUT needed).
expect(value().canSend).toBe(true);
await act(async () => {
value().selectProvider('anthropic');
});
expect(value().modelId).toBe('');
expect(value().canSend).toBe(false);
await act(async () => {
value().selectModel('anthropic', 'claude');
});
await flush();
// PUT for the new tuple is still in flight — send MUST stay disabled.
expect(value().canSend).toBe(false);
await act(async () => {
putDeferred?.resolve(
json({ selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' } }),
);
});
await flush();
expect(value().canSend).toBe(true);
// 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 () => {
const fetchMock = installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
});
await mount();
await act(async () => {
value().selectProvider('anthropic');
});
await act(async () => {
value().selectModel('anthropic', 'claude');
});
await flush();
for (const call of fetchMock.mock.calls) {
expect(String(call[0])).not.toContain('/api/providers');
}
});
});
@@ -1,208 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
fetchCatalog,
fetchHarnesses,
fetchPersistedSelection,
persistSelection,
type SelectionErrorCode,
} from './chat-api';
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
export interface HarnessPersistError {
code: SelectionErrorCode;
message: string;
/** The exact tuple the user requested preserved so the failed selection
* stays visible rather than being silently dropped. */
requested: HarnessSelection;
}
export interface HarnessSelectionValue {
harnesses: HarnessSummary[];
catalog: HarnessCatalog | null;
/** True when the selected harness has no usable catalog (404/error). */
catalogUnavailable: boolean;
/** The working (displayed) selection, kept as three distinct ids. Empty
* strings mean "not chosen yet" there is deliberately no first-row default. */
harnessId: string;
providerId: string;
modelId: string;
/** The last tuple confirmed persisted by the server, or null. */
persistedSelection: HarnessSelection | null;
/** True when a persisted selection references a model no longer present as an
* available catalog entry it stays visibly displayed rather than dropped. */
isStale: boolean;
/** True ONLY once a full tuple has been confirmed persisted AND it is a
* currently-available catalog entry. Send stays disabled otherwise, so a send
* can never race ahead of successful persistence. */
canSend: boolean;
persistError: HarnessPersistError | null;
selectHarness: (harnessId: string) => void;
selectProvider: (providerId: string) => void;
/** Persist the EXACT catalog row's `{providerId, modelId}` the caller
* 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. */
selectModel: (providerId: string, modelId: string) => void;
}
/** A tuple is a currently-usable catalog option only when the catalog holds a
* matching, available entry the single gate that keeps a stale/unavailable
* model from ever counting as sendable. */
function isAvailableInCatalog(
selection: HarnessSelection | null,
catalog: HarnessCatalog | null,
): boolean {
if (selection === null || catalog === null) return false;
return catalog.models.some(
(model) =>
model.providerId === selection.providerId &&
model.modelId === selection.modelId &&
model.availability === 'available',
);
}
function tuplesEqual(a: HarnessSelection | null, b: HarnessSelection | null): boolean {
if (a === null || b === null) return a === b;
return a.harnessId === b.harnessId && a.providerId === b.providerId && a.modelId === b.modelId;
}
/**
* Owns the harness/catalog/selection state for the chat composer: loads the
* harness list and any persisted tuple on mount, loads a harness's catalog when
* chosen, and PUT-persists the full `{harnessId, providerId, modelId}` tuple
* when a model is picked. It never auto-selects a catalog row, keeps a
* stale/unavailable persisted tuple visible, and only reports `canSend` true
* once a full tuple has actually persisted as an available catalog entry.
*/
export function useHarnessSelection(): HarnessSelectionValue {
const [harnesses, setHarnesses] = useState<HarnessSummary[]>([]);
const [catalog, setCatalog] = useState<HarnessCatalog | null>(null);
const [catalogUnavailable, setCatalogUnavailable] = useState(false);
const [harnessId, setHarnessId] = useState('');
const [providerId, setProviderId] = useState('');
const [modelId, setModelId] = useState('');
const [persistedSelection, setPersistedSelection] = useState<HarnessSelection | null>(null);
const [persistError, setPersistError] = useState<HarnessPersistError | null>(null);
// Monotonic request ids so a slow in-flight catalog/persist response can never
// overwrite the result of a newer request the user has since triggered.
const catalogRequestRef = useRef(0);
const persistRequestRef = useRef(0);
const loadCatalog = useCallback(async (id: string): Promise<void> => {
const requestId = catalogRequestRef.current + 1;
catalogRequestRef.current = requestId;
setCatalog(null);
setCatalogUnavailable(false);
const result = await fetchCatalog(id);
if (catalogRequestRef.current !== requestId) return;
if (result.ok) {
setCatalog(result.catalog);
setCatalogUnavailable(false);
} else {
setCatalog(null);
setCatalogUnavailable(true);
}
}, []);
useEffect(() => {
let active = true;
void (async (): Promise<void> => {
const [list, persisted] = await Promise.all([fetchHarnesses(), fetchPersistedSelection()]);
if (!active) return;
setHarnesses(list);
if (persisted !== null) {
// Adopt the persisted tuple as the displayed selection and load its
// catalog. If the model has since been retired, it still shows (stale).
setHarnessId(persisted.harnessId);
setProviderId(persisted.providerId);
setModelId(persisted.modelId);
setPersistedSelection(persisted);
await loadCatalog(persisted.harnessId);
}
// No persisted selection → nothing is auto-selected; the user must choose.
})();
return () => {
active = false;
};
}, [loadCatalog]);
const selectHarness = useCallback(
(id: string): void => {
setHarnessId(id);
// Changing harness invalidates the provider/model draft — never carry a
// model across harnesses.
setProviderId('');
setModelId('');
setPersistError(null);
void loadCatalog(id);
},
[loadCatalog],
);
const selectProvider = useCallback((id: string): void => {
setProviderId(id);
// A new provider invalidates the chosen model — no cross-provider carryover.
setModelId('');
setPersistError(null);
}, []);
const selectModel = useCallback(
(selectedProviderId: string, selectedModelId: string): void => {
// Bind the model to the EXACT catalog row's provider — never to ambient
// provider state — so two providers exposing the same modelId can never
// collide or mis-resolve. Keep the displayed provider consistent with the
// resolved row.
setProviderId(selectedProviderId);
setModelId(selectedModelId);
setPersistError(null);
const requested: HarnessSelection = {
harnessId,
providerId: selectedProviderId,
modelId: selectedModelId,
};
const requestId = persistRequestRef.current + 1;
persistRequestRef.current = requestId;
void (async (): Promise<void> => {
const result = await persistSelection(requested);
if (persistRequestRef.current !== requestId) return;
if (result.ok) {
setPersistedSelection(result.selection);
setPersistError(null);
} else {
// Leave persistedSelection unchanged (send stays disabled) and surface
// the typed error carrying the exact requested tuple.
setPersistError({
code: result.code,
message: result.message,
requested: result.requested,
});
}
})();
},
[harnessId],
);
const draft: HarnessSelection = { harnessId, providerId, modelId };
const isStale = persistedSelection !== null && !isAvailableInCatalog(persistedSelection, catalog);
const canSend =
persistedSelection !== null &&
!catalogUnavailable &&
tuplesEqual(draft, persistedSelection) &&
isAvailableInCatalog(persistedSelection, catalog);
return {
harnesses,
catalog,
catalogUnavailable,
harnessId,
providerId,
modelId,
persistedSelection,
isStale,
canSend,
persistError,
selectHarness,
selectProvider,
selectModel,
};
}
+7 -498
View File
@@ -38,128 +38,6 @@ function findButton(container: HTMLElement, text: string): HTMLButtonElement {
return button;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const DEFAULT_CATALOG = {
harnessId: 'pi',
version: '2026-08-11',
fingerprint: 'fp',
models: [
{
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
displayName: 'GPT-5',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
{
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
displayName: 'Claude',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
],
};
/** A harness/catalog/selection HTTP stub for the chat-api the selection hook
* drives. `selection` seeds the persisted tuple returned by the GET (a valid
* in-catalog tuple by default, so `canSend` settles true after mount). */
function harnessFetch(
selection: unknown = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
): typeof fetch {
return vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
const method = String(init?.method ?? 'GET').toUpperCase();
if (url === '/api/harnesses') {
return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
}
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
return jsonResponse(DEFAULT_CATALOG);
}
if (url === '/api/chat/preferences/selection' && method === 'GET') {
return jsonResponse({ selection });
}
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
return jsonResponse({ selection: JSON.parse(String(init?.body)) });
}
return new Response('not found', { status: 404 });
}) as unknown as typeof fetch;
}
/** Drains the selection hook's chained mount fetches (harnesses selection
* catalog) and any pending PUT so derived `canSend` settles before assertions. */
async function flushAsync(times = 5): Promise<void> {
for (let i = 0; i < times; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
/** 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 root: Root | null;
let container: HTMLElement;
@@ -179,22 +57,12 @@ beforeEach(async () => {
fake = createFakeChatSocket();
getSocketMock.mockReset().mockReturnValue(fake.socket);
destroySocketMock.mockReset();
vi.stubGlobal('fetch', harnessFetch());
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<ChatPage />);
});
// Settle the selection hook's mount fetches so the default in-catalog tuple
// persists and `canSend` is true for the existing send-path tests.
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 () => {
@@ -202,28 +70,8 @@ afterEach(async () => {
root?.unmount();
});
document.body.replaceChildren();
vi.unstubAllGlobals();
});
/** Re-mounts ChatPage against a custom fetch stub (e.g. an unset selection) for
* tests that need a non-default selection scenario. */
async function remountWithFetch(fetchImpl: typeof fetch): Promise<void> {
await act(async () => {
root?.unmount();
});
vi.stubGlobal('fetch', fetchImpl);
root = createRoot(container);
await act(async () => {
root?.render(<ChatPage />);
});
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', () => {
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
await act(async () => {
@@ -542,62 +390,24 @@ describe('ChatPage', () => {
expect(container.querySelector('[role="alert"]')).toBeTruthy();
});
it('renders harness and provider as separate selects (not merged) and no free-text provider/model inputs', async () => {
// The old free-text inputs are gone.
expect(container.querySelector('input[aria-label="Provider"]')).toBeNull();
expect(container.querySelector('input[aria-label="Model"]')).toBeNull();
const harnessSelect = container.querySelector(
'select[aria-label="Harness"]',
) as HTMLSelectElement;
const providerSelect = container.querySelector(
'select[aria-label="Provider"]',
) as HTMLSelectElement;
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
expect(harnessSelect).toBeTruthy();
expect(providerSelect).toBeTruthy();
expect(modelSelect).toBeTruthy();
// Harness and provider are distinct controls carrying distinct identifiers.
expect(harnessSelect).not.toBe(providerSelect);
expect([...harnessSelect.options].map((o) => o.value)).toContain('pi');
expect([...providerSelect.options].map((o) => o.value)).toContain('openai');
expect([...providerSelect.options].map((o) => o.value)).toContain('anthropic');
// The model options are catalog-derived (not hardcoded) and scoped to the
// selected provider (openai, from the persisted tuple) using a collision-safe
// composite identity — the anthropic row is absent, not a bare 'claude'.
const modelValues = [...modelSelect.options].map((o) => o.value);
expect(modelValues).toContain('openai:gpt-5');
expect(modelValues).not.toContain('anthropic:claude');
expect(modelValues).not.toContain('claude');
});
it('sends provider/model derived from the persisted catalog tuple (never free text) and emits abort from Stop', async () => {
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
const providerInput = container.querySelector(
'input[aria-label="Provider"]',
) as HTMLInputElement;
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
const stopButtonBefore = container.querySelector(
'button[aria-label="Stop"]',
) as HTMLButtonElement;
expect(stopButtonBefore.disabled).toBe(true);
// Choose a fresh tuple from the catalog 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 () => {
// Composite provider+model option identity (provider was switched to
// anthropic above); the bare 'claude' no longer identifies an option.
selectValue(modelSelect, 'anthropic:claude');
});
await flushAsync();
await act(async () => {
setValue(textarea, 'hello there');
setValue(providerInput, 'anthropic');
setValue(modelInput, 'claude');
});
await act(async () => {
textarea.dispatchEvent(
@@ -605,7 +415,6 @@ describe('ChatPage', () => {
);
});
// The projected provider/model come from the validated persisted tuple.
expect(fake.emitted).toContainEqual({
event: 'message',
payload: {
@@ -634,178 +443,6 @@ describe('ChatPage', () => {
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 () => {
await remountWithFetch(harnessFetch(null));
const sendButton = findButton(container, 'Send');
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'should not send');
});
// Content present, but no selection persisted → Send stays disabled.
expect(sendButton.disabled).toBe(true);
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(0);
});
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
@@ -983,134 +620,6 @@ describe('ChatPage', () => {
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
});
it('scopes the model options to the intentionally selected provider (cross-provider models absent)', async () => {
await remountWithFetch(harnessFetch(null));
const harnessSelect = container.querySelector(
'select[aria-label="Harness"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(harnessSelect, 'pi');
});
await flushAsync();
const providerSelect = container.querySelector(
'select[aria-label="Provider"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(providerSelect, 'openai');
});
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== '');
// Only the selected provider's models are offered — provider B's model
// (anthropic:claude) is absent, so a user cannot pick across providers.
expect(optionValues).toEqual(['openai:gpt-5']);
expect(optionValues).not.toContain('anthropic:claude');
});
it('keeps identical modelIds under two providers distinct and resolves the pick to the exact tuple', async () => {
const COLLIDING_CATALOG = {
harnessId: 'pi',
version: '2026-08-11',
fingerprint: 'fp',
models: [
{
harnessId: 'pi',
providerId: 'alpha',
modelId: 'gpt-x',
displayName: 'Alpha GPT-X',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
{
harnessId: 'pi',
providerId: 'beta',
modelId: 'gpt-x',
displayName: 'Beta GPT-X',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
],
};
const collidingFetch = vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
const method = String(init?.method ?? 'GET').toUpperCase();
if (url === '/api/harnesses') {
return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
}
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
return jsonResponse(COLLIDING_CATALOG);
}
if (url === '/api/chat/preferences/selection' && method === 'GET') {
return jsonResponse({ selection: null });
}
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
return jsonResponse({ selection: JSON.parse(String(init?.body)) });
}
return new Response('not found', { status: 404 });
}) as unknown as typeof fetch;
await remountWithFetch(collidingFetch);
const harnessSelect = container.querySelector(
'select[aria-label="Harness"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(harnessSelect, 'pi');
});
await flushAsync();
const providerSelect = container.querySelector(
'select[aria-label="Provider"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(providerSelect, 'alpha');
});
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
// The colliding modelId is provider-qualified in the option value, never a
// bare id, so the two providers' 'gpt-x' rows are uniquely identifiable.
const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== '');
expect(optionValues).toEqual(['alpha:gpt-x']);
await act(async () => {
selectValue(modelSelect, 'alpha:gpt-x');
});
await flushAsync();
// The controlled select highlights the alpha row via the composite identity.
expect(modelSelect.value).toBe('alpha:gpt-x');
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'ping');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
// The persisted/sent tuple resolves to provider alpha — NOT beta — even
// though the bare modelId 'gpt-x' exists under both providers.
expect(fake.emitted).toContainEqual({
event: 'message',
payload: {
conversationId: undefined,
content: 'ping',
provider: 'alpha',
modelId: 'gpt-x',
},
});
});
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
expect(fake.listeners.size).toBeGreaterThan(0);
-3
View File
@@ -6,7 +6,6 @@ import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
import { SessionPanel } from '@/spa/chat/session-panel';
import { ToolCallList } from '@/spa/chat/tool-call-list';
import { useChatConnection } from '@/spa/chat/use-chat-connection';
import { useHarnessSelection } from '@/spa/chat/use-harness-selection';
/** Renders a real value normally, but an honest "unavailable" label instead
* of a fabricated `0` for a missing/malformed count a real `0 tokens` and
@@ -24,7 +23,6 @@ function formatCost(value: unknown): string {
export function ChatPage(): ReactElement {
const { state, actions } = useChatConnection();
const harness = useHarnessSelection();
const hasConversation = state.conversationId !== null;
return (
@@ -88,7 +86,6 @@ export function ChatPage(): ReactElement {
streaming={state.streaming}
sending={state.sending}
hasConversation={hasConversation}
harness={harness}
/>
</div>
);
@@ -1,71 +0,0 @@
# #1019 — Zero-timeout queue-guard harness race
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
- **Branch:** `fix/1019-ci-queue-timeout-harness`
- **Owner:** `be-coder-08`
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
## Objective
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
## Scope
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
## Acceptance criteria
1. RED deterministically reproduces deadline pre-emption before the provider call.
2. Every case that intends status classification positively proves provider observation.
3. Pending observes `pending` before deterministic virtual-time expiration.
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
5. The exact CI-base image passes the final harness repeatedly with zero failures.
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
## Plan
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
## Budget
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
## Evidence
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log`**100 pass / 0 fail / 100 total**.
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
## Review remediation — semantic timeout vs. liveness bound
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
Remediation:
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
Post-review evidence:
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
## 60% context hold
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
@@ -1,202 +0,0 @@
# AMD1213-D — transaction and helper trust remediation
- **Task:** AMD1213-D (issue #1213 amendment; controlling packet `comms/20260813T212447Z__from-scrappy__dc43de.md`)
- **Objective:** Address D1D6 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 D1D6, 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 D1D6 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.
@@ -1,93 +0,0 @@
# W-B — Measure Pi's real tool registry
- **Task / internal ref:** W-B from the lease-remediation orchestrator brief (no matching `docs/TASKS.md` row; workers do not modify that file)
- **Objective:** identify the exact tool names emitted as `event.toolName` by the installed Pi runtime and compare them with the broker's Pi read-only carve-out.
- **Scope:** measurement and report only; no broker or runtime source changes. W-C is out of scope.
- **Budget:** no explicit token cap; constrained to this scratchpad and one local commit.
- **Installed runtime:** `@earendil-works/pi-coding-agent` / `pi` `0.84.1`.
## Method
I created a throwaway extension at `/tmp/measure-pi-tool-registry.ts` (not in the worktree). On `session_start` it recorded `pi.getAllTools()` and `pi.getActiveTools()`; on every `tool_call` it appended the exact `event.toolName`. I then launched an isolated, ephemeral Pi session with all built-ins explicitly selected:
```text
PI_OFFLINE=1 pi --mode print --no-session --no-approve \
--no-context-files --no-skills --no-prompt-templates --no-extensions \
-e /tmp/measure-pi-tool-registry.ts \
--tools read,bash,edit,write,grep,find,ls <deterministic probe prompt>
```
The prompt exercised file read, content search, file search, directory listing, shell execution, file write, and file edit. Pi exited `0`; every selected tool produced one `tool_call`. The write/edit control artifact ended with exact content `after`, proving the mutating calls executed in order.
This runtime observation was cross-checked against the installed distribution's canonical registry at `dist/core/tools/index.js:17`, which declares the same seven names. The gate consumes the measured field directly at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:368`.
## Exact distinct built-in set
The installed Pi built-in registry is exactly:
```text
{bash, edit, find, grep, ls, read, write}
```
| Tool | Runtime registry observation | `tool_call` observation | Installed definition |
| --- | --- | --- | --- |
| `read` | `<builtin:read>` | observed once | `dist/core/tools/read.js:138` |
| `bash` | `<builtin:bash>` | observed once | `dist/core/tools/bash.js:231` |
| `edit` | `<builtin:edit>` | observed once | `dist/core/tools/edit.js:170` |
| `write` | `<builtin:write>` | observed once | `dist/core/tools/write.js:138` |
| `grep` | `<builtin:grep>` | observed once | `dist/core/tools/grep.js:79` |
| `find` | `<builtin:find>` | observed once | `dist/core/tools/find.js:79` |
| `ls` | `<builtin:ls>` | observed once | `dist/core/tools/ls.js:61` |
The raw distinct `event.toolName` result was:
```json
["bash", "edit", "find", "grep", "ls", "read", "write"]
```
Pi registers all seven, but its default active set is only `read`, `bash`, `edit`, and `write` (`dist/core/sdk.js:132`). The probe explicitly activated all seven so the three search/list tools could be observed at the hook.
## Positive control
The known `read` tool was the control. The method surfaced it twice:
1. `pi.getAllTools()` returned `read` with source path `<builtin:read>`.
2. Reading `/tmp/pi-registry-probe/seed.txt`, which contained `CONTROL_TOKEN`, produced one hook record with `event.toolName === "read"`.
The control was therefore positive; the seven-name result is measured, not an empty-probe inference.
## Carve-out comparison and collision result
The broker currently declares `{"read", "grep", "find", "ls"}` at `packages/mosaic/framework/tools/lease-broker/daemon.py:54`.
- `read`: real built-in.
- `grep`: real built-in.
- `find`: real built-in.
- `ls`: real built-in.
All four carve-out names are exact, case-sensitive Pi tool names.
The general execution/writing tool names are `bash`, `edit`, and `write`. Their intersection with the carve-out is empty:
```text
{bash, edit, write} ∩ {read, grep, find, ls} = ∅
```
Therefore no general shell-exec or file-mutating Pi tool shares a name with a carve-out entry. `grep` and `find` may invoke constrained search helpers internally, but neither exposes an arbitrary command interface; the arbitrary command tool is distinctly named `bash`.
The Mosaic extension separately registers the non-built-in custom tool `mosaic_context_recover` at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:379`; the broker handles that identity through its dedicated recovery exemption rather than the read-only set (`daemon.py:722`). Unknown or third-party custom tools are not part of Pi's built-in seven-name registry and remain outside the carve-out.
## Verification evidence
- `pi --version``0.84.1`.
- Isolated probe exit → `0`.
- Runtime `getAllTools()` count → `7`, all with `sourceInfo.source === "builtin"`.
- Distinct hook names → `bash`, `edit`, `find`, `grep`, `ls`, `read`, `write`.
- Hook counts → exactly one call for each of the seven names.
- Mutation artifact after `write` then `edit` → exact content `after`.
- Installed registry source → `allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"])`.
## Risks / limitations
- The probe deliberately disabled all other extensions, so extension-defined third-party tools were excluded from the built-in registry measurement. The production gate still receives those names and treats names outside the broker carve-out as mutating/fail-closed.
- Explicit `--tools` activation was required to exercise `grep`, `find`, and `ls`; this does not imply they are active in Pi's default four-tool configuration.
@@ -1,99 +0,0 @@
# PR merge squash message field
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
- **Owner:** `be-coder-08`
- **Branch:** `fix/pr-merge-message-field`
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
## Objective
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
## Binding requirements
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
## Derived interface decisions
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
## Canonical delivery plan
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
## Evidence
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
## Remediation and current review state
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
4. Codex review rounds 35 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
## Disposable provider fixture acceptance
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
## Fixture preflight
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
## Fixture result
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
- Trailer-shaped lines, verbatim and in order:
1. `Co-authored-by: be-coder-07 <[email protected]>`
2. `Co-authored-by: be-coder-08 <[email protected]>`
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
## Current hold point
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
## Security review 96 remediation
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
Security remediation:
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
@@ -39,7 +39,6 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
| TypeScript strict typing | `guides/TYPESCRIPT.md` |
| QA / test strategy | `guides/QA-TESTING.md` |
| Documentation (any code/API/auth/infra change) | `guides/DOCUMENTATION.md` |
| Writing style (docs, comms, any prose) | `guides/WRITING-STYLE.md` |
| Secrets / vault usage | `guides/VAULT-SECRETS.md` |
| Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` |
| Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` |
@@ -27,14 +27,6 @@ Master/slave model:
- Do not perform destructive git/file actions without explicit instruction.
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
### Output standards (writing + code)
- Technical documentation follows **MOS-STE** (Mosaic Simplified Technical English — an adapted ASD-STE100 profile): short sentences, one instruction per sentence, active voice, one word per meaning, one term per concept. Full rules: `~/.config/mosaic/guides/WRITING-STYLE.md`.
- Apply MOS-STE **hardest to verification artifacts** (acceptance criteria, witness predicates, gate/alarm conditions). There an ambiguous term produces a false green, not just a confused reader.
- Source code follows the **Google Style Guide** for the language.
- User-facing comms follow the user's declared `communicationStyle` in `USER.md` "Communication Preferences" (`direct` | `friendly` | `formal`, default `direct`); `guides/WRITING-STYLE.md` §5 maps each value to output. The documentation standard does not change with user preference.
- **Carve-out:** MOS-STE does NOT apply to content that must carry a specific human voice (letters, personal or marketing prose, voice-matched output). A declared voice profile wins.
### Secrets handling (HARD RULE)
- Vault is the canonical source-of-truth for every secret in every environment. No exceptions.
@@ -1,134 +0,0 @@
# Writing Style Standard — MOS-STE (MANDATORY)
This guide defines how agents write. It sets one style standard per output type.
It is written in the standard it defines, as a worked example.
**Adapted, not compliant.** MOS-STE (Mosaic Simplified Technical English) is an
adapted profile of ASD-STE100. Mosaic does not license or certify against
ASD-STE100. Mosaic uses the load-bearing rules and fits them to agent work. This
is the same stance Mosaic takes toward DO-178B/C: use the rigor, do not claim the
certification.
## Scope — which standard governs which output
| Output type | Standard |
| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Technical documentation (READMEs, runbooks, PRDs, procedures, ADRs, guides, acceptance criteria, design docs) | **MOS-STE** (this guide) |
| Source code and code comments | **Google Style Guide** for the language (§4) |
| Inter-agent comms | MOS-STE by default (concise, structured) |
| User-facing comms | **Per-user style choice** — read `USER.md` "Communication Preferences" (§5) |
| End-user prose the user owns (marketing, letters, personal writing, voice-matched content) | The user's declared voice. MOS-STE does NOT apply. |
**The user-voice carve-out is absolute.** Do not apply MOS-STE to content that
must carry a specific human voice (for example a cover letter, a personal
message, or marketing copy). That content needs the user's voice. MOS-STE would
damage it. When a project declares a voice profile, that profile wins.
## 1. Why one standard
Agent documentation drifts across projects. Different agents use different terms,
sentence styles, and structures for the same concept. Readers lose time.
Assumptions hide in ambiguous prose. One standard gives agents a clear target. It
gives reviewers a clear test.
## 2. Where MOS-STE matters most — verification artifacts
Apply MOS-STE hardest to acceptance criteria, witness predicates, gate
definitions, and alarm conditions. In prose, an ambiguous term produces a
confused reader. In a verification artifact, an ambiguous term produces a false
green — a check that passes without testing the claim.
The one-term-one-concept rule (rule 9) is the guard. When one word names two
concepts in one predicate, the check can test the wrong concept and still pass.
**Worked failure.** A rename used a witness predicate with three clauses: ref A
present, ref B absent, tip committed from this host. Every clause tested the git
_ref_ (the channel). The claim under test was about a _field inside the payload_.
The word "beacon" named two concepts in one sentence. Deleting ref B was the next
scheduled step. That step flips the last clause green and certifies a state in
which the payload still names the wrong host. The predicate was one planned action
away from a false green on its normal path. The payload field was never tested.
Rule: when N failure modes share one observable, the observable is not a
diagnostic. In a verification artifact, that ambiguity does not confuse a reader —
it certifies the defect.
## 3. MOS-STE rules
### 3.1 Sentence rules
1. Keep sentences short. Use 20 words or fewer for a procedure. Use 25 words or
fewer for a description. (Reasoning and doctrine prose relaxes this limit —
see §3.4. A future lint enforces §3.1, not §3.4.)
2. Write one instruction per sentence. In a procedure, give one command per step.
3. Use the active voice. Write "Run the script." Do not write "The script should
be run."
4. Use the imperative for instructions. Start the sentence with the verb.
5. Use simple verb tenses. Prefer the present tense. Avoid the perfect and
progressive tenses when a simple tense works.
6. Do not use an `-ing` form when it makes the meaning unclear.
7. Write positive statements. State what to do, not only what to avoid.
### 3.2 Word rules
8. Use one word for one meaning. Do not use the same word in two senses.
9. Use one term for one concept. Do not use synonyms for variety. Example: choose
`secret`, `credential`, or `key` for each concept, and keep it.
10. Use articles (`a`, `the`). Do not drop words to save space.
11. Keep an approved-terms glossary per project. Add each domain noun and each
chosen verb. Technical names (for example `Vault`, `cgroup`, `systemd`) are
always allowed.
12. Define an abbreviation at its first use. Then use it consistently.
### 3.3 Structure rules
13. Use a list for parallel items or sequential steps. Do not put them in one long
sentence.
14. Use a table for data with more than two dimensions.
15. Use parallel structure in headings and steps.
16. Repeat the noun. Do not use a pronoun when the reference is unclear.
### 3.4 Adaptation notes (where MOS-STE deviates from ASD-STE100, and why)
- **No licensed dictionary.** ASD-STE100 ships a controlled dictionary under
copyright. MOS-STE uses per-project glossaries instead (rule 11).
- **Domain terms are allowed.** MOS-STE keeps every term the work needs.
- **Reasoning prose gets structure, not amputation.** Apply the sentence and word
rules to design and doctrine writing. Allow the length a subtle argument needs.
Readable-first beats rule-strict when the two conflict.
## 4. Code — Google Style Guide
Write source code to the Google Style Guide for the language (Python, TypeScript,
Shell, Go, and so on). Match the existing file when a local convention already
exists. Keep code comments to the MOS-STE sentence and word rules.
## 5. User-facing comms — a per-user choice
Mosaic is multi-user. Different users want different comms styles. The framework
already carries the selectable setting: `communicationStyle` (`direct` |
`friendly` | `formal`, default `direct`). `mosaic init` writes it, and the
builder renders it into the generated `USER.md` "Communication Preferences"
section. This guide adds the OUTPUT meaning of each value; do not invent new
values.
The builder renders the style as prose bullets, not the token name, so match on
the leading bullet the generated `USER.md` actually contains:
| `USER.md` leading bullet | Style | User-facing output |
| ----------------------------- | ------------------ | ---------------------------------------------------------------------- |
| "Direct and concise" | `direct` (default) | MOS-STE structure — short, active, defined terms, tables for overview. |
| "Warm and conversational" | `friendly` | Warmer register. Full sentences, explain reasoning, fewer tables. |
| "Professional and structured" | `formal` | Professional and structured. Thorough, with explicit recommendations. |
This setting governs **user-facing comms only**. It does not change the
documentation standard (§3), which is always MOS-STE regardless of the value.
## 6. Enforcement
- **Now:** human review only. **No mechanical prose check exists today.** The
pre-push gate runs typecheck, lint, build, and tests; it inspects no prose.
Reviewers check output against the scope table and the MOS-STE rules by hand.
- **Future:** an MOS-STE lint check (built from the §3.1 sentence rules) and a
Google-style linter in the pre-push gate. A future linter enforces §3.1, not
§3.4 — see the note at rule 1.
@@ -1 +0,0 @@
Mosaic lease promotion was processed mechanically; no action is needed.
@@ -1,89 +0,0 @@
{
"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,7 +1,48 @@
{
"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"
}
]
}
],
"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": [
@@ -38,6 +79,11 @@
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
"timeout": 3
},
{
"type": "command",
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
@@ -257,11 +303,5 @@
"cpan",
"nohup"
],
"enableAllMcpTools": true,
"mcpServers": {
"sequential-thinking": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
}
}
"enableAllMcpTools": true
}
@@ -1,17 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# 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.
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
MODE="apply"
RUNTIME="all"
STRICT_CHECK=0
CLAUDE_CONFIG_DIR=""
PYTHON_BIN=""
NODE_BIN=""
NPX_BIN=""
TIMEOUT_BIN=""
PKG="@modelcontextprotocol/server-sequential-thinking"
err() { echo "[mosaic-seq] ERROR: $*" >&2; }
@@ -19,130 +13,250 @@ log() { echo "[mosaic-seq] $*"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--check) MODE="check"; shift ;;
--runtime) RUNTIME="${2:?--runtime requires a value}"; shift 2 ;;
--strict) STRICT_CHECK=1; shift ;;
--claude-config-dir) CLAUDE_CONFIG_DIR="${2:?--claude-config-dir requires a value}"; shift 2 ;;
--python-bin) PYTHON_BIN="${2:?--python-bin requires a value}"; shift 2 ;;
--node-bin) NODE_BIN="${2:?--node-bin requires a value}"; shift 2 ;;
--npx-bin) NPX_BIN="${2:?--npx-bin requires a value}"; shift 2 ;;
--timeout-bin) TIMEOUT_BIN="${2:?--timeout-bin requires a value}"; shift 2 ;;
*) err "Unknown argument: $1"; exit 2 ;;
--check)
MODE="check"
shift
;;
--runtime)
if [[ $# -lt 2 ]]; then
err "--runtime requires a value: claude|codex|opencode|all"
exit 2
fi
RUNTIME="$2"
shift 2
;;
--strict)
STRICT_CHECK=1
shift
;;
*)
err "Unknown argument: $1"
exit 2
;;
esac
done
case "$RUNTIME" in all|claude|codex|opencode) ;; *) err "Invalid runtime: $RUNTIME"; exit 2;; esac
# Explicit fleet-seat operation is capability-minimal. Legacy operator repair
# keeps its documented PATH-based compatibility contract.
if [[ -n "$CLAUDE_CONFIG_DIR" || -n "$PYTHON_BIN$NODE_BIN$NPX_BIN$TIMEOUT_BIN" ]]; then
[[ -n "$PYTHON_BIN" && -n "$NODE_BIN" && -n "$NPX_BIN" ]] || { err "Fleet capabilities are required"; exit 2; }
else
PYTHON_BIN=python3
NODE_BIN=node
NPX_BIN=npx
TIMEOUT_BIN=timeout
fi
case "$RUNTIME" in
all|claude|codex|opencode) ;;
*)
err "Invalid runtime: $RUNTIME (expected claude|codex|opencode|all)"
exit 2
;;
esac
require_binary() {
local name="$1"
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() {
local timeout_sec="${MOSAIC_SEQ_WARM_TIMEOUT_SEC:-15}"
if [[ -n "$TIMEOUT_BIN" ]]; then "$TIMEOUT_BIN" "$timeout_sec" "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1
else "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1; fi
if command -v timeout >/dev/null 2>&1; then
timeout "$timeout_sec" npx -y "$PKG" --help >/dev/null 2>&1
else
npx -y "$PKG" --help >/dev/null 2>&1
fi
}
claude_config_python='import json, os, stat, tempfile
check_claude_config() {
python3 - <<'PY'
import json
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
}
def die(): raise SystemExit(1)
def secure_dir(p):
p=Path(p)
if not p.is_absolute(): die()
# Every parent may be sticky /tmp, but none may be a symlink. The fleet
# config root itself must be private and owned by the invoking principal.
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()
apply_claude_config() {
python3 - <<'PY'
import json
from pathlib import Path
p = Path.home() / ".claude" / "settings.json"
p.parent.mkdir(parents=True, exist_ok=True)
if p.exists():
try:
s=os.fstat(fd)
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=b""
while len(data)<=1048576:
c=os.read(fd,65536)
if not c: break
data+=c
if len(data)>1048576: die()
return data, (s.st_dev,s.st_ino)
finally: os.close(fd)
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
data = {}
else:
data = {}
mcp = data.get("mcpServers")
if not isinstance(mcp, dict):
mcp = {}
mcp["sequential-thinking"] = {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
}
data["mcpServers"] = mcp
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
PY
}
def entry_ok(data):
try: d=json.loads(data.decode()); e=d.get("mcpServers",{}).get("sequential-thinking",{})
except Exception: return False
return e.get("command")=="npx" and e.get("args")==["-y","@modelcontextprotocol/server-sequential-thinking"]
check_codex_config() {
local cfg="$HOME/.codex/config.toml"
[[ -f "$cfg" ]] || return 1
grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \
grep -q '^command = "npx"' "$cfg" && \
grep -q '@modelcontextprotocol/server-sequential-thinking' "$cfg"
}
def explicit_check_or_apply(apply):
root=secure_dir(os.environ["CLAUDE_CONFIG_DIR"]); p=root/".claude.json"
if not apply: return 0 if entry_ok(read_private(str(p))[0]) else 1
old={}; identity=None
if os.path.lexists(p):
raw,identity=read_private(str(p))
try: old=json.loads(raw.decode())
except Exception: old={}
mcp=old.get("mcpServers") if isinstance(old.get("mcpServers"),dict) else {}
mcp["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; old["mcpServers"]=mcp
fd,tmp=tempfile.mkstemp(prefix=".claude.json.",dir=root)
apply_codex_config() {
local cfg="$HOME/.codex/config.toml"
mkdir -p "$(dirname "$cfg")"
[[ -f "$cfg" ]] || touch "$cfg"
local tmp
tmp="$(mktemp)"
awk '
BEGIN { skip = 0 }
/^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]/ { skip = 1; next }
skip && /^\[/ { skip = 0 }
!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:
os.fchmod(fd,0o600); os.write(fd,(json.dumps(old,indent=2)+"\n").encode()); os.fsync(fd); os.close(fd)
try: now=os.lstat(p); current=(now.st_dev,now.st_ino)
except FileNotFoundError: current=None
if current!=identity: die()
os.replace(tmp,p)
finally:
try: os.close(fd)
except OSError: pass
try: os.unlink(tmp)
except FileNotFoundError: pass
return 0
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
data = {}
else:
data = {}
mcp = data.get("mcp")
if not isinstance(mcp, dict):
mcp = {}
mcp["sequential-thinking"] = {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"],
"enabled": True
}
data["mcp"] = mcp
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
PY
}
if os.environ.get("CLAUDE_CONFIG_DIR"):
raise SystemExit(explicit_check_or_apply(os.environ.get("SEQ_APPLY")=="1"))
# Compatibility path is intentionally not fleet-authoritative.
p=Path.home()/".claude.json"
if not p.exists() and not os.environ.get("SEQ_APPLY")=="1": p=Path.home()/".claude"/"settings.json"
if os.environ.get("SEQ_APPLY")=="1":
try: d=json.loads(p.read_text()) if p.exists() else {}
except Exception: d={}
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
p.parent.mkdir(parents=True,exist_ok=True); p.write_text(json.dumps(d,indent=2)+"\n")
raise SystemExit(0)
try: raise SystemExit(0 if entry_ok(p.read_bytes()) else 1)
except Exception: raise SystemExit(1)'
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
}
check_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=0 "$PYTHON_BIN" -c "$claude_config_python"; }
apply_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=1 "$PYTHON_BIN" -c "$claude_config_python"; }
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_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
for line in (p.read_text().splitlines() if p.exists() else []):
if re.match(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]$",line): skip=True; continue
if skip and line.startswith("["): skip=False
if not skip: out.append(line)
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)' ; }
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")'; }
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; }
if [[ "$MODE" == check ]]; then
apply_runtime_config() {
case "$RUNTIME" in
all)
apply_claude_config
apply_codex_config
apply_opencode_config
;;
claude)
apply_claude_config
;;
codex)
apply_codex_config
;;
opencode)
apply_opencode_config
;;
esac
}
if [[ "$MODE" == "check" ]]; then
check_software
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
log "sequential-thinking MCP is configured and available (${RUNTIME})"; exit 0
# Runtime launch checks should be local/fast by default.
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
warm_package || { err "sequential-thinking package warm-up failed"; exit 1; }
apply_runtime_config
log "sequential-thinking MCP configured (${RUNTIME})"
@@ -233,14 +233,6 @@ for runtime_file in \
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
done
if [[ -d "$MOSAIC_HOME/runtime/claude/commands" ]]; then
mkdir -p "$HOME/.claude/commands"
for command_file in "$MOSAIC_HOME/runtime/claude/commands/"*; do
[[ -f "$command_file" ]] || continue
copy_file_managed "$command_file" "$HOME/.claude/commands/$(basename "$command_file")"
done
fi
# OpenCode runtime adapter (thin pointer to AGENTS.md)
opencode_adapter="$MOSAIC_HOME/runtime/opencode/AGENTS.md"
if [[ -f "$opencode_adapter" ]]; then
@@ -153,24 +153,7 @@ if [[ $link_only -eq 1 ]]; then
exit 0
fi
# Skills are linked into the MOSAIC-OWNED harness homes, never a base install.
# Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in
# commands/launch.js):
# claude CLAUDE_CONFIG_DIR -> <home>/skills
# pi PI_CODING_AGENT_DIR -> <home>/skills (replaces ~/.pi/agent)
# codex CODEX_HOME -> <home>/skills
# opencode XDG_CONFIG_HOME -> <home>/opencode/skills (XDG adds a level)
link_targets=(
"$MOSAIC_HOME/.claude/skills"
"$MOSAIC_HOME/.codex/skills"
"$MOSAIC_HOME/.opencode/opencode/skills"
"$MOSAIC_HOME/.pi/skills"
)
# Pre-isolation installs planted the same symlink farm directly in the operator's
# base installs. Those are now orphaned: the launcher no longer reads them, but
# they persist and make a "clean" base install look mosaic-managed.
legacy_link_targets=(
"$HOME/.claude/skills"
"$HOME/.codex/skills"
"$HOME/.config/opencode/skills"
@@ -262,72 +245,13 @@ prune_stale_links_in_target() {
# -m resolves lexical dangling targets too. If resolution fails, ownership
# is unproven and the link must be preserved.
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
# $canonical_real must be length-checked BEFORE use as a prefix: if it were
# ever empty, "$resolved" == "$canonical_real/"* collapses to == "/"* and
# matches every absolute path. Combined with the is_mosaic_skill_name skip
# above, that inverts the function precisely — it would delete exactly the
# FOREIGN symlinks and keep the mosaic ones. (#1087, reported by mos-claude.)
if [[ -n "$resolved" && -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
rm -f "$link_path"
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
fi
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
}
# Remove mosaic-owned symlinks left in a base install by a pre-isolation sync.
#
# Ownership is proven by RESOLUTION, not by name: only links resolving inside the
# canonical or local skills dirs are removed. Anything else — a real directory, a
# link elsewhere, an unresolvable link — is left untouched. This mirrors the
# refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills
# directory are managed") and preserves e.g. codex's own `.system` dir.
#
# The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is
# missing, and an empty dir is the correct end state, not an absent one.
cleanup_legacy_target() {
local target_dir="$1"
local removed=0 kept=0
[[ -d "$target_dir" ]] || return 0
while IFS= read -r -d '' link_path; do
local resolved owned=0
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
# Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"*
# match every absolute path and delete foreign links.
if [[ -n "$resolved" ]]; then
if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
owned=1
elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then
owned=1
fi
fi
if [[ $owned -eq 1 ]]; then
rm -f "$link_path"
removed=$((removed + 1))
else
kept=$((kept + 1))
fi
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
if [[ $removed -gt 0 ]]; then
echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)"
fi
}
for legacy in "${legacy_link_targets[@]}"; do
# Skip anything that is also a current target, so isolation can never
# self-destruct if the two lists ever overlap.
skip=0
for target in "${link_targets[@]}"; do
[[ "$legacy" == "$target" ]] && skip=1
done
[[ $skip -eq 1 ]] && continue
cleanup_legacy_target "$legacy"
done
for target in "${link_targets[@]}"; do
mkdir -p "$target"
+58 -468
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
set -euo pipefail
@@ -14,8 +14,6 @@ MERGE_METHOD="squash"
DELETE_BRANCH=false
DRY_RUN=false
EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
usage() {
cat <<EOF
@@ -29,16 +27,12 @@ Options:
-d, --delete-branch Delete the head branch after merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
-h, --help Show this help message
Examples:
$(basename "$0") -n 42 # Merge PR #42
$(basename "$0") -n 42 -m squash # Squash merge
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
EOF
exit "${1:-1}"
}
@@ -63,25 +57,9 @@ while [[ $# -gt 0 ]]; do
shift
;;
--expect-head)
if [[ $# -lt 2 ]]; then
echo "Error: --expect-head requires one full commit SHA." >&2
exit 1
fi
EXPECT_HEAD="$2"
shift 2
;;
--co-author-trailers)
CO_AUTHOR_TRAILERS=true
shift
;;
--escalate-to)
if [[ $# -lt 2 ]]; then
echo "Error: --escalate-to requires one principal name." >&2
exit 1
fi
ESCALATE_TO="$2"
shift 2
;;
-h|--help)
usage 0
;;
@@ -110,30 +88,17 @@ if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
if [[ "$CO_AUTHOR_TRAILERS" == true && -z "$ESCALATE_TO" ]]; then
echo "Error: --co-author-trailers requires --escalate-to with a named principal." >&2
exit 1
fi
if [[ -n "$ESCALATE_TO" && ! "$ESCALATE_TO" =~ ^[A-Za-z0-9_.-]+$ ]]; then
echo "Error: --escalate-to must be one exact principal name." >&2
exit 1
fi
if [[ "$CO_AUTHOR_TRAILERS" != true && -n "$ESCALATE_TO" ]]; then
echo "Error: --escalate-to is valid only with --co-author-trailers." >&2
exit 1
fi
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
exit 1
fi
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
exit 1
@@ -157,442 +122,70 @@ PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
write_curl_auth_config() {
local mode="$1" credential="$2"
printf '%s' "$credential" | python3 -c '
import sys
mode = sys.argv[1]
credential = sys.stdin.read()
if not credential or any(char in credential for char in "\r\n"):
raise SystemExit(1)
escaped = credential.replace("\\", "\\\\").replace("\"", "\\\"")
if mode == "token":
print(f"header = \"Authorization: token {escaped}\"")
elif mode == "basic":
print(f"user = \"{escaped}\"")
else:
raise SystemExit(1)
' "$mode"
}
LAST_GITEA_HTTP_CODE="000"
LAST_GITEA_ERROR=""
MERGE_TEMP_DIRS=()
GITEA_CURL_MAX_BYTES="${MOSAIC_GITEA_CURL_MAX_BYTES:-1048576}"
GITEA_CURL_MAX_TIME="${MOSAIC_GITEA_CURL_MAX_TIME_SEC:-30}"
GITEA_CURL_CONNECT_TIMEOUT="${MOSAIC_GITEA_CURL_CONNECT_TIMEOUT_SEC:-10}"
for bound in "$GITEA_CURL_MAX_BYTES" "$GITEA_CURL_MAX_TIME" "$GITEA_CURL_CONNECT_TIMEOUT"; do
if [[ ! "$bound" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: Gitea curl bounds must be positive integers; refusing request." >&2
exit 1
fi
done
GITEA_CURL_BOUNDS=(
--max-filesize "$GITEA_CURL_MAX_BYTES"
--max-time "$GITEA_CURL_MAX_TIME"
--connect-timeout "$GITEA_CURL_CONNECT_TIMEOUT"
)
format_gitea_error_response() {
local response_file="$1"
python3 - "$response_file" <<'PY'
import json
import sys
with open(sys.argv[1], "rb") as handle:
raw = handle.read(65536)
try:
response = json.loads(raw.decode("utf-8", errors="replace"))
except (UnicodeDecodeError, json.JSONDecodeError):
message = "non-JSON response omitted"
else:
if isinstance(response, dict):
message = response.get("message") or response.get("error")
if not message and response.get("errors") is not None:
message = json.dumps(response["errors"], separators=(",", ":"))
else:
message = None
if not message:
message = "JSON response contained no error message"
message = str(message)
if len(message) > 500:
message = message[:500] + "..."
print(ascii(message))
PY
}
cleanup_merge_temp_dirs() {
local path
for path in "${MERGE_TEMP_DIRS[@]}"; do
[[ -n "$path" ]] && rm -rf -- "$path"
done
}
trap cleanup_merge_temp_dirs EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
fetch_gitea_pr_head() {
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
local response_file raw_code api_url auth_config curl_rc
response_file=$(mktemp "$work_root/pr-merge-pr.XXXXXX")
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}"
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
rm -f "$response_file"
return 1
fi
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$response_file" \
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$response_file"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$response_file")
rm -f "$response_file"
return 1
fi
if ! python3 - "$response_file" <<'PY'
import json
import re
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
pull = json.load(handle)
head = pull.get("head") if isinstance(pull, dict) else None
sha = str(head.get("sha") or "") if isinstance(head, dict) else ""
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
raise SystemExit(1)
print(sha)
PY
then
echo "Error: Gitea PR response has no valid head SHA; refusing merge." >&2
rm -f "$response_file"
return 1
fi
rm -f "$response_file"
}
fetch_gitea_pr_commits() {
local host="$1" auth_mode="$2" credential="$3" work_root="$4"
local page page_file combined_file merged_file raw_code page_count api_url auth_config curl_rc
mkdir -p "$work_root"
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
return 1
fi
combined_file=$(mktemp "$work_root/pr-merge-commits.XXXXXX")
printf '[]' > "$combined_file"
page=1
while true; do
page_file=$(mktemp "$work_root/pr-merge-commits-page.XXXXXX")
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/commits?limit=50&page=${page}"
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$page_file" \
-H "User-Agent: curl/8" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$page_file" "$combined_file"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$page_file")
rm -f "$page_file" "$combined_file"
return 1
fi
if ! page_count=$(python3 - "$page_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
page = json.load(handle)
if not isinstance(page, list):
raise SystemExit(1)
print(len(page))
PY
); then
echo "Error: Gitea PR commits response is not a JSON array; refusing merge." >&2
rm -f "$page_file" "$combined_file"
return 1
fi
merged_file=$(mktemp "$work_root/pr-merge-commits-merged.XXXXXX")
if ! python3 - "$combined_file" "$page_file" > "$merged_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
combined = json.load(handle)
with open(sys.argv[2], encoding="utf-8") as handle:
page = json.load(handle)
json.dump(combined + page, sys.stdout, separators=(",", ":"))
PY
then
echo "Error: Could not combine paginated PR commit metadata; refusing merge." >&2
rm -f "$page_file" "$combined_file" "$merged_file"
return 1
fi
mv "$merged_file" "$combined_file"
rm -f "$page_file"
if [[ "$page_count" -lt 50 ]]; then
break
fi
page=$((page + 1))
if [[ "$page" -gt 1000 ]]; then
echo "Error: PR commit pagination exceeded 1000 pages; refusing merge." >&2
rm -f "$combined_file"
return 1
fi
done
cat "$combined_file"
rm -f "$combined_file"
}
# LIMITATION: author.login resolution proves the commit address maps to a registered account.
# It does NOT prove the named principal authored the commit — git author metadata is self-asserted.
# This gate checks ATTRIBUTION LINKAGE, not AUTHORSHIP. Commit signing is out of scope and unadopted.
build_coauthor_message_fields() {
local commits_file="$1" context_file="$2" head_file="$3"
python3 - "$commits_file" "$context_file" "$head_file" <<'PY'
import json
import re
import sys
commits_path, context_path, head_path = sys.argv[1:]
with open(commits_path, encoding="utf-8") as handle:
commits = json.load(handle)
head_sha = open(head_path, encoding="utf-8").read().strip()
context_parts = open(context_path, "rb").read().split(b"\0")
if len(context_parts) != 4 or context_parts[-1] != b"":
raise SystemExit(1)
poster, title, principal = (part.decode("utf-8") for part in context_parts[:3])
if not isinstance(commits, list) or not commits:
print(
f"BLOCK: provider returned no PR commits; author identity is unmeasurable. "
f"Refusing merge; escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not poster:
print(
f"BLOCK: PR poster login is empty; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha):
print(
f"BLOCK: inspected PR head SHA is invalid; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
seen = set()
trailers = []
head_seen = False
for item in commits:
if not isinstance(item, dict):
print(f"BLOCK: malformed PR commit metadata; escalate to named principal '{principal}'.", file=sys.stderr)
raise SystemExit(75)
sha = str(item.get("sha") or "<unknown>")
if sha == head_sha:
head_seen = True
commit = item.get("commit") if isinstance(item.get("commit"), dict) else {}
commit_author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
email = str(commit_author.get("email") or "").strip()
provider_author = item.get("author") if isinstance(item.get("author"), dict) else {}
login = str(provider_author.get("login") or "").strip()
if not login:
diagnostic_email = email or "<missing>"
print(
f"BLOCK: commit {sha!r} has author.login=NULL while "
f"commit.author.email={diagnostic_email!r}; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if (
not email.isascii()
or not email.isprintable()
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
or not re.fullmatch(r"[^<>\s]+@[^<>\s]+", email)
):
print(
f"BLOCK: commit {sha!r} has unusable linked identity "
f"author.login={login!r}, commit.author.email={email!r}; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if login == poster or login in seen:
continue
seen.add(login)
trailers.append(f"Co-authored-by: {login} <{email}>")
if not head_seen:
print(
f"BLOCK: inspected PR head is absent from commit enumeration; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not trailers:
print("{}")
raise SystemExit(0)
if not title:
print(
f"BLOCK: PR title is empty; refusing merge; escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
if not title.isprintable() or re.match(r"^[A-Za-z-]+-[Bb]y:", title):
print(
f"BLOCK: PR title is not one printable, non-trailer line; refusing merge; "
f"escalate to named principal '{principal}'.",
file=sys.stderr,
)
raise SystemExit(75)
print(json.dumps({
"MergeTitleField": title,
"MergeMessageField": "\n".join(trailers),
}, separators=(",", ":")))
PY
}
merge_gitea_api_attempt() {
local host="$1" auth_mode="$2" credential="$3"
local api_url attempt_dir body_file raw_code commits_file fields_file context_file head_file payload_file work_root attempt_rc auth_config curl_rc
LAST_GITEA_HTTP_CODE="000"
LAST_GITEA_ERROR=""
merge_gitea_with_api() {
local host="$1" api_url token basic_auth body_file raw_code payload
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
work_root="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
mkdir -p "$work_root"
attempt_dir=$(mktemp -d "$work_root/pr-merge-attempt.XXXXXX")
chmod 0700 "$attempt_dir"
MERGE_TEMP_DIRS+=("$attempt_dir")
body_file=$(mktemp "$attempt_dir/api-response.XXXXXX")
fields_file=$(mktemp "$attempt_dir/message-fields.XXXXXX")
payload_file=$(mktemp "$attempt_dir/payload.XXXXXX")
printf '{}' > "$fields_file"
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
commits_file=$(mktemp "$attempt_dir/pr-merge-commits-input.XXXXXX")
context_file=$(mktemp "$attempt_dir/pr-merge-message-context.XXXXXX")
head_file=$(mktemp "$attempt_dir/pr-merge-head-input.XXXXXX")
printf '%s\0%s\0%s\0' "$PR_AUTHOR" "$PR_TITLE" "$ESCALATE_TO" > "$context_file"
if fetch_gitea_pr_head "$host" "$auth_mode" "$credential" "$attempt_dir" > "$head_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
if [[ "$(<"$head_file")" != "$HEAD_SHA" ]]; then
echo "BLOCK: authenticated PR head moved from reviewed $HEAD_SHA to $(<"$head_file"); refusing merge; escalate to named principal '$ESCALATE_TO'." >&2
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return 75
fi
if fetch_gitea_pr_commits "$host" "$auth_mode" "$credential" "$attempt_dir" > "$commits_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
if build_coauthor_message_fields "$commits_file" "$context_file" "$head_file" > "$fields_file"; then
:
else
attempt_rc=$?
rm -f "$body_file" "$fields_file" "$payload_file" "$commits_file" "$context_file" "$head_file"
return "$attempt_rc"
fi
rm -f "$commits_file" "$context_file" "$head_file"
fi
if ! python3 - "$fields_file" "$HEAD_SHA" "$DELETE_BRANCH" > "$payload_file" <<'PY'
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
fields = json.load(handle)
head_sha, delete_branch = sys.argv[2:]
head_sha, delete_branch = sys.argv[1:]
payload = {"Do": "squash", "head_commit_id": head_sha}
if delete_branch == "true":
payload["delete_branch_after_merge"] = True
payload.update(fields)
allowed = {"Do", "head_commit_id", "delete_branch_after_merge", "MergeTitleField", "MergeMessageField"}
if payload.get("Do") != "squash" or set(payload) - allowed:
raise SystemExit(1)
print(json.dumps(payload, separators=(",", ":")))
PY
then
rm -f "$body_file" "$fields_file" "$payload_file"
return 1
fi
rm -f "$fields_file"
)
if ! auth_config=$(write_curl_auth_config "$auth_mode" "$credential"); then
echo "Error: Could not construct Gitea authentication config; refusing request." >&2
rm -f "$body_file" "$payload_file"
return 1
token=$(get_gitea_token "$host" || true)
if [[ -n "$token" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
-X POST \
-H "User-Agent: curl/8" \
-H "Authorization: token $token" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then
rm -f "$body_file"
return 0
fi
fi
raw_code=$(curl -sS -K - "${GITEA_CURL_BOUNDS[@]}" -w '%{http_code}' -o "$body_file" \
-X POST -H "User-Agent: curl/8" \
-H 'Content-Type: application/json' \
--data-binary "@$payload_file" "$api_url" <<<"$auth_config")
curl_rc=$?
LAST_GITEA_HTTP_CODE="${raw_code:-000}"
if [[ "$curl_rc" -ne 0 ]]; then
LAST_GITEA_ERROR="curl transport failed (rc=$curl_rc)"
rm -f "$body_file" "$payload_file"
rm -rf -- "$attempt_dir"
return 1
fi
if [[ ! "$raw_code" =~ ^2 ]]; then
LAST_GITEA_ERROR=$(format_gitea_error_response "$body_file")
fi
rm -f "$body_file" "$payload_file"
rm -rf -- "$attempt_dir"
[[ "$raw_code" =~ ^2 ]]
}
merge_gitea_with_api() {
local host="$1" token attempt_rc
basic_auth=$(get_gitea_basic_auth "$host" || true)
if [[ -n "$basic_auth" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
-X POST \
-u "$basic_auth" \
-H "User-Agent: curl/8" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then
rm -f "$body_file"
return 0
fi
fi
if ! token=$(get_gitea_token "$host"); then
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
return 1
fi
if [[ -z "$token" ]]; then
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
return 1
fi
if merge_gitea_api_attempt "$host" token "$token"; then
return 0
else
attempt_rc=$?
fi
if [[ "$attempt_rc" -eq 75 ]]; then
return 75
fi
if [[ "$LAST_GITEA_HTTP_CODE" != "401" ]]; then
echo "Error: Gitea API merge failed with the identity-bound token (HTTP ${LAST_GITEA_HTTP_CODE:-000}).${LAST_GITEA_ERROR:+ Provider response: $LAST_GITEA_ERROR}" >&2
return 1
fi
echo "Error: Gitea API rejected the identity-bound token with HTTP 401; refusing cross-principal credential fallback." >&2
python3 - "${raw_code:-000}" "$body_file" <<'PY' >&2
import json
import sys
code, path = sys.argv[1], sys.argv[2]
try:
with open(path, encoding="utf-8", errors="replace") as handle:
raw = handle.read(500)
data = json.loads(raw) if raw else {}
message = data.get("message") or data.get("error") or raw or "empty response"
except Exception:
try:
message = open(path, encoding="utf-8", errors="replace").read(500) or "empty response"
except Exception:
message = "unreadable response"
print(f"Error: Gitea API merge failed with HTTP {code}: {message}")
PY
rm -f "$body_file"
return 1
}
@@ -602,10 +195,11 @@ if [[ "$DRY_RUN" == true ]]; then
echo "Error: Cannot determine host from origin remote URL" >&2
exit 1
}
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
if [[ -n "$TEA_LOGIN" ]]; then
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
else
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with authenticated Gitea API fallback (base=$BASE_BRANCH, method=squash)."
fi
else
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
@@ -615,10 +209,6 @@ fi
case "$PLATFORM" in
github)
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
echo "Error: --co-author-trailers currently requires the Gitea REST message-field contract." >&2
exit 1
fi
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
"${cmd[@]}"
@@ -629,7 +219,7 @@ case "$PLATFORM" in
exit 1
}
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
# tea cannot express it, so every Gitea merge uses the authenticated API path.
# tea cannot express it, so exact-head merges use the authenticated API path.
merge_gitea_with_api "$HOST"
;;
*)
@@ -9,51 +9,10 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
STATUS_OBSERVED="$WORK_DIR/status-observed"
CLOCK_LOG="$WORK_DIR/clock.log"
WATCHDOG_PYTHON="/usr/bin/python3"
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
WATCHDOG_TIMEOUT_SEC=5
WATCHDOG_EXIT=90
FEATURE_BRANCH="fix/rm-03-fixture"
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
exit 1
fi
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
cat > "$WATCHDOG_SCRIPT" <<'PY'
import os
import signal
import subprocess
import sys
if len(sys.argv) < 3:
raise SystemExit(2)
timeout_seconds = float(sys.argv[1])
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
try:
return_code = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
print(
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
"before completing its intended path",
file=sys.stderr,
)
raise SystemExit(90)
if return_code < 0:
raise SystemExit(128 - return_code)
raise SystemExit(return_code)
PY
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
@@ -74,9 +33,6 @@ printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
case "$url" in
*/branches/*)
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
while :; do :; done
fi
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
exit 7
fi
@@ -88,7 +44,6 @@ case "$url" in
fi
;;
*/status)
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
case "${MOSAIC_STUB_STATUS_MODE:?}" in
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
@@ -109,31 +64,7 @@ case "$url" in
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
esac
SH
cat > "$STUB_DIR/date" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
echo "unexpected date invocation: $*" >&2
exit 2
fi
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
printf '1002\n'
else
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
printf '1000\n'
fi
SH
cat > "$STUB_DIR/sleep" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
SH
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
chmod +x "$STUB_DIR/curl"
run_guard() {
local status_mode="$1"
@@ -153,46 +84,13 @@ run_guard() {
export GITEA_URL=https://git.example.test
export MOSAIC_STUB_STATUS_MODE="$status_mode"
fi
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
# Provider observation is the synchronization event. The one-second
# timeout is subject semantics under virtual time, never a wall wait.
# The absolute Python runtime uses an internal monotonic wait and kills
# the subject's isolated process group. Neither operation can resolve
# to the virtual date/sleep stubs at the front of PATH.
local subject_rc
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
subject_rc=0
else
subject_rc=$?
fi
return "$subject_rc"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
)
}
failures=0
assert_provider_observed() {
local name="$1" require_expiration="${2:-0}"
if [[ ! -e "$STATUS_OBSERVED" ]]; then
echo "FAIL $name: status provider was not observed" >&2
failures=$((failures + 1))
fi
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
failures=$((failures + 1))
fi
if [[ "$require_expiration" -eq 1 ]]; then
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
echo "FAIL $name: pending path did not expire after provider observation" >&2
failures=$((failures + 1))
fi
fi
}
run_assertion() {
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
local output rc
@@ -227,13 +125,6 @@ run_assertion() {
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
if [[ "$status_mode" != "credential-unresolvable" ]]; then
if [[ "$status_mode" == "pending" ]]; then
assert_provider_observed "$name" 1
else
assert_provider_observed "$name"
fi
fi
}
set -e
@@ -259,27 +150,6 @@ MOSAIC_TEST_PURPOSE=merge run_assertion merge-failure nonzero failure 'ASSERTED_
MOSAIC_TEST_PURPOSE=merge run_assertion merge-no-status nonzero no-status 'ASSERTED_NOT_READY state=no-status'
MOSAIC_TEST_PURPOSE=merge run_assertion merge-unknown nonzero unknown 'ASSERTED_NOT_READY state=unknown'
# Positive liveness control: a subject mutant hangs before the branch lookup
# can reach the status provider. Only the independent real-clock watchdog may
# terminate it, and its failure must be distinct from subject timeout rc=124.
set +e
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
watchdog_rc=$?
set -e
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
failures=$((failures + 1))
fi
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
printf '%s\n' "$watchdog_output" >&2
failures=$((failures + 1))
fi
if [[ -e "$STATUS_OBSERVED" ]]; then
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
failures=$((failures + 1))
fi
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
failures=$((failures + 1))
@@ -300,7 +170,6 @@ if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
assert_provider_observed merge-provider-unreachable
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
@@ -364,7 +233,6 @@ if [[ "$audit_failure_output" != *"audit"* ]]; then
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
failures=$((failures + 1))
fi
assert_provider_observed audit-unavailable
if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
@@ -51,23 +51,22 @@ for arg in "$@"; do
prev=""
continue
fi
if [[ "$prev" == "data" ]]; then
if [[ "$prev" == "-d" ]]; then
post_data="$arg"
[[ "$post_data" == @* ]] && post_data=$(<"${post_data#@}")
prev=""
continue
fi
if [[ "$prev" == "config" ]]; then
[[ "$arg" == "-" ]] && cat >/dev/null
prev=""
if [[ "$arg" == "-o" ]]; then
prev="-o"
continue
fi
case "$arg" in
-o) prev="-o" ;;
-d|--data|--data-binary) prev="data" ;;
-K|--config) prev="config" ;;
-w) write_code=true ;;
esac
if [[ "$arg" == "-d" ]]; then
prev="-d"
continue
fi
if [[ "$arg" == "-w" ]]; then
write_code=true
fi
done
emit_response() {
local body="$1"
@@ -37,30 +37,13 @@ cat > "$WORK_DIR/gitea/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
payload=""
out_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--data|--data-binary)
payload="$2"
[[ "$payload" == @* ]] && payload=$(<"${payload#@}")
shift 2
;;
-o)
out_file="$2"
shift 2
;;
-K|--config)
[[ "$2" == "-" ]] && cat >/dev/null
shift 2
;;
-w|-X|-H)
shift 2
;;
*) shift ;;
esac
for ((i=1; i<=$#; i++)); do
if [[ "${!i}" == "-d" ]]; then
j=$((i + 1))
payload="${!j}"
fi
done
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
[[ -n "$out_file" ]] && printf '{}' > "$out_file"
printf '200'
SH
chmod +x "$WORK_DIR/gitea/curl"
@@ -1,541 +0,0 @@
#!/usr/bin/env bash
# Regression harness for the optional, identity-checked Gitea squash message.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SUBJECT="${MOSAIC_TEST_SUBJECT:-$SCRIPT_DIR/pr-merge.sh}"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-message-field}"
ORIG_PATH="$PATH"
failures=0
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
fail() {
echo "FAIL $1" >&2
failures=$((failures + 1))
}
make_case() {
local name="$1" case_dir
case_dir="$WORK_DIR/$name"
mkdir -p "$case_dir/bin" "$case_dir/agent"
cp "$SUBJECT" "$case_dir/pr-merge.sh"
chmod +x "$case_dir/pr-merge.sh"
cat > "$case_dir/detect-platform.sh" <<'SH'
#!/usr/bin/env bash
detect_platform() { PLATFORM=gitea; printf 'gitea\n'; }
get_repo_owner() { printf 'acme\n'; }
get_repo_name() { printf 'widgets\n'; }
get_remote_host() { printf 'git.example.test\n'; }
get_gitea_token() {
printf 'resolved\n' >> "${MOSAIC_TEST_TOKEN_RESOLUTION_LOG:?}"
if [[ "${MOSAIC_TEST_TOKEN_AVAILABLE:-true}" != "true" ]]; then
return 1
fi
printf 'fixture-token\n'
}
get_gitea_basic_auth() {
printf 'resolved\n' >> "${MOSAIC_TEST_BASIC_RESOLUTION_LOG:?}"
if [[ "${MOSAIC_TEST_BASIC_AVAILABLE:-false}" == "true" ]]; then
printf 'fixture-user:fixture-password\n'
return "${MOSAIC_TEST_BASIC_RC:-0}"
fi
return 1
}
get_gitea_login_for_host() { return 1; }
SH
cat > "$case_dir/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
if [[ "${MOSAIC_TEST_TITLE_MODE:-safe}" == "injection" ]]; then
title='Preserve authors\n\nCo-authored-by: victim <[email protected]>'
else
title='Preserve both branch authors'
fi
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified) head_sha=2222222222222222222222222222222222222222 ;;
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
single) head_sha=1111111111111111111111111111111111111111 ;;
*) echo "unknown commits mode" >&2; exit 2 ;;
esac
printf '{"number":42,"title":"%s","author":"poster","baseRefName":"main","headRefName":"feature/fixture","headRefOid":"%s","headRepository":"acme/widgets"}\n' "$title" "$head_sha"
SH
cat > "$case_dir/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 0
SH
cat > "$case_dir/bin/python3" <<'SH'
#!/usr/bin/env bash
for arg in "$@"; do
case "$arg" in
*"Preserve both branch authors"*|*"[email protected]"*)
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
;;
esac
done
exec "${MOSAIC_TEST_REAL_PYTHON:?}" "$@"
SH
cat > "$case_dir/bin/curl" <<'SH'
#!/usr/bin/env bash
set -eu
for arg in "$@"; do
case "$arg" in
*"Preserve both branch authors"*|*"[email protected]"*)
: > "${MOSAIC_TEST_METADATA_ARGV_MARKER:?}"
;;
esac
done
url=""
method="GET"
out_file=""
data=""
config=""
auth_mode="none"
has_max_filesize=0
has_max_time=0
has_connect_timeout=0
while [[ $# -gt 0 ]]; do
case "$1" in
-o)
out_file="$2"
shift 2
;;
-w)
shift 2
;;
-X)
method="$2"
shift 2
;;
-d|--data|--data-binary)
data="$2"
if [[ "$data" == @* ]]; then
data=$(<"${data#@}")
fi
shift 2
;;
-K|--config)
if [[ "$2" == "-" ]]; then
config=$(cat)
fi
shift 2
;;
--max-filesize)
has_max_filesize=1
shift 2
;;
--max-time)
has_max_time=1
shift 2
;;
--connect-timeout)
has_connect_timeout=1
shift 2
;;
-H|--header|-u|--user)
if [[ "$2" == *"fixture-token"* ]]; then
: > "${MOSAIC_TEST_TOKEN_ARGV_MARKER:?}"
fi
if [[ "$2" == *"fixture-password"* ]]; then
: > "${MOSAIC_TEST_BASIC_ARGV_MARKER:?}"
fi
shift 2
;;
http://*|https://*)
url="$1"
shift
;;
*)
shift
;;
esac
done
if [[ "$config" == *"Authorization: token fixture-token"* ]]; then
auth_mode="token"
: > "${MOSAIC_TEST_AUTH_CONFIG_MARKER:?}"
elif [[ "$config" == *"user = \"fixture-user:fixture-password\""* ]]; then
auth_mode="basic"
: > "${MOSAIC_TEST_BASIC_CONFIG_MARKER:?}"
fi
printf '%s %s %s\n' "$method" "$auth_mode" "$url" >> "${MOSAIC_TEST_CURL_LOG:?}"
printf '%s:%s:%s\n' "$has_max_filesize" "$has_max_time" "$has_connect_timeout" >> "${MOSAIC_TEST_CURL_BOUNDS_LOG:?}"
case "$url" in
*/pulls/42)
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified) head_sha=2222222222222222222222222222222222222222 ;;
null-login|unsafe-identity) head_sha=3333333333333333333333333333333333333333 ;;
single) head_sha=1111111111111111111111111111111111111111 ;;
*) echo "unknown commits mode" >&2; exit 2 ;;
esac
if [[ "${MOSAIC_TEST_HEAD_MODE:-stable}" == "moved" ]]; then
head_sha=4444444444444444444444444444444444444444
fi
body="{\"head\":{\"sha\":\"$head_sha\"}}"
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
fi
;;
*/pulls/42/commits*)
case "${MOSAIC_TEST_COMMITS_MODE:?}" in
verified)
if [[ "${MOSAIC_TEST_EMAIL_MODE:-safe}" == "escape" ]]; then
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"alice+\u001b[[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
else
body='[{"sha":"2222222222222222222222222222222222222222","commit":{"author":{"name":"Alice","email":"[email protected]"}},"author":{"login":"alice"}},{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
fi
;;
null-login)
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Unresolved Author","email":"[email protected]\n\u001b[31m"}},"author":null}]'
;;
unsafe-identity)
body='[{"sha":"unsafe\n\u001b[31m","commit":{"author":{"name":"Unsafe","email":"not-an-email"}},"author":{"login":"unsafe"}},{"sha":"3333333333333333333333333333333333333333","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
;;
single)
body='[{"sha":"1111111111111111111111111111111111111111","commit":{"author":{"name":"Poster","email":"[email protected]"}},"author":{"login":"poster"}}]'
;;
*)
echo "unknown commits mode" >&2
exit 2
;;
esac
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "inspection" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
fi
;;
*/pulls/42/merge)
body='{}'
code=200
if [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "merge" && "$auth_mode" == "token" ]]; then
body='{"message":"token rejected"}'
code=401
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "provider-error" ]]; then
body='{"message":"branch policy rejected\n\u001b[31m"}'
code=409
elif [[ "${MOSAIC_TEST_FALLBACK_MODE:-none}" == "forbidden" ]]; then
body='{"message":"permission denied"}'
code=403
else
printf '%s' "$data" > "${MOSAIC_TEST_MERGE_PAYLOAD:?}"
fi
;;
*/users/*)
body='{"message":"not found"}'
code=404
;;
*)
body='{"message":"unexpected URL"}'
code=500
;;
esac
if [[ -n "$out_file" ]]; then
printf '%s' "$body" > "$out_file"
else
printf '%s' "$body"
fi
printf '%s' "$code"
case "${MOSAIC_TEST_CURL_FAILURE:-none}" in
oversize) exit 63 ;;
stalled) exit 28 ;;
esac
SH
chmod +x "$case_dir/detect-platform.sh" "$case_dir/pr-metadata.sh" \
"$case_dir/ci-queue-wait.sh" "$case_dir/bin/curl" "$case_dir/bin/python3"
printf '%s\n' "$case_dir"
}
run_case() {
local case_dir="$1" mode="$2"
shift 2
MOSAIC_TEST_COMMITS_MODE="$mode" \
MOSAIC_TEST_CURL_LOG="$case_dir/curl.log" \
MOSAIC_TEST_CURL_BOUNDS_LOG="$case_dir/curl-bounds.log" \
MOSAIC_TEST_MERGE_PAYLOAD="$case_dir/merge-payload.json" \
MOSAIC_TEST_TOKEN_ARGV_MARKER="$case_dir/token-in-argv" \
MOSAIC_TEST_BASIC_ARGV_MARKER="$case_dir/basic-in-argv" \
MOSAIC_TEST_AUTH_CONFIG_MARKER="$case_dir/auth-via-config" \
MOSAIC_TEST_BASIC_CONFIG_MARKER="$case_dir/basic-via-config" \
MOSAIC_TEST_TOKEN_RESOLUTION_LOG="$case_dir/token-resolution.log" \
MOSAIC_TEST_BASIC_RESOLUTION_LOG="$case_dir/basic-resolution.log" \
MOSAIC_TEST_METADATA_ARGV_MARKER="$case_dir/metadata-in-argv" \
MOSAIC_TEST_REAL_PYTHON="$(command -v python3)" \
AGENT_WORK_ROOT="$case_dir/agent" \
PATH="$case_dir/bin:$ORIG_PATH" \
"$case_dir/pr-merge.sh" -n 42 "$@"
}
# Verified multi-author path: the non-poster trailer is built from one commit's
# linked author.login and that same commit's author email. No /users lookup.
verified_dir=$(make_case verified)
set +e
verified_output=$(run_case "$verified_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
verified_rc=$?
set -e
if [[ "$verified_rc" -ne 0 ]]; then
fail "verified multi-author merge expected rc=0, got rc=$verified_rc: $verified_output"
elif [[ ! -s "$verified_dir/merge-payload.json" ]]; then
fail "verified multi-author merge did not reach the API payload"
else
python3 - "$verified_dir/merge-payload.json" <<'PY' || fail "verified payload did not preserve squash and exact message fields"
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload == {
"Do": "squash",
"head_commit_id": "2222222222222222222222222222222222222222",
"MergeTitleField": "Preserve both branch authors",
"MergeMessageField": "Co-authored-by: alice <[email protected]>",
}, payload
PY
fi
[[ -e "$verified_dir/auth-via-config" ]] || fail "verified path did not authenticate curl through stdin config"
[[ ! -e "$verified_dir/token-in-argv" ]] || fail "verified path placed the Gitea token in curl argv"
[[ ! -e "$verified_dir/metadata-in-argv" ]] || fail "verified path placed PR title or contributor email in child argv"
[[ "$(wc -l < "$verified_dir/token-resolution.log")" -eq 1 ]] || fail "verified path did not bind inspection and merge to one credential resolution"
if grep -q '/users/' "$verified_dir/curl.log" 2>/dev/null; then
fail "verified path performed a forbidden second /users lookup"
fi
if grep -qv '^1:1:1$' "$verified_dir/curl-bounds.log"; then
fail "verified path did not apply size/max-time/connect-time bounds to every provider download"
fi
# A linked email containing a terminal escape must block before mutation.
escape_email_dir=$(make_case escape-email)
set +e
escape_email_output=$(MOSAIC_TEST_EMAIL_MODE=escape run_case "$escape_email_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
escape_email_rc=$?
set -e
[[ "$escape_email_rc" -ne 0 ]] || fail "control-byte email unexpectedly passed"
[[ "$escape_email_output" == *"unusable linked identity"* ]] || fail "control-byte email refusal lost its diagnostic"
[[ ! -e "$escape_email_dir/merge-payload.json" ]] || fail "control-byte email reached the merge API"
# Curl transfer and duration failures must remain failures even with HTTP 200.
for failure_mode in oversize stalled; do
failure_dir=$(make_case "curl-$failure_mode")
set +e
failure_output=$(MOSAIC_TEST_CURL_FAILURE="$failure_mode" run_case "$failure_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
failure_rc=$?
set -e
[[ "$failure_rc" -ne 0 ]] || fail "curl $failure_mode failure was discarded: $failure_output"
[[ ! -e "$failure_dir/merge-payload.json" ]] || fail "curl $failure_mode failure reached the merge API"
done
# The authenticated head is re-read under the mutation credential but cannot
# replace the canonical preflight/review head. A move blocks before enumeration
# or mutation even though the provider returned a valid new SHA.
moved_dir=$(make_case moved-head)
set +e
moved_output=$(MOSAIC_TEST_HEAD_MODE=moved \
run_case "$moved_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
moved_rc=$?
set -e
[[ "$moved_rc" -ne 0 ]] || fail "moved authenticated head unexpectedly passed"
[[ "$moved_output" == *"authenticated PR head moved from reviewed"* ]] || fail "moved head refusal lost its diagnostic"
[[ "$moved_output" == *"tl-mosaic"* ]] || fail "moved head refusal omitted the named escalation principal"
[[ ! -e "$moved_dir/merge-payload.json" ]] || fail "moved head refusal reached the merge API"
moved_sequence=$(awk '{print $1 ":" $2}' "$moved_dir/curl.log" | paste -sd, -)
[[ "$moved_sequence" == "GET:token" ]] || fail "moved head refusal performed post-move inspection/mutation (calls=$moved_sequence)"
# Token resolution failure is not an authentication response. It must fail
# closed instead of borrowing a Basic credential under a different principal.
token_missing_dir=$(make_case token-missing)
set +e
token_missing_output=$(MOSAIC_TEST_TOKEN_AVAILABLE=false MOSAIC_TEST_BASIC_AVAILABLE=true \
run_case "$token_missing_dir" single 2>&1)
token_missing_rc=$?
set -e
[[ "$token_missing_rc" -ne 0 ]] || fail "missing token unexpectedly borrowed Basic Auth"
[[ "$token_missing_output" == *"required Gitea token"* ]] || fail "missing token refusal lost its diagnostic"
[[ ! -e "$token_missing_dir/basic-resolution.log" ]] || fail "missing token resolved Basic Auth after identity failure"
[[ ! -e "$token_missing_dir/curl.log" ]] || fail "missing token reached a provider request"
# A failed Basic resolver must never use its nonempty output or reach mutation.
basic_rc_dir=$(make_case basic-resolver-rc)
set +e
basic_rc_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_BASIC_RC=91 MOSAIC_TEST_FALLBACK_MODE=inspection \
run_case "$basic_rc_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
basic_rc_rc=$?
set -e
[[ "$basic_rc_rc" -ne 0 ]] || fail "failed Basic resolver output unexpectedly authorized a merge: $basic_rc_output"
[[ ! -e "$basic_rc_dir/merge-payload.json" ]] || fail "failed Basic resolver reached the merge API"
# HTTP 401 never changes principals: inspection rejection fails closed without
# resolving or attempting Basic Auth.
fallback_inspect_dir=$(make_case fallback-inspection)
set +e
fallback_inspect_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=inspection \
run_case "$fallback_inspect_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
fallback_inspect_rc=$?
set -e
[[ "$fallback_inspect_rc" -ne 0 ]] || fail "inspection token rejection unexpectedly changed principals"
[[ "$fallback_inspect_output" == *"refusing cross-principal credential fallback"* ]] || fail "inspection token rejection lost its refusal diagnostic"
[[ ! -e "$fallback_inspect_dir/basic-resolution.log" ]] || fail "inspection token rejection resolved Basic Auth"
[[ ! -e "$fallback_inspect_dir/merge-payload.json" ]] || fail "inspection token rejection reached merge mutation"
inspect_sequence=$(awk '{print $1 ":" $2}' "$fallback_inspect_dir/curl.log" | paste -sd, -)
[[ "$inspect_sequence" == "GET:token" ]] || fail "inspection rejection made unexpected provider calls (calls=$inspect_sequence)"
# Token rejection at merge likewise fails closed without cross-principal retry.
fallback_merge_dir=$(make_case fallback-merge)
set +e
fallback_merge_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=merge \
run_case "$fallback_merge_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
fallback_merge_rc=$?
set -e
[[ "$fallback_merge_rc" -ne 0 ]] || fail "merge token rejection unexpectedly changed principals"
[[ "$fallback_merge_output" == *"refusing cross-principal credential fallback"* ]] || fail "merge token rejection lost its refusal diagnostic"
[[ ! -e "$fallback_merge_dir/basic-resolution.log" ]] || fail "merge token rejection resolved Basic Auth"
[[ ! -e "$fallback_merge_dir/merge-payload.json" ]] || fail "merge token rejection recorded a successful payload"
merge_sequence=$(awk '{print $1 ":" $2}' "$fallback_merge_dir/curl.log" | paste -sd, -)
[[ "$merge_sequence" == "GET:token,GET:token,POST:token" ]] || fail "merge rejection made unexpected provider calls (calls=$merge_sequence)"
# BLOCK path: a commit email exists but author.login is null. It must name both
# facts, name the escalation principal, and never reach the merge endpoint.
null_dir=$(make_case null-login)
set +e
null_output=$(run_case "$null_dir" null-login --co-author-trailers --escalate-to tl-mosaic 2>&1)
null_rc=$?
set -e
[[ "$null_rc" -ne 0 ]] || fail "null-login author expected a non-zero BLOCK"
[[ "$null_output" == *"BLOCK"* ]] || fail "null-login author omitted BLOCK diagnostic"
[[ "$null_output" == *"author.login=NULL"* ]] || fail "null-login author omitted the null provider fact"
[[ "$null_output" == *"[email protected]"* ]] || fail "null-login author omitted the commit email fact"
[[ "$null_output" == *'\n\x1b[31m'* ]] || fail "null-login author diagnostic did not escape control characters"
[[ "$null_output" != *$'\033'* ]] || fail "null-login author diagnostic emitted a raw terminal escape"
[[ "$(printf '%s\n' "$null_output" | wc -l)" -eq 1 ]] || fail "null-login author diagnostic permitted newline injection"
[[ "$null_output" == *"tl-mosaic"* ]] || fail "null-login author omitted the named escalation principal"
[[ ! -e "$null_dir/merge-payload.json" ]] || fail "null-login BLOCK still reached the merge API"
# Every provider-derived field in alternate BLOCK diagnostics is log-safe too,
# including an invalid non-head SHA that contains control characters.
unsafe_dir=$(make_case unsafe-identity)
set +e
unsafe_output=$(run_case "$unsafe_dir" unsafe-identity --co-author-trailers --escalate-to tl-mosaic 2>&1)
unsafe_rc=$?
set -e
[[ "$unsafe_rc" -ne 0 ]] || fail "unsafe identity expected a non-zero BLOCK"
[[ "$unsafe_output" == *"unusable linked identity"* ]] || fail "unsafe identity omitted its BLOCK reason"
[[ "$unsafe_output" == *'\n\x1b[31m'* ]] || fail "unsafe identity SHA did not escape control characters"
[[ "$unsafe_output" != *$'\033'* ]] || fail "unsafe identity diagnostic emitted a raw terminal escape"
[[ "$(printf '%s\n' "$unsafe_output" | wc -l)" -eq 1 ]] || fail "unsafe identity diagnostic permitted newline injection"
[[ ! -e "$unsafe_dir/merge-payload.json" ]] || fail "unsafe identity BLOCK still reached the merge API"
# The provider PR title cannot add an unchecked trailer outside the constructed
# message field: multi-line and trailer-shaped titles block before mutation.
title_dir=$(make_case title-injection)
set +e
title_output=$(MOSAIC_TEST_TITLE_MODE=injection \
run_case "$title_dir" verified --co-author-trailers --escalate-to tl-mosaic 2>&1)
title_rc=$?
set -e
[[ "$title_rc" -ne 0 ]] || fail "title trailer injection unexpectedly passed"
[[ "$title_output" == *"not one printable, non-trailer line"* ]] || fail "title injection refusal lost its diagnostic"
[[ ! -e "$title_dir/merge-payload.json" ]] || fail "title injection reached the merge API"
# Provider failures remain diagnosable after their temporary response file is
# removed, but provider-controlled control characters stay log-safe.
error_dir=$(make_case provider-error)
set +e
error_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=provider-error \
run_case "$error_dir" single 2>&1)
error_rc=$?
set -e
[[ "$error_rc" -ne 0 ]] || fail "provider error unexpectedly passed"
[[ "$error_output" == *"HTTP 409"* ]] || fail "provider error omitted the HTTP status"
[[ "$error_output" == *"branch policy rejected"* ]] || fail "provider error response was discarded"
[[ "$error_output" == *'\n\x1b[31m'* ]] || fail "provider error response did not escape control characters"
[[ "$error_output" != *$'\033'* ]] || fail "provider error response emitted a raw terminal escape"
[[ "$error_output" != *"Basic Auth fallback"* ]] || fail "provider error advertised removed Basic Auth fallback"
[[ ! -e "$error_dir/basic-resolution.log" ]] || fail "HTTP 409 policy denial incorrectly triggered Basic Auth fallback"
# Authorization denials likewise fail closed instead of changing principals.
forbidden_dir=$(make_case forbidden)
set +e
forbidden_output=$(MOSAIC_TEST_BASIC_AVAILABLE=true MOSAIC_TEST_FALLBACK_MODE=forbidden \
run_case "$forbidden_dir" single 2>&1)
forbidden_rc=$?
set -e
[[ "$forbidden_rc" -ne 0 ]] || fail "HTTP 403 authorization denial unexpectedly passed"
[[ "$forbidden_output" == *"HTTP 403"* ]] || fail "authorization denial omitted the HTTP status"
[[ "$forbidden_output" != *"Basic Auth fallback"* ]] || fail "authorization denial advertised removed Basic Auth fallback"
[[ ! -e "$forbidden_dir/basic-resolution.log" ]] || fail "HTTP 403 authorization denial incorrectly triggered Basic Auth fallback"
# The BLOCK destination cannot be generic or inferred after failure: opting in
# without a named principal is refused before any provider operation.
principal_dir=$(make_case missing-principal)
set +e
principal_output=$(run_case "$principal_dir" verified --co-author-trailers 2>&1)
principal_rc=$?
set -e
[[ "$principal_rc" -ne 0 ]] || fail "co-author mode without a named principal unexpectedly passed"
[[ "$principal_output" == *"requires --escalate-to with a named principal"* ]] || fail "missing-principal refusal lost its diagnostic"
[[ ! -e "$principal_dir/merge-payload.json" ]] || fail "missing-principal refusal reached the merge API"
# A trailing value-taking option receives a stable CLI diagnostic instead of a
# set -u unbound-variable crash.
value_dir=$(make_case missing-principal-value)
set +e
value_output=$(run_case "$value_dir" verified --co-author-trailers --escalate-to 2>&1)
value_rc=$?
set -e
[[ "$value_rc" -ne 0 ]] || fail "missing --escalate-to value unexpectedly passed"
[[ "$value_output" == *"--escalate-to requires one principal name"* ]] || fail "missing --escalate-to value lost its diagnostic"
[[ "$value_output" != *"unbound variable"* ]] || fail "missing --escalate-to value crashed under set -u"
[[ ! -e "$value_dir/merge-payload.json" ]] || fail "missing --escalate-to value reached the merge API"
# Negative control: ordinary single-author merge remains byte-for-byte payload
# compatible and hardcoded to squash, with no optional message fields.
single_dir=$(make_case single)
set +e
single_output=$(run_case "$single_dir" single 2>&1)
single_rc=$?
set -e
if [[ "$single_rc" -ne 0 ]]; then
fail "ordinary single-author merge expected rc=0, got rc=$single_rc: $single_output"
elif [[ ! -s "$single_dir/merge-payload.json" ]]; then
fail "ordinary single-author merge did not reach the API payload"
else
python3 - "$single_dir/merge-payload.json" <<'PY' || fail "ordinary single-author payload changed"
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload == {
"Do": "squash",
"head_commit_id": "1111111111111111111111111111111111111111",
}, payload
PY
fi
[[ -e "$single_dir/auth-via-config" ]] || fail "ordinary path did not authenticate curl through stdin config"
[[ ! -e "$single_dir/token-in-argv" ]] || fail "ordinary path placed the Gitea token in curl argv"
[[ "$(wc -l < "$single_dir/token-resolution.log")" -eq 1 ]] || fail "ordinary path did not use exactly one credential resolution"
# Squash is not defaultable: an explicit non-squash method must remain refused.
method_dir=$(make_case method-refusal)
set +e
method_output=$(run_case "$method_dir" single -m merge 2>&1)
method_rc=$?
set -e
[[ "$method_rc" -ne 0 ]] || fail "non-squash method unexpectedly passed"
[[ "$method_output" == *"enforces squash merge only"* ]] || fail "non-squash refusal lost its policy diagnostic"
[[ ! -e "$method_dir/merge-payload.json" ]] || fail "non-squash refusal reached the merge API"
if [[ "$failures" -ne 0 ]]; then
echo "pr-merge message-field regression failed ($failures assertions)" >&2
exit 1
fi
echo "pr-merge message-field regression passed (verified, BLOCK, and unchanged squash control)"
@@ -39,7 +39,7 @@ MAX_FRAME: Final = 64 * 1024
MAX_STATE: Final = 4 * 1024 * 1024
MAX_PENDING_TOKENS: Final = 256
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
MAX_LEASE_TTL_SECONDS: Final = 3600
MAX_LEASE_TTL_SECONDS: Final = 300
STATE_VERSION: Final = 1
READ_DEADLINE_SECONDS: Final = 1.0
HANDLE_QUEUE_TIMEOUT_SECONDS: Final = 1.0
@@ -50,8 +50,8 @@ LEASE_PENDING: Final = "PENDING_VERIFICATION"
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
LEASE_VERIFIED: Final = "VERIFIED"
READ_ONLY_TOOLS: Final = {
"claude": frozenset({"Read", "Grep", "Glob"}),
"pi": frozenset({"read", "ls"}),
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
"pi": frozenset({"read", "grep", "find", "ls"}),
}
RECOVERY_TOOL: Final = "mosaic_context_recover"
@@ -8,9 +8,7 @@ import json
import os
import socket
import sys
import time
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime, timezone
from pathlib import Path
from typing import Final
@@ -55,48 +53,6 @@ def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, o
return value
def _self_starttime() -> str | None:
"""Field 22 of our own /proc stat — the anchor starttime the broker records.
Read past the comm field's parens, since a process name may contain them.
"""
try:
raw = Path(f"/proc/{os.getpid()}/stat").read_text()
return raw.rsplit(")", 1)[1].split()[19]
except (OSError, IndexError, ValueError):
return None
def _append_launch_record(environ: Mapping[str, str], record: dict[str, object]) -> None:
"""Append one NDJSON event to the #797 Runtime Session Ledger.
`fleet/run/sessions/` is operator-classified in framework-manifest.txt and is
already covered by test-upgrade-manifest-guard.sh, so an upgrade can neither
overwrite nor prune it. Files 0600 under a 0700 dir, matching what that guard
asserts.
Never raises: a launch must not be denied over bookkeeping. But it also never
fails silently a missing record is exactly the kind of gap that made the
2026-08-06 MUTATOR_UNVERIFIED investigation cost a day.
"""
try:
mosaic_home = environ.get("MOSAIC_HOME") or str(Path.home() / ".config" / "mosaic")
directory = Path(mosaic_home) / "fleet" / "run" / "sessions"
directory.mkdir(parents=True, exist_ok=True)
os.chmod(directory, 0o700)
framed = {
"seq": time.time_ns() // 1_000_000,
"ts": datetime.now(timezone.utc).isoformat(),
**record,
}
path = directory / "events.ndjson"
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
with os.fdopen(descriptor, "w") as handle:
handle.write(json.dumps(framed, separators=(",", ":")) + "\n")
except (OSError, ValueError, TypeError) as error:
print(f"[mosaic] WARNING: launch record not written: {error}", file=sys.stderr)
def main(
argv: Sequence[str] | None = None,
*,
@@ -138,9 +94,8 @@ def main(
# silent pass and never folded into the generic registration-failure
# branch.
try:
activation_capability = probe_activation_capability(source_environment)
assert_activation_capability_matches(
activation_capability,
probe_activation_capability(source_environment),
expected_activation_capability,
)
except VersionCouplingError as version_error:
@@ -173,32 +128,6 @@ def main(
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
return 1
# Immutable launch record, half two. `mosaic` wrote `session.launch` with the
# config/provenance it knows; only this process knows the broker session id
# and the activation capability it just asserted. os.execvpe preserves the
# PID, so this PID is BOTH the anchor pid and the join key back to that
# record. Never fatal — bookkeeping must not deny a launch — but never
# silent either.
_append_launch_record(
source_environment,
{
"kind": "lease.register",
# Joins back to `mosaic`'s session.launch record. NOT pid: execRuntime()
# spawns rather than execs, so this process is a CHILD of mosaic with a
# different pid. This pid IS the broker anchor pid (os.execvpe below
# preserves it), which is a separate and still-useful fact.
"launch_id": source_environment.get("MOSAIC_LAUNCH_ID"),
"pid": os.getpid(),
"runtime": arguments.runtime,
"session_id": session_id,
"runtime_generation": generation,
"generation_file": str(generation_file),
"anchor_starttime": _self_starttime(),
"activation_capability": activation_capability,
"command": Path(command[0]).name,
},
)
environment = dict(source_environment)
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
@@ -1,337 +0,0 @@
#!/usr/bin/env python3
"""Lease promotion client — the half the enforcement toolkit never shipped.
The enforcement half (``daemon.py`` + ``mutator-gate.py``) ships and denies. The
promotion half has no production caller anywhere in the package: as of 0.0.48,
0.0.49 and 0.0.50-next.2207, ``begin_verification`` / ``observe_receipt`` /
``promote_lease`` are invoked only by ``broker-test-client.ts``, the acceptance
spec, unit tests, and two probes under ``docs/``. Consequence: **no lease on any
host can reach VERIFIED**, so every mutator is denied ``MUTATOR_UNVERIFIED`` by a
gate nothing can satisfy.
THE PROTOCOL (``daemon.py:578-754``)
------------------------------------
1. ``begin_verification`` broker revokes, mints a challenge, and returns the
exact ``receipt`` text the MODEL must emit
2. *the model emits that text verbatim as its ENTIRE latest message*
3. the runtime adapter ships that message to the daemon-owned observer socket
4. ``observe_receipt`` -> ``PENDING_PROMOTION``
5. ``promote_lease`` -> ``VERIFIED``
THIS MODULE IMPLEMENTS 1, 4 AND 5 NEVER 2
-------------------------------------------
Step 2 is the security property, not a formality. ``is_verbatim_receipt`` uses
``hmac.compare_digest`` against the exact minted string explicitly "not a
transcript substring" (``receipt_challenge.py``). Promotion therefore requires a
live model that received the challenge in its context and echoed it exactly.
``receipt-observer-client.py`` will post ANY string as the latest assistant
message. A promotion client that posted its own receipt would satisfy the broker
while proving nothing a gate-disabler indistinguishable from a working fix
unless someone looks for it. **This module never posts a receipt.** Emitting it
belongs to the runtime adapter, where a real model turn happens.
The construction binds the exact normative source bytes. ``h_source`` /
``h_payload`` are derived by the framework's own
``normative_fragments.build_payload`` rather than reimplemented: the broker
derives them the same way and any divergence yields ``PAYLOAD_BINDING_MISMATCH``.
There must be exactly one implementation.
WHAT THE BINDING DOES *NOT* PROVE
---------------------------------
It is tempting to read a VERIFIED lease as "this agent is running THIS law".
**It does not mean that**, and writing it down that way is how the belief spread.
The broker holds no reference copy of any normative source and never opens one;
it recomputes ``h_source`` / ``h_payload`` from the fragment bytes THIS CLIENT
sent and compares them to the binding THIS CLIENT sent (``daemon.py:602-616``).
Both sides of that comparison originate here, so it detects corruption in
transit and nothing else. What the binding actually asserts is "the client
claims these bytes, self-consistently".
Making it mean the stronger thing requires the broker to re-read the on-disk
sources itself, against a manifest the agent cannot rewrite i.e. broker code
attestation under its own uid. Until then, do not cite a VERIFIED lease as
evidence of law integrity.
Usage
-----
lease_promote.py --begin # prints the receipt the MODEL must emit
lease_promote.py --complete <challenge> # after the adapter observed it
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import socket
import sys
from pathlib import Path
from typing import Final
# Isolated (`python -I`) adapter invocations must still import co-located
# framework modules; never depend on the caller's PYTHONPATH.
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
if _MODULE_DIRECTORY not in sys.path:
sys.path.insert(0, _MODULE_DIRECTORY)
from normative_fragments import NormativeFragment, build_payload # noqa: E402
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 3.0
SCHEMA_VERSION: Final = 1
MANIFEST_VERSION: Final = 1
GENERATOR_VERSION: Final = "mosaic/lease_promote@1"
DEFAULT_TTL_SECONDS: Final = 3600
# Normative sources whose exact bytes bind the lease, in binding order. Order is
# load-bearing: ``h_source`` frames the resolved sequence, so reordering changes
# the derivation. Never fabricate a source that is not on disk.
FRAGMENT_SOURCES: Final = (
"CONSTITUTION.md",
"AGENTS.md",
"SOUL.md",
"USER.md",
"STANDARDS.md",
"TOOLS.md",
)
# Framework-owned sources, reconciled on every upgrade — `install.sh:76`
# FRAMEWORK_OWNED and `config/file-adapter.ts` FRAMEWORK_OWNED_FILES — plus the
# per-runtime contract shipped under `framework/runtime/<runtime>/`. A deployment
# missing one of these is broken, not minimal, so their absence is refused rather
# than silently dropped from the binding.
#
# SOUL.md and USER.md are deliberately excluded: install.sh does not seed them
# ("intentionally NOT seeded here — they are generated by `mosaic init`"), so a
# fresh install legitimately lacks both. TOOLS.md is user-seeded on first install
# only. Absence of those three is reported, not fatal.
REQUIRED_SOURCES: Final = frozenset({"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"})
class IncompleteBinding(RuntimeError):
"""A source that must bind this lease could not be read.
**Never downgrade this to a skip.** The broker recomputes the hashes from the
fragments it is sent, so an omitted fragment is internally consistent and
``PAYLOAD_BINDING_MISMATCH`` cannot fire a partial law promotes exactly like
a complete one, and nothing downstream can tell the difference. Dropping an
unreadable source therefore does not degrade the binding, it forges a smaller
one. Fail here, where the omission is still visible.
"""
def mosaic_home() -> Path:
return Path(os.environ.get("MOSAIC_HOME") or Path.home() / ".config" / "mosaic")
def broker_socket() -> Path:
value = os.environ.get("MOSAIC_LEASE_BROKER_SOCKET")
if value:
return Path(value)
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
if runtime_dir:
return Path(runtime_dir) / "mosaic-lease" / "broker.sock"
return Path(f"/run/user/{os.getuid()}/mosaic-lease/broker.sock")
def session_identity() -> tuple[str, int, str]:
"""Session id, CURRENT generation, runtime.
The generation file wins over the env var, matching ``lease_generation.py``.
Sending a generation HIGHER than the broker's would revoke this session's own
authority (``daemon.py:342-344``), so this never guesses.
"""
session_id = os.environ["MOSAIC_LEASE_SESSION_ID"]
runtime = os.environ["MOSAIC_LEASE_RUNTIME"]
state_file = os.environ.get("MOSAIC_LEASE_GENERATION_FILE")
if state_file:
try:
return session_id, int(Path(state_file).read_text().strip()), runtime
except (OSError, ValueError):
pass
return session_id, int(os.environ["MOSAIC_RUNTIME_GENERATION"]), runtime
def build_construction(runtime: str) -> tuple[dict[str, object], object]:
"""Assemble the wire construction and derive its hashes with the sole builder."""
runtime_contract = f"runtime/{runtime}/RUNTIME.md"
sources = list(FRAGMENT_SOURCES) + [runtime_contract]
required = REQUIRED_SOURCES | {runtime_contract}
wire_fragments: list[dict[str, str]] = []
objects: list[NormativeFragment] = []
absent: list[str] = []
for source_id in sources:
try:
content = (mosaic_home() / source_id).read_bytes()
except FileNotFoundError:
# Genuinely not on disk. Legitimate only for operator-owned sources.
if source_id in required:
raise IncompleteBinding(
f"required normative source is absent: {source_id}"
) from None
absent.append(source_id)
continue
except OSError as exc:
# The path resolves but will not read — EACCES, EIO, EISDIR, ELOOP.
# That is an anomaly for EVERY source, optional ones included: an
# unreadable file is not an un-configured one, and treating it as
# absent is what lets a permission change quietly shrink the law.
raise IncompleteBinding(
f"normative source is present but unreadable: {source_id} "
f"({type(exc).__name__})"
) from exc
digest = hashlib.sha256(content).hexdigest()
wire_fragments.append(
{
"source_id": source_id,
"content_base64": base64.b64encode(content).decode("ascii"),
"expected_sha256": digest,
}
)
objects.append(NormativeFragment(source_id, content, digest))
if not wire_fragments:
raise IncompleteBinding("no normative sources found — refusing an empty binding")
# Absence is legitimate here but never invisible. The omission is already
# baked into h_source (the framed source sequence differs), but nothing
# compares h_source to an expected value, so this line is the only place a
# human learns the binding was narrower than the full set.
if absent:
print(
f"lease_promote: binding omits absent operator sources: {', '.join(absent)}",
file=sys.stderr,
)
result = build_payload(
manifest_version=MANIFEST_VERSION,
generator_version=GENERATOR_VERSION,
fragments=objects,
)
if result.injectionDecision != "ACCEPTED" or not result.promotion:
raise RuntimeError(f"construction refused locally: {result.source_reason}")
return (
{
"manifest_version": MANIFEST_VERSION,
"generator_version": GENERATOR_VERSION,
"fragments": wire_fragments,
},
result,
)
def broker_request(payload: dict[str, object]) -> dict[str, object]:
raw = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
if len(raw) > MAX_FRAME:
raise ValueError(
f"request too large ({len(raw)} bytes); broker frame cap is {MAX_FRAME}"
)
response = bytearray()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(BROKER_TIMEOUT_SECONDS)
connection.connect(str(broker_socket()))
connection.sendall(raw)
connection.shutdown(socket.SHUT_WR)
while len(response) <= MAX_FRAME:
chunk = connection.recv(4096)
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
raise ValueError("invalid broker reply")
value = json.loads(response)
if not isinstance(value, dict):
raise ValueError("invalid broker reply")
return value
def begin(
ttl_seconds: int = DEFAULT_TTL_SECONDS,
compaction_epoch: int = 0,
request_epoch: int = 0,
) -> dict[str, object]:
"""Step 1. Returns the broker reply, including the exact ``receipt`` text."""
session_id, generation, runtime = session_identity()
construction, derived = build_construction(runtime)
return broker_request(
{
"action": "begin_verification",
"session_id": session_id,
"runtime_generation": generation,
"runtime": runtime,
"ttl_seconds": ttl_seconds,
"binding": {
"compaction_epoch": compaction_epoch,
"request_epoch": request_epoch,
"h_source": derived.h_source,
"h_payload": derived.h_payload,
"schema_version": SCHEMA_VERSION,
},
"construction": construction,
}
)
def complete(challenge: str) -> dict[str, object]:
"""Steps 4-5. Assumes the model already emitted the receipt and the adapter
shipped it to the observer socket."""
session_id, generation, _ = session_identity()
observed = broker_request(
{
"action": "observe_receipt",
"session_id": session_id,
"runtime_generation": generation,
"receipt_challenge": challenge,
}
)
if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION":
return {"stage": "observe_receipt", **observed}
promoted = broker_request(
{
"action": "promote_lease",
"session_id": session_id,
"runtime_generation": generation,
"receipt_challenge": challenge,
}
)
return {"stage": "promote_lease", **promoted}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Mosaic lease promotion client.")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--begin",
action="store_true",
help="mint a challenge; prints the receipt the MODEL must emit verbatim",
)
group.add_argument(
"--complete",
metavar="CHALLENGE",
help="observe the emitted receipt and promote the lease",
)
parser.add_argument("--ttl-seconds", type=int, default=DEFAULT_TTL_SECONDS)
arguments = parser.parse_args(argv)
try:
if arguments.begin:
print(json.dumps(begin(ttl_seconds=arguments.ttl_seconds), indent=2))
else:
print(json.dumps(complete(arguments.complete), indent=2))
except KeyError as exc:
print(f"missing lease environment: {exc}; not a lease-gated session", file=sys.stderr)
return 2
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
print(f"{type(exc).__name__}: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,399 +0,0 @@
#!/usr/bin/env python3
"""Claude UserPromptSubmit hook for operator-triggered lease promotion."""
from __future__ import annotations
import fcntl
import importlib.util
import json
import os
import secrets
import stat
import subprocess
import sys
import time
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Final, TextIO
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
if _MODULE_DIRECTORY not in sys.path:
sys.path.insert(0, _MODULE_DIRECTORY)
from receipt_challenge import receipt_for # noqa: E402
_observer_spec = importlib.util.spec_from_file_location(
"mosaic_receipt_observer_client", Path(__file__).resolve().with_name("receipt-observer-client.py")
)
if _observer_spec is None or _observer_spec.loader is None:
raise RuntimeError("unable to load receipt observer client")
_observer_module = importlib.util.module_from_spec(_observer_spec)
_observer_spec.loader.exec_module(_observer_module)
observer_request = _observer_module.observer_request
MAX_FRAME: Final = 64 * 1024
PENDING_MAX_AGE_SECONDS: Final = 60 * 60
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
PROMOTION_PROMPT: Final = "/mosaic-promote"
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
PENDING_DIRECTORY: Final = "mosaic-lease"
AUTHORIZATION_DIRECTORY: Final = "authorizations"
AUTHORIZATION_TTL_SECONDS: Final = 60
LEASE_TTL_SECONDS: Final = 60 * 60
LOCK_FILE: Final = "promotion.lock"
RESULT_FILE: Final = "last-result.json"
EXPECTED_BEGIN_KEYS: Final = frozenset(
{"ok", "state", "receipt_challenge", "receipt", "binding"}
)
EXPECTED_BINDING_KEYS: Final = frozenset(
{
"compaction_epoch",
"request_epoch",
"h_source",
"h_payload",
"runtime_generation",
"schema_version",
}
)
class PromotionAlreadyInProgress(RuntimeError):
pass
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
value: dict[str, object] = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate promoter JSON key")
value[key] = item
return value
def read_hook_input(stream: object) -> dict[str, object]:
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
raise ValueError("invalid UserPromptSubmit input")
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
if not isinstance(value, dict):
raise ValueError("invalid UserPromptSubmit input")
return value
def emit_context(stream: TextIO, message: str) -> None:
json.dump(
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": message,
}
},
stream,
separators=(",", ":"),
)
stream.write("\n")
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
if not runtime_dir.is_absolute():
raise ValueError("XDG_RUNTIME_DIR must be absolute")
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
raise ValueError("invalid lease session id")
return runtime_dir, f"pending-{session_id}"
def open_pending_directory(runtime_dir: Path) -> int:
directory_flags = (
os.O_RDONLY
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_DIRECTORY", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
runtime_descriptor = os.open(runtime_dir, directory_flags)
try:
runtime_metadata = os.fstat(runtime_descriptor)
if (
not stat.S_ISDIR(runtime_metadata.st_mode)
or runtime_metadata.st_uid != os.getuid()
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
):
raise ValueError("unsafe XDG runtime directory")
try:
os.mkdir(PENDING_DIRECTORY, mode=0o700, dir_fd=runtime_descriptor)
except FileExistsError:
pass
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
finally:
os.close(runtime_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISDIR(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o700
):
os.close(descriptor)
raise ValueError("unsafe promotion pending directory")
return descriptor
def acquire_lock(directory_descriptor: int) -> int:
flags = (
os.O_RDWR
| os.O_CREAT
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o600
):
os.close(descriptor)
raise ValueError("unsafe promotion lock file")
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as error:
os.close(descriptor)
raise PromotionAlreadyInProgress() from error
return descriptor
def sweep_stale_pending(directory_descriptor: int, current_time: float) -> None:
cutoff = current_time - PENDING_MAX_AGE_SECONDS
removed = False
with os.scandir(directory_descriptor) as entries:
for candidate in entries:
if not (
candidate.name.startswith("pending-")
or candidate.name.startswith(".pending-")
):
continue
try:
metadata = candidate.stat(follow_symlinks=False)
if metadata.st_mtime < cutoff and not stat.S_ISDIR(metadata.st_mode):
os.unlink(candidate.name, dir_fd=directory_descriptor)
removed = True
except FileNotFoundError:
continue
if removed:
os.fsync(directory_descriptor)
def consume_authorization(directory_descriptor: int, session_id: str, wall_clock: float) -> str | None:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
authorization_descriptor = os.open(AUTHORIZATION_DIRECTORY, flags, dir_fd=directory_descriptor)
except FileNotFoundError:
return None
try:
metadata = os.fstat(authorization_descriptor)
if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
raise ValueError("unsafe promotion authorization directory")
name = f"{session_id}.auth"
try:
descriptor = os.open(name, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), dir_fd=authorization_descriptor)
except FileNotFoundError:
return None
try:
token_metadata = os.fstat(descriptor)
if not stat.S_ISREG(token_metadata.st_mode) or token_metadata.st_uid != os.getuid() or stat.S_IMODE(token_metadata.st_mode) != 0o600 or token_metadata.st_size <= 0 or token_metadata.st_size > MAX_FRAME:
raise ValueError("unsafe promotion authorization")
raw = os.read(descriptor, MAX_FRAME + 1)
finally:
os.close(descriptor)
os.unlink(name, dir_fd=authorization_descriptor)
os.fsync(authorization_descriptor)
token = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
if not isinstance(token, dict) or set(token) != {"nonce", "seat", "session_id", "expires_at", "ts"}:
return None
nonce = token.get("nonce")
expires_at = token.get("expires_at")
issued_at = token.get("ts")
if token.get("session_id") != session_id or not isinstance(token.get("seat"), str) or not isinstance(nonce, str) or len(nonce) != 64 or any(char not in "0123456789abcdef" for char in nonce) or type(expires_at) not in (int, float) or type(issued_at) not in (int, float) or expires_at <= wall_clock or expires_at > issued_at + AUTHORIZATION_TTL_SECONDS:
return None
return nonce
finally:
os.close(authorization_descriptor)
def write_result(directory_descriptor: int, attempt_id: str, verified: bool, reason: str | None, session_id: str, wall_clock: float) -> None:
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=directory_descriptor)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
json.dump({"attempt_id": attempt_id, "expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None, "reason": reason, "session_id": session_id, "ts": wall_clock, "verified": verified}, stream, separators=(",", ":"), sort_keys=True)
stream.flush(); os.fsync(stream.fileno())
os.replace(temporary, RESULT_FILE, src_dir_fd=directory_descriptor, dst_dir_fd=directory_descriptor)
os.fsync(directory_descriptor)
finally:
os.close(descriptor)
def write_pending(directory_descriptor: int, name: str, challenge: str) -> None:
temporary = f".{name}.tmp-{secrets.token_hex(8)}"
flags = (
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
stream.write(challenge)
stream.flush()
os.fsync(stream.fileno())
os.replace(
temporary,
name,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
)
os.fsync(directory_descriptor)
except Exception:
try:
os.unlink(temporary, dir_fd=directory_descriptor)
except FileNotFoundError:
pass
raise
finally:
os.close(descriptor)
def parse_begin_reply(
completed: subprocess.CompletedProcess[str],
) -> tuple[str, dict[str, object] | None]:
if completed.returncode != 0:
return f"PROMOTER_EXIT_{completed.returncode}", None
try:
value = json.loads(
completed.stdout,
object_pairs_hook=reject_duplicate_json_keys,
)
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
return "INVALID_PROMOTER_REPLY", None
if not isinstance(value, dict):
return "INVALID_PROMOTER_REPLY", None
if value.get("ok") is False and set(value) == {"ok", "code"}:
code = value.get("code")
return code if isinstance(code, str) and code else "PROMOTION_BEGIN_REFUSED", value
if set(value) != EXPECTED_BEGIN_KEYS or value.get("ok") is not True:
return "INVALID_PROMOTER_REPLY", None
if value.get("state") != "PENDING_VERIFICATION":
return "INVALID_PROMOTER_REPLY", None
challenge = value.get("receipt_challenge")
receipt = value.get("receipt")
binding = value.get("binding")
if (
not isinstance(challenge, str)
or len(challenge) != 64
or any(character not in "0123456789abcdef" for character in challenge)
or not isinstance(receipt, str)
or not isinstance(binding, dict)
or set(binding) != EXPECTED_BINDING_KEYS
):
return "INVALID_PROMOTER_REPLY", None
integer_fields = (
"compaction_epoch",
"request_epoch",
"runtime_generation",
"schema_version",
)
if any(type(binding.get(field)) is not int or binding[field] < 0 for field in integer_fields):
return "INVALID_PROMOTER_REPLY", None
if not all(
isinstance(binding.get(field), str)
and len(binding[field]) == 64
and all(character in "0123456789abcdef" for character in binding[field])
for field in ("h_source", "h_payload")
):
return "INVALID_PROMOTER_REPLY", None
if not secrets.compare_digest(
receipt.encode("utf-8"),
receipt_for(challenge, binding).encode("utf-8"),
):
return "INVALID_PROMOTER_REPLY", None
return "", value
def main(
*,
environ: Mapping[str, str] | None = None,
stdin: object | None = None,
stdout: TextIO | None = None,
stderr: TextIO | None = None,
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
now: Callable[[], float] = time.time,
) -> int:
source_environment = os.environ if environ is None else environ
input_stream = sys.stdin if stdin is None else stdin
output_stream = sys.stdout if stdout is None else stdout
error_stream = sys.stderr if stderr is None else stderr
try:
hook_input = read_hook_input(input_stream)
except (OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
print(f"Mosaic promotion trigger ignored invalid hook input: {error}", file=error_stream)
return 0
if hook_input.get("prompt") != PROMOTION_PROMPT:
return 0
directory_descriptor: int | None = None
lock_descriptor: int | None = None
try:
runtime_dir, pending_name = session_pending_name(source_environment)
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
directory_descriptor = open_pending_directory(runtime_dir)
lock_descriptor = acquire_lock(directory_descriptor)
wall_clock = now()
nonce = consume_authorization(directory_descriptor, session_id, wall_clock)
if nonce is None:
write_result(directory_descriptor, "0" * 64, False, "NOT_AUTHORIZED", session_id, wall_clock)
print("Mosaic promotion denied: NOT_AUTHORIZED.", file=error_stream)
return 0
sweep_stale_pending(directory_descriptor, wall_clock)
completed = run([sys.executable, "-I", "-S", "-B", str(PROMOTER), "--begin"], check=False, capture_output=True, text=True, env=dict(source_environment), timeout=PROMOTER_TIMEOUT_SECONDS)
code, reply = parse_begin_reply(completed)
if code or reply is None:
write_result(directory_descriptor, nonce, False, code or "PROMOTION_BEGIN_FAILED", session_id, now())
return 0
challenge = str(reply["receipt_challenge"])
observation = observer_request(
Path(source_environment["MOSAIC_RECEIPT_OBSERVER_SOCKET"]),
{"action": "record_runtime_observation", "session_id": session_id, "runtime_generation": int(source_environment["MOSAIC_RUNTIME_GENERATION"]), "runtime": "claude", "latest_assistant_message": reply["receipt"]},
)
if set(observation) != {"ok"} or observation.get("ok") is not True:
write_result(directory_descriptor, challenge, False, "OBSERVATION_REJECTED", session_id, now())
return 0
completion = run([sys.executable, "-I", "-S", "-B", str(PROMOTER), "--complete", challenge], check=False, capture_output=True, text=True, env=dict(source_environment), timeout=PROMOTER_TIMEOUT_SECONDS)
try:
outcome = json.loads(completion.stdout, object_pairs_hook=reject_duplicate_json_keys)
except (json.JSONDecodeError, ValueError):
outcome = None
if completion.returncode == 0 and isinstance(outcome, dict) and outcome.get("stage") == "promote_lease" and outcome.get("ok") is True and outcome.get("state") == "VERIFIED":
write_result(directory_descriptor, challenge, True, None, session_id, now())
else:
reason = outcome.get("code") if isinstance(outcome, dict) and isinstance(outcome.get("code"), str) else "PROMOTION_INCOMPLETE"
write_result(directory_descriptor, challenge, False, reason, session_id, now())
except PromotionAlreadyInProgress:
print("Mosaic promotion denied: PROMOTION_ALREADY_IN_PROGRESS.", file=error_stream)
except (KeyError, OSError, RecursionError, ValueError, subprocess.SubprocessError) as error:
print(f"Mosaic promotion begin failed: {type(error).__name__}: {error}", file=error_stream)
finally:
if lock_descriptor is not None:
os.close(lock_descriptor)
if directory_descriptor is not None:
os.close(directory_descriptor)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,361 +0,0 @@
#!/usr/bin/env python3
"""Claude Stop hook that completes a pending operator-triggered promotion."""
from __future__ import annotations
import fcntl
import json
import os
import secrets
import stat
import subprocess
import sys
import time
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Final, NamedTuple, TextIO
MAX_FRAME: Final = 64 * 1024
PROMOTER_TIMEOUT_SECONDS: Final = 10.0
LEASE_TTL_SECONDS: Final = 60 * 60
PROMOTER: Final = Path(__file__).resolve().with_name("lease_promote.py")
PENDING_DIRECTORY: Final = "mosaic-lease"
LOCK_FILE: Final = "promotion.lock"
RESULT_FILE: Final = "last-result.json"
TERMINAL_FAILURE_CODES: Final = frozenset(
{
"RECEIPT_REPLAY",
"RECEIPT_MISMATCH",
"INVALID_LEASE_TRANSITION",
"PROMOTION_TOKEN_INVALID",
}
)
class PendingChallenge(NamedTuple):
value: str
device: int
inode: int
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
value: dict[str, object] = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate promoter JSON key")
value[key] = item
return value
def session_pending_name(environ: Mapping[str, str]) -> tuple[Path, str]:
runtime_dir = Path(environ["XDG_RUNTIME_DIR"])
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
if not runtime_dir.is_absolute():
raise ValueError("XDG_RUNTIME_DIR must be absolute")
if len(session_id) != 64 or any(character not in "0123456789abcdef" for character in session_id):
raise ValueError("invalid lease session id")
return runtime_dir, f"pending-{session_id}"
def open_pending_directory(runtime_dir: Path) -> int | None:
directory_flags = (
os.O_RDONLY
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_DIRECTORY", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
try:
runtime_descriptor = os.open(runtime_dir, directory_flags)
except FileNotFoundError:
return None
try:
runtime_metadata = os.fstat(runtime_descriptor)
if (
not stat.S_ISDIR(runtime_metadata.st_mode)
or runtime_metadata.st_uid != os.getuid()
or stat.S_IMODE(runtime_metadata.st_mode) != 0o700
):
raise ValueError("unsafe XDG runtime directory")
try:
descriptor = os.open(PENDING_DIRECTORY, directory_flags, dir_fd=runtime_descriptor)
except FileNotFoundError:
return None
finally:
os.close(runtime_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISDIR(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o700
):
os.close(descriptor)
raise ValueError("unsafe promotion pending directory")
return descriptor
def acquire_lock(directory_descriptor: int) -> int:
flags = (
os.O_RDWR
| os.O_CREAT
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(LOCK_FILE, flags, 0o600, dir_fd=directory_descriptor)
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o600
):
os.close(descriptor)
raise ValueError("unsafe promotion lock file")
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(descriptor)
raise
return descriptor
def read_pending(directory_descriptor: int, name: str) -> PendingChallenge | None:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(name, flags, dir_fd=directory_descriptor)
except FileNotFoundError:
return None
try:
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_uid != os.getuid()
or stat.S_IMODE(metadata.st_mode) != 0o600
or metadata.st_size <= 0
or metadata.st_size > MAX_FRAME
):
raise ValueError("unsafe promotion pending file")
raw = os.read(descriptor, MAX_FRAME + 1)
finally:
os.close(descriptor)
if len(raw) > MAX_FRAME:
raise ValueError("oversized promotion challenge")
challenge = raw.decode("utf-8")
if (
len(challenge) != 64
or any(character not in "0123456789abcdef" for character in challenge)
):
raise ValueError("invalid promotion challenge")
return PendingChallenge(challenge, metadata.st_dev, metadata.st_ino)
def write_result(
directory_descriptor: int,
attempt_id: str,
verified: bool,
reason: str | None,
session_id: str,
wall_clock: float,
) -> None:
result = {
"attempt_id": attempt_id,
"expires_at_wallclock": wall_clock + LEASE_TTL_SECONDS if verified else None,
"reason": reason,
"session_id": session_id,
"ts": wall_clock,
"verified": verified,
}
temporary = f".{RESULT_FILE}.tmp-{secrets.token_hex(8)}"
flags = (
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_descriptor)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as stream:
json.dump(result, stream, separators=(",", ":"), sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(
temporary,
RESULT_FILE,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
)
os.fsync(directory_descriptor)
except Exception:
try:
os.unlink(temporary, dir_fd=directory_descriptor)
except FileNotFoundError:
pass
raise
finally:
os.close(descriptor)
def delete_pending_if_unchanged(
directory_descriptor: int,
name: str,
pending: PendingChallenge,
error_stream: TextIO,
) -> None:
quarantine = f".{name}.delete-{secrets.token_hex(8)}"
try:
os.rename(
name,
quarantine,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
)
except FileNotFoundError:
return
except OSError as error:
print(f"Mosaic promotion could not quarantine pending file: {error}", file=error_stream)
return
try:
moved = os.stat(
quarantine,
dir_fd=directory_descriptor,
follow_symlinks=False,
)
if (moved.st_dev, moved.st_ino) == (pending.device, pending.inode):
os.unlink(quarantine, dir_fd=directory_descriptor)
os.fsync(directory_descriptor)
return
print("Mosaic promotion pending file changed; preserving replacement.", file=error_stream)
try:
os.link(
quarantine,
name,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
follow_symlinks=False,
)
except FileExistsError:
print(
f"Mosaic promotion preserved replacement as {quarantine}.",
file=error_stream,
)
else:
os.unlink(quarantine, dir_fd=directory_descriptor)
os.fsync(directory_descriptor)
except OSError as error:
print(f"Mosaic promotion could not resolve pending file: {error}", file=error_stream)
def parse_reply(completed: subprocess.CompletedProcess[str]) -> dict[str, object] | None:
if completed.returncode != 0:
return None
try:
value = json.loads(
completed.stdout,
object_pairs_hook=reject_duplicate_json_keys,
)
except (json.JSONDecodeError, RecursionError, TypeError, ValueError):
return None
if not isinstance(value, dict):
return None
if set(value) == {"stage", "ok", "state"}:
if (
value.get("stage") == "promote_lease"
and value.get("ok") is True
and value.get("state") == "VERIFIED"
):
return value
return None
if set(value) == {"stage", "ok", "code"}:
if (
value.get("stage") in {"observe_receipt", "promote_lease"}
and value.get("ok") is False
and isinstance(value.get("code"), str)
and value.get("code")
):
return value
return None
def main(
*,
environ: Mapping[str, str] | None = None,
stderr: TextIO | None = None,
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
now: Callable[[], float] = time.time,
) -> int:
source_environment = os.environ if environ is None else environ
error_stream = sys.stderr if stderr is None else stderr
directory_descriptor: int | None = None
lock_descriptor: int | None = None
try:
runtime_dir, pending_name = session_pending_name(source_environment)
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
directory_descriptor = open_pending_directory(runtime_dir)
if directory_descriptor is None:
return 0
try:
lock_descriptor = acquire_lock(directory_descriptor)
except (BlockingIOError, FileNotFoundError):
print("Mosaic promotion completion deferred: promotion is in progress.", file=error_stream)
return 0
pending = read_pending(directory_descriptor, pending_name)
if pending is None:
return 0
completed = run(
[
sys.executable,
"-I",
"-S",
"-B",
str(PROMOTER),
"--complete",
pending.value,
],
check=False,
capture_output=True,
text=True,
env=dict(source_environment),
timeout=PROMOTER_TIMEOUT_SECONDS,
)
reply = parse_reply(completed)
if reply is not None and reply.get("ok") is True:
write_result(directory_descriptor, pending.value, True, None, session_id, now())
delete_pending_if_unchanged(
directory_descriptor,
pending_name,
pending,
error_stream,
)
print("Mosaic lease promotion completed.", file=error_stream)
return 0
if reply is not None:
code = str(reply["code"])
print(f"Mosaic promotion incomplete: {code}.", file=error_stream)
if code in TERMINAL_FAILURE_CODES:
write_result(directory_descriptor, pending.value, False, code, session_id, now())
delete_pending_if_unchanged(
directory_descriptor,
pending_name,
pending,
error_stream,
)
else:
diagnostic = completed.stderr.strip() or f"promoter exit {completed.returncode}"
print(f"Mosaic promotion retryable failure: {diagnostic}.", file=error_stream)
except (KeyError, OSError, RecursionError, UnicodeError, ValueError, subprocess.SubprocessError) as error:
print(f"Mosaic promotion completion deferred: {type(error).__name__}: {error}", file=error_stream)
finally:
if lock_descriptor is not None:
os.close(lock_descriptor)
if directory_descriptor is not None:
os.close(directory_descriptor)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -22,23 +22,13 @@ from typing import Final
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
BENIGN_OBSERVATION_UNAVAILABLE_CODE: Final = "OBSERVATION_UNAVAILABLE"
def reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
value: dict[str, object] = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate observer JSON key")
value[key] = item
return value
def read_json(stream: object) -> dict[str, object]:
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
raise ValueError("invalid observer input")
value = json.loads(raw, object_pairs_hook=reject_duplicate_json_keys)
value = json.loads(raw)
if not isinstance(value, dict):
raise ValueError("invalid observer input")
return value
@@ -110,9 +100,9 @@ def observer_request(socket_path: Path, request: dict[str, object]) -> dict[str,
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or response.count(b"\n") != 1 or not response.endswith(b"\n"):
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
raise ValueError("invalid observer reply")
value = json.loads(response[:-1], object_pairs_hook=reject_duplicate_json_keys)
value = json.loads(response)
if not isinstance(value, dict):
raise ValueError("invalid observer reply")
return value
@@ -129,12 +119,7 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
if arguments.runtime == "claude":
if not arguments.latest_entry:
raise ValueError("Claude observer requires --latest-entry")
if "last_assistant_message" in source:
message = source["last_assistant_message"]
if not isinstance(message, str):
raise ValueError("invalid Claude observer input")
else:
message = claude_latest_entry(source)
message = claude_latest_entry(source)
else:
if arguments.latest_entry:
raise ValueError("Pi observer is message_end only")
@@ -148,18 +133,10 @@ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None
"runtime": arguments.runtime,
"latest_assistant_message": message,
})
except (KeyError, OSError, RecursionError, ValueError, json.JSONDecodeError) as error:
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
return 2
if set(reply) == {"ok"} and reply.get("ok") is True:
return 0
if (
set(reply) == {"ok", "code"}
and reply.get("ok") is False
and reply.get("code") == BENIGN_OBSERVATION_UNAVAILABLE_CODE
):
return 0
return 2
return 0 if reply == {"ok": True} else 2
if __name__ == "__main__":
@@ -33,7 +33,7 @@ def is_verbatim_receipt(message: str, challenge: str, binding: dict[str, object]
"""Require the exact one current-cycle receipt, not a transcript substring."""
expected = receipt_for(challenge, binding)
return hmac.compare_digest(message.encode("utf-8"), expected.encode("utf-8"))
return hmac.compare_digest(message, expected)
def latest_assistant_digest(message: str) -> str:
@@ -39,12 +39,11 @@ ORIG_PATH="$PATH"
# loop — which would make the control a false negative. A root dotfile is
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
STRIPPED="$FW/.install-rollback-control.tmp.sh"
SIGNALED="$FW/.install-signal-control.tmp.sh"
NOEXIT="$FW/.install-noexit-control.tmp.sh"
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
@@ -181,86 +180,41 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
# A bash signal trap that merely returns lets the script continue past the
# interrupt — restoring the snapshot, then resuming the sync and reporting
# success. The earlier test used a child cp shim to signal its parent, making
# child completion race Bash's interrupted wait. Concurrency is not part of the
# guarded property: sync_framework_keep() runs in the installer's own Bash
# process, and `kill` is a builtin. Generate two installer fixtures that signal
# themselves at the same known mid-sync point. Their TERM handlers emit the same
# observable before diverging, so missing signal delivery fails BOTH arms rather
# than manufacturing a pass. The only semantic difference between fixtures is
# the explicit `exit 1` whose load-bearing behavior this control proves.
TERM_MARKER='[test-control] TERM handler entered'
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
make_signal_installer() {
local output="$1" handler="$2"
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
local target_cp=' cp "$abs" "$dst/$rel"'
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
local inject_close=' fi'
if ! awk \
-v target_trap="$target_trap" -v target_cp="$target_cp" \
-v handler="$handler" -v inject_open="$inject_open" \
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
$0 == target_cp {
print inject_open
print inject_kill
print inject_close
injection_sites++
}
{ print }
$0 == target_trap {
print handler
handler_sites++
}
END {
if (handler_sites != 1 || injection_sites != 1) exit 42
}
' "$INSTALL" > "$output"; then
rm -f "$output"
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
exit 1
fi
chmod +x "$output"
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
# fires and ONLY the signal path governs), and assert the shipped installer
# restores AND exits without reporting success. The control strips `exit 1` from
# the trap and shows the buggy resume-to-success.
make_term_shim() {
local dir="$1"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
case "\$dest" in
*/$POISON_REL)
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
exec env PATH="$ORIG_PATH" cp "\$@" ;;
esac
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
}
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
signal_fixture_ready() {
local fixture="$1" expected_handler="$2"
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
&& grep -Fqx "$expected_handler" "$fixture"
}
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
"signaled_fixture_ready"
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
"noexit_fixture_ready"
chk "[control] removing the explicit TERM exit changes the fixture" \
"! cmp -s '$SIGNALED' '$NOEXIT'"
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
# the selected copy. Echoes "<exit>\t<out>\t<home>".
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
run_signal_upgrade() {
local installer="$1" H OUT rc
H=$(mktemp -d); OUT=$(mktemp)
local installer="$1" H OUT SHIM rc
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
seed_home "$H"
make_term_shim "$SHIM"
set +e
PATH="$ORIG_PATH" \
PATH="$SHIM:$ORIG_PATH" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
}
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
chk "[signal] TERM handler observable fires exactly once" \
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
"[ '$rcC' -ne 0 ]"
chk "[signal] restore_snapshot fires on the interrupt" \
@@ -268,13 +222,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
chk "[signal] does NOT resume to report sync success after the interrupt" \
"! grep -q 'file phase complete' '$OUTC'"
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
chk "[control] TERM handler observable fires exactly once" \
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
chk "[control] without 'exit 1' the handler restores before returning" \
"grep -q 'restoring previous state from snapshot' '$OUTD'"
chk "[control] without 'exit 1' the installer exits zero after resuming" \
"[ '$rcD' -eq 0 ]"
# Control: strip `exit 1` from the signal trap → the handler returns, the script
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
"$INSTALL" > "$NOEXIT"
chk "[control] the exit-strip actually changed the installer" \
"! cmp -s '$INSTALL' '$NOEXIT'"
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
"grep -q 'file phase complete' '$OUTD'"
@@ -355,10 +309,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
# Cleanup (generated installer controls are also removed by the EXIT trap).
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
echo
echo "RESULT: $pass passed, $fail failed"
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
-2
View File
@@ -15,7 +15,6 @@ import { registerAgentCommand } from './commands/agent.js';
import { registerInteractionCommand } from './commands/interaction.js';
import { registerConfigCommand } from './commands/config.js';
import { registerFleetCommand } from './commands/fleet.js';
import { registerPromoteCommand } from './commands/promote.js';
import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js';
import { registerRestoreCommand } from './commands/restore.js';
@@ -371,7 +370,6 @@ registerInteractionCommand(program);
// ─── fleet ─────────────────────────────────────────────────────────────
registerFleetCommand(program);
registerPromoteCommand(program);
// ─── config ────────────────────────────────────────────────────────────
@@ -1,219 +0,0 @@
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();
});
});
@@ -1,59 +0,0 @@
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,7 +82,6 @@ describe('registerFleetCommand', () => {
expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add',
'agent',
'apply',
'backlog',
'create',
@@ -92,7 +91,6 @@ describe('registerFleetCommand', () => {
'init',
'install',
'install-systemd',
'launch',
'migrate-v1',
'persona',
'plan',
-26
View File
@@ -38,10 +38,6 @@ import {
registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps,
} from './fleet-agent-crud-command.js';
import {
registerFleetAgentScaffoldCommand,
type FleetAgentScaffoldCommandDeps,
} from './fleet-agent-scaffold-command.js';
import {
registerFleetMigrationCommand,
type FleetMigrationCommandDeps,
@@ -67,7 +63,6 @@ import { registerFleetBacklogCommand } from './fleet-backlog.js';
import { registerFleetPersonaCommand } from './fleet-personas.js';
import { registerFleetProfileCommand } from './fleet-profiles.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).
@@ -102,10 +97,6 @@ export interface FleetCommandDeps {
*/
sleepFn?: SleepFn;
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;
/**
* Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY.
@@ -113,8 +104,6 @@ export interface FleetCommandDeps {
*/
isStdinTTY?: boolean;
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
/** Test-only user-data root for `fleet agent new` (production: ~/.mosaic). */
fleetDataHome?: FleetAgentScaffoldCommandDeps['fleetDataHome'];
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
}
@@ -2052,14 +2041,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// fleet/ directory as the roster and heartbeats.
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
// from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag.
registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
@@ -2073,13 +2054,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// profile. DRY-RUN by default; --write persists under the same --mosaic-home.
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
// plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps);
@@ -201,34 +201,10 @@ describe('guardClaudeSettingsWiring', () => {
// red-first). Instead it proves the wiring is genuinely delegated: the
// no-deps call must agree with an explicit call to the same real
// predicate, not some other hardcoded value.
//
// 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;
const reallyActivatable = leaseEnforcementActivatable();
const outcome = guardClaudeSettingsWiring(fixtureJson());
if (before) {
if (reallyActivatable) {
expect(outcome.exitCode).toBe(0);
expect(outcome.wired).toBe(true);
} else {
@@ -1,338 +0,0 @@
/**
* 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 -420
View File
@@ -1,17 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { Command } from 'commander';
import { spawnSync } from 'node:child_process';
import {
chmodSync,
copyFileSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
writeFileSync,
symlinkSync,
rmSync,
} from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
@@ -19,8 +8,6 @@ import {
enumerateSkillDirs,
piForceSkillNames,
registerRuntimeLaunchers,
checkSequentialThinking,
resolveExecutableFromPath,
type RuntimeLaunchHandler,
type ClaudexLaunchHandler,
} from './launch.js';
@@ -99,269 +86,6 @@ 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', () => {
it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
@@ -625,146 +349,3 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
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/,
);
});
});
+44 -677
View File
@@ -8,26 +8,23 @@
import { execFileSync, execSync, spawnSync } from 'node:child_process';
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
writeFileSync,
readdirSync,
realpathSync,
rmSync,
appendFileSync,
} from 'node:fs';
import { createHash, randomBytes } from 'node:crypto';
import { createRequire } from 'node:module';
import { homedir, hostname } from 'node:os';
import { join, dirname, relative, resolve, sep, delimiter, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
import {
buildResolvedFleetCommsBlock,
renderToolsContractStatus,
resolveFleetIdentity,
} from '../fleet/comms-onboarding.js';
import { assertNoSymlinkAncestors, readRegularFileSecure } from '../fleet/secure-file.js';
import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
@@ -36,14 +33,7 @@ import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
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;
}
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
const RUNTIME_LABELS: Record<RuntimeName, string> = {
claude: 'Claude Code',
@@ -52,175 +42,6 @@ const RUNTIME_LABELS: Record<RuntimeName, string> = {
pi: 'Pi',
};
// ─── Harness home isolation ──────────────────────────────────────────────────
// Mosaic-launched runtimes read config from a dedicated home under the mosaic
// tree — never the operator's base install. A bare `claude` / `pi` therefore
// keeps its own config AND its own auth, and stays a working break-glass no
// matter what mosaic does to its own tree.
//
// These paths are manifest-UNKNOWN, which resolves to operator ownership
// (framework-manifest.txt rule 3, #791), so a keep-mode `mosaic update` can
// neither overwrite nor prune them. Overwrite-mode install still would.
//
// opencode has no dedicated config-dir variable and follows XDG, so isolating it
// sets XDG_CONFIG_HOME for that process tree. That is blunter than the other
// three: it also relocates XDG lookups for anything opencode spawns.
const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
claude: 'CLAUDE_CONFIG_DIR',
pi: 'PI_CODING_AGENT_DIR',
codex: 'CODEX_HOME',
opencode: 'XDG_CONFIG_HOME',
};
/** Dedicated runtime home, optionally scoped to a user fleet agent. */
export function harnessHome(runtime: RuntimeName, fleet?: FleetHarnessContext): string {
return join(fleet?.agentDir ?? MOSAIC_HOME, `.${runtime}`);
}
/**
* 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.
*/
function harnessEnv(runtime: RuntimeName, fleet?: FleetHarnessContext): Record<string, string> {
const key = HARNESS_HOME_ENV[runtime];
if (!key) return {};
const home = harnessHome(runtime, fleet);
mkdirSync(home, { recursive: true });
return { [key]: home };
}
// ─── Launch record (immutable provenance) ────────────────────────────────────
// MANDATORY and MECHANICAL: every launch appends one record of what the agent
// actually launched with, written before exec. No model involvement, no opt-out.
//
// WHY LAUNCH-TIME AND NOT INSPECT-LATER: pi rewrites its own argv to a bare
// `pi`, so /proc/<pid>/cmdline DESTROYS the launch evidence. That has already
// produced a confident wrong diagnosis ("this agent bypassed the launcher"),
// disproved only by the parent process's argv and only because the parent had
// not yet exited. A record written before exec is the only place this survives.
//
// Lands in fleet/run/sessions/ — the #797 Runtime Session Ledger path, already
// operator-classified in framework-manifest.txt and already covered by
// test-upgrade-manifest-guard.sh, so an upgrade can neither overwrite nor prune
// it.
//
// CORRELATION is by an explicit MOSAIC_LAUNCH_ID, never by pid: execRuntime()
// uses spawnSync, so the runtime is a CHILD with a different pid.
// launch-runtime.py appends the matching `lease.register` event.
//
// NEVER records a credential value: env is captured as PRESENT NAMES ONLY, and
// oversized argv values (the composed system prompt) become a digest + length.
const LAUNCH_LEDGER_DIR = join(MOSAIC_HOME, 'fleet', 'run', 'sessions');
const CLI_VERSION: string | null = (() => {
try {
// Resolved RELATIVELY: the package `exports` map does not expose
// package.json, so '@mosaicstack/mosaic/package.json' throws
// ERR_PACKAGE_PATH_NOT_EXPORTED. Same relative depth from src/ and dist/.
return (createRequire(import.meta.url)('../../package.json') as { version: string }).version;
} catch {
return null;
}
})();
interface NormativeFragmentDigest {
source_id: string;
sha256: string | null;
bytes: number | null;
missing?: boolean;
}
function sha256Of(value: string | Buffer): string {
return createHash('sha256').update(value).digest('hex');
}
/**
* Hash the normative sources injected into the agent. This is "what the agent
* IS" and it is the same fragment set the lease broker hashes for promotion,
* so an unexpected digest here is a mechanically detectable red flag rather than
* a matter of judgement.
*/
function normativeFragmentDigests(
runtime: RuntimeName,
mosaicHome: string = MOSAIC_HOME,
): NormativeFragmentDigest[] {
const candidates: Array<[string, string]> = [
['CONSTITUTION.md', join(mosaicHome, 'CONSTITUTION.md')],
['AGENTS.md', join(mosaicHome, 'AGENTS.md')],
['SOUL.md', join(mosaicHome, 'SOUL.md')],
['USER.md', join(mosaicHome, 'USER.md')],
['STANDARDS.md', join(mosaicHome, 'STANDARDS.md')],
['TOOLS.md', join(mosaicHome, 'TOOLS.md')],
[`runtime/${runtime}/RUNTIME.md`, join(mosaicHome, 'runtime', runtime, 'RUNTIME.md')],
];
return candidates.map(([sourceId, path]) => {
try {
const bytes = readFileSync(path);
return { source_id: sourceId, sha256: sha256Of(bytes), bytes: bytes.length };
} catch {
return { source_id: sourceId, sha256: null, bytes: null, missing: true };
}
});
}
/** argv with oversized values replaced by a digest, so the record stays small
* and never inlines injected content verbatim. */
function redactArgv(argv: string[]): string[] {
return argv.map((a) =>
typeof a === 'string' && a.length > 256
? `<redacted sha256:${sha256Of(a).slice(0, 16)} bytes:${a.length}>`
: a,
);
}
function recordLaunch(
runtime: RuntimeName,
cliArgs: string[],
yolo: boolean,
fleet?: FleetHarnessContext,
launchEnv: NodeJS.ProcessEnv = process.env,
): void {
try {
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
// propagates through every `...process.env` / `...baseEnv` spread below.
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
process.env['MOSAIC_LAUNCH_ID'] = launchId;
const record = {
seq: Date.now(),
kind: 'session.launch',
launch_id: launchId,
ts: new Date().toISOString(),
host: hostname(),
pid: process.pid,
runtime,
mode: yolo ? 'yolo' : 'normal',
cwd: process.cwd(),
cli_version: CLI_VERSION,
config_home: harnessHome(runtime, fleet),
config_home_isolated: true,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime, fleet?.mosaicHome),
// names only — values are never recorded
mosaic_env_present: Object.keys(launchEnv)
.filter((k) => k.startsWith('MOSAIC_'))
.sort(),
};
appendFileSync(join(ledgerDir, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
mode: 0o600,
});
} catch (err) {
// Never block a launch on bookkeeping — but never fail silently either.
console.error(
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// ─── Pre-flight checks ──────────────────────────────────────────────────────
function checkMosaicHome(): void {
@@ -250,125 +71,6 @@ 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 {
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
if (!existsSync(soulPath)) {
@@ -401,13 +103,13 @@ interface SettingsAudit {
warnings: string[];
}
function auditClaudeSettings(fleet?: FleetHarnessContext): SettingsAudit {
function auditClaudeSettings(): SettingsAudit {
const warnings: string[] = [];
const settingsPath = join(harnessHome('claude', fleet), 'settings.json');
const settingsPath = join(homedir(), '.claude', 'settings.json');
const settings = readJson(settingsPath);
if (!settings) {
warnings.push(`${settingsPath} not found — hooks and plugins will be missing`);
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
return { warnings };
}
@@ -471,141 +173,13 @@ function printSettingsWarnings(audit: SettingsAudit): void {
);
}
interface TrustedCapability {
readonly path: string;
readonly content: Buffer;
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 }),
},
},
);
function checkSequentialThinking(runtime: string): void {
const checker = fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return; // Skip if checker doesn't exist
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
if (result.status !== 0) {
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
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}`);
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`);
process.exit(1);
}
}
@@ -750,11 +324,7 @@ function buildPrdBlock(): string {
* `mosaicHome` is parameterized for testability; production callers use the
* module-level default.
*/
export function composeContract(
runtime: RuntimeName,
mosaicHome: string = MOSAIC_HOME,
env: NodeJS.ProcessEnv = process.env,
): string {
export function composeContract(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
const runtimeContractPaths: Record<RuntimeName, string> = {
claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
@@ -811,13 +381,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);
}
const fleetIdentity = resolveFleetIdentity(mosaicHome, env['MOSAIC_AGENT_NAME']);
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
if (!fleetIdentity.ok) {
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
}
const canonicalMember = fleetIdentity.identity?.member;
if (canonicalMember && env['MOSAIC_AGENT_CLASS']?.trim()) {
const ambientClass = canonicalizeRoleClass(env['MOSAIC_AGENT_CLASS']).canonicalClass;
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
if (ambientClass !== canonicalMember.className) {
throw new Error(
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
@@ -854,13 +424,13 @@ For required push/merge/issue-close/release actions, execute without routine con
// Fleet launches derive every identity projection from the one canonical roster
// member resolved above. Non-fleet launches retain the legacy ambient persona
// and tool-policy behavior.
const personaClass = canonicalMember?.className ?? env['MOSAIC_AGENT_CLASS'];
const personaClass = canonicalMember?.className ?? process.env['MOSAIC_AGENT_CLASS'];
const persona = readPersonaContractBlock(mosaicHome, personaClass);
if (persona) parts.push('\n\n' + persona);
const toolPolicyName = canonicalMember
? canonicalMember.toolPolicy
: env['MOSAIC_AGENT_TOOL_POLICY'];
: process.env['MOSAIC_AGENT_TOOL_POLICY'];
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
if (toolPolicy) parts.push('\n\n' + toolPolicy);
@@ -884,8 +454,8 @@ function readFleetToolPolicyBlock(policy: string | undefined): string {
}
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
function buildRuntimePrompt(runtime: RuntimeName, env: NodeJS.ProcessEnv = process.env): string {
return composeContract(runtime, MOSAIC_HOME, env);
function buildRuntimePrompt(runtime: RuntimeName): string {
return composeContract(runtime);
}
// ─── Session lock ────────────────────────────────────────────────────────────
@@ -966,12 +536,8 @@ function checkResumableSession(): void {
// ─── Write config for runtimes that read from fixed paths ────────────────────
function ensureRuntimeConfig(
runtime: RuntimeName,
destPath: string,
env: NodeJS.ProcessEnv = process.env,
): void {
const prompt = buildRuntimePrompt(runtime, env);
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
const prompt = buildRuntimePrompt(runtime);
mkdirSync(dirname(destPath), { recursive: true });
const existing = readOptional(destPath);
if (existing !== prompt) {
@@ -995,9 +561,7 @@ function skillRealPath(dir: string): string {
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
* skills dir and the project-local one relative to the launch cwd. */
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
// PI_CODING_AGENT_DIR replaces ~/.pi/agent (not ~/.pi), so skills live at
// <home>/skills — there is no extra 'agent' segment under the isolated home.
return [join(harnessHome('pi'), 'skills'), join(cwd, '.pi', 'skills')];
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
}
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
@@ -1164,156 +728,34 @@ function getMissionPrompt(): string {
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
}
interface RuntimeLaunchContext {
readonly mosaicHome?: string;
readonly fleet?: FleetHarnessContext;
readonly declaredEnv?: Readonly<Record<string, string>>;
/** 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);
}
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never {
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul();
checkRuntime(runtime);
// Pi doesn't need sequential-thinking (has native thinking levels)
if (runtime !== 'pi') {
checkSequentialThinking(runtime, context.fleet);
checkSequentialThinking(runtime);
}
checkResumableSession();
const missionPrompt = context.mosaicHome === undefined ? getMissionPrompt() : '';
const missionPrompt = getMissionPrompt();
const hasMissionNoArgs = missionPrompt && args.length === 0;
const label = RUNTIME_LABELS[runtime];
const modeStr = yolo ? ' in YOLO mode' : '';
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
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) {
case 'claude': {
// Audit Claude Code settings and warn about missing hooks/plugins
const settingsAudit = auditClaudeSettings(context.fleet);
const settingsAudit = auditClaudeSettings();
printSettingsWarnings(settingsAudit);
const prompt = composeContract('claude', mosaicHome, contractEnv);
const prompt = buildRuntimePrompt('claude');
const cliArgs: string[] = [];
cliArgs.push('--append-system-prompt', prompt);
if (hasMissionNoArgs) {
@@ -1322,24 +764,12 @@ function launchRuntime(
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
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);
}
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
break;
}
case 'codex': {
ensureRuntimeConfig(
'codex',
join(harnessHome('codex', context.fleet), 'instructions.md'),
contractEnv,
);
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
@@ -1347,38 +777,19 @@ function launchRuntime(
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv);
execRuntime(resolvedRuntime ?? 'codex', cliArgs, {
...launchEnv,
...harnessEnv('codex', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
: {}),
});
execRuntime('codex', cliArgs);
break;
}
case 'opencode': {
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
ensureRuntimeConfig(
'opencode',
join(harnessHome('opencode', context.fleet), 'opencode', 'AGENTS.md'),
contractEnv,
);
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
console.log(`[mosaic] Launching ${label}${modeStr}...`);
recordLaunch('opencode', args, yolo, context.fleet, launchEnv);
execRuntime(resolvedRuntime ?? 'opencode', args, {
...launchEnv,
...harnessEnv('opencode', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
: {}),
});
execRuntime('opencode', args);
break;
}
case 'pi': {
const prompt = composeContract('pi', mosaicHome, contractEnv);
const prompt = buildRuntimePrompt('pi');
const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...buildPiSkillArgs(args));
cliArgs.push(...discoverPiExtension());
@@ -1388,11 +799,7 @@ function launchRuntime(
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('pi', cliArgs, yolo, context.fleet, launchEnv);
if (process.env['MOSAIC_LAUNCH_ID']) {
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
}
execLeaseGatedRuntime('pi', cliArgs, launchEnv, false, context.fleet);
execLeaseGatedRuntime('pi', cliArgs);
break;
}
}
@@ -1420,71 +827,31 @@ function execLeaseGatedRuntime(
args: string[],
baseEnv: NodeJS.ProcessEnv = process.env,
dangerous = false,
fleet?: FleetHarnessContext,
): void {
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
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(
interpreter,
'python3',
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
{
...baseEnv,
...harnessEnv(runtime, fleet),
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
},
);
}
/** Fleet entry point reusing the normative runtime launch and exec path. */
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;
/** exec into the runtime, replacing the current process. */
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
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
const result = spawnSync(target, args, {
const result = spawnSync(cmd, args, {
stdio: 'inherit',
env,
});
process.exit(result.status ?? 0);
} catch (err) {
console.error(`[mosaic] Failed to launch ${label}:`, err instanceof Error ? err.message : err);
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
process.exit(1);
}
}
@@ -1,298 +0,0 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import type { FleetRoster } from './fleet.js';
import { TmuxPromotionTransport } from '../fleet/promotion-transport.js';
import {
promoteSeat,
registerPromoteCommand,
type PromotionBreadcrumbStore,
type PromotionResult,
type PromotionTransport,
} from './promote.js';
const attemptId = 'a'.repeat(64);
const target = {
bundle: 'local',
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
};
function transport(): PromotionTransport {
return {
resolve: vi.fn(async () => target),
sendPromotion: vi.fn(async () => {}),
};
}
describe('mosaic promote', () => {
it('accepts only a fresh result correlated to this attempt', async () => {
const promotionTransport = transport();
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
readResult: vi
.fn()
.mockResolvedValueOnce({
attempt_id: 'c'.repeat(64),
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
})
.mockResolvedValueOnce({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
}),
};
const result = await promoteSeat('claude-seat', {
clock: () => 1_000,
sleep: async () => {},
store,
timeoutMs: 1,
transport: promotionTransport,
});
expect(result).toEqual({
bundle: 'local',
expiresAtWallclock: 4_600,
reason: null,
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
status: 'VERIFIED',
});
expect(promotionTransport.sendPromotion).toHaveBeenCalledWith(target);
expect(store.readResult).toHaveBeenCalledTimes(2);
});
it('accepts a fresh result for this session when completion consumed the pending nonce', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
})),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result.status).toBe('VERIFIED');
});
it('returns UNVERIFIED within the command bound when a tmux runner wedges', async () => {
vi.useFakeTimers();
const roster: FleetRoster = {
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
defaults: { workingDirectory: '~/src' },
runtimes: {},
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
transport: 'tmux',
version: 1,
};
const promotionTransport = new TmuxPromotionTransport({
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner: async () => new Promise(() => {}),
});
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => null),
};
try {
let observedResult: PromotionResult | undefined;
void promoteSeat('claude-seat', {
store,
transport: promotionTransport,
}).then((result) => {
observedResult = result;
});
await vi.advanceTimersByTimeAsync(5_000);
expect(observedResult).toMatchObject({
reason: 'RESOLVE_FAILED: Promotion transport command timed out after 5000ms.',
seat: 'claude-seat',
status: 'UNVERIFIED',
});
} finally {
vi.useRealTimers();
}
});
it('prints VERIFIED with the resolved seat, session, bundle, and wall-clock expiry', async () => {
const promotionTransport = transport();
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: Number.MAX_SAFE_INTEGER,
verified: true,
})),
};
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
const program = new Command().exitOverride();
registerPromoteCommand(program, {
mintAuthorization: async () => {},
store,
transport: promotionTransport,
});
try {
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
expect(output).toHaveBeenCalledWith(
`VERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=1970-01-01T01:16:40.000Z`,
);
expect(process.exitCode).not.toBe(1);
} finally {
output.mockRestore();
process.exitCode = undefined;
}
});
it('prints UNVERIFIED and exits 1 when delivery fails', async () => {
const promotionTransport: PromotionTransport = {
resolve: vi.fn(async () => target),
sendPromotion: vi.fn(async () => {
throw new Error('tmux unavailable');
}),
};
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => null),
};
const output = vi.spyOn(console, 'log').mockImplementation(() => {});
const program = new Command().exitOverride();
registerPromoteCommand(program, {
mintAuthorization: async () => {},
store,
transport: promotionTransport,
});
try {
process.exitCode = undefined;
await program.parseAsync(['node', 'mosaic', 'promote', 'claude-seat']);
expect(output).toHaveBeenCalledWith(
`UNVERIFIED seat=claude-seat session=${target.sessionId} bundle=local expiry=none reason=DELIVERY_FAILED: tmux unavailable`,
);
expect(process.exitCode).toBe(1);
} finally {
output.mockRestore();
process.exitCode = undefined;
}
});
it('rejects a stale result even when its nonce matches', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(attemptId),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_000,
verified: true,
})),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result).toEqual({
bundle: 'local',
expiresAtWallclock: null,
reason: 'PROMOTION_TIMEOUT',
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
status: 'UNVERIFIED',
});
});
it('does not accept a result for a pending attempt that existed before send', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => attemptId),
readResult: vi.fn(async () => ({
attempt_id: attemptId,
expires_at_wallclock: 4_600,
reason: null,
session_id: target.sessionId,
ts: 1_001,
verified: true,
})),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result.status).toBe('UNVERIFIED');
expect(store.readResult).not.toHaveBeenCalled();
});
it('returns UNVERIFIED after a bounded timeout without reading stdin', async () => {
const promotionTransport = transport();
let now = 1_000;
const store: PromotionBreadcrumbStore = {
readAttemptId: vi.fn(async () => null),
readResult: vi.fn(async () => null),
};
const result = await promoteSeat('claude-seat', {
clock: () => now,
sleep: async () => {
now += 10;
},
store,
timeoutMs: 10,
transport: promotionTransport,
});
expect(result).toEqual({
bundle: 'local',
expiresAtWallclock: null,
reason: 'PROMOTION_TIMEOUT',
seat: 'claude-seat',
sessionId: 'b'.repeat(64),
status: 'UNVERIFIED',
});
expect(promotionTransport.sendPromotion).toHaveBeenCalledOnce();
expect(store.readResult).toHaveBeenCalledTimes(2);
});
});
-369
View File
@@ -1,369 +0,0 @@
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { constants } from 'node:fs';
import { mkdir, open, rename } from 'node:fs/promises';
import { join } from 'node:path';
import type { Command } from 'commander';
import {
TmuxPromotionTransport,
type PromotionTarget,
type PromotionTransport,
} from '../fleet/promotion-transport.js';
export type { PromotionTransport } from '../fleet/promotion-transport.js';
import { resolveFleetPaths, type CommandRunner } from './fleet.js';
const ATTEMPT_ID_PATTERN = /^[a-f0-9]{64}$/;
const DEFAULT_POLL_INTERVAL_MS = 250;
const DEFAULT_TIMEOUT_MS = 30_000;
const SUBPROCESS_TIMEOUT_MS = 4_500;
const PENDING_DIRECTORY = 'mosaic-lease';
const AUTHORIZATION_DIRECTORY = 'authorizations';
const AUTHORIZATION_TTL_SECONDS = 60;
const RESULT_FILE = 'last-result.json';
export interface PromotionBreadcrumb {
attempt_id: string;
expires_at_wallclock: number | null;
reason: string | null;
session_id: string;
ts: number;
verified: boolean;
}
export interface PromotionBreadcrumbStore {
readAttemptId(sessionId: string): Promise<string | null>;
readResult(): Promise<PromotionBreadcrumb | null>;
}
export interface PromotionResult {
bundle: string;
expiresAtWallclock: number | null;
reason: string | null;
seat: string;
sessionId: string;
status: 'VERIFIED' | 'UNVERIFIED';
}
export interface PromoteSeatOptions {
clock?: () => number;
pollIntervalMs?: number;
sleep?: (milliseconds: number) => Promise<void>;
store: PromotionBreadcrumbStore;
target?: PromotionTarget;
timeoutMs?: number;
transport: PromotionTransport;
}
export interface PromoteCommandDeps {
mintAuthorization?: (target: PromotionTarget) => Promise<void>;
mosaicHome?: string;
runner?: CommandRunner;
store?: PromotionBreadcrumbStore;
transport?: PromotionTransport;
}
/** Private, local result store shared with the in-seat completion hook. */
export class FilePromotionBreadcrumbStore implements PromotionBreadcrumbStore {
constructor(private readonly runtimeDirectory = defaultRuntimeDirectory()) {}
async readAttemptId(sessionId: string): Promise<string | null> {
if (!ATTEMPT_ID_PATTERN.test(sessionId)) return null;
const content = await readPrivateFile(
join(this.runtimeDirectory, PENDING_DIRECTORY, `pending-${sessionId}`),
);
const attemptId = content?.trim();
return attemptId !== undefined && ATTEMPT_ID_PATTERN.test(attemptId) ? attemptId : null;
}
async readResult(): Promise<PromotionBreadcrumb | null> {
const content = await readPrivateFile(
join(this.runtimeDirectory, PENDING_DIRECTORY, RESULT_FILE),
);
if (content === null) return null;
try {
return parseBreadcrumb(JSON.parse(content) as unknown);
} catch {
return null;
}
}
}
/** Drives one bounded, non-interactive in-seat promotion attempt. */
export async function promoteSeat(
seat: string,
options: PromoteSeatOptions,
): Promise<PromotionResult> {
const clock = options.clock ?? wallClockSeconds;
const sleep = options.sleep ?? defaultSleep;
const timeoutMs = normalizeTimeout(options.timeoutMs);
const pollIntervalMs = normalizePollInterval(options.pollIntervalMs);
let target: PromotionTarget;
try {
target = options.target ?? (await options.transport.resolve(seat));
} catch (error: unknown) {
return unverifiedUnresolvedSeat(seat, `RESOLVE_FAILED: ${errorMessage(error)}`);
}
const previousAttemptId = await options.store.readAttemptId(target.sessionId);
const preSendTimestamp = clock();
try {
await options.transport.sendPromotion(target);
} catch (error: unknown) {
return unverified(target, `DELIVERY_FAILED: ${errorMessage(error)}`);
}
const deadline = preSendTimestamp + timeoutMs / 1_000;
let attemptId: string | null = null;
while (true) {
const currentAttemptId = await options.store.readAttemptId(target.sessionId);
if (currentAttemptId !== null && currentAttemptId !== previousAttemptId) {
attemptId = currentAttemptId;
}
if (attemptId !== null || previousAttemptId === null) {
// Completion can consume a first attempt's nonce before this poll observes it.
// In that branch, correlation degrades to session_id + fresh timestamp, which
// is acceptable for this 0600, same-UID local trust boundary.
const breadcrumb = await options.store.readResult();
if (
breadcrumb !== null &&
breadcrumb.session_id === target.sessionId &&
(attemptId === null || breadcrumb.attempt_id === attemptId) &&
breadcrumb.ts > preSendTimestamp
) {
return {
bundle: target.bundle,
expiresAtWallclock: breadcrumb.expires_at_wallclock,
reason: breadcrumb.reason,
seat: target.seat,
sessionId: target.sessionId,
status: breadcrumb.verified ? 'VERIFIED' : 'UNVERIFIED',
};
}
}
if (clock() >= deadline) return unverified(target, 'PROMOTION_TIMEOUT');
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - clock()) * 1_000));
}
}
export function registerPromoteCommand(program: Command, deps: PromoteCommandDeps = {}): void {
const mosaicHome = deps.mosaicHome ?? resolveFleetPaths().mosaicHome;
const transport =
deps.transport ?? new TmuxPromotionTransport({ mosaicHome, runner: deps.runner ?? runCommand });
const store = deps.store ?? new FilePromotionBreadcrumbStore();
program
.command('promote <seat>')
.description('Promote a Claude fleet seat and report the correlated lease result')
.option(
'--timeout <ms>',
`Bounded result wait in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`,
)
.action(async (seat: string, opts: { timeout?: string }) => {
if (
process.env['MOSAIC_LEASE_SESSION_ID'] !== undefined &&
deps.mintAuthorization === undefined
) {
console.error('mosaic promote must run outside a lease-gated seat.');
process.exitCode = 1;
return;
}
let target: PromotionTarget;
try {
target = await transport.resolve(seat);
await (deps.mintAuthorization ?? mintAuthorization)(target);
} catch (error: unknown) {
console.error(`mosaic promote authorization failed: ${errorMessage(error)}`);
process.exitCode = 1;
return;
}
const result = await promoteSeat(seat, {
store,
target,
timeoutMs: parseOptionTimeout(opts.timeout),
transport,
});
const expiry =
result.expiresAtWallclock === null
? 'none'
: new Date(result.expiresAtWallclock * 1_000).toISOString();
const reason = result.reason === null ? '' : ` reason=${result.reason}`;
console.log(
`${result.status} seat=${result.seat} session=${result.sessionId} bundle=${result.bundle} expiry=${expiry}${reason}`,
);
if (result.status === 'UNVERIFIED') process.exitCode = 1;
});
}
async function mintAuthorization(target: PromotionTarget): Promise<void> {
const directory = join(defaultRuntimeDirectory(), PENDING_DIRECTORY, AUTHORIZATION_DIRECTORY);
await mkdir(directory, { mode: 0o700, recursive: true });
const token = {
expires_at: wallClockSeconds() + AUTHORIZATION_TTL_SECONDS,
nonce: randomBytes(32).toString('hex'),
seat: target.seat,
session_id: target.sessionId,
ts: wallClockSeconds(),
};
const destination = join(directory, `${target.sessionId}.auth`);
const temporary = join(directory, `.${target.sessionId}.${randomBytes(8).toString('hex')}.tmp`);
const handle = await open(
temporary,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
0o600,
);
try {
await handle.chmod(0o600);
await handle.writeFile(JSON.stringify(token));
await handle.sync();
} finally {
await handle.close();
}
await rename(temporary, destination);
}
function defaultRuntimeDirectory(): string {
const configured = process.env['XDG_RUNTIME_DIR'];
if (configured) return configured;
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
return `/run/user/${uid}`;
}
async function readPrivateFile(path: string): Promise<string | null> {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch {
return null;
}
try {
const metadata = await handle.stat();
if (
!metadata.isFile() ||
metadata.uid !== (typeof process.getuid === 'function' ? process.getuid() : 0) ||
(metadata.mode & 0o077) !== 0
) {
return null;
}
return handle.readFile({ encoding: 'utf8' });
} catch {
return null;
} finally {
await handle.close();
}
}
function parseBreadcrumb(value: unknown): PromotionBreadcrumb | null {
if (!isRecord(value) || Object.keys(value).length !== 6) return null;
const { attempt_id, expires_at_wallclock, reason, session_id, ts, verified } = value;
if (
typeof attempt_id !== 'string' ||
!ATTEMPT_ID_PATTERN.test(attempt_id) ||
typeof verified !== 'boolean' ||
typeof session_id !== 'string' ||
!ATTEMPT_ID_PATTERN.test(session_id) ||
typeof ts !== 'number' ||
!Number.isFinite(ts) ||
(expires_at_wallclock !== null &&
(typeof expires_at_wallclock !== 'number' || !Number.isFinite(expires_at_wallclock))) ||
(reason !== null && typeof reason !== 'string')
) {
return null;
}
return { attempt_id, expires_at_wallclock, reason, session_id, ts, verified };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function normalizeTimeout(value: number | undefined): number {
return value !== undefined && Number.isFinite(value) ? Math.max(0, value) : DEFAULT_TIMEOUT_MS;
}
function normalizePollInterval(value: number | undefined): number {
return value !== undefined && Number.isFinite(value)
? Math.max(1, value)
: DEFAULT_POLL_INTERVAL_MS;
}
function parseOptionTimeout(value: string | undefined): number | undefined {
if (value === undefined) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function unverifiedUnresolvedSeat(seat: string, reason: string): PromotionResult {
return {
bundle: 'unresolved',
expiresAtWallclock: null,
reason,
seat,
sessionId: 'unresolved',
status: 'UNVERIFIED',
};
}
function unverified(target: PromotionTarget, reason: string): PromotionResult {
return {
bundle: target.bundle,
expiresAtWallclock: null,
reason,
seat: target.seat,
sessionId: target.sessionId,
status: 'UNVERIFIED',
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function wallClockSeconds(): number {
return Date.now() / 1_000;
}
function defaultSleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function runCommand(
command: string,
args: string[],
): Promise<{
exitCode: number;
stderr: string;
stdout: string;
}> {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let settled = false;
const finish = (result: { exitCode: number; stderr: string; stdout: string }): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(result);
};
const timeout = setTimeout(() => {
child.kill('SIGKILL');
finish({
exitCode: 124,
stderr: `Promotion transport subprocess timed out after ${SUBPROCESS_TIMEOUT_MS}ms.`,
stdout,
});
}, SUBPROCESS_TIMEOUT_MS);
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (error: Error) => {
finish({ exitCode: 127, stderr: error.message, stdout });
});
child.on('close', (code: number | null) => {
finish({ exitCode: code ?? 1, stderr, stdout });
});
});
}
+3 -8
View File
@@ -332,11 +332,9 @@ describe('Claude skill bridge', () => {
expect(result.status, result.stderr).toBe(0);
expect(readlinkSync(liveForeignLink)).toBe(liveForeignTarget);
expect(readlinkSync(danglingForeignLink)).toBe(join(mosaicHome, 'foreign-missing'));
// Skills now link into the mosaic-owned harness home, never the base install.
expect(readlinkSync(join(mosaicHome, '.claude', 'skills', 'missing'))).toBe(
expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe(
join(paths.mosaicSkillsDir, 'missing'),
);
expect(existsSync(join(paths.claudeSkillsDir, 'missing'))).toBe(false);
});
it('preserves live and dangling foreign Claude symlinks while linking missing skills', () => {
@@ -351,20 +349,17 @@ describe('Claude skill bridge', () => {
symlinkSync(external, liveLink);
symlinkSync(join(root, 'external-missing'), danglingLink);
const mosaicHome = join(root, '.config', 'mosaic');
const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], {
encoding: 'utf8',
env: { ...process.env, HOME: root, MOSAIC_HOME: mosaicHome },
env: { ...process.env, HOME: root, MOSAIC_HOME: join(root, '.config', 'mosaic') },
});
expect(result.status, result.stderr).toBe(0);
expect(readlinkSync(liveLink)).toBe(external);
expect(readlinkSync(danglingLink)).toBe(join(root, 'external-missing'));
// Skills now link into the mosaic-owned harness home, never the base install.
expect(readlinkSync(join(mosaicHome, '.claude', 'skills', 'missing'))).toBe(
expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe(
join(paths.mosaicSkillsDir, 'missing'),
);
expect(existsSync(join(paths.claudeSkillsDir, 'missing'))).toBe(false);
});
});
@@ -1,304 +0,0 @@
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));
}
@@ -1,104 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { CommandResult, CommandRunner, FleetRoster } from '../commands/fleet.js';
import { TmuxPromotionTransport } from './promotion-transport.js';
const sessionId = 'a'.repeat(64);
const roster: FleetRoster = {
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
defaults: { workingDirectory: '~/src' },
runtimes: {},
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
transport: 'tmux',
version: 1,
};
function result(stdout = '', exitCode = 0, stderr = ''): CommandResult {
return { exitCode, stderr, stdout };
}
describe('TmuxPromotionTransport', () => {
it('resolves the exact roster seat and sends the registered command literally', async () => {
const runner = vi
.fn<CommandRunner>()
.mockResolvedValueOnce(result('1234 claude 0 0 0 0\n'))
.mockResolvedValueOnce(result())
.mockResolvedValueOnce(result());
const environmentReader = vi.fn(async () => `MOSAIC_LEASE_SESSION_ID=${sessionId}\0`);
const transport = new TmuxPromotionTransport({
environmentReader,
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner,
});
const target = await transport.resolve('claude-seat');
await transport.sendPromotion(target);
expect(target).toEqual({
bundle: 'mosaic-fleet',
seat: 'claude-seat',
sessionId,
});
expect(environmentReader).toHaveBeenCalledWith(1234);
expect(runner).toHaveBeenNthCalledWith(2, 'tmux', [
'-L',
'mosaic-fleet',
'send-keys',
'-t',
'=claude-seat:0.0',
'-l',
'/mosaic-promote',
]);
expect(runner).toHaveBeenNthCalledWith(3, 'tmux', [
'-L',
'mosaic-fleet',
'send-keys',
'-t',
'=claude-seat:0.0',
'Enter',
]);
});
// Regression for #1124: the launcher runs claude as a spawnSync CHILD of
// node(mosaic), so the lease env is on the child, not the tmux pane pid. The
// transport must WALK the subtree. This test exercises the real walk (no
// full mock of the resolution) — the seam the original unit test hid.
it('walks the pane subtree to the claude child that carries the lease id', async () => {
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
// pane pid 1234 = node(mosaic): NO lease env. child 5678 = claude: carries it.
const environmentReader = vi.fn(async (pid: number) =>
pid === 5678 ? `FOO=bar\0MOSAIC_LEASE_SESSION_ID=${sessionId}\0` : `FOO=bar\0`,
);
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
const transport = new TmuxPromotionTransport({
environmentReader,
childrenReader,
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner,
});
const target = await transport.resolve('claude-seat');
expect(target.sessionId).toBe(sessionId);
expect(environmentReader).toHaveBeenCalledWith(1234); // pane pid: no lease
expect(environmentReader).toHaveBeenCalledWith(5678); // walked to the child
expect(childrenReader).toHaveBeenCalledWith(1234); // walk actually ran
});
it('fails closed when no process in the pane subtree carries a lease id', async () => {
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
const environmentReader = vi.fn(async () => `FOO=bar\0`);
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
const transport = new TmuxPromotionTransport({
environmentReader,
childrenReader,
mosaicHome: '/mosaic',
rosterLoader: async () => roster,
runner,
});
await expect(transport.resolve('claude-seat')).rejects.toThrow('no readable lease session');
expect(childrenReader).toHaveBeenCalledWith(1234);
});
});
@@ -1,208 +0,0 @@
import { readFile } from 'node:fs/promises';
import {
buildTmuxListPanesCommand,
getRosterAgent,
parseTmuxListPanes,
resolveFleetPaths,
type CommandResult,
type CommandRunner,
type FleetRoster,
RUNTIME_ACCEPTABLE_COMMANDS,
socketArgs,
} from '../commands/fleet.js';
import { loadFleetRoster } from './fleet-roster-v1.js';
const PROMOTION_COMMAND = '/mosaic-promote';
const SESSION_ID_PATTERN = /^[a-f0-9]{64}$/;
const TRANSPORT_COMMAND_TIMEOUT_MS = 5_000;
export interface PromotionTarget {
bundle: string;
seat: string;
sessionId: string;
}
export interface PromotionTransport {
resolve(seat: string): Promise<PromotionTarget>;
sendPromotion(target: PromotionTarget): Promise<void>;
}
export interface TmuxPromotionTransportOptions {
environmentReader?: (pid: number) => Promise<string>;
childrenReader?: (pid: number) => Promise<number[]>;
mosaicHome: string;
rosterLoader?: () => Promise<FleetRoster>;
runner: CommandRunner;
}
// The launcher runs the runtime as a spawnSync CHILD of node(mosaic) (see
// launch.ts:99 — deliberate, so the parent survives to propagate signals), so
// MOSAIC_LEASE_SESSION_ID lives on the claude child, NOT on the tmux pane's root
// pid. Bound the descendant search so a hung/large process tree can't stall it.
const MAX_SUBTREE_PIDS = 128;
/** Local, roster-bound transport for the in-seat promotion command. */
export class TmuxPromotionTransport implements PromotionTransport {
private readonly environmentReader: (pid: number) => Promise<string>;
private readonly childrenReader: (pid: number) => Promise<number[]>;
private readonly rosterLoader: () => Promise<FleetRoster>;
constructor(private readonly options: TmuxPromotionTransportOptions) {
this.environmentReader = options.environmentReader ?? readPaneEnvironment;
this.childrenReader = options.childrenReader ?? readChildPids;
this.rosterLoader =
options.rosterLoader ??
(() => loadFleetRoster(resolveFleetPaths(options.mosaicHome).rosterPath));
}
async resolve(seat: string): Promise<PromotionTarget> {
const roster = await this.rosterLoader();
const agent = getRosterAgent(roster, seat);
if (agent.runtime !== 'claude') {
throw new Error(`Lease promotion is currently available only for Claude seats: ${seat}.`);
}
const paneResult = await this.run(
buildTmuxListPanesCommand(agent.name, roster.tmux.socketName),
);
if (paneResult.exitCode !== 0) {
throw new Error(`Promotion seat is unavailable: ${seat}.`);
}
const pane = parseTmuxListPanes(paneResult.stdout);
const allowedCommands = RUNTIME_ACCEPTABLE_COMMANDS.claude;
if (
pane.dead ||
pane.pid === null ||
pane.command === null ||
allowedCommands === undefined ||
!allowedCommands.includes(pane.command)
) {
throw new Error(`Promotion seat runtime identity mismatch: ${seat}.`);
}
const sessionId = await this.resolveLeaseSessionId(pane.pid);
if (sessionId === null) {
throw new Error(`Promotion seat has no readable lease session: ${seat}.`);
}
return {
bundle: roster.tmux.socketName || 'default',
seat: agent.name,
sessionId,
};
}
/**
* Find the lease session id in the pane's process subtree. The pane's root pid
* is node(mosaic), which has no lease env; the id lives on the claude child.
* BFS from the root, bounded, returning the first descendant that carries a
* valid MOSAIC_LEASE_SESSION_ID. Fail-closed (null) if none is found.
*/
private async resolveLeaseSessionId(rootPid: number): Promise<string | null> {
const queue: number[] = [rootPid];
const seen = new Set<number>();
while (queue.length > 0 && seen.size < MAX_SUBTREE_PIDS) {
const pid = queue.shift()!;
if (seen.has(pid)) continue;
seen.add(pid);
let sessionId: string | null = null;
try {
sessionId = parseLeaseSessionId(await this.environmentReader(pid));
} catch {
sessionId = null;
}
if (sessionId !== null) return sessionId;
let children: number[] = [];
try {
children = await this.childrenReader(pid);
} catch {
children = [];
}
for (const child of children) {
if (!seen.has(child)) queue.push(child);
}
}
return null;
}
async sendPromotion(target: PromotionTarget): Promise<void> {
const targetPane = `=${target.seat}:0.0`;
const socketName = target.bundle === 'default' ? '' : target.bundle;
// Registered Claude commands must arrive as their exact literal text; the
// fleet agent sender prepends an identity envelope, so it cannot carry this
// command without preventing the UserPromptSubmit matcher from recognizing it.
await this.runPromotionCommand([
'tmux',
...socketArgs(socketName),
'send-keys',
'-t',
targetPane,
'-l',
PROMOTION_COMMAND,
]);
await this.runPromotionCommand([
'tmux',
...socketArgs(socketName),
'send-keys',
'-t',
targetPane,
'Enter',
]);
}
private async runPromotionCommand(command: string[]): Promise<void> {
const result = await this.run(command);
if (result.exitCode !== 0) {
throw new Error('Promotion command delivery failed.');
}
}
private async run(command: string[]): Promise<CommandResult> {
const [executable, ...args] = command;
if (executable === undefined) {
throw new Error('Promotion transport command is empty.');
}
return await withTimeout(this.options.runner(executable, args), TRANSPORT_COMMAND_TIMEOUT_MS);
}
}
function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Promotion transport command timed out after ${timeoutMs}ms.`));
}, timeoutMs);
void operation.then(
(value) => {
clearTimeout(timeout);
resolve(value);
},
(error: unknown) => {
clearTimeout(timeout);
reject(error);
},
);
});
}
async function readPaneEnvironment(pid: number): Promise<string> {
return readFile(`/proc/${pid}/environ`, 'utf8');
}
async function readChildPids(pid: number): Promise<number[]> {
// Linux exposes direct children of the main thread here (CONFIG_PROC_CHILDREN).
try {
const raw = await readFile(`/proc/${pid}/task/${pid}/children`, 'utf8');
return raw
.split(/\s+/)
.filter(Boolean)
.map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isInteger(value) && value > 0);
} catch {
return [];
}
}
function parseLeaseSessionId(environment: string): string | null {
const value = environment
.split('\0')
.find((entry) => entry.startsWith('MOSAIC_LEASE_SESSION_ID='))
?.slice('MOSAIC_LEASE_SESSION_ID='.length);
return value !== undefined && SESSION_ID_PATTERN.test(value) ? value : null;
}

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