Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3d9bd890c | ||
|
|
9efd903c16 | ||
|
|
47c5476430 | ||
|
|
bf6b245f3c | ||
|
|
478e925041 | ||
|
|
309a99a600 | ||
|
|
c1a42cdb81 | ||
|
|
a12eeb4786 | ||
|
|
326a1a58b5 | ||
|
|
2755f86f7b | ||
|
|
fe2cf19461 | ||
|
|
4fde3f622d | ||
|
|
9de9ffa56b | ||
|
|
cb960237d3 | ||
|
|
5e15431027 | ||
|
|
c16256d48c | ||
|
|
92e790ae9d | ||
|
|
0fdcfa0ff4 | ||
|
|
4e2f9888a0 | ||
|
|
9a92bb64ff | ||
|
|
fe26b37e81 | ||
|
|
378c227cbb | ||
|
|
4522adaa5e | ||
|
|
a42d5e2ee5 | ||
|
|
7102ccb93e | ||
|
|
afdaa6d0e6 | ||
|
|
41749bbd33 | ||
|
|
120af4e193 | ||
|
|
216cd72226 | ||
|
|
ec260e678f | ||
|
|
b590a5c3d8 | ||
|
|
540ec5b6ef | ||
|
|
563d1ac053 | ||
|
|
722163671f | ||
|
|
f158be8003 | ||
|
|
b0f7d26dd9 | ||
|
|
3a1203b2f8 | ||
|
|
df4c591ab4 | ||
|
|
4fa2768962 | ||
|
|
aa0a7b5fa2 | ||
|
|
42ac19af48 | ||
|
|
f744f32214 | ||
|
|
8ff7aac0ca | ||
|
|
80a45b1e1c | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 | ||
|
|
2fa6bcd576 | ||
|
|
1afe2b36dc |
@@ -46,6 +46,28 @@ steps:
|
||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||
# with everything it guards; this direct line keeps one instrument running.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||
# Tool-index gate: a shipped wrapper that appears in no resident index doc
|
||||
# is undiscoverable from inside a session, and an agent that cannot learn a
|
||||
# wrapper exists reaches for raw curl instead — which is how a Gitea review
|
||||
# got filed PENDING three times. Ships-and-documented is one commit, or red.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh
|
||||
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||
# joins CI directly rather than the exclusions file.
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||
# endpoints and ordinary commands through. Both directions are asserted —
|
||||
# a guard that over-blocks gets routed around, which fails just as hard.
|
||||
- bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh
|
||||
# Hermetic regression for mosaic-worktree.sh at fleet scale: stubs git onto
|
||||
# PATH so `list` faces ~450 KB of porcelain. The defect it pins is invisible
|
||||
# at small size — `git … | awk '…exit'` gives the producer SIGPIPE, which
|
||||
# under `set -euo pipefail` aborts the caller silently with rc=141 and no
|
||||
# output. A repo only reaches that once it has enough worktrees, so the
|
||||
# stub supplies the scale instead of the host's own checkout.
|
||||
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
||||
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
|
||||
@@ -74,6 +74,14 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
|
||||
|
||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
||||
|
||||
Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi,
|
||||
`/goal set <statement>` starts a bounded persistent loop that checks every turn and successful
|
||||
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
|
||||
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
|
||||
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
|
||||
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
|
||||
into `~/.pi/agent/extensions/`.
|
||||
|
||||
### TUI & Gateway
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# T1 report: canonical ungated Claude base and lease overlay
|
||||
|
||||
## Changed
|
||||
|
||||
- Replaced `packages/mosaic/framework/runtime/claude/settings.json` with the canonical ungated base. It retains the model, QA hooks, plugins, command allowlist, permissions, and `mcpServers.sequential-thinking`.
|
||||
- Added `packages/mosaic/framework/runtime/claude/lease-overlay.json`. It contains only `hooks` and the six removed lease hook entries.
|
||||
- Added the byte-identical pre-split source fixture at `packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json`.
|
||||
- Added `packages/mosaic/src/runtime/claude-settings-base.spec.ts`.
|
||||
|
||||
`framework-manifest.txt` already declares `runtime/**`, so the new overlay is framework-owned and shipped without a manifest change.
|
||||
|
||||
## Lease-hook enumeration
|
||||
|
||||
The actual template has six lease hook entries, matching fred's refined boundary:
|
||||
|
||||
1. `PreToolUse` matcher `.*`: `mutator-gate.py`
|
||||
2. `Stop`: one combined command containing `receipt-observer-client.py` then `promote-complete.py`
|
||||
3. `UserPromptSubmit` matcher `^/mosaic-promote$`: `promote-begin.py`
|
||||
4. `PreCompact`: `revoke-lease.py --reason pre-compact`
|
||||
5. `SessionStart` matcher `compact`: `revoke-lease.py --reason session-start-compact`
|
||||
6. `SessionStart` matcher `resume|clear`: `revoke-lease.py --reason session-start-rollover --bump-generation`
|
||||
|
||||
There is no delta from the refined six-entry enumeration. The Stop entry contains the receipt-observer and promote-complete commands together, rather than as two separate hook objects.
|
||||
|
||||
## Tests and checks
|
||||
|
||||
`pnpm install --frozen-lockfile` was run first because `node_modules` was absent. It completed successfully.
|
||||
|
||||
Red-first run before artifacts existed:
|
||||
|
||||
```text
|
||||
RUN v2.1.9 .../packages/mosaic
|
||||
❯ src/runtime/claude-settings-base.spec.ts (4 tests | 4 failed)
|
||||
× keeps every lease command out of the ungated base
|
||||
→ mutator-gate: expected true to be false
|
||||
× reconstructs the pre-split gated hooks while retaining the canonical MCP correction
|
||||
→ ENOENT: .../lease-overlay.json
|
||||
× ships sequential-thinking in the base
|
||||
→ expected undefined to deeply equal { 'sequential-thinking': ... }
|
||||
× limits the overlay to lease hook entries
|
||||
→ ENOENT: .../lease-overlay.json
|
||||
```
|
||||
|
||||
Final focused acceptance run:
|
||||
|
||||
```text
|
||||
RUN v2.1.9 .../packages/mosaic
|
||||
✓ src/runtime/claude-settings-base.spec.ts (4 tests) 19ms
|
||||
Test Files 1 passed (1)
|
||||
Tests 4 passed (4)
|
||||
```
|
||||
|
||||
`pnpm --filter @mosaicstack/mosaic lint` passed:
|
||||
|
||||
```text
|
||||
> @mosaicstack/[email protected] lint
|
||||
> eslint src
|
||||
```
|
||||
|
||||
`pnpm --filter @mosaicstack/mosaic typecheck` failed on pre-existing workspace resolution and unrelated package errors. The new spec no longer appears in the error list. Initial failures include missing `@mosaicstack/{brain,forge,log,macp,memory,queue,storage,quality-rails,db,config,prdy,types}` declarations, followed by existing `fleet-backlog.ts`, `gateway-doctor.ts`, and TUI implicit-`any` errors. Exit status: 2.
|
||||
|
||||
A focused legacy consumer run confirms an existing assumption that `settings.json` itself is gated:
|
||||
|
||||
```text
|
||||
pnpm --filter @mosaicstack/mosaic exec vitest run src/mutator-gate/mutator-gate.acceptance.spec.ts
|
||||
❯ src/mutator-gate/mutator-gate.acceptance.spec.ts (20 tests | 6 failed)
|
||||
× non-dangerous parser residual is denied by the global all-tools hook without a lease
|
||||
→ expected all-tools mutator-gate command in settings.json
|
||||
× Claude and Pi compaction observer wiring is complete and fail-closed
|
||||
→ expected PreCompact/SessionStart revoke-lease hooks in settings.json
|
||||
```
|
||||
|
||||
The other four failures in that focused run reported `STALE_GENERATION` where the test expected `MUTATOR_UNVERIFIED`, plus one successful-gate assertion. I did not redesign this legacy suite because the task explicitly says to report consumers that assume the base is gated.
|
||||
|
||||
## Consumers found
|
||||
|
||||
Direct `runtime/claude/settings.json` path consumers found by the required repository grep:
|
||||
|
||||
- `packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets`: copies the base to `~/.claude/settings.json`.
|
||||
- `packages/mosaic/src/commands/install-ordering-guard.ts` and `.spec.ts`: documentation and behavior assume the source embeds enforcement hooks.
|
||||
- `packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh`: comments and assertions expect `mutator-gate.py` and `receipt-observer-client.py` in the base.
|
||||
- `packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts`: reads the base and asserts mutator, promotion, and compaction lease wiring.
|
||||
- `packages/mosaic/src/lease-broker/promotion_trigger_unittest.py`: reads the base and asserts promotion wiring.
|
||||
- `packages/mosaic/src/lease-broker/recovery_runtime_unittest.py`: reads the base.
|
||||
- `packages/mosaic/src/runtime/update-checker.ts` and `.spec.ts`: references the path in settings wiring/update checks.
|
||||
- Documentation-only references: `docs/compaction-refresh/probes/p6_constrained_recovery.py`, `docs/plans/agent-reflection-loop-PRD.md`, `docs/tasks/544-agent-reflection-loop.md`, and the framework QA documentation/scripts found by grep.
|
||||
|
||||
I did not change these consumers. The install/link and lease acceptance consumers must be taught to select and compose `lease-overlay.json` when a gated promotion seat is requested. That composition behavior is outside T1.
|
||||
|
||||
## Ambiguity handled
|
||||
|
||||
The exact pre-split template fixture has no `mcpServers` key (SHA-256 `44e74ea1e9d424fffa020ee666402662ac856b88bf6ae7f3b8931eed29dc75a4`). The task simultaneously requires a byte-for-byte pre-split fixture, `mcpServers.sequential-thinking` in the base, and `deep-merge(base, overlay) == original`. Those three conditions cannot all hold because a merge cannot remove the required MCP key.
|
||||
|
||||
The acceptance test preserves the exact fixture and asserts that the normalized merge equals the pre-split template plus the required canonical `mcpServers.sequential-thinking` correction. It verifies all original hook content is reconstructed and the base carries the required MCP. Production three-layer merge semantics remain W-F1 work.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# REPORT-T2
|
||||
|
||||
Date: 2026-08-13 11:29 CDT
|
||||
Branch: `feat/wf-fleet-t2-launch`
|
||||
Base: `216cd722`
|
||||
Issue: #1209
|
||||
|
||||
## What changed
|
||||
|
||||
- Added `mosaic fleet launch <name> [--dry-run]` in `packages/mosaic/src/commands/fleet-launch-command.ts` and registered it on the existing fleet command.
|
||||
- Added strict schema-one parsing for the user-owned `~/.mosaic/fleet/agents/<name>/profile.json`:
|
||||
- required `schema` and `harness`
|
||||
- default bundle `primary`
|
||||
- optional `model`, `overlay`, `plugins`, `skills`, and string-valued `env`
|
||||
- unknown-key refusal naming the key
|
||||
- dedicated `SCHEMA_TOO_NEW` code and upgrade guidance
|
||||
- Added the three-layer settings composer. Objects merge recursively, scalars use the higher layer, arrays replace, and `null` deletes a key. The selected agent overlay defaults to no overlay when the profile field is absent.
|
||||
- Writes canonical merged settings to `<agent-home>/settings.json` and the future harvest comparison snapshot to `<agent-dir>/settings.generated.json`.
|
||||
- Resolves `primary` to its named bundle, reads an optional account email, and reports forms such as `primary -> fred_example.com ([email protected])`.
|
||||
- Validates credential targets with `lstat`, rejects symlink credential files, resolves and checks containment under the harness auth root, and refuses a real credential file at the seat-link path as first-auth state.
|
||||
- Installs selected plugin and skill entries as seat-local symlinks, prunes stale symlinks, and refuses real objects instead of deleting them.
|
||||
- Builds a declared seat environment with the harness home variable, `MOSAIC_AGENT_NAME`, and profile environment entries. Mechanical values override conflicting profile entries.
|
||||
- Extended `launch.ts` so `harnessHome()` accepts fleet context and remains the home-resolution seam. The fleet launcher uses the existing runtime preflight, prompt, ledger, lease-gated, and process execution path over a minimal ambient environment.
|
||||
- Added deterministic dry-run output containing source layers, merged settings, output and snapshot paths, resolved bundle, symlink plans, declared environment, and harness argv.
|
||||
- Added 17 focused tests, including the required merge, schema, A3, dry-run snapshot, managed-link, command dry-run, execution-seam, and non-zero failure cases.
|
||||
|
||||
## Reconciliation decisions and contradictions
|
||||
|
||||
### Prominent contradiction: roster registries do not contain the frozen launch schema
|
||||
|
||||
The existing code has two other profile/registry concepts:
|
||||
|
||||
- `fleet-profiles.ts` models system-type YAML roster templates. Its `FleetProfile` has no harness bundle, overlay, plugin, skill, or seat environment fields.
|
||||
- roster-v2 models topology and lifecycle. It requires class, provider, reasoning, tool policy, working directory, lifecycle, and launch-yolo fields that schema-one `profile.json` does not contain.
|
||||
|
||||
Deriving a complete roster-v2 member from the frozen per-agent profile is therefore not possible without inventing values. Launch now reads only the per-agent `profile.json` and does not require roster-v2 or the legacy v1 roster. roster-v2 remains the existing lifecycle/topology registry. No second launch registry was introduced.
|
||||
|
||||
The pre-existing `resolveFleetIdentity()` path requires a legacy roster and a secure tmux helper whenever `MOSAIC_AGENT_NAME` is present during contract composition. For profile-backed launch, `launch.ts` excludes roster identity keys only from the contract-build environment, then exports the declared profile seat identity to the harness process. Legacy root runtime launches retain the existing roster-backed behavior. This is the smallest reconciliation that allows profile-only launch without fabricating roster-v2 fields.
|
||||
|
||||
### Historical whole-store plugin link
|
||||
|
||||
The prototype used a whole `plugins` directory symlink, while this task requires selected entry links and pruning. Launch refuses that historical shape with an explicit migration message. It does not delete or silently convert the whole-store link.
|
||||
|
||||
### Existing `FleetProfile` name
|
||||
|
||||
The system-type YAML `FleetProfile` remains unchanged. The new type is named `FleetAgentLaunchProfile` to keep the concepts separate while treating per-agent `profile.json` as the launch SSOT.
|
||||
|
||||
## Ambiguities and bounded choices
|
||||
|
||||
- The design does not freeze the generated snapshot filename. This implementation uses `settings.generated.json` in the agent directory, beside the hidden harness home.
|
||||
- The design explicitly identifies Claude `.credentials.json` and Pi `auth.json`. Codex and OpenCode use `auth.json` in the filename map, matching their harness-home composition shape, but no real credential launch was performed in this task.
|
||||
- Full interactive harvest-back disposition is not implemented. The task asks to store the generated snapshot for the future diff, and this change does that.
|
||||
- A machine descriptor file and content digests were not added. Dry-run and execution consume one resolved in-memory composition, and dry-run prints that composition.
|
||||
- No real harness process or real operator home was used. Every new filesystem test uses a temporary fixture root.
|
||||
|
||||
## Test run
|
||||
|
||||
Dependency install and build:
|
||||
|
||||
```text
|
||||
$ pnpm install --frozen-lockfile
|
||||
Scope: all 28 workspace projects
|
||||
Lockfile is up to date, resolution step is skipped
|
||||
Done in 4.7s using pnpm v10.6.2
|
||||
|
||||
$ pnpm --filter @mosaicstack/mosaic... build
|
||||
Scope: 13 of 28 workspace projects
|
||||
packages/mosaic build: Done
|
||||
```
|
||||
|
||||
Focused and touched integration tests:
|
||||
|
||||
```text
|
||||
$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-launch-command.spec.ts src/commands/launch.spec.ts src/commands/fleet.spec.ts
|
||||
Test Files 3 passed (3)
|
||||
Tests 256 passed (256)
|
||||
```
|
||||
|
||||
Typecheck and lint:
|
||||
|
||||
```text
|
||||
$ pnpm --filter @mosaicstack/mosaic typecheck
|
||||
> tsc --noEmit
|
||||
(exit 0)
|
||||
|
||||
$ pnpm exec eslint packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
|
||||
(exit 0)
|
||||
|
||||
$ pnpm exec prettier --check packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
|
||||
Checking formatting...
|
||||
All matched files use Prettier code style!
|
||||
```
|
||||
|
||||
Package-wide Vitest result:
|
||||
|
||||
```text
|
||||
$ pnpm --filter @mosaicstack/mosaic exec vitest run
|
||||
Test Files 1 failed | 83 passed (84)
|
||||
Tests 4 failed | 1535 passed (1539)
|
||||
```
|
||||
|
||||
All four failures are in `src/mutator-gate/mutator-gate.acceptance.spec.ts`. Three expected `MUTATOR_UNVERIFIED` but received `STALE_GENERATION`; one runtime-gate assertion expected status zero and received status two. An isolated rerun produced the same four failures. I did not confirm whether they predate this branch. The focused launch, fleet, and typecheck runs are green.
|
||||
@@ -0,0 +1,46 @@
|
||||
# T3 report: `mosaic fleet agent new`
|
||||
|
||||
## Changed
|
||||
|
||||
- Added `packages/mosaic/src/fleet/fleet-agent-scaffold.ts`.
|
||||
- Creates user-owned seats at `~/.mosaic/fleet/agents/<name>` (test seam: `fleetDataHome`, environment default: `MOSAIC_DATA_HOME`).
|
||||
- Writes schema-one `profile.json` with default `harness: "claude"`, `bundle: "primary"`, optional `model`, `overlay: "overlay.json"`, and mandatory `env.MOSAIC_AGENT_NAME`.
|
||||
- Writes a positive `SOUL.md` identity and materializes that identity in `.claude/CLAUDE.md` or `.pi/AGENTS.md`.
|
||||
- Writes `overlay.json` as `{}`. Claude homes get `.claude.json` with `hasCompletedOnboarding: true` and `theme: "dark"`. No settings file is composed.
|
||||
- Creates the appropriate credential symlink (`.credentials.json` for Claude, `auth.json` for Pi), allowing an intentional dangling destination and reporting it at the command surface.
|
||||
- Compares every existing object (including link targets as link text), succeeds only byte-identically, and otherwise refuses with the differing paths.
|
||||
- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.ts` and wired `fleet agent new <name> [--harness claude|pi] [--bundle B] [--model M]` in `packages/mosaic/src/commands/fleet.ts`.
|
||||
- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts` with temp-root-only coverage: exact Claude/Pi layouts, literal quote/backtick/`$( )` handling, unsafe names and option failures, idempotence, changed-file refusal, and credential-link comparison.
|
||||
|
||||
## Reconciliation
|
||||
|
||||
`fleet-agent-crud-command.ts` currently registers roster-v2 `get/create/update/delete/plan` directly under `mosaic fleet`; it has no `agent new` command or profile schema. T3 adds an `agent` namespace for the profile-owned user-data scaffold and leaves roster-v2 CRUD unchanged.
|
||||
|
||||
No roster projection is created. Current roster-v2 requires fields that cannot be derived from the new profile (`class`, provider, working directory, reasoning, tool policy, lifecycle), while no current `mosaic fleet launch <name>` consumes these profiles. Writing such a roster entry would create the forbidden second registry and invent semantics. The profile is therefore the sole state created here. When the launcher owns profile-to-roster projection, it must derive it there and emit the required actionable unscaffolded-name message.
|
||||
|
||||
## Validation
|
||||
|
||||
```text
|
||||
$ pnpm install --frozen-lockfile
|
||||
Done in 4.1s using pnpm v10.6.2
|
||||
|
||||
$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-agent-scaffold-command.spec.ts
|
||||
✓ src/commands/fleet-agent-scaffold-command.spec.ts (13 tests) 28ms
|
||||
Test Files 1 passed (1)
|
||||
Tests 13 passed (13)
|
||||
|
||||
$ pnpm --filter @mosaicstack/mosaic exec eslint src/fleet/fleet-agent-scaffold.ts src/commands/fleet-agent-scaffold-command.ts src/commands/fleet-agent-scaffold-command.spec.ts src/commands/fleet.ts
|
||||
(exit 0)
|
||||
|
||||
$ pnpm exec prettier --check packages/mosaic/src/fleet/fleet-agent-scaffold.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts packages/mosaic/src/commands/fleet.ts
|
||||
All matched files use Prettier code style!
|
||||
|
||||
$ git diff --check
|
||||
(exit 0)
|
||||
```
|
||||
|
||||
`pnpm --filter @mosaicstack/mosaic typecheck` remains blocked by pre-existing unresolved workspace package entries (`@mosaicstack/brain`, `@mosaicstack/db`, `@mosaicstack/types`, and others). The typecheck output had no diagnostics naming T3 files. Running the pre-existing CRUD command spec is blocked by the same `@mosaicstack/db` Vite resolution failure through `fleet-backlog.ts`.
|
||||
|
||||
## Skipped ambiguity
|
||||
|
||||
The design asks for a generated harness-home `settings.json` as part of an earlier generic home-template description, but the task explicitly says composed settings are left to launch. T3 creates no `settings.json`; launch composition remains the owner.
|
||||
@@ -417,7 +417,7 @@ describe('ConversationsController — search endpoint', () => {
|
||||
},
|
||||
];
|
||||
brain = createMockBrain({ searchResults });
|
||||
controller = new ConversationsController(brain as never);
|
||||
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
});
|
||||
|
||||
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);
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
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);
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
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);
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
const result = await controller.addMessage(
|
||||
CONV_ID,
|
||||
|
||||
@@ -35,6 +35,25 @@ function payload(content: string, messageId: string, correlationId: string): Dis
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat runtime router must never be exercised on the Discord approval/stop control paths —
|
||||
* those paths run entirely through the command-authorization, runtime-provider and durable-session
|
||||
* dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService`
|
||||
* slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because
|
||||
* approval/stop never resolve a chat runtime, this fixture is never triggered and the integration
|
||||
* stays a GREEN cross-surface control.
|
||||
*/
|
||||
function failIfUsedChatRuntimeRouter() {
|
||||
return {
|
||||
onModuleInit: () => {
|
||||
throw new Error('chat runtime router must not initialise on the Discord control path');
|
||||
},
|
||||
get active(): never {
|
||||
throw new Error('chat runtime must not be resolved on the Discord approval/stop path');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authorization(): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
return new CommandAuthorizationService(
|
||||
@@ -113,7 +132,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
|
||||
},
|
||||
);
|
||||
const gateway = new ChatGateway(
|
||||
{} as never,
|
||||
failIfUsedChatRuntimeRouter() 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);
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 {} }));
|
||||
@@ -12,10 +14,25 @@ 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' };
|
||||
@@ -74,6 +91,12 @@ function makeAgentSession(owner = USER_A): AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing
|
||||
* USER_A's conversation id) is never granted the session. Because every method exists and no method
|
||||
* throws for a wrong shape, production runs to its real ownership decision — the RED never comes from
|
||||
* a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch.
|
||||
*/
|
||||
function makeScopedAgentService() {
|
||||
const foreign = makeAgentSession(USER_A);
|
||||
return {
|
||||
@@ -87,7 +110,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 ForbiddenException('Session scope mismatch')),
|
||||
createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')),
|
||||
onEvent: vi.fn(() => vi.fn()),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
@@ -96,6 +119,201 @@ function makeScopedAgentService() {
|
||||
};
|
||||
}
|
||||
|
||||
type ScopedAgentService = ReturnType<typeof makeScopedAgentService>;
|
||||
|
||||
/**
|
||||
* A structurally-complete harness conversation service that throws if any method is invoked.
|
||||
* Fronted behind the legacy runtime's harness slot: the legacy path must never reach it.
|
||||
*/
|
||||
const failIfUsedConversationService = {
|
||||
attach: () => {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
detach: () => {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
send: () => {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */
|
||||
const boundConversationService = {
|
||||
attach: () => Promise.reject(new Error('unused')),
|
||||
detach: () => Promise.reject(new Error('unused')),
|
||||
send: () => Promise.reject(new Error('unused')),
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
for (const id of adapterIds) {
|
||||
registry.register({
|
||||
id,
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime}
|
||||
* that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to
|
||||
* reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService.
|
||||
* The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a
|
||||
* call landing on it proves the router-delegation redesign is live rather than the old direct path.
|
||||
*/
|
||||
function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter {
|
||||
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||
const harness = new HarnessChatRuntime(failIfUsedConversationService);
|
||||
const router = new ChatRuntimeRouter(
|
||||
new HarnessRegistry(),
|
||||
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
embedded,
|
||||
harness,
|
||||
'legacy',
|
||||
);
|
||||
router.onModuleInit();
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* The AgentService method names the controller/gateway must NEVER drive on the runtime at the
|
||||
* delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record
|
||||
* one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime
|
||||
* call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text.
|
||||
*/
|
||||
const FORBIDDEN_AGENT_OPS = [
|
||||
'getSession',
|
||||
'createSession',
|
||||
'onEvent',
|
||||
'addChannel',
|
||||
'prompt',
|
||||
'setThinking',
|
||||
'abort',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields
|
||||
* an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at
|
||||
* INVOCATION time and forwards to the real method (bound to the real target, so the router's internal
|
||||
* delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING
|
||||
* properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a
|
||||
* canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim.
|
||||
*
|
||||
* The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router
|
||||
* at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the
|
||||
* production source cannot. This replaces the earlier `source.toContain('<frozen op>')` proof — which
|
||||
* a dead declaration could satisfy while production still executed a shim — with invocation evidence.
|
||||
*/
|
||||
function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter {
|
||||
return new Proxy(target, {
|
||||
get(t, prop) {
|
||||
const value = Reflect.get(t, prop);
|
||||
if (typeof value === 'function' && typeof prop === 'string') {
|
||||
return (...args: unknown[]) => {
|
||||
calls.push(prop);
|
||||
return (value as (...a: unknown[]) => unknown).apply(t, args);
|
||||
};
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}) as ChatRuntimeRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts).
|
||||
*
|
||||
* BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider
|
||||
* (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production
|
||||
* resolves whichever its constructor declares:
|
||||
* - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the
|
||||
* router (and its embedded fake) is never reached.
|
||||
* - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never
|
||||
* touched (stays at zero) and scope is observed inside the embedded fake behind the router.
|
||||
* The SAME test body reds today and greens later; a method-for-method AgentService shim on the router
|
||||
* records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and
|
||||
* restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed
|
||||
* scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link
|
||||
* makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings.
|
||||
*/
|
||||
function buildRestModule(
|
||||
directAgentService: ScopedAgentService,
|
||||
embeddedAgentService: ScopedAgentService,
|
||||
routerCalls: string[],
|
||||
): Promise<TestingModule> {
|
||||
return (
|
||||
Test.createTestingModule({
|
||||
controllers: [ChatController],
|
||||
providers: [
|
||||
{ provide: AgentService, useValue: directAgentService },
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: () =>
|
||||
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
|
||||
},
|
||||
],
|
||||
})
|
||||
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects
|
||||
// AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph
|
||||
// resolves and the test reds on BEHAVIOUR, not on a DI collection error.
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile()
|
||||
);
|
||||
}
|
||||
|
||||
function buildGatewayModule(
|
||||
directAgentService: ScopedAgentService,
|
||||
embeddedAgentService: ScopedAgentService,
|
||||
routerCalls: string[],
|
||||
): Promise<TestingModule> {
|
||||
const brain = {
|
||||
conversations: {
|
||||
// The sender OWNS this durable conversation, so the browser-send admission gate lets the turn
|
||||
// reach the router seam. Foreignness is asserted downstream at the in-memory agent session
|
||||
// (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection
|
||||
// property has its own dedicated coverage.
|
||||
findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }),
|
||||
},
|
||||
};
|
||||
return Test.createTestingModule({
|
||||
providers: [
|
||||
ChatGateway,
|
||||
{ provide: AgentService, useValue: directAgentService },
|
||||
{ provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } },
|
||||
{ provide: BRAIN, useValue: brain },
|
||||
{ provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } },
|
||||
{ provide: CommandExecutorService, useValue: { execute: vi.fn() } },
|
||||
{
|
||||
provide: RoutingEngineService,
|
||||
useValue: {
|
||||
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: () =>
|
||||
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
}
|
||||
|
||||
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
||||
it('requires explicit owner+tenant scope on protected session operations', () => {
|
||||
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
||||
@@ -152,50 +370,66 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
||||
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);
|
||||
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 });
|
||||
|
||||
await expect(
|
||||
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
// 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();
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
// Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail.
|
||||
|
||||
// RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router.
|
||||
// Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the
|
||||
// controller @Inject(AgentService) and never calls the router). GREEN once it drives the op.
|
||||
expect
|
||||
.soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router')
|
||||
.toContain('completeLegacyRestTurn');
|
||||
// RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on
|
||||
// the router. An AgentService-shaped router shim records one of these → RED, defeating the shim
|
||||
// on invocation evidence (not source text). A dead named method added alongside the shim does not
|
||||
// help: it is never invoked, so it never enters routerCalls while a forbidden op still does.
|
||||
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||
expect
|
||||
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||
.not.toContain(op);
|
||||
}
|
||||
// RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today;
|
||||
// restoring the direct injection keeps it failing).
|
||||
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||
// RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the
|
||||
// router (fails today; the router path is never taken).
|
||||
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
|
||||
// Zero foreign mutation on either path (holds today and at GREEN).
|
||||
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
|
||||
|
||||
// Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the
|
||||
// controller no longer declares the direct embedded AgentService dependency. A negative source
|
||||
// check cannot be satisfied by dead text — it only fails when the injection is present.
|
||||
const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||
expect.soft(controllerSource).not.toContain('@Inject(AgentService)');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
|
||||
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 };
|
||||
}
|
||||
|
||||
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => {
|
||||
function makeSocket() {
|
||||
return {
|
||||
id: 'socket-b',
|
||||
@@ -206,57 +440,519 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () =>
|
||||
};
|
||||
}
|
||||
|
||||
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
// TESS test B — WebSocket send/attach.
|
||||
it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => {
|
||||
const directAgentService = makeScopedAgentService();
|
||||
const embeddedAgentService = makeScopedAgentService();
|
||||
const routerCalls: string[] = [];
|
||||
const moduleRef = await buildGatewayModule(
|
||||
directAgentService,
|
||||
embeddedAgentService,
|
||||
routerCalls,
|
||||
);
|
||||
try {
|
||||
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||
const socket = makeSocket();
|
||||
|
||||
await gateway.handleMessage(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
content: 'attach to foreign session',
|
||||
});
|
||||
await Promise.resolve(
|
||||
gateway.handleMessage(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
content: 'attach to foreign session',
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(agentService.onEvent).not.toHaveBeenCalled();
|
||||
expect(agentService.addChannel).not.toHaveBeenCalled();
|
||||
// RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router.
|
||||
expect
|
||||
.soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router')
|
||||
.toContain('prepareLegacySocketTurn');
|
||||
// RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
|
||||
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||
expect
|
||||
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||
.not.toContain(op);
|
||||
}
|
||||
// RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it).
|
||||
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||
// RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached).
|
||||
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
// Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN).
|
||||
expect.soft(directAgentService.onEvent).not.toHaveBeenCalled();
|
||||
expect.soft(directAgentService.addChannel).not.toHaveBeenCalled();
|
||||
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
|
||||
expect
|
||||
.soft(socket.emit)
|
||||
.toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
|
||||
// Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency.
|
||||
const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
|
||||
expect.soft(gatewaySource).not.toContain('@Inject(AgentService)');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
// TESS test C — WebSocket set:thinking.
|
||||
it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => {
|
||||
const directAgentService = makeScopedAgentService();
|
||||
const embeddedAgentService = makeScopedAgentService();
|
||||
const routerCalls: string[] = [];
|
||||
const moduleRef = await buildGatewayModule(
|
||||
directAgentService,
|
||||
embeddedAgentService,
|
||||
routerCalls,
|
||||
);
|
||||
try {
|
||||
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||
const socket = makeSocket();
|
||||
|
||||
await Promise.resolve(
|
||||
gateway.handleSetThinking(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
level: 'high',
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
|
||||
// RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router.
|
||||
expect
|
||||
.soft(routerCalls, 'gateway must invoke setLegacyThinking on the router')
|
||||
.toContain('setLegacyThinking');
|
||||
// RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
|
||||
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||
expect
|
||||
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||
.not.toContain(op);
|
||||
}
|
||||
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect
|
||||
.soft(socket.emit)
|
||||
.toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
// TESS test D — WebSocket abort.
|
||||
it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => {
|
||||
const directAgentService = makeScopedAgentService();
|
||||
const embeddedAgentService = makeScopedAgentService();
|
||||
const routerCalls: string[] = [];
|
||||
const moduleRef = await buildGatewayModule(
|
||||
directAgentService,
|
||||
embeddedAgentService,
|
||||
routerCalls,
|
||||
);
|
||||
try {
|
||||
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||
const socket = makeSocket();
|
||||
|
||||
await Promise.resolve(
|
||||
gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }),
|
||||
).catch(() => undefined);
|
||||
|
||||
// RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router.
|
||||
expect
|
||||
.soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router')
|
||||
.toContain('abortLegacyTurn');
|
||||
// RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
|
||||
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||
expect
|
||||
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||
.not.toContain(op);
|
||||
}
|
||||
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect
|
||||
.soft(socket.emit)
|
||||
.toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
// TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal.
|
||||
it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => {
|
||||
// pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and
|
||||
// must be refused with a fixed typed code, touching neither the embedded AgentService nor the
|
||||
// harness conversation service.
|
||||
const agentService = makeScopedAgentService();
|
||||
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||
const harnessConversation = {
|
||||
attach: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
send: vi.fn(),
|
||||
subscribeFrom: vi.fn(),
|
||||
};
|
||||
const harness = new HarnessChatRuntime(harnessConversation as never);
|
||||
const router = new ChatRuntimeRouter(
|
||||
registryWith(['pi']),
|
||||
boundConversationService,
|
||||
embedded,
|
||||
harness,
|
||||
'pi-rpc',
|
||||
);
|
||||
router.onModuleInit();
|
||||
|
||||
const brain = {
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
const gateway = new ChatGateway(
|
||||
router as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{ getManifest: vi.fn().mockReturnValue([]) } as never,
|
||||
{ execute: vi.fn() } as never,
|
||||
{ resolve: vi.fn() } as never,
|
||||
);
|
||||
const socket = {
|
||||
id: 'socket-b',
|
||||
connected: true,
|
||||
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
|
||||
await Promise.resolve(
|
||||
gateway.handleMessage(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
content: 'route me',
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ code: 'runtime_unsupported' }),
|
||||
);
|
||||
expect(agentService.getSession).not.toHaveBeenCalled();
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
expect(harnessConversation.attach).not.toHaveBeenCalled();
|
||||
expect(harnessConversation.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5).
|
||||
// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService
|
||||
// fake (every touched method exists, so a RED can only come from behavior, never a
|
||||
// `getSession is not a function` TypeError). Ownership context is minted through the
|
||||
// real `ownConversation` factory — the only sanctioned way to reach a port op.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId };
|
||||
const CONVERSATION_UNAVAILABLE_RESULT = {
|
||||
ok: false,
|
||||
code: 'conversation_unavailable',
|
||||
retryable: false,
|
||||
} as const;
|
||||
|
||||
/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */
|
||||
function makeStream(): LegacyRuntimeStream {
|
||||
return { channelId: 'websocket:test-1', onEvent: vi.fn() };
|
||||
}
|
||||
|
||||
/**
|
||||
* getSession → undefined (session missing), createSession → rejects with `err`. Exercises the
|
||||
* `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves
|
||||
* the turn short-circuited before any dispatch.
|
||||
*/
|
||||
function makeCollapsingAgentService(err: Error) {
|
||||
return {
|
||||
getSession: vi.fn(() => undefined),
|
||||
createSession: vi.fn().mockRejectedValue(err),
|
||||
onEvent: vi.fn(() => vi.fn()),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
recordTokenUsage: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */
|
||||
function makeLeaseAgentService() {
|
||||
const session = makeAgentSession(USER_A);
|
||||
const unsubscribe = vi.fn();
|
||||
const svc = {
|
||||
getSession: vi.fn(() => session),
|
||||
createSession: vi.fn(),
|
||||
onEvent: vi.fn(() => unsubscribe),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
recordTokenUsage: vi.fn(),
|
||||
};
|
||||
return { svc, unsubscribe, session };
|
||||
}
|
||||
|
||||
/**
|
||||
* getSession → a live owned session (REST resolveOrCreate succeeds), onEvent returns a `detach`
|
||||
* spy, and `prompt` REJECTS with a non-timeout error. Drives the REST-turn catch path so the single
|
||||
* idempotent teardown must clear the 120s timeout and detach the listener exactly once.
|
||||
*/
|
||||
function makeRejectingPromptAgentService() {
|
||||
const session = makeAgentSession(USER_A);
|
||||
const detach = vi.fn();
|
||||
const svc = {
|
||||
getSession: vi.fn(() => session),
|
||||
createSession: vi.fn(),
|
||||
onEvent: vi.fn(() => detach),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt: vi.fn().mockRejectedValue(new Error('agent backend exploded')),
|
||||
recordTokenUsage: vi.fn(),
|
||||
};
|
||||
return { svc, detach };
|
||||
}
|
||||
|
||||
describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => {
|
||||
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
|
||||
|
||||
it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => {
|
||||
const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner'));
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' });
|
||||
|
||||
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
|
||||
expect(svc.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => {
|
||||
const svc = makeCollapsingAgentService(new NotFoundException('no such conversation'));
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' });
|
||||
|
||||
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
|
||||
expect(svc.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => {
|
||||
const foreign = new EmbeddedChatRuntime(
|
||||
makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never,
|
||||
);
|
||||
const missing = new EmbeddedChatRuntime(
|
||||
makeCollapsingAgentService(new NotFoundException('no such conversation')) as never,
|
||||
);
|
||||
|
||||
const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' });
|
||||
const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' });
|
||||
|
||||
expect(foreignResult).toEqual(missingResult);
|
||||
expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => {
|
||||
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
|
||||
|
||||
it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => {
|
||||
const { svc } = makeLeaseAgentService();
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
it('does not mutate thinking level on another owner/tenant session', () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
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;
|
||||
|
||||
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
||||
const first = await lease.dispatch();
|
||||
expect(first).toEqual({ ok: true, value: undefined });
|
||||
expect(svc.prompt).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
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(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
// Must NOT throw out of the port — a partial setup collapses to a total safe failure.
|
||||
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
|
||||
expect(prepared.ok).toBe(false);
|
||||
// Exactly what was acquired (the event listener) is rolled back.
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => {
|
||||
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
|
||||
|
||||
it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => {
|
||||
const { svc, detach } = makeRejectingPromptAgentService();
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
// A rejected `done` promise firing after completeLegacyRestTurn has already returned would
|
||||
// surface as an unhandledRejection — the leak this test fences. Capture any that escape.
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown): void => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const result = await runtime.completeLegacyRestTurn(ctx, {
|
||||
content: 'trigger a backend failure',
|
||||
});
|
||||
|
||||
// The rejection collapses to a total safe failure (not a timeout) — never throws out of the port.
|
||||
expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false });
|
||||
// The single idempotent dispose ran in the catch: listener detached exactly once.
|
||||
expect(detach).toHaveBeenCalledTimes(1);
|
||||
|
||||
// dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second
|
||||
// detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise.
|
||||
vi.advanceTimersByTime(600_000);
|
||||
expect(detach).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
// Let any scheduled rejection surface on a real macrotask, then confirm none did.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => {
|
||||
const session = makeAgentSession(USER_A);
|
||||
const detach = vi.fn();
|
||||
const svc = {
|
||||
getSession: vi.fn(() => session),
|
||||
createSession: vi.fn(),
|
||||
onEvent: vi.fn(() => detach),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
// The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential
|
||||
// `await prompt()` the timer could never even be observed, so the turn hung forever.
|
||||
prompt: vi.fn(() => new Promise<void>(() => undefined)),
|
||||
recordTokenUsage: vi.fn(),
|
||||
};
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
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(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,920 @@
|
||||
import 'reflect-metadata';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { HarnessModule } from '../harness/harness.module.js';
|
||||
import { ChatModule } from './chat.module.js';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import {
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
HARNESS_REGISTRY,
|
||||
type HarnessConversationServiceBinding,
|
||||
} from '../harness/harness.tokens.js';
|
||||
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||
import {
|
||||
ChatRuntimeUnavailableError,
|
||||
ownConversation,
|
||||
type ChatRuntime,
|
||||
type ChatRuntimeMode,
|
||||
type LegacyEmbeddedChatPort,
|
||||
type LegacyRuntimeStream,
|
||||
type LegacySessionPresentation,
|
||||
type LegacySocketTurnLease,
|
||||
type OwnedConversationContext,
|
||||
} from './chat-runtime.js';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
|
||||
/**
|
||||
* Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one
|
||||
* runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and
|
||||
* never downgrades `pi-rpc` to embedded execution. Red-first: the router is an
|
||||
* unimplemented stub, so every behavioural assertion below fails until Step Three.
|
||||
*/
|
||||
|
||||
const embedded: ChatRuntime = { kind: 'embedded' };
|
||||
const harness: ChatRuntime = { kind: 'harness' };
|
||||
|
||||
/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */
|
||||
const boundConversationService = {
|
||||
attach: () => Promise.reject(new Error('unused')),
|
||||
detach: () => Promise.reject(new Error('unused')),
|
||||
send: () => Promise.reject(new Error('unused')),
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
for (const id of adapterIds) {
|
||||
registry.register({
|
||||
id,
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
function buildRouter(
|
||||
mode: ChatRuntimeMode,
|
||||
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
|
||||
): ChatRuntimeRouter {
|
||||
return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down a module that was deliberately driven to a fail-closed init.
|
||||
* `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing
|
||||
* (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed
|
||||
* startup error, this time into teardown. Each caller here has already captured and asserted that
|
||||
* exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise —
|
||||
* swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly.
|
||||
*/
|
||||
async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise<void> {
|
||||
await moduleRef.close().catch((err: unknown) => {
|
||||
if (err instanceof ChatRuntimeUnavailableError) return;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
describe('ChatRuntimeRouter', () => {
|
||||
it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: boundConversationService,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).not.toThrow();
|
||||
expect(router.active).toBe(harness);
|
||||
expect(router.active.kind).toBe('harness');
|
||||
});
|
||||
|
||||
it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => {
|
||||
// Empty registry + unavailable service: legacy must ignore both and still start.
|
||||
const router = buildRouter('legacy', {
|
||||
adapters: [],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).not.toThrow();
|
||||
expect(router.active).toBe(embedded);
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
});
|
||||
|
||||
it('fails closed at init when pi-rpc mode has no registered pi adapter', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
|
||||
try {
|
||||
router.onModuleInit();
|
||||
expect.unreachable('onModuleInit must throw when the pi adapter is absent');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
|
||||
try {
|
||||
router.onModuleInit();
|
||||
expect.unreachable('onModuleInit must throw when the conversation service is unbound');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: [],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
|
||||
// A failed pi-rpc init must not silently expose the embedded runtime.
|
||||
expect(() => router.active).toThrow();
|
||||
let leaked: ChatRuntime | undefined;
|
||||
try {
|
||||
leaked = router.active;
|
||||
} catch {
|
||||
leaked = undefined;
|
||||
}
|
||||
expect(leaked).not.toBe(embedded);
|
||||
});
|
||||
|
||||
it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
|
||||
try {
|
||||
router.onModuleInit();
|
||||
expect.unreachable('onModuleInit must throw');
|
||||
} catch (err) {
|
||||
const message = (err as ChatRuntimeUnavailableError).message;
|
||||
expect(message).toBe(
|
||||
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
|
||||
);
|
||||
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input).
|
||||
*
|
||||
* The unit suite above constructs the router but never invokes a legacy port operation, so the
|
||||
* six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that
|
||||
* deletes one of them SURVIVES for lack of a test that drives that operation. This group closes
|
||||
* that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a
|
||||
* valid branded {@link OwnedConversationContext} and valid input, against a recording embedded
|
||||
* stub whose method returns a distinguishable `ok:true` success and increments a per-op counter.
|
||||
*
|
||||
* For each operation:
|
||||
* - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }`
|
||||
* result AND that the embedded stub was touched zero times (no effects);
|
||||
* - the paired legacy test proves that same stub method IS reached and returns its distinguishable
|
||||
* success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful,
|
||||
* not vacuously true because the stub could never be called.
|
||||
*
|
||||
* Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED
|
||||
* (the router returns the embedded `ok:true` value and records the call), with every outer guard
|
||||
* and the other five inner guards intact. `next` is untouched; nothing here changes production.
|
||||
*/
|
||||
describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => {
|
||||
const RUNTIME_UNSUPPORTED = {
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
retryable: false,
|
||||
} as const;
|
||||
|
||||
const PRESENTATION: LegacySessionPresentation = {
|
||||
provider: 'embedded-provider',
|
||||
modelId: 'embedded-model',
|
||||
thinkingLevel: 'low',
|
||||
availableThinkingLevels: ['low', 'high'],
|
||||
};
|
||||
|
||||
const stream: LegacyRuntimeStream = {
|
||||
channelId: 'websocket:test-socket',
|
||||
onEvent: () => {},
|
||||
};
|
||||
|
||||
const ctx = (): OwnedConversationContext =>
|
||||
ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' });
|
||||
|
||||
/**
|
||||
* Per-operation invocation counters with declared keys (not an index signature) so each
|
||||
* `calls.<op>` is definitely `number` under `noUncheckedIndexedAccess`.
|
||||
*/
|
||||
type LegacyPortCallCounts = {
|
||||
completeLegacyRestTurn: number;
|
||||
prepareLegacySocketTurn: number;
|
||||
setLegacyThinking: number;
|
||||
abortLegacyTurn: number;
|
||||
applyLegacyModelOverride: number;
|
||||
readLegacySessionPresentation: number;
|
||||
dispatchVerifiedDiscordIngress: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* An embedded port that records every invocation and returns a distinguishable `ok:true`
|
||||
* value per operation. If a router op reaches it (its guard removed), both the recorded call
|
||||
* count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result.
|
||||
*/
|
||||
function recordingEmbeddedPort(): {
|
||||
port: ChatRuntime & LegacyEmbeddedChatPort;
|
||||
calls: LegacyPortCallCounts;
|
||||
} {
|
||||
const calls: LegacyPortCallCounts = {
|
||||
completeLegacyRestTurn: 0,
|
||||
prepareLegacySocketTurn: 0,
|
||||
setLegacyThinking: 0,
|
||||
abortLegacyTurn: 0,
|
||||
applyLegacyModelOverride: 0,
|
||||
readLegacySessionPresentation: 0,
|
||||
dispatchVerifiedDiscordIngress: 0,
|
||||
};
|
||||
const lease: LegacySocketTurnLease = {
|
||||
presentation: PRESENTATION,
|
||||
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
|
||||
dispose: () => Promise.resolve(),
|
||||
};
|
||||
const port: ChatRuntime & LegacyEmbeddedChatPort = {
|
||||
kind: 'embedded',
|
||||
completeLegacyRestTurn: () => {
|
||||
calls.completeLegacyRestTurn += 1;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
value: { text: 'EMBEDDED-REST', presentation: PRESENTATION },
|
||||
});
|
||||
},
|
||||
prepareLegacySocketTurn: () => {
|
||||
calls.prepareLegacySocketTurn += 1;
|
||||
return Promise.resolve({ ok: true, value: lease });
|
||||
},
|
||||
setLegacyThinking: () => {
|
||||
calls.setLegacyThinking += 1;
|
||||
return { ok: true, value: PRESENTATION };
|
||||
},
|
||||
abortLegacyTurn: () => {
|
||||
calls.abortLegacyTurn += 1;
|
||||
return Promise.resolve({ ok: true, value: undefined });
|
||||
},
|
||||
applyLegacyModelOverride: () => {
|
||||
calls.applyLegacyModelOverride += 1;
|
||||
return { ok: true, value: PRESENTATION };
|
||||
},
|
||||
readLegacySessionPresentation: () => {
|
||||
calls.readLegacySessionPresentation += 1;
|
||||
return { ok: true, value: PRESENTATION };
|
||||
},
|
||||
dispatchVerifiedDiscordIngress: () => {
|
||||
calls.dispatchVerifiedDiscordIngress += 1;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
value: {
|
||||
presentation: PRESENTATION,
|
||||
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
|
||||
dispose: () => Promise.resolve(),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
return { port, calls };
|
||||
}
|
||||
|
||||
function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
|
||||
return new ChatRuntimeRouter(
|
||||
registryWith(['pi']),
|
||||
boundConversationService,
|
||||
port,
|
||||
harness,
|
||||
'pi-rpc',
|
||||
);
|
||||
}
|
||||
function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
|
||||
return new ChatRuntimeRouter(
|
||||
registryWith([]),
|
||||
boundConversationService,
|
||||
port,
|
||||
harness,
|
||||
'legacy',
|
||||
);
|
||||
}
|
||||
|
||||
// completeLegacyRestTurn ---------------------------------------------------
|
||||
it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.completeLegacyRestTurn).toBe(0);
|
||||
});
|
||||
it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.completeLegacyRestTurn).toBe(1);
|
||||
});
|
||||
|
||||
// prepareLegacySocketTurn --------------------------------------------------
|
||||
it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await piRouter(port).prepareLegacySocketTurn(
|
||||
ctx(),
|
||||
{ content: 'hello' },
|
||||
stream,
|
||||
);
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.prepareLegacySocketTurn).toBe(0);
|
||||
});
|
||||
it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await legacyRouter(port).prepareLegacySocketTurn(
|
||||
ctx(),
|
||||
{ content: 'hello' },
|
||||
stream,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.prepareLegacySocketTurn).toBe(1);
|
||||
});
|
||||
|
||||
// setLegacyThinking (sync) -------------------------------------------------
|
||||
it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = piRouter(port).setLegacyThinking(ctx(), 'high');
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.setLegacyThinking).toBe(0);
|
||||
});
|
||||
it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = legacyRouter(port).setLegacyThinking(ctx(), 'high');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.setLegacyThinking).toBe(1);
|
||||
});
|
||||
|
||||
// abortLegacyTurn ----------------------------------------------------------
|
||||
it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await piRouter(port).abortLegacyTurn(ctx());
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.abortLegacyTurn).toBe(0);
|
||||
});
|
||||
it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await legacyRouter(port).abortLegacyTurn(ctx());
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.abortLegacyTurn).toBe(1);
|
||||
});
|
||||
|
||||
// applyLegacyModelOverride (sync) ------------------------------------------
|
||||
it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.applyLegacyModelOverride).toBe(0);
|
||||
});
|
||||
it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.applyLegacyModelOverride).toBe(1);
|
||||
});
|
||||
|
||||
// readLegacySessionPresentation (sync) -------------------------------------
|
||||
it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = piRouter(port).readLegacySessionPresentation(ctx());
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.readLegacySessionPresentation).toBe(0);
|
||||
});
|
||||
it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = legacyRouter(port).readLegacySessionPresentation(ctx());
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.readLegacySessionPresentation).toBe(1);
|
||||
});
|
||||
|
||||
// dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) ---------
|
||||
it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const discordCtx = ctx() as unknown as Parameters<
|
||||
ChatRuntimeRouter['dispatchVerifiedDiscordIngress']
|
||||
>[0];
|
||||
const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.dispatchVerifiedDiscordIngress).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 1 (real Nest module-graph readiness).
|
||||
*
|
||||
* The unit suite above constructs the router directly. This group drives the SAME contract
|
||||
* through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting
|
||||
* idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry`
|
||||
* via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest
|
||||
* lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose
|
||||
* `onModuleInit` throws a generic Error, so:
|
||||
* - readiness cases fail because the graph never comes up (init rejects), and
|
||||
* - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed
|
||||
* `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that
|
||||
* "throws anything" cannot mask these greens.
|
||||
* The router is NOT wired into a production module yet, so it is provided here via a factory
|
||||
* over the real registry token. Importing the real `ChatModule` bare is deliberately avoided:
|
||||
* it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a
|
||||
* collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router.
|
||||
*/
|
||||
describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => {
|
||||
async function bootRouterGraph(
|
||||
mode: ChatRuntimeMode,
|
||||
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
|
||||
) {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [HarnessModule],
|
||||
providers: [
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: (registry: HarnessRegistry) =>
|
||||
new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode),
|
||||
inject: [HARNESS_REGISTRY],
|
||||
},
|
||||
],
|
||||
})
|
||||
// The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern,
|
||||
// never exercised here); stub it so the graph resolves. The registry is NOT overridden —
|
||||
// group 1 asserts against the genuine production HarnessRegistry.
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
// Resolve the production registry singleton and register the requested adapters ON IT, so
|
||||
// the router (which injects the same singleton) sees them when its lifecycle hook runs.
|
||||
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||
for (const id of opts.adapters) {
|
||||
registry.register({
|
||||
id,
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
}
|
||||
return moduleRef;
|
||||
}
|
||||
|
||||
// Capture an init rejection without letting a resolved init masquerade as success.
|
||||
const initError = (moduleRef: { init(): Promise<unknown> }): Promise<unknown> =>
|
||||
moduleRef.init().then(
|
||||
() => new Error('module init resolved but the contract requires it to reject'),
|
||||
(err: unknown) => err,
|
||||
);
|
||||
|
||||
it('brings the graph up and resolves only the harness runtime in pi-rpc mode (pi adapter + bound service)', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: boundConversationService,
|
||||
});
|
||||
try {
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active).toBe(harness);
|
||||
expect(router.active.kind).toBe('harness');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('brings the graph up in legacy mode over the REAL empty HarnessRegistry and resolves only the embedded runtime', async () => {
|
||||
const moduleRef = await bootRouterGraph('legacy', {
|
||||
adapters: [],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
try {
|
||||
// Defense-in-depth: the production module wires the genuine registry, empty by default —
|
||||
// guards against a test-double registry silently satisfying the readiness check.
|
||||
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||
expect(registry).toBeInstanceOf(HarnessRegistry);
|
||||
expect(registry.list()).toHaveLength(0);
|
||||
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active).toBe(embedded);
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at module init when pi-rpc mode has no registered pi adapter (specific typed error, not a stub throw)', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at module init when pi-rpc mode has the unavailable conversation-service sentinel', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces only fixed, browser-safe failure text when the graph fails closed (no stub/exception detail)', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
const message = (err as ChatRuntimeUnavailableError).message;
|
||||
expect(message).toBe(
|
||||
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
|
||||
);
|
||||
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(|not implemented/);
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 1b (production ChatModule wiring, declaration proof).
|
||||
*
|
||||
* Correction #1 (Scrappy fe3e02) asked for a red that imports the real `ChatModule` and calls
|
||||
* `module.init()`. Investigated and found impractical/masking-prone: `ChatModule` provides
|
||||
* `ChatGateway`, whose 10-argument constructor injects app-global providers (AgentService, AUTH,
|
||||
* BRAIN, RoutingEngineService) plus the Commands/GC/Mcp/Reload subsystems across a forwardRef
|
||||
* cycle. Booting it in isolation is a full-app integration boot — "override only unrelated
|
||||
* dependencies" balloons into faking ~4 subsystems, and `overrideProvider` cannot even grant the
|
||||
* cross-module export-scope visibility ChatGateway needs (probe: `ChatGateway` unresolved at
|
||||
* `CommandExecutorService`). That is exactly the STOP-and-return branch of the directive.
|
||||
*
|
||||
* The faithful, unmaskable cover instead of a fragile boot: read the PRODUCTION `ChatModule`'s own
|
||||
* Nest `@Module` metadata to prove it DECLARES the exclusive router provider and imports the real
|
||||
* `HarnessModule` (the genuine registry source). This inspects the actual module object — not
|
||||
* source text, not a test factory — so nothing can mask it. Group 1 above separately proves the
|
||||
* router RESOLVES against the real, empty `HarnessRegistry` through the Nest lifecycle; the union
|
||||
* of the two covers "the router is wired through ChatModule to the real registry" without the
|
||||
* impractical single-graph boot. RED today (ChatModule provides only ChatGateway and imports only
|
||||
* CommandsModule); GREEN once Step Three registers the router and imports HarnessModule.
|
||||
*/
|
||||
describe('ChatModule production wiring (Task Five, Step Two group 1b — declaration proof)', () => {
|
||||
// Unwrap a forwardRef(() => Module) import to the module it references; pass others through.
|
||||
const resolveImport = (imp: unknown): unknown =>
|
||||
imp &&
|
||||
typeof imp === 'object' &&
|
||||
typeof (imp as { forwardRef?: unknown }).forwardRef === 'function'
|
||||
? (imp as { forwardRef: () => unknown }).forwardRef()
|
||||
: imp;
|
||||
|
||||
// A provider entry is either a class (shorthand) or a { provide, ... } object; take its token.
|
||||
const providerToken = (provider: unknown): unknown =>
|
||||
typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
|
||||
|
||||
it('declares the exclusive ChatRuntimeRouter as a provider on the production ChatModule', () => {
|
||||
const providers: unknown[] = Reflect.getMetadata('providers', ChatModule) ?? [];
|
||||
expect(providers.map(providerToken)).toContain(ChatRuntimeRouter);
|
||||
});
|
||||
|
||||
it('imports the real HarnessModule into the production ChatModule (registry source, not a test double)', () => {
|
||||
const imports: unknown[] = Reflect.getMetadata('imports', ChatModule) ?? [];
|
||||
expect(imports.map(resolveImport)).toContain(HarnessModule);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 1c (bounded real-`ChatModule` boot).
|
||||
*
|
||||
* Scrappy adjudication d67d2b (option c): boot the ACTUAL production `ChatModule` as the SUT and
|
||||
* assert the exclusive router resolves THROUGH it — the single-graph proof group 1 (router over the
|
||||
* real registry) and group 1b (production-module metadata) each cover only a half of. The heavy,
|
||||
* UNRELATED cycle is the only thing bounded away, per the established isolation pattern in
|
||||
* `apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`:
|
||||
* - `CommandsModule` (drags the Commands <-> Reload <-> Chat forwardRef cycle plus GC/Mcp/queue)
|
||||
* is replaced wholesale with an empty module via `.overrideModule(...).useModule(...)`;
|
||||
* - `ChatGateway` (10-arg constructor, an HTTP/socket concern never exercised here) is replaced
|
||||
* with an inert value;
|
||||
* - the sole legacy-controller dependency, `AgentService`, is supplied by a tiny `@Global()` stub;
|
||||
* - the HTTP-only `AuthGuard` is stubbed.
|
||||
* Nothing about the router, `HarnessModule`, the registry, or the conversation-service binding is
|
||||
* faked in the production-legacy case — those are retrieved from the REAL `ChatModule` graph. Mode
|
||||
* is driven only through the production `CHAT_HARNESS_RUNTIME` env contract (`resolveChatRuntimeMode`).
|
||||
*
|
||||
* Red-first: today `ChatModule` neither imports `HarnessModule` nor provides `ChatRuntimeRouter`, so
|
||||
* the booted graph contains no router/registry/conversation-service tokens. `init()` may resolve
|
||||
* (there is no router lifecycle hook yet to reject), so every case fails on the MISSING actual
|
||||
* router/registry/service wiring — not on unrelated DI, which is bounded away. GREEN at Step Three
|
||||
* once `ChatModule` imports `HarnessModule`, provides the exclusive router, and binds the
|
||||
* conversation-service token (defaulting to the unavailable sentinel).
|
||||
*/
|
||||
describe('ChatModule bounded real boot (Task Five, Step Two group 1c)', () => {
|
||||
// The unrelated heavy cycle, replaced wholesale — not stubbed provider-by-provider.
|
||||
@Module({})
|
||||
class EmptyCommandsModule {}
|
||||
|
||||
// The ONLY genuine legacy dependency of the real ChatController, supplied inertly and globally so
|
||||
// the pre-refactor controller instantiates without dragging AgentModule into the graph.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: AgentService, useValue: {} }],
|
||||
exports: [AgentService],
|
||||
})
|
||||
class LegacyControllerDepsModule {}
|
||||
|
||||
const ORIGINAL_RUNTIME_ENV = process.env['CHAT_HARNESS_RUNTIME'];
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_RUNTIME_ENV === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||
else process.env['CHAT_HARNESS_RUNTIME'] = ORIGINAL_RUNTIME_ENV;
|
||||
});
|
||||
|
||||
/**
|
||||
* Boot the real ChatModule with only the unrelated cycle bounded away. `mode` is set through the
|
||||
* genuine production env contract before providers instantiate. The optional overrides replace
|
||||
* the registry / conversation-service the router injects, exercising the pi-rpc precondition
|
||||
* branches through the ACTUAL module (they are no-ops today because those tokens are not yet in
|
||||
* the graph — which is exactly why the router-retrieval assertions go red).
|
||||
*/
|
||||
async function bootChatModule(
|
||||
mode: ChatRuntimeMode,
|
||||
overrides: {
|
||||
registryAdapters?: readonly string[];
|
||||
conversationService?: HarnessConversationServiceBinding;
|
||||
} = {},
|
||||
): Promise<TestingModule> {
|
||||
if (mode === 'pi-rpc') process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
else delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||
|
||||
let builder = Test.createTestingModule({
|
||||
imports: [LegacyControllerDepsModule, ChatModule],
|
||||
})
|
||||
.overrideModule(CommandsModule)
|
||||
.useModule(EmptyCommandsModule)
|
||||
.overrideProvider(ChatGateway)
|
||||
.useValue({})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true });
|
||||
|
||||
if (overrides.registryAdapters) {
|
||||
builder = builder
|
||||
.overrideProvider(HARNESS_REGISTRY)
|
||||
.useValue(registryWith(overrides.registryAdapters));
|
||||
}
|
||||
if (overrides.conversationService !== undefined) {
|
||||
builder = builder
|
||||
.overrideProvider(HARNESS_CONVERSATION_SERVICE)
|
||||
.useValue(overrides.conversationService);
|
||||
}
|
||||
return builder.compile();
|
||||
}
|
||||
|
||||
// Capture an init rejection without letting a resolved init masquerade as success.
|
||||
const initError = (moduleRef: TestingModule): Promise<unknown> =>
|
||||
moduleRef.init().then(
|
||||
() => new Error('module init resolved but the contract requires it to reject'),
|
||||
(err: unknown) => err,
|
||||
);
|
||||
|
||||
it('legacy mode: the actual router resolves the embedded runtime, the actual registry is empty, and the conversation-service token is the unavailable sentinel', async () => {
|
||||
const moduleRef = await bootChatModule('legacy');
|
||||
try {
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
|
||||
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||
expect(registry).toBeInstanceOf(HarnessRegistry);
|
||||
expect(registry.list()).toHaveLength(0);
|
||||
|
||||
const service = moduleRef.get<HarnessConversationServiceBinding>(
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
{
|
||||
strict: false,
|
||||
},
|
||||
);
|
||||
expect(service).toBe(HARNESS_CONVERSATION_SERVICE_UNAVAILABLE);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('pi-rpc mode over the REAL empty registry fails closed at init with the typed adapter-unavailable error', async () => {
|
||||
const moduleRef = await bootChatModule('pi-rpc');
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('pi-rpc mode with a pi adapter present but the sentinel conversation service fails closed with the typed conversation-service-unavailable error', async () => {
|
||||
const moduleRef = await bootChatModule('pi-rpc', {
|
||||
registryAdapters: ['pi'],
|
||||
conversationService: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('pi-rpc mode with a pi adapter and a bound conversation service: the actual router selects the harness runtime', async () => {
|
||||
const moduleRef = await bootChatModule('pi-rpc', {
|
||||
registryAdapters: ['pi'],
|
||||
conversationService: boundConversationService,
|
||||
});
|
||||
try {
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active.kind).toBe('harness');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 2 (WHOLE production `AppModule` boot, legacy end-to-end wiring).
|
||||
*
|
||||
* The groups above bound away the heavy cycle to isolate the router. This group instead boots the
|
||||
* ACTUAL production `AppModule` (the exact graph `main.ts` runs) in the default LEGACY chat-runtime
|
||||
* mode, overriding ONLY the storage/network side-effect adapters so the boot is bounded and offline
|
||||
* — never the chat/router/harness/reload/commands surface under test. The bounded fakes are exactly
|
||||
* the disk/network leaves:
|
||||
* - `ProviderService` (the #1 hang risk: its real `onModuleInit` starts an unref'd health-check
|
||||
* `setInterval` and fetches Ollama over HTTP) → inert no-op instance;
|
||||
* - `DB_HANDLE`/`DB` → a fake Drizzle-shaped handle that satisfies `runPgliteMigrations` (the local
|
||||
* tier's `DatabaseModule.onModuleInit`) AND `DefaultRoutingRulesSeed.onModuleInit` (which reads a
|
||||
* system-rule count — the fake reports rules already present so the seed insert is skipped),
|
||||
* opening no real database;
|
||||
* - `STORAGE_ADAPTER`/`MEMORY`/`MEMORY_ADAPTER`/`AUTH`/`BRAIN`/`LOG_SERVICE` → inert fakes so no
|
||||
* storage/auth/log backend is contacted.
|
||||
* Local tier (the repo's `mosaic.config.json`) already disables BullMQ/Redis and the queue handles;
|
||||
* Discord/Telegram/MCP plugins are env-gated and disarmed by deleting their tokens. Nothing about the
|
||||
* router, `ChatModule`, `HarnessModule`, or `ChatGateway` is faked — those come from the REAL graph.
|
||||
*
|
||||
* The boot+init MUST SUCCEED cleanly (proven by `beforeAll` completing and the ChatGateway test
|
||||
* passing). Red-first: on this branch `ChatRuntimeRouter` is registered in NO module (ChatModule
|
||||
* provides only ChatGateway), so `moduleRef.get(ChatRuntimeRouter)` throws `UnknownElementException`
|
||||
* — a WIRING gap, NOT an init failure. That single retrieval is the intended behavioural red; it
|
||||
* flips green once Step Three registers the exclusive router. The ChatGateway retrieval and its
|
||||
* browser-facing method surface are asserted alongside and pass today, pinning that the boot itself
|
||||
* is healthy so the router failure cannot be mistaken for a mis-shaped fake or an unbounded side
|
||||
* effect.
|
||||
*/
|
||||
describe('AppModule production boot — legacy ChatRuntimeRouter wiring (Task Five, Step Two group 2)', () => {
|
||||
// A Drizzle-shaped fake that satisfies both DB consumers reached during a local-tier init:
|
||||
// • runPgliteMigrations(): reads handle.db.$client.exec + handle.db.execute(SELECT hashes);
|
||||
// exec is a no-op and execute yields an empty ledger, so migration statements no-op through.
|
||||
// • DefaultRoutingRulesSeed.seedDefaultRules(): db.select().from().where() must resolve to a
|
||||
// row set — we report a non-zero system-rule count so the seeding INSERT branch is skipped.
|
||||
const fakeDb = {
|
||||
$client: { exec: async (): Promise<void> => {} },
|
||||
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: async (): Promise<void> => {} }),
|
||||
};
|
||||
const fakeDbHandle = { db: fakeDb, close: async (): Promise<void> => {} };
|
||||
const fakeStorageAdapter = {
|
||||
name: 'fake',
|
||||
migrate: async (): Promise<void> => {},
|
||||
close: async (): Promise<void> => {},
|
||||
};
|
||||
// Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch.
|
||||
const fakeProviderService = {
|
||||
onModuleInit: async (): Promise<void> => {},
|
||||
onModuleDestroy: (): void => {},
|
||||
getRegistry: () => ({
|
||||
getAvailable: () => [],
|
||||
getAll: () => [],
|
||||
find: () => undefined,
|
||||
}),
|
||||
getDefaultModel: () => undefined,
|
||||
listAvailableModels: () => [],
|
||||
listProviders: () => [],
|
||||
getAdapter: () => undefined,
|
||||
getProvidersHealth: () => [],
|
||||
};
|
||||
const fakeBrain = { conversations: {}, agents: {} };
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
let moduleRef: TestingModule;
|
||||
let envSnapshot: Record<string, string | undefined>;
|
||||
|
||||
beforeAll(async () => {
|
||||
envSnapshot = { ...process.env };
|
||||
// Env hygiene: disarm the network-facing plugins/adapters and pin the legacy runtime mode.
|
||||
delete process.env['DATABASE_URL'];
|
||||
delete process.env['DISCORD_BOT_TOKEN'];
|
||||
delete process.env['TELEGRAM_BOT_TOKEN'];
|
||||
delete process.env['MCP_SERVERS'];
|
||||
delete process.env['CHAT_HARNESS_RUNTIME']; // resolveChatRuntimeMode → 'legacy'
|
||||
process.env['MOSAIC_STORAGE_TIER'] = 'local';
|
||||
|
||||
moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||
// Storage/network side-effect adapters ONLY — never the router/chat/harness surface under test.
|
||||
.overrideProvider('DB_HANDLE')
|
||||
.useValue(fakeDbHandle)
|
||||
.overrideProvider('DB')
|
||||
.useValue(fakeDb)
|
||||
.overrideProvider('STORAGE_ADAPTER')
|
||||
.useValue(fakeStorageAdapter)
|
||||
.overrideProvider('AUTH')
|
||||
.useValue({})
|
||||
.overrideProvider('BRAIN')
|
||||
.useValue(fakeBrain)
|
||||
.overrideProvider('LOG_SERVICE')
|
||||
.useValue({})
|
||||
.overrideProvider('MEMORY')
|
||||
.useValue({})
|
||||
.overrideProvider('MEMORY_ADAPTER')
|
||||
.useValue({})
|
||||
.overrideProvider(ProviderService)
|
||||
.useValue(fakeProviderService)
|
||||
.compile();
|
||||
|
||||
// The boot itself MUST succeed cleanly — a rejection here is a bounding failure, not the red.
|
||||
await moduleRef.init();
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
if (moduleRef) await moduleRef.close();
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in envSnapshot)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(envSnapshot)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Passes TODAY: the real ChatGateway is provided by the real ChatModule and its browser-facing
|
||||
// surface exists. This pins that the whole-AppModule boot came up healthy, so the router failure
|
||||
// below is unambiguously a wiring gap and not a mis-shaped fake or an unbounded side effect.
|
||||
it('boots the whole AppModule and exposes the real ChatGateway with its browser-facing methods', () => {
|
||||
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||
expect(typeof gateway.broadcastReload).toBe('function');
|
||||
expect(typeof gateway.getModelOverride).toBe('function');
|
||||
expect(typeof gateway.setModelOverride).toBe('function');
|
||||
expect(typeof gateway.broadcastSessionInfo).toBe('function');
|
||||
});
|
||||
|
||||
// RED TODAY: ChatRuntimeRouter is registered in no module on this branch, so this retrieval throws
|
||||
// UnknownElementException — the intended red-first wiring failure. GREEN once Step Three registers
|
||||
// the exclusive router in the production graph, where legacy mode resolves the embedded runtime.
|
||||
it('resolves the exclusive ChatRuntimeRouter to the embedded runtime in legacy mode', () => {
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import {
|
||||
isHarnessConversationServiceAvailable,
|
||||
type HarnessConversationServiceBinding,
|
||||
} from '../harness/harness.tokens.js';
|
||||
import type {
|
||||
ChatRuntime,
|
||||
ChatRuntimeMode,
|
||||
LegacyBrowserMessagePayload,
|
||||
LegacyEmbeddedChatPort,
|
||||
LegacyRuntimeResult,
|
||||
LegacyRuntimeStream,
|
||||
LegacySessionPresentation,
|
||||
LegacySocketTurnLease,
|
||||
OwnedConversationContext,
|
||||
VerifiedDiscordIngressContext,
|
||||
VerifiedDiscordTurnLease,
|
||||
} from './chat-runtime.js';
|
||||
import { ChatRuntimeUnavailableError, resolveChatRuntimeMode } from './chat-runtime.js';
|
||||
|
||||
/** The fixed fail-closed result for a legacy browser operation issued under `pi-rpc`. */
|
||||
const RUNTIME_UNSUPPORTED = {
|
||||
ok: false as const,
|
||||
code: 'runtime_unsupported' as const,
|
||||
retryable: false as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the one live {@link ChatRuntime} for this process and enforces the
|
||||
* `pi-rpc` readiness preconditions at module init — before the gateway accepts
|
||||
* traffic. It never falls back from `pi-rpc` to embedded execution: an unmet
|
||||
* `pi-rpc` precondition is a typed startup failure ({@link ChatRuntimeUnavailableError}),
|
||||
* and until `onModuleInit` selects a runtime, {@link active} throws rather than
|
||||
* exposing any runtime — a failed `pi-rpc` init can never leak the embedded one.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatRuntimeRouter implements OnModuleInit, LegacyEmbeddedChatPort {
|
||||
private readonly mode: ChatRuntimeMode;
|
||||
|
||||
/** The single resolved runtime. Undefined until a successful `onModuleInit`. */
|
||||
private resolved: ChatRuntime | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly harnessRegistry: HarnessRegistry,
|
||||
private readonly conversationService: HarnessConversationServiceBinding,
|
||||
private readonly embedded: ChatRuntime,
|
||||
private readonly harness: ChatRuntime,
|
||||
mode: ChatRuntimeMode = resolveChatRuntimeMode(),
|
||||
) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.mode === 'legacy') {
|
||||
// Legacy ignores the pi-rpc preconditions entirely and always runs embedded.
|
||||
this.resolved = this.embedded;
|
||||
return;
|
||||
}
|
||||
|
||||
// pi-rpc: both preconditions are hard startup failures, checked in a fixed order.
|
||||
if (!this.harnessRegistry.has('pi')) {
|
||||
this.resolved = undefined;
|
||||
throw new ChatRuntimeUnavailableError('adapter_unavailable');
|
||||
}
|
||||
if (!isHarnessConversationServiceAvailable(this.conversationService)) {
|
||||
this.resolved = undefined;
|
||||
throw new ChatRuntimeUnavailableError('conversation_service_unavailable');
|
||||
}
|
||||
|
||||
this.resolved = this.harness;
|
||||
}
|
||||
|
||||
get active(): ChatRuntime {
|
||||
if (this.resolved === undefined) {
|
||||
// Reached only if init has not run or failed closed; never expose a runtime here.
|
||||
throw new Error('The chat runtime is not available: startup did not resolve a runtime.');
|
||||
}
|
||||
return this.resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The process-wide mode, available before {@link onModuleInit}. Production handlers read
|
||||
* this to fail a legacy browser turn closed under `pi-rpc` *before* parsing the payload as
|
||||
* either browser-legacy input or a Discord envelope — never to branch into a fallback.
|
||||
*/
|
||||
get runtimeMode(): ChatRuntimeMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedded runtime narrowed to its port. Only reached on the legacy path (and for the
|
||||
* verified-Discord op in both modes), where the injected runtime is always a real
|
||||
* `EmbeddedChatRuntime`. The router spec constructs the router with a bare `{ kind }` stub
|
||||
* but never invokes a port op, so this narrowing is never exercised against the stub.
|
||||
*/
|
||||
private get embeddedPort(): LegacyEmbeddedChatPort {
|
||||
return this.embedded as unknown as LegacyEmbeddedChatPort;
|
||||
}
|
||||
|
||||
// --- LegacyEmbeddedChatPort: legacy browser operations fail closed under pi-rpc ---
|
||||
|
||||
completeLegacyRestTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: Readonly<{ content: string }>,
|
||||
): Promise<
|
||||
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||
> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||
}
|
||||
return this.embeddedPort.completeLegacyRestTurn(context, input);
|
||||
}
|
||||
|
||||
prepareLegacySocketTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: LegacyBrowserMessagePayload,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||
}
|
||||
return this.embeddedPort.prepareLegacySocketTurn(context, input, stream);
|
||||
}
|
||||
|
||||
setLegacyThinking(
|
||||
context: OwnedConversationContext,
|
||||
level: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return RUNTIME_UNSUPPORTED;
|
||||
}
|
||||
return this.embeddedPort.setLegacyThinking(context, level);
|
||||
}
|
||||
|
||||
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||
}
|
||||
return this.embeddedPort.abortLegacyTurn(context);
|
||||
}
|
||||
|
||||
applyLegacyModelOverride(
|
||||
context: OwnedConversationContext,
|
||||
modelId: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return RUNTIME_UNSUPPORTED;
|
||||
}
|
||||
return this.embeddedPort.applyLegacyModelOverride(context, modelId);
|
||||
}
|
||||
|
||||
readLegacySessionPresentation(
|
||||
context: OwnedConversationContext,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return RUNTIME_UNSUPPORTED;
|
||||
}
|
||||
return this.embeddedPort.readLegacySessionPresentation(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verified Discord ingress bypasses browser mode: it is embedded-only in BOTH modes and
|
||||
* never reaches the harness or routing-engine selection. It is reached only through a
|
||||
* {@link VerifiedDiscordIngressContext}, which exists only after every ingress check.
|
||||
*/
|
||||
dispatchVerifiedDiscordIngress(
|
||||
context: VerifiedDiscordIngressContext,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
|
||||
return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types';
|
||||
|
||||
/**
|
||||
* The single chat execution strategy resolved by {@link ChatRuntimeRouter}.
|
||||
*
|
||||
* Exactly one runtime is live per process. There is no union that lets a
|
||||
* `pi-rpc` deployment silently fall back to embedded execution: an unmet
|
||||
* `pi-rpc` precondition is a typed startup failure, never a downgrade.
|
||||
*/
|
||||
export type ChatRuntimeMode = 'legacy' | 'pi-rpc';
|
||||
|
||||
export type ChatRuntimeKind = 'embedded' | 'harness';
|
||||
|
||||
/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */
|
||||
export interface ChatRuntime {
|
||||
readonly kind: ChatRuntimeKind;
|
||||
}
|
||||
|
||||
/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */
|
||||
export type ChatRuntimeUnavailableReason =
|
||||
| 'adapter_unavailable'
|
||||
| 'conversation_service_unavailable';
|
||||
|
||||
/**
|
||||
* Raised at module init when `pi-rpc` mode is selected but its preconditions are
|
||||
* unmet. Carries only fixed, browser-safe text — never a raw exception message,
|
||||
* stack, or provider detail — and reports the frozen ack code `runtime_unsupported`.
|
||||
*/
|
||||
export class ChatRuntimeUnavailableError extends Error {
|
||||
readonly code = 'runtime_unsupported' as const;
|
||||
readonly reason: ChatRuntimeUnavailableReason;
|
||||
|
||||
constructor(reason: ChatRuntimeUnavailableReason) {
|
||||
super(
|
||||
reason === 'adapter_unavailable'
|
||||
? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.'
|
||||
: 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.',
|
||||
);
|
||||
this.name = 'ChatRuntimeUnavailableError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the process-wide chat runtime mode from the environment. Anything other
|
||||
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
|
||||
*/
|
||||
export function resolveChatRuntimeMode(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): ChatRuntimeMode {
|
||||
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transitional embedded chat port (Task Five).
|
||||
//
|
||||
// The legacy embedded browser behaviour is moved behind this exact interface so
|
||||
// neither the controller nor the gateway retains AgentService, RoutingEngine,
|
||||
// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime`
|
||||
// implements the port; `ChatRuntimeRouter` exposes the same narrowly named
|
||||
// operations and returns `runtime_unsupported` before touching Embedded for legacy
|
||||
// browser operations when the mode is `pi-rpc`.
|
||||
//
|
||||
// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion,
|
||||
// legacy Socket streaming, P3 harness turns, and verified Discord are distinct
|
||||
// transport/trust capabilities — there is deliberately no generic
|
||||
// `sendConversationTurn` nor an AgentService-shaped mirror on the router.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser
|
||||
* DTOs are never structurally assignable to it. The factory that mints one may be called
|
||||
* only after authentication with `scopeFromUser(...)`, never with payload authority fields.
|
||||
*/
|
||||
declare const ownedConversationContextBrand: unique symbol;
|
||||
|
||||
/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */
|
||||
export interface OwnedConversationContext {
|
||||
readonly [ownedConversationContextBrand]: true;
|
||||
readonly conversationId: string;
|
||||
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned
|
||||
* conversations all collapse to `conversation_unavailable`. Ownership/mode/validation
|
||||
* failures are total results and never throw.
|
||||
*/
|
||||
export type LegacyRuntimeFailure =
|
||||
| { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false }
|
||||
| { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false }
|
||||
| { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly code: 'thinking_level_invalid';
|
||||
readonly retryable: false;
|
||||
readonly availableThinkingLevels: readonly string[];
|
||||
}
|
||||
| { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true }
|
||||
| { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false }
|
||||
| { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean }
|
||||
| { readonly ok: false; readonly code: 'timeout'; readonly retryable: true };
|
||||
|
||||
/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */
|
||||
export type LegacyRuntimeResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| LegacyRuntimeFailure;
|
||||
|
||||
/** User-facing session projection. Carries no session object, handle, or credential path. */
|
||||
export interface LegacySessionPresentation {
|
||||
readonly provider: string;
|
||||
readonly modelId: string;
|
||||
readonly thinkingLevel: string;
|
||||
readonly availableThinkingLevels: readonly string[];
|
||||
readonly agentName?: string;
|
||||
readonly routingDecision?: RoutingDecisionInfo;
|
||||
}
|
||||
|
||||
/** Terminal usage stats, normalized by Embedded from AgentService metrics. */
|
||||
export interface LegacyUsage {
|
||||
readonly provider: string;
|
||||
readonly modelId: string;
|
||||
readonly thinkingLevel: string;
|
||||
readonly tokens: Readonly<{
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
}>;
|
||||
readonly cost: number;
|
||||
readonly context: Readonly<{ percent: number | null; window: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw
|
||||
* exception, tool arguments, or credential-bearing path — the gateway sees only these.
|
||||
*/
|
||||
export type LegacyRuntimeEvent =
|
||||
| { readonly type: 'started' }
|
||||
| { readonly type: 'text_delta'; readonly text: string }
|
||||
| { readonly type: 'thinking_delta'; readonly text: string }
|
||||
| {
|
||||
readonly type: 'tool_started';
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
}
|
||||
| {
|
||||
readonly type: 'tool_finished';
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
readonly isError: boolean;
|
||||
}
|
||||
| { readonly type: 'settled'; readonly usage?: LegacyUsage };
|
||||
|
||||
/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */
|
||||
export interface LegacyBrowserMessagePayload {
|
||||
readonly content: string;
|
||||
readonly provider?: string;
|
||||
readonly modelId?: string;
|
||||
readonly agentId?: string;
|
||||
readonly attachments?: readonly ChannelAttachmentDto[];
|
||||
}
|
||||
|
||||
/** A prepared-but-not-yet-dispatched legacy socket turn. */
|
||||
export interface LegacySocketTurnLease {
|
||||
readonly presentation: LegacySessionPresentation;
|
||||
/**
|
||||
* Atomically one-shot and scope-rechecking. A second call returns
|
||||
* `turn_already_dispatched` and performs zero prompt/tool effects.
|
||||
*/
|
||||
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
||||
/** Idempotent, non-throwing. Removes listener and channel, including partial setup. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token
|
||||
* auth plus signature, allowlist, binding, expected-route, replay, configured-agent,
|
||||
* forced-scope, and attachment-normalization checks.
|
||||
*/
|
||||
declare const verifiedDiscordIngressContextBrand: unique symbol;
|
||||
|
||||
/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */
|
||||
export interface VerifiedDiscordIngressContext {
|
||||
readonly [verifiedDiscordIngressContextBrand]: true;
|
||||
readonly conversationId: string;
|
||||
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
||||
readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>;
|
||||
readonly content: string;
|
||||
readonly attachments?: readonly ChannelAttachmentDto[];
|
||||
readonly correlationId: string;
|
||||
readonly discordMessageId: string;
|
||||
readonly discordUserId: string;
|
||||
}
|
||||
|
||||
/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */
|
||||
export interface VerifiedDiscordTurnLease {
|
||||
readonly presentation: LegacySessionPresentation;
|
||||
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Server-owned egress projection the runtime pushes normalized events into. */
|
||||
export interface LegacyRuntimeStream {
|
||||
/** Server-derived, e.g. `websocket:<socket-id>`. Never client-supplied. */
|
||||
readonly channelId: string;
|
||||
onEvent(event: LegacyRuntimeEvent): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter`
|
||||
* mirrors the operation names and fails closed with `runtime_unsupported` for legacy
|
||||
* browser operations under `pi-rpc`.
|
||||
*/
|
||||
export interface LegacyEmbeddedChatPort {
|
||||
completeLegacyRestTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: Readonly<{ content: string }>,
|
||||
): Promise<
|
||||
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||
>;
|
||||
|
||||
prepareLegacySocketTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: LegacyBrowserMessagePayload,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>>;
|
||||
|
||||
setLegacyThinking(
|
||||
context: OwnedConversationContext,
|
||||
level: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||
|
||||
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>>;
|
||||
|
||||
applyLegacyModelOverride(
|
||||
context: OwnedConversationContext,
|
||||
modelId: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||
|
||||
readLegacySessionPresentation(
|
||||
context: OwnedConversationContext,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||
|
||||
dispatchVerifiedDiscordIngress(
|
||||
context: VerifiedDiscordIngressContext,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass
|
||||
* a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied
|
||||
* authority field. The brand is phantom, so this is the only way to obtain the branded type.
|
||||
*/
|
||||
export function ownConversation(
|
||||
conversationId: string,
|
||||
scope: Readonly<{ userId: string; tenantId: string }>,
|
||||
): OwnedConversationContext {
|
||||
return { conversationId, scope } as unknown as OwnedConversationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every
|
||||
* ingress check (service-token auth, signature, allowlist, binding, expected-route, replay,
|
||||
* configured-agent, forced-scope, attachment normalization) before calling this.
|
||||
*/
|
||||
export function verifyDiscordIngress(
|
||||
fields: Omit<VerifiedDiscordIngressContext, typeof verifiedDiscordIngressContextBrand>,
|
||||
): VerifiedDiscordIngressContext {
|
||||
return { ...fields } as unknown as VerifiedDiscordIngressContext;
|
||||
}
|
||||
@@ -3,21 +3,20 @@ import {
|
||||
Post,
|
||||
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;
|
||||
@@ -29,7 +28,7 @@ interface ChatResponse {
|
||||
export class ChatController {
|
||||
private readonly logger = new Logger(ChatController.name);
|
||||
|
||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||
constructor(private readonly runtime: ChatRuntimeRouter) {}
|
||||
|
||||
@Post()
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@@ -40,68 +39,38 @@ 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}`);
|
||||
|
||||
let responseText = '';
|
||||
// The one exclusive runtime owns execution. In legacy mode this reaches the embedded runtime;
|
||||
// in pi-rpc it fails closed with `runtime_unsupported` before ever touching embedded execution.
|
||||
const result = await this.runtime.completeLegacyRestTurn(
|
||||
ownConversation(conversationId, scope),
|
||||
{ content: body.content },
|
||||
);
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
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);
|
||||
if (result.ok) {
|
||||
return { conversationId, text: result.value.text };
|
||||
}
|
||||
|
||||
return { conversationId, text: responseText };
|
||||
throw this.toHttpException(result, conversationId);
|
||||
}
|
||||
|
||||
/** Maps a total {@link LegacyRuntimeFailure} to the fixed browser-safe HTTP surface. */
|
||||
private toHttpException(failure: LegacyRuntimeFailure, conversationId: string): HttpException {
|
||||
switch (failure.code) {
|
||||
case 'conversation_unavailable':
|
||||
return new NotFoundException('Session not found');
|
||||
case 'request_invalid':
|
||||
case 'thinking_level_invalid':
|
||||
return new HttpException('Invalid chat request', HttpStatus.BAD_REQUEST);
|
||||
case 'timeout':
|
||||
return new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
||||
case 'runtime_unsupported':
|
||||
case 'runtime_unavailable':
|
||||
return new HttpException('Agent runtime unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
default:
|
||||
this.logger.error(`Chat turn failed for conversation=${conversationId}: ${failure.code}`);
|
||||
return new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
||||
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ChatRequestDto {
|
||||
@IsOptional()
|
||||
@@ -37,3 +46,56 @@ 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,12 +8,31 @@ 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(
|
||||
{} as never,
|
||||
failIfUsedChatRuntimeRouter() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
@@ -72,3 +91,114 @@ describe('ChatGateway command approval ingress', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so
|
||||
* embedded slash-commands (/model, /agent, and every other non-audited command) are fixed
|
||||
* "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent
|
||||
* fall-through to embedded execution. Only runtime-independent audited system commands
|
||||
* (/reload) pass through as a positive control, and the approval path stays runtime-independent.
|
||||
* The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so
|
||||
* a fence bypass surfaces as a thrown error rather than a silent embedded dispatch.
|
||||
*/
|
||||
function buildPiRpcGateway(commandExecutor: {
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
createApproval: ReturnType<typeof vi.fn>;
|
||||
}): ChatGateway {
|
||||
const piRpcRouter = {
|
||||
runtimeMode: 'pi-rpc' as const,
|
||||
onModuleInit: () => {
|
||||
throw new Error('chat runtime router must not initialise on the pi-rpc command path');
|
||||
},
|
||||
get active(): never {
|
||||
throw new Error('chat runtime must not be resolved on the pi-rpc command path');
|
||||
},
|
||||
};
|
||||
return new ChatGateway(
|
||||
piRpcRouter as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
commandExecutor as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => {
|
||||
const UNSUPPORTED = 'Slash commands are not available on this deployment.';
|
||||
|
||||
it.each(['model', 'agent', 'gc'])(
|
||||
'fails /%s closed before the executor under pi-rpc (execute never called)',
|
||||
async (command): Promise<void> => {
|
||||
const commandExecutor = {
|
||||
execute: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ command, conversationId: 'conversation-1', success: true }),
|
||||
createApproval: vi.fn(),
|
||||
};
|
||||
const gateway = buildPiRpcGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandExecute(client as never, {
|
||||
command,
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.execute).toHaveBeenCalledTimes(0);
|
||||
expect(client.emit).toHaveBeenCalledWith('command:result', {
|
||||
command,
|
||||
conversationId: 'conversation-1',
|
||||
success: false,
|
||||
message: UNSUPPORTED,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise<void> => {
|
||||
const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true };
|
||||
const commandExecutor = {
|
||||
execute: vi.fn().mockResolvedValue(reloadResult),
|
||||
createApproval: vi.fn(),
|
||||
};
|
||||
const gateway = buildPiRpcGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandExecute(client as never, {
|
||||
command: 'reload',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.execute).toHaveBeenCalledTimes(1);
|
||||
expect(commandExecutor.execute).toHaveBeenCalledWith(
|
||||
{ command: 'reload', conversationId: 'conversation-1' },
|
||||
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult);
|
||||
});
|
||||
|
||||
it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise<void> => {
|
||||
const commandExecutor = {
|
||||
execute: vi.fn(),
|
||||
createApproval: vi.fn().mockResolvedValue({
|
||||
approvalId: 'approval-1',
|
||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||
}),
|
||||
};
|
||||
const gateway = buildPiRpcGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandApproval(client as never, {
|
||||
command: 'gc',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
|
||||
{ command: 'gc', conversationId: 'conversation-1' },
|
||||
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith(
|
||||
'command:approval',
|
||||
expect.objectContaining({ success: true, approvalId: 'approval-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,59 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { 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)],
|
||||
imports: [forwardRef(() => CommandsModule), HarnessModule],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatGateway],
|
||||
exports: [ChatGateway],
|
||||
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],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import { AgentService, type AgentSession } from '../agent/agent.service.js';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import type {
|
||||
ChatRuntime,
|
||||
LegacyBrowserMessagePayload,
|
||||
LegacyEmbeddedChatPort,
|
||||
LegacyRuntimeEvent,
|
||||
LegacyRuntimeResult,
|
||||
LegacySessionPresentation,
|
||||
LegacySocketTurnLease,
|
||||
LegacyUsage,
|
||||
OwnedConversationContext,
|
||||
VerifiedDiscordIngressContext,
|
||||
VerifiedDiscordTurnLease,
|
||||
LegacyRuntimeStream,
|
||||
} from './chat-runtime.js';
|
||||
|
||||
/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */
|
||||
const REST_TURN_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}.
|
||||
*
|
||||
* It owns the embedded in-process execution path — the `AgentService` stack that the
|
||||
* `ChatController` and `ChatGateway` drove directly before Task Five. Once the
|
||||
* {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser
|
||||
* HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so
|
||||
* neither the controller nor the gateway retains `AgentService`, `piSession`, session,
|
||||
* listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by
|
||||
* `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation
|
||||
* collapses to `conversation_unavailable` and never throws out of the port.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort {
|
||||
readonly kind = 'embedded' as const;
|
||||
private readonly logger = new Logger(EmbeddedChatRuntime.name);
|
||||
|
||||
constructor(readonly agentService: AgentService) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Legacy REST completion (op A)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async completeLegacyRestTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: Readonly<{ content: string }>,
|
||||
): Promise<
|
||||
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||
> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(conversationId, scope, {});
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
let responseText = '';
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let detach: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
// One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and
|
||||
// detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt
|
||||
// rejection fires first. Without this, a prompt() rejection surfaced through the catch below
|
||||
// would return while leaving the listener attached (free to consume a later turn's events) and
|
||||
// the 120s timer live (its rejection later going unobserved).
|
||||
const dispose = (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
detach?.();
|
||||
};
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
dispose();
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, REST_TURN_TIMEOUT_MS);
|
||||
|
||||
detach = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === 'message_update' &&
|
||||
event.assistantMessageEvent.type === 'text_delta'
|
||||
) {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
dispose();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
scope,
|
||||
);
|
||||
});
|
||||
|
||||
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
|
||||
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
|
||||
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
|
||||
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
|
||||
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
|
||||
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
|
||||
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
|
||||
const prompting = this.agentService.prompt(conversationId, input.content, scope);
|
||||
try {
|
||||
await Promise.all([prompting, done]);
|
||||
} catch (err) {
|
||||
dispose();
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('timed out')) {
|
||||
return { ok: false, code: 'timeout', retryable: true };
|
||||
}
|
||||
this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message);
|
||||
return { ok: false, code: 'operation_failed', retryable: false };
|
||||
}
|
||||
|
||||
const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation;
|
||||
return { ok: true, value: { text: responseText, presentation } };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Legacy Socket streaming (op B)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async prepareLegacySocketTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: LegacyBrowserMessagePayload,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(conversationId, scope, {
|
||||
...(input.provider ? { provider: input.provider } : {}),
|
||||
...(input.modelId ? { modelId: input.modelId } : {}),
|
||||
...(input.agentId ? { agentConfigId: input.agentId } : {}),
|
||||
});
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
let detach: () => void;
|
||||
try {
|
||||
detach = this.subscribe(conversationId, scope, stream);
|
||||
} catch (err) {
|
||||
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
|
||||
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
|
||||
this.logger.error(
|
||||
`Embedded socket subscription failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: this.buildLease(
|
||||
conversationId,
|
||||
scope,
|
||||
input.content,
|
||||
input.attachments,
|
||||
detach,
|
||||
resolved.presentation,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Thinking level (op C) — synchronous, total
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
setLegacyThinking(
|
||||
context: OwnedConversationContext,
|
||||
level: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
|
||||
const availableThinkingLevels = session.piSession.getAvailableThinkingLevels();
|
||||
if (!(availableThinkingLevels as readonly string[]).includes(level)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'thinking_level_invalid',
|
||||
retryable: false,
|
||||
availableThinkingLevels,
|
||||
};
|
||||
}
|
||||
|
||||
session.piSession.setThinkingLevel(level as never);
|
||||
return { ok: true, value: this.presentationForSession(session) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Abort (op D)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
|
||||
try {
|
||||
await session.piSession.abort();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Legacy abort failed for conversation=${context.conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'operation_failed', retryable: false };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Model override (synchronous, total)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
applyLegacyModelOverride(
|
||||
context: OwnedConversationContext,
|
||||
modelId: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
|
||||
this.agentService.updateSessionModel(context.conversationId, modelId, scope);
|
||||
const refreshed = this.agentService.getSession(context.conversationId, scope) ?? session;
|
||||
return { ok: true, value: this.presentationForSession(refreshed) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Presentation read (synchronous, total)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
readLegacySessionPresentation(
|
||||
context: OwnedConversationContext,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
return { ok: true, value: this.presentationForSession(session) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verified Discord ingress (embedded-only in both modes)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async dispatchVerifiedDiscordIngress(
|
||||
context: VerifiedDiscordIngressContext,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(
|
||||
conversationId,
|
||||
scope,
|
||||
{ agentConfigId: context.configuredAgent.agentConfigId },
|
||||
{
|
||||
agentConfigId: context.configuredAgent.agentConfigId,
|
||||
instanceId: context.configuredAgent.instanceId,
|
||||
},
|
||||
);
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
let detach: () => void;
|
||||
try {
|
||||
detach = this.subscribe(conversationId, scope, stream);
|
||||
} catch (err) {
|
||||
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
|
||||
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
|
||||
this.logger.error(
|
||||
`Embedded Discord subscription failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: this.buildLease(
|
||||
conversationId,
|
||||
scope,
|
||||
context.content,
|
||||
context.attachments,
|
||||
detach,
|
||||
resolved.presentation,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves the owned session, creating it on first use. Ownership/scope rejections
|
||||
* (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation
|
||||
* failure surfaces as the retryable `runtime_unavailable`. On success returns the
|
||||
* session presentation so callers avoid a redundant `getSession`.
|
||||
*/
|
||||
private async resolveOrCreate(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>,
|
||||
expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>,
|
||||
): Promise<
|
||||
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
|
||||
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
|
||||
> {
|
||||
// A verified-Discord turn may only run under a session whose configured identity matches the
|
||||
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
|
||||
// pre-existing session AND a freshly created one: a session carrying a different configured
|
||||
// agent — however it arose — is rejected rather than executed under the verified label, so we
|
||||
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
|
||||
// no expectedAgent and skips the check.
|
||||
const identityMatches = (candidate: AgentSession): boolean =>
|
||||
expectedAgent === undefined ||
|
||||
(candidate.agentConfigId === expectedAgent.agentConfigId &&
|
||||
candidate.agentName === expectedAgent.instanceId);
|
||||
|
||||
let session = this.agentService.getSession(conversationId, scope);
|
||||
if (session && !identityMatches(session)) {
|
||||
// Reused same-scope session minted under a different configured identity — reject with zero
|
||||
// effects rather than dispatch a verified turn onto a foreign agent's session.
|
||||
return CONVERSATION_UNAVAILABLE;
|
||||
}
|
||||
if (!session) {
|
||||
try {
|
||||
session = await this.agentService.createSession(conversationId, {
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
...extraOptions,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenException || err instanceof NotFoundException) {
|
||||
return CONVERSATION_UNAVAILABLE;
|
||||
}
|
||||
this.logger.error(
|
||||
`Embedded session creation failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||
}
|
||||
// The just-created session must ALSO carry the reconciled identity before any effect. A
|
||||
// createSession that returns a session under a different configured agent (misconfiguration
|
||||
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
|
||||
if (!identityMatches(session)) {
|
||||
return CONVERSATION_UNAVAILABLE;
|
||||
}
|
||||
}
|
||||
return { ok: true, presentation: this.presentationForSession(session) };
|
||||
}
|
||||
|
||||
/** Installs a normalizing event listener that forwards to the server-owned stream. */
|
||||
private subscribe(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
stream: LegacyRuntimeStream,
|
||||
): () => void {
|
||||
const unsubscribe = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
const normalized = this.normalizeEvent(conversationId, scope, event);
|
||||
if (normalized) stream.onEvent(normalized);
|
||||
},
|
||||
scope,
|
||||
);
|
||||
try {
|
||||
this.agentService.addChannel(conversationId, stream.channelId, scope);
|
||||
} catch (err) {
|
||||
// Partial setup: the listener was acquired but the channel attach failed. Roll back
|
||||
// exactly what was acquired (the listener) before the failure escapes, so no leaked
|
||||
// subscription survives; the caller converts the rethrow into a total safe failure.
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch {
|
||||
/* idempotent teardown */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch {
|
||||
/* idempotent teardown */
|
||||
}
|
||||
try {
|
||||
this.agentService.removeChannel(conversationId, stream.channelId, scope);
|
||||
} catch {
|
||||
/* idempotent teardown */
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds an atomically one-shot, scope-rechecking dispatch lease. */
|
||||
private buildLease(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
content: string,
|
||||
attachments: VerifiedDiscordIngressContext['attachments'],
|
||||
detach: () => void,
|
||||
presentation: LegacySessionPresentation,
|
||||
): LegacySocketTurnLease & VerifiedDiscordTurnLease {
|
||||
let dispatched = false;
|
||||
let disposed = false;
|
||||
return {
|
||||
presentation,
|
||||
dispatch: async (): Promise<LegacyRuntimeResult<void>> => {
|
||||
if (dispatched) {
|
||||
return { ok: false, code: 'turn_already_dispatched', retryable: false };
|
||||
}
|
||||
dispatched = true;
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, content, scope, attachments);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Legacy dispatch failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'operation_failed', retryable: false };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
dispose: async (): Promise<void> => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalizes a raw agent event into the redaction-agnostic transport event, or drops it. */
|
||||
private normalizeEvent(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
event: AgentSessionEvent,
|
||||
): LegacyRuntimeEvent | undefined {
|
||||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
return { type: 'started' };
|
||||
case 'agent_end':
|
||||
return { type: 'settled', ...this.usageFor(conversationId, scope) };
|
||||
case 'message_update': {
|
||||
const assistant = event.assistantMessageEvent;
|
||||
if (assistant.type === 'text_delta') return { type: 'text_delta', text: assistant.delta };
|
||||
if (assistant.type === 'thinking_delta') {
|
||||
return { type: 'thinking_delta', text: assistant.delta };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case 'tool_execution_start':
|
||||
return { type: 'tool_started', toolCallId: event.toolCallId, toolName: event.toolName };
|
||||
case 'tool_execution_end':
|
||||
return {
|
||||
type: 'tool_finished',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
isError: event.isError,
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers terminal usage from the Pi session and records it into session metrics.
|
||||
* Embedded owns AgentService metrics; the gateway never touches `piSession` stats.
|
||||
*/
|
||||
private usageFor(conversationId: string, scope: ActorTenantScope): { usage?: LegacyUsage } {
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
const piSession = session?.piSession;
|
||||
const stats = piSession?.getSessionStats();
|
||||
if (!session || !stats) return {};
|
||||
const contextUsage = piSession?.getContextUsage();
|
||||
|
||||
const tokens = {
|
||||
input: stats.tokens?.input ?? 0,
|
||||
output: stats.tokens?.output ?? 0,
|
||||
cacheRead: stats.tokens?.cacheRead ?? 0,
|
||||
cacheWrite: stats.tokens?.cacheWrite ?? 0,
|
||||
total: stats.tokens?.total ?? 0,
|
||||
};
|
||||
|
||||
this.agentService.recordTokenUsage(conversationId, { ...tokens });
|
||||
|
||||
return {
|
||||
usage: {
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
thinkingLevel: piSession?.thinkingLevel ?? 'off',
|
||||
tokens,
|
||||
cost: stats.cost ?? 0,
|
||||
context: {
|
||||
percent: contextUsage?.percent ?? null,
|
||||
window: contextUsage?.contextWindow ?? 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Presentation from a live session id, or undefined when no owned session exists. */
|
||||
private presentationFor(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): LegacySessionPresentation | undefined {
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
return session ? this.presentationForSession(session) : undefined;
|
||||
}
|
||||
|
||||
/** User-facing projection carrying no session handle, credential, or raw stats. */
|
||||
private presentationForSession(session: AgentSession): LegacySessionPresentation {
|
||||
return {
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
thinkingLevel: session.piSession.thinkingLevel,
|
||||
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
||||
...(session.agentName ? { agentName: session.agentName } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The shared terminal `conversation_unavailable` failure (missing/foreign/lost ownership). */
|
||||
const CONVERSATION_UNAVAILABLE = {
|
||||
ok: false as const,
|
||||
code: 'conversation_unavailable' as const,
|
||||
retryable: false as const,
|
||||
};
|
||||
|
||||
/** Narrows a branded context scope to the `AgentService` actor/tenant scope (identical shape). */
|
||||
function toScope(scope: Readonly<{ userId: string; tenantId: string }>): ActorTenantScope {
|
||||
return { userId: scope.userId, tenantId: scope.tenantId };
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
AttachConversation,
|
||||
ConversationSnapshot,
|
||||
DetachConversation,
|
||||
HarnessActorContext,
|
||||
HarnessConversationService,
|
||||
HarnessEventEnvelope,
|
||||
HarnessSelection,
|
||||
SendHarnessTurn,
|
||||
TurnReceipt,
|
||||
} from '@mosaicstack/types';
|
||||
import { HarnessChatRuntime } from './harness-chat.runtime.js';
|
||||
|
||||
/**
|
||||
* Task Five, Step One (harness runtime). Proves the `pi-rpc` runtime executes
|
||||
* exclusively through the {@link HarnessConversationService} RPC boundary and
|
||||
* forwards the caller's exact selection tuple and idempotency key without
|
||||
* substitution. Red-first: the runtime is an unimplemented stub, so every
|
||||
* delegation assertion fails until Step Three.
|
||||
*/
|
||||
|
||||
const context: HarnessActorContext = {
|
||||
actorId: 'actor-1',
|
||||
tenantId: 'tenant-1',
|
||||
seatId: 'seat-1',
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude-opus-4-8',
|
||||
};
|
||||
|
||||
const conversationId = '11111111-1111-4111-8111-111111111111';
|
||||
const idempotencyKey = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
const sendInput: SendHarnessTurn & { idempotencyKey: string } = {
|
||||
context,
|
||||
conversationId,
|
||||
selection,
|
||||
turnId: 'turn-abc',
|
||||
correlationId: 'corr-1',
|
||||
content: 'hello',
|
||||
idempotencyKey,
|
||||
};
|
||||
|
||||
const attachInput: AttachConversation & { afterSequence?: number } = {
|
||||
context,
|
||||
conversationId,
|
||||
clientId: 'client-1',
|
||||
selection,
|
||||
afterSequence: 0,
|
||||
};
|
||||
|
||||
const detachInput: DetachConversation = {
|
||||
context,
|
||||
conversationId,
|
||||
clientId: 'client-1',
|
||||
};
|
||||
|
||||
interface RecordedCalls {
|
||||
attach: (AttachConversation & { afterSequence?: number })[];
|
||||
detach: DetachConversation[];
|
||||
send: (SendHarnessTurn & { idempotencyKey: string })[];
|
||||
subscribeFrom: { conversationId: string; afterSequence: number }[];
|
||||
}
|
||||
|
||||
const snapshot: ConversationSnapshot = {
|
||||
session: {
|
||||
conversationId,
|
||||
nativeSessionId: 'native-1',
|
||||
seatId: 'seat-1',
|
||||
selection,
|
||||
state: 'idle',
|
||||
attachedClientIds: ['client-1'],
|
||||
},
|
||||
lastSequence: 0,
|
||||
replay: [],
|
||||
};
|
||||
|
||||
function build(): { runtime: HarnessChatRuntime; calls: RecordedCalls } {
|
||||
const calls: RecordedCalls = { attach: [], detach: [], send: [], subscribeFrom: [] };
|
||||
const service: HarnessConversationService = {
|
||||
attach: (input) => {
|
||||
calls.attach.push(input);
|
||||
return Promise.resolve(snapshot);
|
||||
},
|
||||
detach: (input) => {
|
||||
calls.detach.push(input);
|
||||
return Promise.resolve();
|
||||
},
|
||||
send: (input) => {
|
||||
calls.send.push(input);
|
||||
// The service echoes only the requested tuple; there is no representable substitute.
|
||||
const receipt: TurnReceipt = {
|
||||
conversationId: input.conversationId,
|
||||
turnId: 'turn-server',
|
||||
correlationId: input.correlationId,
|
||||
state: 'accepted',
|
||||
selection: input.selection,
|
||||
};
|
||||
return Promise.resolve(receipt);
|
||||
},
|
||||
subscribeFrom: (id, afterSequence) => {
|
||||
calls.subscribeFrom.push({ conversationId: id, afterSequence });
|
||||
|
||||
return (async function* (): AsyncIterable<HarnessEventEnvelope> {
|
||||
return;
|
||||
})();
|
||||
},
|
||||
};
|
||||
return { runtime: new HarnessChatRuntime(service), calls };
|
||||
}
|
||||
|
||||
describe('HarnessChatRuntime', () => {
|
||||
it('is the harness runtime kind and needs only a HarnessConversationService', () => {
|
||||
const { runtime } = build();
|
||||
expect(runtime.kind).toBe('harness');
|
||||
});
|
||||
|
||||
it('delegates send to the conversation service with the exact tuple and idempotency key', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
const receipt = await runtime.send(sendInput);
|
||||
|
||||
expect(calls.send).toHaveLength(1);
|
||||
const firstSend = calls.send[0]!;
|
||||
expect(firstSend).toEqual(sendInput);
|
||||
expect(firstSend.idempotencyKey).toBe(idempotencyKey);
|
||||
expect(firstSend.selection).toEqual(selection);
|
||||
// The runtime must not substitute an effective tuple onto the receipt.
|
||||
expect(receipt.selection).toEqual(selection);
|
||||
});
|
||||
|
||||
it('delegates attach to the conversation service and returns its snapshot', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
const result = await runtime.attach(attachInput);
|
||||
|
||||
expect(calls.attach).toHaveLength(1);
|
||||
expect(calls.attach[0]).toEqual(attachInput);
|
||||
expect(result).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('delegates detach to the conversation service', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
await runtime.detach(detachInput);
|
||||
|
||||
expect(calls.detach).toHaveLength(1);
|
||||
expect(calls.detach[0]).toEqual(detachInput);
|
||||
});
|
||||
|
||||
it('delegates subscribeFrom to the conversation service journal replay', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
const iterable = runtime.subscribeFrom(conversationId, 7);
|
||||
// Drain to prove it is the service-backed async iterable, not a fabricated one.
|
||||
const drained: unknown[] = [];
|
||||
for await (const event of iterable) {
|
||||
drained.push(event);
|
||||
}
|
||||
expect(drained).toHaveLength(0);
|
||||
|
||||
expect(calls.subscribeFrom).toHaveLength(1);
|
||||
expect(calls.subscribeFrom[0]).toEqual({ conversationId, afterSequence: 7 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
AttachConversation,
|
||||
ConversationSnapshot,
|
||||
DetachConversation,
|
||||
HarnessConversationService,
|
||||
HarnessEventEnvelope,
|
||||
SendHarnessTurn,
|
||||
TurnReceipt,
|
||||
} from '@mosaicstack/types';
|
||||
import type { ChatRuntime } from './chat-runtime.js';
|
||||
|
||||
/**
|
||||
* The `pi-rpc` chat runtime. It executes browser chat exclusively through the
|
||||
* harness-neutral {@link HarnessConversationService} RPC boundary — it never
|
||||
* touches the embedded `AgentService`/`ProviderService`/`RoutingEngineService`
|
||||
* stack, and it forwards the caller's exact selection tuple and idempotency key
|
||||
* without substitution.
|
||||
*
|
||||
* It owns no state and adds no policy: every method forwards the caller's exact
|
||||
* argument to the injected {@link HarnessConversationService} and returns its
|
||||
* result unchanged, so the requested selection tuple and idempotency key can
|
||||
* never be substituted on the way through.
|
||||
*/
|
||||
export class HarnessChatRuntime implements ChatRuntime {
|
||||
readonly kind = 'harness' as const;
|
||||
|
||||
constructor(private readonly conversations: HarnessConversationService) {}
|
||||
|
||||
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot> {
|
||||
return this.conversations.attach(input);
|
||||
}
|
||||
|
||||
detach(input: DetachConversation): Promise<void> {
|
||||
return this.conversations.detach(input);
|
||||
}
|
||||
|
||||
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt> {
|
||||
return this.conversations.send(input);
|
||||
}
|
||||
|
||||
subscribeFrom(
|
||||
conversationId: string,
|
||||
afterSequence: number,
|
||||
): AsyncIterable<HarnessEventEnvelope> {
|
||||
return this.conversations.subscribeFrom(conversationId, afterSequence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ChatRuntimeMode } from '../chat/chat-runtime.js';
|
||||
import { ConversationsController } from './conversations.controller.js';
|
||||
|
||||
/**
|
||||
* Task 5 harness fence for the conversations REST write path.
|
||||
*
|
||||
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
|
||||
* legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a
|
||||
* fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write.
|
||||
* Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`.
|
||||
*
|
||||
* Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution,
|
||||
* injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from
|
||||
* `process.env` at request time. The two "env is flipped after construction" tests below are the
|
||||
* load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn
|
||||
* RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`.
|
||||
*/
|
||||
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const USER = { id: 'user-1' };
|
||||
|
||||
function sendMessageDto() {
|
||||
return {
|
||||
role: 'user' as const,
|
||||
content: 'hello from the legacy REST write path',
|
||||
metadata: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function brainWithMessageSpy() {
|
||||
const addMessage = vi.fn().mockResolvedValue({
|
||||
id: 'message-1',
|
||||
conversationId: CONVERSATION_ID,
|
||||
role: 'user',
|
||||
content: 'hello from the legacy REST write path',
|
||||
});
|
||||
return {
|
||||
brain: { conversations: { addMessage } } as never,
|
||||
addMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */
|
||||
function routerFixedTo(mode: ChatRuntimeMode) {
|
||||
return { runtimeMode: mode };
|
||||
}
|
||||
|
||||
let priorMode: string | undefined;
|
||||
|
||||
describe('conversations REST write path — Task 5 harness fence', () => {
|
||||
beforeEach(() => {
|
||||
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
|
||||
});
|
||||
|
||||
it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => {
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
|
||||
|
||||
await expect(
|
||||
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
|
||||
).rejects.toMatchObject({ code: 'runtime_unsupported' });
|
||||
|
||||
// Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be
|
||||
// written, so no duplicate message can be produced.
|
||||
expect(addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes through the repository when the router resolved legacy (GREEN control)', async () => {
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
|
||||
|
||||
const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
|
||||
|
||||
expect(addMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
conversationId: CONVERSATION_ID,
|
||||
role: 'user',
|
||||
content: 'hello from the legacy REST write path',
|
||||
metadata: undefined,
|
||||
},
|
||||
USER.id,
|
||||
);
|
||||
expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID });
|
||||
});
|
||||
|
||||
it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => {
|
||||
// The runtime mode is fixed at module init. A later env mutation must not reopen the fence:
|
||||
// a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write.
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
|
||||
|
||||
await expect(
|
||||
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
|
||||
).rejects.toMatchObject({ code: 'runtime_unsupported' });
|
||||
|
||||
expect(addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => {
|
||||
// Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a
|
||||
// request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write.
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
|
||||
|
||||
await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
|
||||
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
@@ -19,6 +20,7 @@ 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,
|
||||
@@ -26,10 +28,41 @@ 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 {
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
/**
|
||||
* `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3).
|
||||
* The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at
|
||||
* request time — a single source of truth, so the controller cannot disagree with the router
|
||||
* about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode`
|
||||
* so this class depends on nothing else the router exposes.
|
||||
*/
|
||||
constructor(
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Inject(ChatRuntimeRouter) private readonly router: Pick<ChatRuntimeRouter, 'runtimeMode'>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: { id: string }) {
|
||||
@@ -94,6 +127,13 @@ 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,7 +1,14 @@
|
||||
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 {}
|
||||
|
||||
@@ -245,9 +245,21 @@ describe('EnrollmentService.createToken', () => {
|
||||
const after = Date.now();
|
||||
|
||||
const expiresMs = new Date(result.expiresAt).getTime();
|
||||
// Should be at most 900s from now
|
||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
||||
|
||||
// The property under test is CLAMPING: a 9999s request must come back as 900s.
|
||||
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
|
||||
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
|
||||
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
|
||||
// an unclamped result still misses by three orders of magnitude.
|
||||
//
|
||||
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
|
||||
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
|
||||
const CI_JITTER_MS = 5_000;
|
||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
|
||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||
// Explicitly pin the clamp itself, independent of any timing allowance:
|
||||
// unclamped (9999s) would exceed this by ~9_099_000 ms.
|
||||
expect(expiresMs - before).toBeLessThan(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HarnessRegistry } from './harness.registry.js';
|
||||
import { HarnessService } from './harness.service.js';
|
||||
import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js';
|
||||
import {
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
HARNESS_REGISTRY,
|
||||
HARNESS_SERVICE,
|
||||
} from './harness.tokens.js';
|
||||
import { HarnessController } from './harness.controller.js';
|
||||
import { HarnessSelectionController } from './harness-selection.controller.js';
|
||||
import { HarnessSelectionService } from './harness-selection.service.js';
|
||||
@@ -20,9 +25,13 @@ import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||
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],
|
||||
exports: [HARNESS_REGISTRY, HARNESS_SERVICE, HARNESS_CONVERSATION_SERVICE],
|
||||
})
|
||||
export class HarnessModule {}
|
||||
|
||||
@@ -4,8 +4,42 @@
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ 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';
|
||||
@@ -25,6 +29,7 @@ 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>();
|
||||
|
||||
@@ -150,6 +155,57 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 5 fence (C): the Discord SEND path runs through the exclusive {@link ChatRuntimeRouter},
|
||||
* constructed here in `pi-rpc` mode with a fully-resolved runtime (`active` = harness). A verified
|
||||
* Discord *service* turn must nonetheless execute on the {@link EmbeddedChatRuntime} — never the
|
||||
* harness, never the routing engine — per the Q1/Q2 adjudication: the router owns a dedicated
|
||||
* verified-ingress dispatch that delegates to embedded regardless of mode, with zero harness
|
||||
* fallback. The gateway is given the router in the former direct-`AgentService` constructor slot.
|
||||
*
|
||||
* RED today: production still reads that slot as a bare `AgentService`, so `this.agentService`
|
||||
* resolves to the router, `getSession(...)` is not a function, the send path throws and is caught
|
||||
* (an `error` is emitted and the handler returns) BEFORE it ever reaches the embedded runtime. The
|
||||
* failure is behavioural wiring — collection, DI, and `onModuleInit` all succeed. GREEN re-routes
|
||||
* the verified Discord dispatch through the router into the embedded runtime, satisfying the
|
||||
* preserved create/prompt assertions without weakening any control. `harnessConversations.append`
|
||||
* proves the harness path is never touched even though the pi-rpc router resolved it as `active`.
|
||||
*
|
||||
* Correction #4 is proved behaviourally, not by naming an accessor: the verified-ingress dispatch
|
||||
* is reachable only from the fully-verified `discordService` branch (the create/prompt tests below)
|
||||
* and never from a browser-emittable socket event (the browser-forgery refusal test).
|
||||
*/
|
||||
function readyPiRpcRegistry(): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
// A registered 'pi' adapter + an available (non-sentinel) conversation service let the pi-rpc
|
||||
// router resolve `active` = harness instead of failing closed at init, so these tests model the
|
||||
// real hostile condition — the harness runtime IS live — rather than a degraded router.
|
||||
registry.register({ id: 'pi' } as never);
|
||||
return registry;
|
||||
}
|
||||
|
||||
function piRpcRouterFronting(
|
||||
agentService: unknown,
|
||||
harnessConversations: { append: ReturnType<typeof vi.fn> },
|
||||
): ChatRuntimeRouter {
|
||||
const routerConversationServiceTripwire = {
|
||||
append: () => {
|
||||
throw new Error('router conversation service must not be resolved on the Discord path');
|
||||
},
|
||||
};
|
||||
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||
const harness = new HarnessChatRuntime(harnessConversations as never);
|
||||
const router = new ChatRuntimeRouter(
|
||||
readyPiRpcRegistry(),
|
||||
routerConversationServiceTripwire as never,
|
||||
embedded,
|
||||
harness,
|
||||
'pi-rpc',
|
||||
);
|
||||
router.onModuleInit();
|
||||
return router;
|
||||
}
|
||||
|
||||
describe('Discord ingress security', () => {
|
||||
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
||||
const [binding] = parseDiscordInteractionBindings(
|
||||
@@ -433,6 +489,7 @@ describe('Discord ingress security', () => {
|
||||
|
||||
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
|
||||
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([
|
||||
{
|
||||
@@ -489,8 +546,9 @@ describe('Discord ingress security', () => {
|
||||
},
|
||||
};
|
||||
const routingEngine = { resolve: vi.fn() };
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
agentService as never,
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
@@ -531,6 +589,575 @@ 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 () => {
|
||||
@@ -593,11 +1220,16 @@ 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(undefined);
|
||||
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||
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'],
|
||||
@@ -611,6 +1243,7 @@ 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),
|
||||
@@ -618,8 +1251,9 @@ describe('Discord ingress security', () => {
|
||||
addMessage,
|
||||
},
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
agentService as never,
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
@@ -667,6 +1301,66 @@ 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,11 +10,16 @@ import type {
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ChatSendCapabilityPayload,
|
||||
ChatSendProtocol,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
HarnessSelection,
|
||||
HarnessTurnAckPayload,
|
||||
HarnessTurnSendPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
ServerToClientEvents,
|
||||
@@ -37,11 +42,16 @@ export type {
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ChatSendCapabilityPayload,
|
||||
ChatSendProtocol,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
HarnessSelection,
|
||||
HarnessTurnAckPayload,
|
||||
HarnessTurnSendPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
ServerToClientEvents,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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; provider?: string; modelId?: string }) => void;
|
||||
onSend: (input: { content: string; selection: HarnessSelection }) => boolean;
|
||||
onStop: () => void;
|
||||
streaming: boolean;
|
||||
/** True from local send time through server turn startup/ack and
|
||||
@@ -44,11 +45,17 @@ export function Composer({
|
||||
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) return;
|
||||
if (!harness.canSend || harness.persistedSelection === null) return;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return;
|
||||
onSend({ content: trimmed, ...harness.projection });
|
||||
setContent('');
|
||||
// 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('');
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
||||
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
||||
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;
|
||||
@@ -51,8 +55,10 @@ 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. */
|
||||
simulateReconnect(): void;
|
||||
* 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;
|
||||
} {
|
||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||
const emitted: EmittedEvent[] = [];
|
||||
@@ -63,6 +69,7 @@ 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;
|
||||
@@ -105,8 +112,9 @@ export function createFakeChatSocket(): {
|
||||
}
|
||||
}
|
||||
|
||||
function simulateReconnect(): void {
|
||||
function simulateReconnect(nextId: string = socket.id): 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,6 +21,7 @@ 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>;
|
||||
@@ -33,6 +34,126 @@ 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,
|
||||
@@ -67,6 +188,20 @@ 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' });
|
||||
@@ -344,7 +479,10 @@ describe('useChatConnection', () => {
|
||||
|
||||
it('sendMessage emits optional conversationId/provider/modelId and appends an optimistic user turn', async () => {
|
||||
await act(async () => {
|
||||
latest?.actions.sendMessage({ content: 'hello', provider: 'anthropic', modelId: 'claude' });
|
||||
latest?.actions.sendMessage({
|
||||
content: 'hello',
|
||||
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
@@ -373,6 +511,408 @@ describe('useChatConnection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('turn:send harness routing (Task Five, Step Two red-first)', () => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
|
||||
// The pi turn-runtime routes sends through `turn:send`. Reset the generation
|
||||
// (clearing the outer `legacy-message` advertisement + first-wins lock) and
|
||||
// advertise `turn-send` for this exact connection, so every send below takes
|
||||
// the turn-runtime branch.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
async function establishConversation(): Promise<void> {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
}
|
||||
|
||||
it('emits a single turn:send with the nested selection tuple and a UUID idempotencyKey — never the legacy message event', async () => {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'hello', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
||||
expect(sends).toHaveLength(1);
|
||||
expect(sends[0]?.payload).toEqual({
|
||||
conversationId: 'c1',
|
||||
content: 'hello',
|
||||
selection,
|
||||
idempotencyKey: UUID,
|
||||
});
|
||||
// The pi-rpc sender must not fall back to the embedded `message` event.
|
||||
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||
});
|
||||
|
||||
it('generates the idempotencyKey with exactly one crypto.randomUUID() call per accepted send', async () => {
|
||||
const gen = vi.fn(() => UUID);
|
||||
const restore = installRandomUUID(gen);
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'first', selection });
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'second', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(gen).toHaveBeenCalledTimes(2);
|
||||
const keys = fake.emitted
|
||||
.filter((e) => e.event === 'turn:send')
|
||||
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
|
||||
expect(keys).toEqual([UUID, UUID]);
|
||||
});
|
||||
|
||||
it('does not send before an active conversation id exists (no first-send auto-create)', async () => {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let returned: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'too early', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||
// Nothing optimistically appended when the send is refused.
|
||||
expect(latest?.state.messages.some((m) => m.text === 'too early')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when it emits and false when the send is refused', async () => {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let refusedEarly: boolean | undefined;
|
||||
let acceptedAfter: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
refusedEarly = harnessSend()({ content: 'early', selection });
|
||||
});
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
acceptedAfter = harnessSend()({ content: 'now', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(refusedEarly).toBe(false);
|
||||
expect(acceptedAfter).toBe(true);
|
||||
});
|
||||
|
||||
it('when secure UUID generation throws: emits nothing, appends nothing, releases the lock, and a later send succeeds', async () => {
|
||||
await establishConversation();
|
||||
|
||||
const failing = installRandomUUID(() => {
|
||||
throw new Error('secure random unavailable');
|
||||
});
|
||||
let firstReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
firstReturn = harnessSend()({ content: 'blocked', selection });
|
||||
});
|
||||
} finally {
|
||||
failing();
|
||||
}
|
||||
|
||||
expect(firstReturn).toBe(false);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
expect(latest?.state.messages.some((m) => m.text === 'blocked')).toBe(false);
|
||||
|
||||
// The send lock must have been released, so a subsequent valid send works.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let secondReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
secondReturn = harnessSend()({ content: 'retry', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(secondReturn).toBe(true);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('turn:ack receipt + rejection contract (Task Five, Step Two group 4)', () => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
|
||||
// turn:ack is the receipt for a `turn:send`, so these establish under the pi
|
||||
// turn-runtime: reset the generation (clearing the outer `legacy-message`
|
||||
// advertisement + lock) and advertise `turn-send` for this connection.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Establish the conversation and send one accepted turn under a controlled
|
||||
// idempotency key. Returns the crypto restore fn so callers unwind it.
|
||||
async function establishAndSend(): Promise<() => void> {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'hello', selection });
|
||||
});
|
||||
return restore;
|
||||
}
|
||||
|
||||
it('surfaces a turn:ack receipt echoing the exact idempotencyKey, server receiptId, and requested selection tuple', async () => {
|
||||
const restore = await establishAndSend();
|
||||
try {
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
receiptId: 'r1',
|
||||
selection,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// RED anchor: no turn:ack handler exists, so nothing is recorded. Green
|
||||
// only when Step Three echoes the exact tuple back into state — never a
|
||||
// substituted or fabricated one.
|
||||
expect(harnessTurnState().turnReceipt).toEqual({
|
||||
idempotencyKey: UUID,
|
||||
receiptId: 'r1',
|
||||
selection,
|
||||
});
|
||||
});
|
||||
|
||||
it('on a rejected turn:ack surfaces a visible safe notice, never the raw internal error, and fabricates no receipt tuple', async () => {
|
||||
const restore = await establishAndSend();
|
||||
try {
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
error: 'ADAPTER_BOOM internal stack: pi adapter unavailable at 0xdeadbeef',
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// RED anchor: a rejected ack must surface a visible notice; today no
|
||||
// handler runs, so state.error stays null.
|
||||
expect(harnessTurnState().error).toBeTruthy();
|
||||
// The raw internal exception text must never reach the browser surface.
|
||||
expect(harnessTurnState().error ?? '').not.toContain('ADAPTER_BOOM');
|
||||
expect(harnessTurnState().error ?? '').not.toContain('0xdeadbeef');
|
||||
// A rejection must not fabricate a success receipt tuple.
|
||||
expect(harnessTurnState().turnReceipt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('uses one fixed safe rejection notice regardless of the internal cause (frozen union, not a passthrough)', async () => {
|
||||
const firstRestore = await establishAndSend();
|
||||
try {
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
error: 'cause-ALPHA adapter_unavailable',
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
firstRestore();
|
||||
}
|
||||
const firstNotice = harnessTurnState().error;
|
||||
|
||||
// A fresh turn on the same conversation, rejected for a DIFFERENT internal
|
||||
// reason, must surface the identical fixed notice.
|
||||
const secondRestore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'again', selection });
|
||||
});
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
error: 'cause-BRAVO conversation_service_unavailable',
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
secondRestore();
|
||||
}
|
||||
const secondNotice = harnessTurnState().error;
|
||||
|
||||
// RED anchor: both are null today; green requires a single frozen safe
|
||||
// string surfaced for both distinct internal causes.
|
||||
expect(firstNotice).toBeTruthy();
|
||||
expect(secondNotice).toBeTruthy();
|
||||
expect(firstNotice).toBe(secondNotice);
|
||||
expect(firstNotice ?? '').not.toContain('ALPHA');
|
||||
expect(secondNotice ?? '').not.toContain('BRAVO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency-key failure semantics (Task Five, Step Two group 5)', () => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID_A = '11111111-1111-4111-8111-111111111111';
|
||||
const UUID_B = '22222222-2222-4222-9222-222222222222';
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
// The idempotency key is minted only on the pi turn-runtime `turn:send`
|
||||
// branch: reset the generation (clearing the outer `legacy-message`
|
||||
// advertisement + lock) and advertise `turn-send` for this connection.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
async function establish(): Promise<void> {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
}
|
||||
|
||||
it('mints a DISTINCT UUID-v4 idempotencyKey for each of two accepted turns — a key is never reused across turns', async () => {
|
||||
const keys = [UUID_A, UUID_B];
|
||||
let call = 0;
|
||||
const restore = installRandomUUID(() => keys[call++] ?? UUID_A);
|
||||
try {
|
||||
await establish();
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'first', selection });
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'second', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
const sent = fake.emitted
|
||||
.filter((e) => e.event === 'turn:send')
|
||||
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
|
||||
// RED anchor: current sendMessage emits the legacy `message`, so no
|
||||
// turn:send keys exist at all.
|
||||
expect(sent).toHaveLength(2);
|
||||
expect(sent[0]).toMatch(UUID_V4);
|
||||
expect(sent[1]).toMatch(UUID_V4);
|
||||
expect(sent[0]).not.toBe(sent[1]);
|
||||
});
|
||||
|
||||
it('when crypto.randomUUID is ABSENT: surfaces a visible fixed idempotency-unavailable notice, emits nothing, appends nothing, releases the lock synchronously, and a later valid send succeeds', async () => {
|
||||
await establish();
|
||||
|
||||
const restoreCrypto = removeRandomUUID();
|
||||
let firstReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
firstReturn = harnessSend()({ content: 'no-secure-random', selection });
|
||||
});
|
||||
} finally {
|
||||
restoreCrypto();
|
||||
}
|
||||
|
||||
// RED anchors: a refused send returns false and surfaces a visible notice.
|
||||
expect(firstReturn).toBe(false);
|
||||
expect(harnessTurnState().error).toBeTruthy();
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
expect(latest?.state.messages.some((m) => m.text === 'no-secure-random')).toBe(false);
|
||||
|
||||
// The lock released synchronously (no server event needed): a later valid
|
||||
// send goes through.
|
||||
const restore = installRandomUUID(() => UUID_A);
|
||||
let secondReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
secondReturn = harnessSend()({ content: 'recovered', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
expect(secondReturn).toBe(true);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces the SAME fixed idempotency-unavailable notice whether randomUUID is absent or throws, never leaking the thrown message', async () => {
|
||||
// Case 1: absent.
|
||||
await establish();
|
||||
const restoreAbsent = removeRandomUUID();
|
||||
try {
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'absent', selection });
|
||||
});
|
||||
} finally {
|
||||
restoreAbsent();
|
||||
}
|
||||
const absentNotice = harnessTurnState().error;
|
||||
|
||||
// Case 2: throws with a distinctive internal message.
|
||||
const failing = installRandomUUID(() => {
|
||||
throw new Error('SECURE_RANDOM_BOOM entropy pool drained');
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'throws', selection });
|
||||
});
|
||||
} finally {
|
||||
failing();
|
||||
}
|
||||
const throwNotice = harnessTurnState().error;
|
||||
|
||||
// RED anchor: both are null today.
|
||||
expect(absentNotice).toBeTruthy();
|
||||
expect(throwNotice).toBeTruthy();
|
||||
expect(absentNotice).toBe(throwNotice);
|
||||
// The thrown internal detail must never reach the browser surface.
|
||||
expect(throwNotice ?? '').not.toContain('SECURE_RANDOM_BOOM');
|
||||
expect(throwNotice ?? '').not.toContain('entropy pool');
|
||||
});
|
||||
});
|
||||
|
||||
it('abort emits abort with the active conversationId', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
@@ -684,7 +1224,16 @@ 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.
|
||||
// reconnect must not be permanently blocked by the interrupted turn. The
|
||||
// disconnect also voids the negotiated send protocol (MAJOR-1), so model the
|
||||
// reconnect handshake — the socket reconnects and the server re-advertises
|
||||
// how this connection may send — before probing the released lock.
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
latest?.actions.sendMessage({ content: 'after reconnect' });
|
||||
});
|
||||
@@ -1665,4 +2214,319 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
asConversationId,
|
||||
asFiniteNumber,
|
||||
asHarnessSelection,
|
||||
asString,
|
||||
asStringArray,
|
||||
isRecord,
|
||||
@@ -21,10 +22,14 @@ import type {
|
||||
AgentStartPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatSendCapabilityPayload,
|
||||
ChatSendProtocol,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
HarnessSelection,
|
||||
HarnessTurnAckPayload,
|
||||
MessageAckPayload,
|
||||
SessionInfoPayload,
|
||||
SessionUsagePayload,
|
||||
@@ -130,6 +135,42 @@ 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
|
||||
@@ -236,6 +277,14 @@ 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
|
||||
@@ -268,6 +317,18 @@ 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
|
||||
@@ -308,7 +369,7 @@ export interface ChatConnectionState {
|
||||
}
|
||||
|
||||
export interface ChatConnectionActions {
|
||||
sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||
sendMessage: (input: { content: string; selection?: HarnessSelection }) => boolean;
|
||||
abort: () => void;
|
||||
setThinking: (level: string) => void;
|
||||
executeCommand: (input: { command: string; args?: string }) => void;
|
||||
@@ -341,6 +402,8 @@ const initialState: ChatConnectionState = {
|
||||
approvalRequestPending: false,
|
||||
systemReload: null,
|
||||
error: null,
|
||||
sendProtocol: 'unavailable',
|
||||
turnReceipt: null,
|
||||
messages: [],
|
||||
messageSeq: 0,
|
||||
toolSeq: 0,
|
||||
@@ -361,7 +424,12 @@ 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' }
|
||||
@@ -778,6 +846,30 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
};
|
||||
}
|
||||
|
||||
case 'server/turn:ack': {
|
||||
// The harness runtime's turn acknowledgement. The success shape carries a
|
||||
// receipt id + minted idempotencyKey + echoed selection; the failure shape
|
||||
// is discriminated on `ok === false`. Every field is runtime-untrusted (the
|
||||
// top-of-reducer guard already rejected a non-object payload).
|
||||
const record = action.payload as Record<string, unknown>;
|
||||
if (record.ok === false) {
|
||||
// A rejected turn surfaces a FIXED browser-safe notice — never the raw
|
||||
// server `message`/`error`/`code`, which can carry adapter internals — and
|
||||
// does not disturb any previously accepted receipt.
|
||||
return { ...state, error: TURN_REJECTED_NOTICE };
|
||||
}
|
||||
const idempotencyKey = asString(record.idempotencyKey);
|
||||
// The web ack uses `receiptId`; fall back to the frozen contract's `turnId`.
|
||||
const receiptId = asString(record.receiptId) || asString(record.turnId);
|
||||
const selection = asHarnessSelection(record.selection);
|
||||
if (idempotencyKey.length === 0 || receiptId.length === 0 || selection === null) {
|
||||
// A malformed success frame is ignored outright rather than recorded as a
|
||||
// half-populated receipt.
|
||||
return state;
|
||||
}
|
||||
return { ...state, turnReceipt: { idempotencyKey, receiptId, selection } };
|
||||
}
|
||||
|
||||
case 'local/send': {
|
||||
const message: ChatTranscriptMessage = {
|
||||
// Sourced from the reducer-owned `messageSeq` counter — see the
|
||||
@@ -802,6 +894,39 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
};
|
||||
}
|
||||
|
||||
case 'local/capability': {
|
||||
// The FIRST valid `chat:send-capability` for this connection generation has
|
||||
// been accepted (connection-id gating + first-wins enforced in the handler);
|
||||
// record how this connection may now send. This is the reactive mirror of
|
||||
// the synchronous `protocolRef` the send path reads.
|
||||
return { ...state, sendProtocol: action.protocol };
|
||||
}
|
||||
|
||||
case 'local/reset-protocol': {
|
||||
// A (re)connect or disconnect voids any negotiated protocol: a fresh or
|
||||
// dropped connection has no usable send capability until the server
|
||||
// (re-)advertises. Reset to `unavailable` so no stale advertisement can
|
||||
// authorize a send across a connection boundary.
|
||||
if (state.sendProtocol === 'unavailable') return state;
|
||||
return { ...state, sendProtocol: 'unavailable' };
|
||||
}
|
||||
|
||||
case 'local/send-unavailable': {
|
||||
// A send was attempted while the negotiated protocol is `unavailable`
|
||||
// (never advertised / advertised unavailable / rejected advertisement).
|
||||
// Surface the single FIXED safe notice — nothing was emitted, minted,
|
||||
// appended, or locked.
|
||||
return { ...state, error: SEND_PROTOCOL_UNAVAILABLE_NOTICE };
|
||||
}
|
||||
|
||||
case 'local/turn-idempotency-unavailable': {
|
||||
// The idempotency-key mint failed closed (crypto.randomUUID absent or
|
||||
// throwing), so the turn was refused before emit. Surface a FIXED notice —
|
||||
// never the underlying thrown message, which can leak entropy-source
|
||||
// internals.
|
||||
return { ...state, error: IDEMPOTENCY_UNAVAILABLE_NOTICE };
|
||||
}
|
||||
|
||||
case 'local/disconnect': {
|
||||
// A transient socket disconnect must not leave the UI stuck waiting on
|
||||
// a turn/approval/send that will never resolve on this connection.
|
||||
@@ -882,6 +1007,20 @@ 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();
|
||||
|
||||
@@ -913,7 +1052,45 @@ 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' });
|
||||
};
|
||||
|
||||
@@ -930,6 +1107,11 @@ 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) {
|
||||
@@ -950,24 +1132,79 @@ 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, 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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
abort: () => {
|
||||
|
||||
@@ -226,7 +226,10 @@ describe('useHarnessSelection', () => {
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
expect(value().canSend).toBe(true);
|
||||
expect(value().projection).toEqual({ provider: 'openai', modelId: 'gpt-5' });
|
||||
// Task Five: the composer sends the nested `persistedSelection` tuple directly.
|
||||
// The Task-Four compat flat `projection` ({provider, modelId}) is removed — the
|
||||
// harnessId must never be dropped on the way to the wire.
|
||||
expect('projection' in value()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
|
||||
@@ -386,7 +389,8 @@ describe('useHarnessSelection', () => {
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
});
|
||||
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
||||
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
||||
expect('projection' in value()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
|
||||
@@ -420,7 +424,8 @@ describe('useHarnessSelection', () => {
|
||||
});
|
||||
await flush();
|
||||
expect(value().canSend).toBe(true);
|
||||
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
||||
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
||||
expect('projection' in value()).toBe(false);
|
||||
});
|
||||
|
||||
it('never requests any /api/providers* endpoint across the whole flow', async () => {
|
||||
|
||||
@@ -42,10 +42,6 @@ export interface HarnessSelectionValue {
|
||||
* 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;
|
||||
/** The compatibility `{provider, modelId}` projection for the legacy socket
|
||||
* send path — derived ONLY from the validated persisted tuple, never from any
|
||||
* free-text or unpersisted draft. Empty when nothing is sendable. */
|
||||
projection: { provider?: string; modelId?: string };
|
||||
}
|
||||
|
||||
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
||||
@@ -193,9 +189,6 @@ export function useHarnessSelection(): HarnessSelectionValue {
|
||||
!catalogUnavailable &&
|
||||
tuplesEqual(draft, persistedSelection) &&
|
||||
isAvailableInCatalog(persistedSelection, catalog);
|
||||
const projection: { provider?: string; modelId?: string } = canSend
|
||||
? { provider: persistedSelection.providerId, modelId: persistedSelection.modelId }
|
||||
: {};
|
||||
|
||||
return {
|
||||
harnesses,
|
||||
@@ -211,6 +204,5 @@ export function useHarnessSelection(): HarnessSelectionValue {
|
||||
selectHarness,
|
||||
selectProvider,
|
||||
selectModel,
|
||||
projection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,6 +108,58 @@ async function flushAsync(times = 5): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic idempotency key for the Task Five red-first page send test. */
|
||||
const PAGE_UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
|
||||
/** Install a controllable `crypto.randomUUID` and return a restore fn. Uses
|
||||
* defineProperty on the crypto instance so it works whether or not the native
|
||||
* method is configurable (it lives on the prototype; an own property shadows it). */
|
||||
function installRandomUUID(fn: () => string): () => void {
|
||||
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
||||
if (!g.crypto) {
|
||||
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
|
||||
}
|
||||
const cryptoObj = g.crypto as { randomUUID?: () => string };
|
||||
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
|
||||
Object.defineProperty(cryptoObj, 'randomUUID', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: fn,
|
||||
});
|
||||
return () => {
|
||||
if (original) {
|
||||
Object.defineProperty(cryptoObj, 'randomUUID', original);
|
||||
} else {
|
||||
Reflect.deleteProperty(cryptoObj, 'randomUUID');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five MAJOR-1: the send path is PROTOCOL-driven — the browser may send only
|
||||
* as the server advertised, once per connection, over the server-to-client-only
|
||||
* `chat:send-capability`. Model that advertisement for THIS connection id so the
|
||||
* page send tests take the intended branch. `legacy-message` is the default
|
||||
* (advertised in `beforeEach`/`remountWithFetch`); the pi turn-runtime tests
|
||||
* reset the generation and re-advertise `turn-send` via the helper below.
|
||||
*/
|
||||
function advertiseSendCapability(protocol: 'legacy-message' | 'turn-send' | 'unavailable'): void {
|
||||
fake.serverEmit('chat:send-capability', { protocol, connectionId: fake.socket.id });
|
||||
}
|
||||
|
||||
/** Reset the negotiated protocol to a fresh, unlocked generation (clearing the
|
||||
* default `legacy-message` advertisement + first-wins lock), then advertise the
|
||||
* pi turn-runtime `turn:send` protocol for this connection. The per-test override
|
||||
* for the page send tests that route through `turn:send`. */
|
||||
async function advertiseTurnSendGeneration(): Promise<void> {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseSendCapability('turn-send');
|
||||
});
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
@@ -137,6 +189,12 @@ beforeEach(async () => {
|
||||
// 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 () => {
|
||||
@@ -159,6 +217,11 @@ async function remountWithFetch(fetchImpl: typeof fetch): Promise<void> {
|
||||
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', () => {
|
||||
@@ -571,6 +634,156 @@ 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));
|
||||
|
||||
|
||||
+246
@@ -1,5 +1,14 @@
|
||||
# PRD: Mosaic Stack v0.1.0
|
||||
|
||||
## Current addendum: #1194 — Installed framework-tool drift detection
|
||||
|
||||
- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash.
|
||||
- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files.
|
||||
- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable.
|
||||
- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling.
|
||||
- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path.
|
||||
- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists.
|
||||
|
||||
## Metadata
|
||||
|
||||
- **Owner:** Jason Woltje
|
||||
@@ -102,6 +111,128 @@ Context compaction, session replacement, and same-PID runtime reloads can leave
|
||||
|
||||
---
|
||||
|
||||
## Pi Persistent Goal Loop (#1150)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is
|
||||
not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an
|
||||
optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native
|
||||
lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is
|
||||
reached.
|
||||
|
||||
The objective is a Mosaic-owned Pi extension deployed from the framework into
|
||||
`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`.
|
||||
|
||||
### Scope
|
||||
|
||||
#### In scope
|
||||
|
||||
1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under
|
||||
`packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make
|
||||
`mosaic pi` load it alongside the core Mosaic extension when present.
|
||||
2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help
|
||||
operations without silently replacing an active goal.
|
||||
3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries,
|
||||
restored on session start and tree navigation, and never rely on a compaction summary as its
|
||||
source of truth.
|
||||
4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every
|
||||
model request so it remains effective across tool turns, retries, and post-compaction requests.
|
||||
5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event.
|
||||
A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`,
|
||||
or `blocked` status without requiring a redundant model turn.
|
||||
6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive
|
||||
evidence-bearing verification report. Any continuation report or successful compaction during
|
||||
verification SHALL reset the verification sequence.
|
||||
7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily
|
||||
`agent_settled`; manual compaction and restored active sessions may schedule a deferred idle
|
||||
continuation without re-entering compaction handlers.
|
||||
8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress
|
||||
limits. Exhausted or blocked goals pause rather than continuing indefinitely.
|
||||
9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the
|
||||
goal extension is framework-owned under `runtime/**`, while no goal extension or configuration
|
||||
asset is created or modified under the operator's main Pi configuration. Pi remains the owner of
|
||||
its native session files used by `appendEntry()`.
|
||||
|
||||
#### Out of scope
|
||||
|
||||
1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete.
|
||||
2. Automatically executing user-supplied shell predicates or accepting executable validation code in
|
||||
`/goal` arguments.
|
||||
3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime
|
||||
supervisor owns process durability.
|
||||
4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice.
|
||||
|
||||
### User and stakeholder requirements
|
||||
|
||||
- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report.
|
||||
- The agent remains oriented after each turn and compaction until verified, paused, blocked,
|
||||
exhausted, or cancelled.
|
||||
- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an
|
||||
extension asset to `~/.pi/agent/extensions/`.
|
||||
- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest
|
||||
sync path.
|
||||
|
||||
### Non-functional requirements
|
||||
|
||||
1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no
|
||||
completion without non-empty reported evidence.
|
||||
2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe
|
||||
context injection, and stale-timer cancellation on session shutdown.
|
||||
3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the
|
||||
active agent's final terminating tool call.
|
||||
4. **Observability:** Pi status/notifications expose phase and bounded counters without recording
|
||||
credentials or hidden model reasoning.
|
||||
5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi
|
||||
provider/network access.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-PGL-01`: A framework-sync fixture installs the extension at
|
||||
`$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are
|
||||
emitted in deterministic order while absent optional files remain backward-compatible.
|
||||
2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement
|
||||
refusal, and bounded input handling.
|
||||
3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every
|
||||
request, two evidence-bearing achievement reports are required, and `agent_settled` continues an
|
||||
unmet goal without duplicate scheduling.
|
||||
4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and
|
||||
rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session
|
||||
branch state is reconstructed correctly.
|
||||
5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous
|
||||
continuation, while pause/cancel/blocked states do not restart.
|
||||
6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load
|
||||
smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before
|
||||
issue #1150 closes.
|
||||
|
||||
### Constraints, risks, and assumptions
|
||||
|
||||
- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`,
|
||||
`context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating
|
||||
tool results.
|
||||
- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory
|
||||
second verification pass, explicit semantic limitations, and operator-visible reports.
|
||||
- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds
|
||||
and paused terminal states.
|
||||
- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from
|
||||
`agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown.
|
||||
- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy;
|
||||
rationale: it provides a real recheck without doubling every turn's model cost. Future policy may
|
||||
add independent or deterministic validators.
|
||||
- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by
|
||||
bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget.
|
||||
- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication
|
||||
is requested.
|
||||
|
||||
### Testing and delivery intent
|
||||
|
||||
Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle
|
||||
objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests,
|
||||
launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one
|
||||
reviewed squash PR to `main` with terminal-green CI.
|
||||
|
||||
---
|
||||
|
||||
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
||||
|
||||
### Problem and objective
|
||||
@@ -146,6 +277,68 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
|
||||
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
||||
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||
|
||||
### Fleet git identity launch propagation (#1043)
|
||||
|
||||
#### Problem and objective
|
||||
|
||||
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
|
||||
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
|
||||
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
|
||||
estates where that refusal is not active. The objective is to make Git identity a deterministic,
|
||||
roster-derived part of the generated launch projection and prove it reaches the launched process.
|
||||
|
||||
#### Normative requirements
|
||||
|
||||
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
|
||||
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
|
||||
tmux launch.
|
||||
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
|
||||
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
|
||||
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
|
||||
against the launched process environment. Source-text/string-presence assertions are insufficient.
|
||||
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
|
||||
removes Git-identity pane propagation and makes the behavioral test fail.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
|
||||
environment projection, including the roster-derived Git identity.
|
||||
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
|
||||
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
|
||||
independent review, and the required RED/green/R7 evidence are recorded before push.
|
||||
|
||||
### Framework shell assertion portability (#1098)
|
||||
|
||||
#### Problem and objective
|
||||
|
||||
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
|
||||
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
|
||||
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
|
||||
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
|
||||
make failures carry the observed records needed for diagnosis.
|
||||
|
||||
#### Normative requirements
|
||||
|
||||
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
|
||||
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
|
||||
can override a successful match.
|
||||
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
|
||||
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
|
||||
utility implementation.
|
||||
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
|
||||
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
|
||||
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
|
||||
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
|
||||
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
|
||||
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
|
||||
emit the indexed observed argv.
|
||||
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
|
||||
Woodpecker pipeline is terminal green before #1098 closes.
|
||||
|
||||
---
|
||||
|
||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||
@@ -1345,6 +1538,59 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
||||
|
||||
---
|
||||
|
||||
## Workspace placement guard hardening (#1174)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
The Bash pre-tool guard must prevent Git checkouts and repository state from being placed under
|
||||
`$HOME` without refusing ordinary Git commands merely because a source, option value, branch name,
|
||||
or metadata mentions `$HOME`. A guard that over-blocks routine work is unsafe because operators
|
||||
will route around it.
|
||||
|
||||
### Scope and requirements
|
||||
|
||||
1. `WPG-REQ-01`: `git clone` and `git worktree add` placement SHALL be judged from their placement
|
||||
operands, not from every HOME-shaped word in the command.
|
||||
2. `WPG-REQ-02`: Clone sources, references, templates, environment assignments, and non-placement
|
||||
worktree metadata MAY resolve under HOME when all placement operands resolve elsewhere.
|
||||
3. `WPG-REQ-03`: Both attached and separate-value `--separate-git-dir` forms SHALL remain placement
|
||||
operands and SHALL be refused when they resolve under HOME.
|
||||
4. `WPG-REQ-04`: Option classification SHALL account for Git's rule-generated boolean negations
|
||||
without relying on an enumerable allowlist of flag spellings.
|
||||
5. `WPG-REQ-05`: Quote removal, escapes, shell command boundaries, redirections, and end-of-options
|
||||
handling SHALL preserve existing fail-closed checkout coverage.
|
||||
6. `WPG-REQ-06`: Absolute placement aliases SHALL resolve shell-known HOME spellings, dot segments,
|
||||
repeated separators, and existing symlink parents before the HOME boundary comparison.
|
||||
7. Relative targets whose effective path depends on the shell cwd are out of scope and tracked by
|
||||
#1197.
|
||||
|
||||
### Acceptance and verification
|
||||
|
||||
1. Git's own option parser accepts each tested flag, including generated `--no-*` forms, while the
|
||||
guard allows a HOME-valued source with an explicit safe destination.
|
||||
2. Equivalent clone and worktree fixtures cover rule-generated negations and remain discriminating
|
||||
against the prior head where the defect existed.
|
||||
3. Real HOME destinations and both `--separate-git-dir` forms remain blocked, including placements
|
||||
after shell command boundaries.
|
||||
4. The full hermetic guard suite, syntax/static checks, adversarial probes, independent review, and
|
||||
terminal-green CI pass before merge.
|
||||
5. Any option-classification residual is documented with its deliberate failure direction.
|
||||
|
||||
### Constraints, risks, and assumptions
|
||||
|
||||
- Security and usability are co-equal: neither a placement bypass nor routine over-block is an
|
||||
acceptable repair.
|
||||
- `ASSUMPTION:` The value-taking option surface exposed by the installed Git version is closed and
|
||||
measurable through Git's own parser/help output; rationale: boolean flags are rule-generated,
|
||||
while separate-value options have explicit grammar and must be classified as such.
|
||||
- Risk: a future Git release may add a new value-taking placement option. Mitigation: document the
|
||||
chosen residual direction and pin every currently supported placement option in behavior tests.
|
||||
- Risk: a symlink can be replaced after pre-execution canonicalization. Mitigation: resolve every
|
||||
existing parent physically and document the remaining inherent TOCTOU window; the worktree helper
|
||||
remains the authoritative path-derivation mechanism, with atomic closure tracked by #1199.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. RESOLVED: **pgvector is sufficient** for semantic search at v0.1.0 scale (personal/family/team = thousands to low hundreds-of-thousands of vectors). `@mosaicstack/memory` defines a `VectorStore` interface with pgvector as the default adapter. The interface boundary makes Qdrant a drop-in migration if PG resource contention or scale demands it later. Zero additional infrastructure for v0.1.0. Rationale: Reduces ops burden; pgvector HNSW indexes are fast at this scale; interface abstraction costs almost nothing now.
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
||||
|
||||
## Pi persistent goals
|
||||
|
||||
- [Persistent goal user guide](guides/user-guide.md#pi-persistent-goals) — `/goal` commands, verification behavior, limits, compaction/resume semantics, and limitations.
|
||||
- [Goal extension developer guide](guides/dev-guide.md#pi-persistent-goal-extension) — framework ownership, launcher ordering, lifecycle design, tests, and local Mosaic-path smoke workflow.
|
||||
- [Goal loop operations](guides/admin-guide.md#pi-goal-loop-operations) — deployment ownership, bounded settings, pause/resume procedures, and supervisor boundary.
|
||||
- [Pi runtime reference](../packages/mosaic/framework/runtime/pi/RUNTIME.md#extensions) — deployed paths, command summary, and bounded environment settings.
|
||||
|
||||
## Fleet configuration management
|
||||
|
||||
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
||||
|
||||
+13
-10
@@ -5,14 +5,14 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
||||
|
||||
## Launch chain
|
||||
|
||||
| Layer | Responsibility |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||
| Layer | Responsibility |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
|
||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||
|
||||
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
||||
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
||||
@@ -24,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
MOSAIC_GIT_IDENTITY=<roster name>
|
||||
MOSAIC_AGENT_CLASS=<roster class>
|
||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||
@@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
||||
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
||||
split runtime and repository identity authority. The generated launch contract supports `claude`,
|
||||
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
|
||||
modifies generated, local, or quarantine state.
|
||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
||||
it has no generated-launch adapter and cannot be added through this path.
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
The launcher consumes validated data, not shell configuration.
|
||||
|
||||
1. Read and validate the canonical roster.
|
||||
2. Render deterministic <name>.env.generated data from that roster.
|
||||
2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
||||
3. Parse optional <name>.env.local through a strict allowlist.
|
||||
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
||||
5. Derive the runtime command from validated runtime/model/reasoning data.
|
||||
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||
5. Reject a Git identity that is unsafe or differs from the generated agent name.
|
||||
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
|
||||
7. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||
|
||||
## File precedence and ownership
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ values, credential material, or command text.
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
MOSAIC_GIT_IDENTITY=<roster name>
|
||||
MOSAIC_AGENT_CLASS=<roster class>
|
||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||
@@ -44,8 +45,9 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
||||
operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
|
||||
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
||||
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||
this projection path.
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
3. [Provider Configuration](#provider-configuration)
|
||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||
5. [Environment Variables Reference](#environment-variables-reference)
|
||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
6. [Pi Goal Loop Operations](#pi-goal-loop-operations)
|
||||
7. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -264,6 +265,16 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
||||
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
||||
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
||||
|
||||
### Mosaic Pi goal loop
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------------------- | ------- | -------------------------------------------------------------------- |
|
||||
| `MOSAIC_GOAL_MAX_TURNS` | `40` | Per-goal autonomous turn limit; accepted range `1..500` |
|
||||
| `MOSAIC_GOAL_MAX_NO_PROGRESS` | `6` | Consecutive identical progress-report limit; accepted range `1..100` |
|
||||
|
||||
These variables are consumed by the framework-owned Pi goal extension at goal creation. Invalid or
|
||||
out-of-range values fall back to the defaults; they do not disable the bounds.
|
||||
|
||||
### Providers
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -374,3 +385,29 @@ Session cleanup is scoped to one session identifier and only removes that sessio
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
||||
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
||||
|
||||
---
|
||||
|
||||
## Pi Goal Loop Operations
|
||||
|
||||
The reviewed runtime asset is deployed at
|
||||
`~/.config/mosaic/runtime/pi/goal-extension.ts` by framework install/update. Do not install another
|
||||
copy under `~/.pi/agent/extensions/`; duplicate registration can create suffixed commands and two
|
||||
competing lifecycle controllers.
|
||||
|
||||
Operational checks:
|
||||
|
||||
1. Run `mosaic pi` and verify `/goal help` is available.
|
||||
2. Use `/goal status` to inspect phase, turn/no-progress limits, compaction checks, and evidence.
|
||||
Reports persist in Pi session data; controller-owned state redacts common credential shapes, but
|
||||
Pi's model/tool-call history is separate. Operators must not place secrets or raw sensitive output
|
||||
in goals, pause reasons, or evidence.
|
||||
3. Use `/goal pause <reason>` before planned maintenance or manual investigation. Pause and cancel
|
||||
abort the current goal-driven run when Pi is busy.
|
||||
4. Use `/goal resume` only after addressing a blocker; counters restart with the configured bounds.
|
||||
5. Use `/goal cancel` before replacing an unfinished goal.
|
||||
|
||||
A blocked or exhausted goal remains stopped and visible; Mosaic does not automatically raise its
|
||||
limits or restart the process. Framework sync owns file deployment, while Pi's native session file
|
||||
owns branch replay. Process/host restart remains the responsibility of the existing runtime or fleet
|
||||
supervisor.
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||
8. [API Endpoint Reference](#api-endpoint-reference)
|
||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
|
||||
9. [API Endpoint Reference](#api-endpoint-reference)
|
||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -396,6 +397,85 @@ M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
|
||||
canonical root directly. Codex still relies on the existing full skill-sync
|
||||
linker and needs separate parity analysis before this lifecycle API is extended.
|
||||
|
||||
## Pi Persistent Goal Extension
|
||||
|
||||
The source of the Mosaic-owned Pi goal controller is:
|
||||
|
||||
```text
|
||||
packages/mosaic/framework/runtime/pi/goal-extension.ts
|
||||
```
|
||||
|
||||
The framework manifest classifies `runtime/**` as framework-owned. Both the bash installer and the
|
||||
TypeScript file adapter therefore deploy the same reviewed source to:
|
||||
|
||||
```text
|
||||
$MOSAIC_HOME/runtime/pi/goal-extension.ts
|
||||
# default: ~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||
```
|
||||
|
||||
Do not copy or link this extension into `~/.pi/agent/extensions/`. The launcher function
|
||||
`discoverPiExtensionArgs()` emits the core `mosaic-extension.ts` first and the optional
|
||||
`goal-extension.ts` second, preserving compatibility with an older installed framework that does
|
||||
not have the goal file yet.
|
||||
|
||||
### Lifecycle design
|
||||
|
||||
| Pi API | Goal-controller responsibility |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `registerCommand('goal')` | Set, inspect, pause, resume, or cancel one branch-specific goal |
|
||||
| `registerTool(...)` | Record a terminating structured progress report with evidence |
|
||||
| `context` | Inject the active goal contract before every provider request |
|
||||
| `turn_end` | Record every turn, reject mixed final reports, and enforce the turn bound |
|
||||
| `agent_settled` | Start one deduplicated continuation only after Pi has no retry/compact/queue work |
|
||||
| `session_compact` | Record the compact check, reset provisional verification, and defer idle work |
|
||||
| `session_start`/`session_tree` | Rebuild state from custom entries on the active branch |
|
||||
| `session_shutdown` | Invalidate deferred callbacks and clear UI state |
|
||||
|
||||
State is appended as `mosaic-goal-state` custom entries, which do not enter model context. The
|
||||
`context` hook creates a fresh hidden `mosaic-goal-context` message for each request instead of
|
||||
trusting compaction summaries. The `mosaic_goal_report` result uses `terminate: true`; when it is the
|
||||
sole final tool call, Pi avoids an unnecessary model response before the controller decides whether
|
||||
to verify, continue, or stop.
|
||||
|
||||
Before state is appended or displayed, the controller applies bounded credential-pattern redaction
|
||||
to the goal statement, report summary/evidence/next step, and stop reason. Fingerprints are computed
|
||||
over redacted report content. Pi session entries are append-only, so a credential-bearing legacy
|
||||
entry cannot honestly be erased by the extension: restoration fails closed, emits a warning, and
|
||||
requires removal of the affected session before setting a new goal. This is defense-in-depth rather
|
||||
than a secret-storage contract, and it does not rewrite Pi's separate model-message/tool-call
|
||||
history. Goal prompts tell the agent not to submit credentials or raw sensitive output, and tests use
|
||||
canaries to prove known forms do not reach new custom entries, status text, context, or tool details
|
||||
while ordinary typed fields such as `token: string` remain intact.
|
||||
|
||||
Completion remains evidence-gated but semantic: two consecutive `achieved` reports are required,
|
||||
and the second run is explicitly a verification pass. This avoids an extra judge-model request after
|
||||
every turn. Deterministic validator commands are intentionally not accepted as `/goal` input in this
|
||||
slice, so never describe this mechanism as proof of arbitrary natural-language completion.
|
||||
|
||||
### Tests and local smoke workflow
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/mosaic exec vitest run \
|
||||
src/runtime/pi-goal-extension.spec.ts \
|
||||
src/commands/launch.spec.ts \
|
||||
src/config/file-adapter.test.ts
|
||||
|
||||
bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||
```
|
||||
|
||||
For an additive local smoke test without reseeding unrelated live framework files:
|
||||
|
||||
```bash
|
||||
install -D -m 0644 \
|
||||
packages/mosaic/framework/runtime/pi/goal-extension.ts \
|
||||
~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||
|
||||
pi --extension ~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||
```
|
||||
|
||||
Use `/goal help`, `/goal set ...`, and `/goal status` in that test session. A released framework
|
||||
sync installs the file, and a released Mosaic CLI loads it automatically through `mosaic pi`.
|
||||
|
||||
## API Endpoint Reference
|
||||
|
||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
4. [Tasks](#tasks)
|
||||
5. [Settings](#settings)
|
||||
6. [CLI Usage](#cli-usage)
|
||||
7. [Sub-package Commands](#sub-package-commands)
|
||||
8. [Telemetry](#telemetry)
|
||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
7. [Pi Persistent Goals](#pi-persistent-goals)
|
||||
8. [Sub-package Commands](#sub-package-commands)
|
||||
9. [Telemetry](#telemetry)
|
||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -317,6 +318,57 @@ mosaic prdy
|
||||
mosaic quality-rails
|
||||
```
|
||||
|
||||
## Pi Persistent Goals
|
||||
|
||||
`mosaic pi` loads a Mosaic-owned goal extension from
|
||||
`~/.config/mosaic/runtime/pi/goal-extension.ts`. It is deliberately not installed in
|
||||
`~/.pi/agent/extensions/`; framework installation and updates manage it with the rest of the Mosaic
|
||||
runtime assets.
|
||||
|
||||
Start Pi, then set a goal:
|
||||
|
||||
```text
|
||||
/goal set Deliver the feature, tests, documentation, and verification evidence
|
||||
# Shorthand:
|
||||
/goal Deliver the feature, tests, documentation, and verification evidence
|
||||
```
|
||||
|
||||
Control and inspect the loop with:
|
||||
|
||||
| Command | Behavior |
|
||||
| ---------------------- | ------------------------------------------------------------------ |
|
||||
| `/goal status` | Show phase, limits, compaction checks, latest report, and evidence |
|
||||
| `/goal pause [reason]` | Stop autonomous continuation while preserving the goal |
|
||||
| `/goal resume` | Resume with fresh turn and no-progress counters |
|
||||
| `/goal cancel` | Cancel the goal and remove its active status |
|
||||
| `/goal help` | Show command help |
|
||||
|
||||
While a goal is active, Mosaic injects its contract before every Pi model request and checks every
|
||||
completed model/tool turn. The agent ends each work cycle with the structured
|
||||
`mosaic_goal_report` tool. `achieved` is provisional until a second consecutive report rechecks the
|
||||
whole goal with evidence. A continuation report or a successful compaction resets provisional
|
||||
verification.
|
||||
|
||||
Goal statements and reports are stored in Pi session data. Mosaic redacts common credential shapes
|
||||
before appending its goal-state entries and before goal tool output or `/goal status`, but
|
||||
pattern-based redaction is not a secret store. Pi's own model-message and tool-call records are
|
||||
outside that redactor. Never put tokens, passwords, private keys, connection strings, or raw
|
||||
sensitive output in a goal or report; cite the command, artifact, and pass/fail result instead.
|
||||
|
||||
The loop stops instead of running forever when it is paused, blocked, cancelled, verified, reaches
|
||||
its turn limit, or repeats the same no-progress report too many times. Defaults are 40 turns and 6
|
||||
repeated no-progress reports. Operators may lower or raise them within enforced bounds before
|
||||
launching Pi:
|
||||
|
||||
```bash
|
||||
MOSAIC_GOAL_MAX_TURNS=60 MOSAIC_GOAL_MAX_NO_PROGRESS=8 mosaic pi
|
||||
```
|
||||
|
||||
Goal state is branch-specific Pi session data. It survives compaction and session resume, but Pi's
|
||||
process still must be relaunched or supervised after a process/host failure. This initial verifier
|
||||
checks structured evidence twice; it cannot mathematically prove every arbitrary natural-language
|
||||
goal. Use explicit acceptance criteria and inspect `/goal status` for consequential work.
|
||||
|
||||
---
|
||||
|
||||
### Claude Code Skill Registration
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# #1099 pipefail + early-exit sweep
|
||||
|
||||
Baseline: `df4c591ab42aa1ae62c12935fdc0e772684864a0`
|
||||
|
||||
This is a site inventory, not a risk count. `FIXED` means the early-exiting consumer no longer has a piped upstream process whose SIGPIPE can become the result under `pipefail`. `NOT-LOAD-BEARING` means the pipeline status is explicitly discarded. `UNREACHABLE-AND-WHY` describes designed input, not a payload-size safety claim.
|
||||
|
||||
## Tranche 1 — runtime and general scripts
|
||||
|
||||
| Baseline site | Verdict | Construction / reason |
|
||||
| --- | --- | --- |
|
||||
| `tools/matrix-presence-harness/run.sh:38` | FIXED | nullglob array selects the first path; no pipeline |
|
||||
| `tools/e2e-install-test.sh:139` | FIXED | capture help completely, then grep via redirection |
|
||||
| `tools/install.sh:312` | FIXED | NUL `mapfile` reads all roots; count != 1 reaches the named malformed-archive diagnostic |
|
||||
| `scripts/analysis/reflect-board-history.sh:76` | FIXED | capture Git history completely, then grep via redirection |
|
||||
| `scripts/analysis/reflect-git-history.sh:67` | FIXED | grep reads from a here-string |
|
||||
| `scripts/analysis/reflect-git-history.sh:69` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/authentik/user-create.sh:72` | FIXED | jq `first(...)` reads the response directly |
|
||||
| `packages/mosaic/framework/tools/git/mutate-push-guard.sh:87` | FIXED | grep `-m1` reads the file directly; downstream `cut` consumes its complete scalar output |
|
||||
| `packages/mosaic/framework/tools/orchestrator/session-resume.sh:94` | FIXED | `mapfile` plus bounded indexed loop replaces `head` pipeline |
|
||||
| `packages/mosaic/framework/tools/prdy/prdy-status.sh:69` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:172` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:173` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:174` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:175` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:176` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:177` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:178` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:16` | FIXED | Bash regex extracts the first field without a pipeline |
|
||||
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:56` | FIXED | grep and bounded sed each read from a here-string |
|
||||
| `packages/mosaic/framework/tools/tmux/send-message.sh:113` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/tmux/send-message.sh:124` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/wake/detector.sh:126` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
||||
| `packages/mosaic/framework/tools/wake/detector.sh:270` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/wake/detector.sh:278` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/wake/digest.sh:647` | FIXED | capture complete locator output, then select first line by parameter expansion |
|
||||
| `packages/mosaic/framework/tools/wake/reconcile.sh:149` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
||||
|
||||
## Explicit withdrawn / non-load-bearing sites
|
||||
|
||||
| Baseline site | Verdict | Reason |
|
||||
| --- | --- | --- |
|
||||
| `tools/install.sh:182` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
|
||||
| `tools/install.sh:356` | UNREACHABLE-AND-WHY | `pnpm pack` writes one matching CLI tarball into a fresh directory immediately before lookup; citation withdrawn in #1099 |
|
||||
| `tools/install.sh:357` | UNREACHABLE-AND-WHY | same fresh-directory invariant for gateway tarball; citation withdrawn in #1099 |
|
||||
| `tools/install.sh:627` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
|
||||
| `scripts/agent/session-start.sh:70` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
|
||||
| `packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh:58` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:25` | UNREACHABLE-AND-WHY | withdrawn in #1099 after designed-input reachability measurement; preserved without re-litigation |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:27` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:30` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:32` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:34` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
|
||||
## Tranche 2 — non-wake test harnesses
|
||||
|
||||
All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passed through the same scanner and asserts all 22 occurrences and 21 normalized identities (the same response-split line occurs twice).
|
||||
|
||||
| Baseline site(s) | Verdict | Construction |
|
||||
| --- | --- | --- |
|
||||
| `systemd/user/test-fleet-units.sh:148` | FIXED | capture tmux output, then grep via redirection |
|
||||
| `git/test-issue-comment-readback.sh:283,302` | FIXED | parameter expansion splits status/body without `head` |
|
||||
| `git/test-pr-review-gitea-comment.sh:228` | FIXED | parameter expansion splits status/body |
|
||||
| `git/test-lane-brief-pr-linkage.sh:72` | FIXED | grep reads from a here-string |
|
||||
| `git/test-pr-review-repo-host-override.sh:225-226` | FIXED | grep reads from a here-string |
|
||||
| `orchestrator/smoke-test.sh:67,72` | FIXED | parameter expansion selects first line |
|
||||
| `orchestrator/test-board-roll.sh:99-100` | FIXED | grep reads from a here-string |
|
||||
| `quality/scripts/test-upgrade-durable-snapshot.sh:180` | FIXED | complete sorted output is read with `mapfile`, then indexed |
|
||||
| `quality/scripts/test-upgrade-rollback.sh:339,356` | FIXED | direct `grep -m1` file reads; cleanup captures before testing |
|
||||
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
|
||||
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
|
||||
|
||||
## Tranche 3 — wake validation harnesses
|
||||
|
||||
All 26 baseline occurrences (25 normalized identities; one preimage selector occurs twice) are `FIXED` and mechanically bound through the wake fixture and shared scanner.
|
||||
|
||||
| Baseline site(s) | Verdict | Construction |
|
||||
| --- | --- | --- |
|
||||
| `wake/test-wake-digest-quarantine.sh:567` | FIXED | complete match populations are captured, then first line selected by parameter expansion |
|
||||
| `wake/test-wake-preimage.sh:182-183,346-347` | FIXED | jq `first(...)` reads each JSONL file directly |
|
||||
| `wake/validate-973/microtest-wake-assert.sh:153,170-171,176,204-209,233-234,251-252,286-287` | FIXED | scalar assertions use here-strings; diagnostics use non-early sed ranges; source line captured before matching |
|
||||
| `wake/validate-973/validate-973.sh:110,119,180,182,187` | FIXED | scalar assertions use here-strings; diagnostic truncation uses consuming sed ranges |
|
||||
|
||||
The scoped inventory is complete: 26 runtime/general + 22 non-wake tests + 26 wake tests fixed; 11 explicitly withdrawn or non-load-bearing sites retain their documented verdicts.
|
||||
@@ -0,0 +1,229 @@
|
||||
# #1043 — Fleet pane git-identity propagation
|
||||
|
||||
## Objective
|
||||
|
||||
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
|
||||
|
||||
## Tracking
|
||||
|
||||
- External issue: `mosaicstack/stack#1043`
|
||||
- Branch: `fix/1043-pane-git-identity`
|
||||
- Coordinator: `tl-mosaic`
|
||||
- `docs/TASKS.md`: read-only by project worker contract; not modified.
|
||||
|
||||
## Constraints
|
||||
|
||||
- RED-first bug reproducer is mandatory.
|
||||
- R7 delete-the-subject mutation must turn the behavioral test red.
|
||||
- Assert launched-process environment, not source text.
|
||||
- One push only; do not poll CI after push.
|
||||
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
|
||||
- Do not modify a live host launcher or obtain/copy another credential.
|
||||
- Self-post the PR, verify provider attribution, then stop.
|
||||
- Final status wording: `believed-fixed, pending jarvis validation`.
|
||||
|
||||
## Scope inventory
|
||||
|
||||
Re-derived against `origin/main` at `85d2108e`:
|
||||
|
||||
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
||||
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
||||
- Roster projection producers:
|
||||
- `packages/mosaic/src/commands/fleet.ts`
|
||||
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
|
||||
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
|
||||
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
|
||||
- Contract and producer tests discovered by repository search.
|
||||
- Generated-environment operator/developer docs and their executable documentation contract test.
|
||||
|
||||
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add the process-environment set-comparison regression first and record RED.
|
||||
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
|
||||
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
|
||||
4. Update affected projection tests and generated-environment docs.
|
||||
5. Run focused and baseline gates.
|
||||
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
|
||||
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
|
||||
|
||||
## Evidence log
|
||||
|
||||
### TDD and mutation evidence
|
||||
|
||||
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
|
||||
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
|
||||
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||
- R7 restoration: restored that launch entry; the same test returned green.
|
||||
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
|
||||
|
||||
### Situational and focused tests
|
||||
|
||||
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
|
||||
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
|
||||
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
|
||||
- `bash -n` on changed shell files — green.
|
||||
- `git diff --check` — green.
|
||||
|
||||
### Baseline gates
|
||||
|
||||
- `pnpm typecheck` — 45/45 tasks green.
|
||||
- `pnpm lint` — 25/25 tasks green.
|
||||
- `pnpm format:check` — green.
|
||||
- `pnpm test:checkout` — green.
|
||||
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
|
||||
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
|
||||
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
|
||||
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
|
||||
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
|
||||
|
||||
### Independent review
|
||||
|
||||
- Codex code review first pass: request changes for missing shell rejection-path coverage.
|
||||
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
|
||||
- Codex code re-review: **approve**, no findings, confidence 0.88.
|
||||
- Codex security review: risk `none`, no findings, confidence 0.97.
|
||||
|
||||
### Acceptance criteria mapping
|
||||
|
||||
| Acceptance criterion | Evidence |
|
||||
| --- | --- |
|
||||
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
|
||||
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
|
||||
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
|
||||
|
||||
### Documentation checklist
|
||||
|
||||
- PRD updated with #1043 requirements and acceptance criteria.
|
||||
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
|
||||
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
|
||||
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
|
||||
|
||||
## Round 2 — PR #1073 review 97 remediation
|
||||
|
||||
### Review blocker
|
||||
|
||||
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
|
||||
|
||||
### RED-first and canonical wiring
|
||||
|
||||
1. Removed the suite's signed exclusion before adding a CI execution path.
|
||||
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
|
||||
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
|
||||
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
|
||||
|
||||
### Workflow-level R7
|
||||
|
||||
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
|
||||
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
|
||||
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
|
||||
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
|
||||
|
||||
### Workflow population
|
||||
|
||||
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
|
||||
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
|
||||
|
||||
### Independent remediation review
|
||||
|
||||
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
|
||||
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
|
||||
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
|
||||
|
||||
### Vitest denominator reconciliation
|
||||
|
||||
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
|
||||
|
||||
- generated environment boundary: 24
|
||||
- fleet documentation: 23
|
||||
- Tess service profile: 6
|
||||
- fleet regen command: 27
|
||||
- fleet agent CRUD command: 22
|
||||
- fleet command: 209
|
||||
- total: **311**
|
||||
|
||||
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
|
||||
|
||||
## Round 3 — Alpine stale-marker portability
|
||||
|
||||
### Objective and plan
|
||||
|
||||
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
|
||||
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
|
||||
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
|
||||
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
|
||||
|
||||
### RED-first evidence
|
||||
|
||||
Before the fix, the canonical CI image command
|
||||
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
exited 1 at the stale-marker setup with exact BusyBox output
|
||||
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
|
||||
|
||||
### Root cause and fix
|
||||
|
||||
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
|
||||
|
||||
### Structural pattern
|
||||
|
||||
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
|
||||
|
||||
### GREEN and quality evidence
|
||||
|
||||
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
|
||||
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
|
||||
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
|
||||
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
|
||||
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
|
||||
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
|
||||
|
||||
### Re-derived inventory and denominators
|
||||
|
||||
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
|
||||
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
|
||||
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
|
||||
|
||||
## Round 4 — bound stale-marker observation
|
||||
|
||||
### Objective and plan
|
||||
|
||||
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
|
||||
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
|
||||
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
|
||||
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
|
||||
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
|
||||
|
||||
### Root cause and bounded observation
|
||||
|
||||
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
|
||||
|
||||
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
|
||||
|
||||
### Two-mutant RED / restored GREEN
|
||||
|
||||
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
|
||||
|
||||
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
|
||||
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
|
||||
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
|
||||
|
||||
### Re-derived inventory
|
||||
|
||||
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
|
||||
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
|
||||
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
|
||||
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
|
||||
|
||||
## Residual risk
|
||||
|
||||
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
|
||||
- Canonical CI result is pending and will not be polled by this seat.
|
||||
@@ -0,0 +1,97 @@
|
||||
# #1098 — Framework shell portability / red main
|
||||
|
||||
## Objective
|
||||
|
||||
Restore terminal-green `main` by making the `test-start-agent-session.sh` clean-environment assertion semantic and portable without removing either newly enumerated framework-shell suite.
|
||||
|
||||
## Scope
|
||||
|
||||
- Tracking issue: `mosaicstack/stack#1098`
|
||||
- Branch: `fix/framework-shell-portability`
|
||||
- Base: `origin/main` at `4fa2768962702d53e16e8b67ee6ad52ebcb0910e`
|
||||
- Primary file: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
- Requirements source: `docs/PRD.md` § Framework shell assertion portability (#1098)
|
||||
- Out of scope: deployed files under `~/.config/mosaic`, pnpm-store cleanup, checkout deletion, and changes to the launcher’s `/usr/bin/env -i` behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. The test inspects the captured NUL-delimited tmux argv semantically and accepts an adjacent `/usr/bin/env`, `-i` pair regardless of trailing payload size or pipe scheduling.
|
||||
2. Missing `/usr/bin/env`, missing `-i`, and non-adjacent `-i` remain failures.
|
||||
3. Failure output includes the observed argv records with stable indexes and shell escaping; it exposes no credentials because this fixture supplies only generated non-secret launch data.
|
||||
4. The focused suite passes on the dev host and in the repository CI image; the blocking PR/main pipeline returns terminal green.
|
||||
5. Independent review passes; PR is squash-merged and #1098 is closed only after merged-main CI is terminal green.
|
||||
|
||||
## Budget
|
||||
|
||||
- ASSUMPTION: 30K-token working budget; rationale: one shell-test defect plus full PR/CI lifecycle.
|
||||
- Auto-reduction: focused shell and package gates first; rely on canonical Woodpecker for the full monorepo suite rather than duplicating a dependency install under constrained `/home`.
|
||||
- Disk baseline before clone/build: `/home` 7.1G free (99% used), `/tmp` 2.4G free (92% used).
|
||||
|
||||
## Investigation
|
||||
|
||||
### First-hand CI evidence
|
||||
|
||||
- Public log: `GET https://ci.mosaicstack.dev/api/repos/47/logs/2269/53041`
|
||||
- Decoded 1,436 entries (11 null `data` entries treated as empty log rows), 190,756 bytes.
|
||||
- Failure: `FAIL: pane command did not clear its environment` immediately after the expected pane-PID warning.
|
||||
- BusyBox primitives, complete assertion pipeline, real CI image, stale/current image digests, Turbo cache masking, gateway failure, and heartbeat-sidecar concurrent writing were independently excluded.
|
||||
|
||||
### Root cause
|
||||
|
||||
The assertion ends in:
|
||||
|
||||
```bash
|
||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i'
|
||||
```
|
||||
|
||||
The script has `set -o pipefail`. `grep -q` exits as soon as it finds the valid `-i` record. Upstream `tail`/`printf` can then receive SIGPIPE, making the aggregate pipeline nonzero even though grep returned 0 and the semantic property is true. This depends on payload size, pipe capacity, and scheduling, explaining a local/image pass with a CI failure.
|
||||
|
||||
Discriminating stress control with `/usr/bin/env` followed immediately by `-i`:
|
||||
|
||||
- 8,192-byte trailing payload: `printf=0 tail=0 grep=0`, aggregate 0.
|
||||
- 16,384-byte trailing payload: `printf=0 tail=141 grep=0`, aggregate 141.
|
||||
- 32,768+ bytes: `printf=141 tail=141 grep=0`, aggregate 141.
|
||||
- A full-reading `grep -xF` control remained 0 for every payload.
|
||||
|
||||
This is a third branch omitted by the earlier present-vs-corrupted split: the pair can be present and intact while `pipefail` reports an upstream SIGPIPE.
|
||||
|
||||
## TDD plan
|
||||
|
||||
1. RED: preserve the one-off stress reproducer above and add an automated large-argv semantic regression that fails under the current pipeline implementation.
|
||||
2. GREEN: parse the authoritative NUL-delimited capture into a Bash array and search for an adjacent `/usr/bin/env`, `-i` pair without a short-circuit pipeline.
|
||||
3. Add negative controls for missing, detached, and reversed tokens.
|
||||
4. On failure, print indexed `%q` argv records before returning nonzero.
|
||||
5. Run focused suite, mutation controls, shell syntax/format checks, then repository baseline gates feasible without dependency installation.
|
||||
6. Independent review, queue guard, push, PR, CI, coordinator merge authorization, squash merge, merged-main CI, issue close.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Checkout created and based on `origin/main` `4fa27689`.
|
||||
- [x] CI log decoded directly.
|
||||
- [x] Root-cause stress control reproduced semantic match + aggregate pipeline failure.
|
||||
- [x] RED evidence: intact `/usr/bin/env`, `-i` fixture produced component statuses `0/141/0` and aggregate 141 under the former `grep -q` pipeline; full-reading semantic control stayed 0.
|
||||
- [x] GREEN implementation: direct NUL-argv adjacency parser, indexed diagnostics, and full-reading scalar predicates replace all load-bearing early-exit pipelines in this test.
|
||||
- [x] Baseline/situational tests:
|
||||
- focused launcher suite: PASS on GNU host and cached Alpine CI image;
|
||||
- paired `test-fleet-units.sh`: PASS;
|
||||
- enumeration guard: PASS (`population=53`, `enumerated=36`, `excluded=18`), 14/14 mutation needles;
|
||||
- `bash -n`, ShellCheck, `git diff --check`: PASS;
|
||||
- static denominator after change: zero load-bearing `grep -q`/`head`/`-m1` pipeline candidates in `test-start-agent-session.sh`;
|
||||
- delete-the-subject mutation removing production `-i`: RED with 78 indexed argv records, byte count, and explicit boundary failure.
|
||||
- [x] Independent review:
|
||||
- first Codex review: request changes — negative fixtures did not each assert diagnostics;
|
||||
- remediation: centralized predicate + diagnostic wrapper and exercised all four negative fixtures;
|
||||
- second Codex review: APPROVE, 0 blockers/should-fix/suggestions;
|
||||
- Codex security review: risk none, 0 findings.
|
||||
- [ ] PR CI, formal fleet review, merge, merged-main CI, issue closure.
|
||||
|
||||
## Documentation disposition
|
||||
|
||||
- Updated canonical `docs/PRD.md` with FSP requirements and acceptance criteria.
|
||||
- This is an internal test/reliability change with no API, user workflow, deployment, navigation, or publishing-surface change; no user/admin/API/sitemap update is required.
|
||||
- `docs/TASKS.md` remains unchanged because the project contract makes it orchestrator-only.
|
||||
|
||||
## Risks
|
||||
|
||||
- The CI failure did not print its captured argv, so the exact CI payload is unavailable. The stress control proves the assertion is non-portable and can emit the exact false verdict; branch CI is the canonical confirmation that replacing it resolves pipeline 2269’s failure class.
|
||||
- Printing fixture argv is safe only while this test’s projection remains non-secret. The diagnostic must stay scoped to the test capture and shell-escaped.
|
||||
@@ -0,0 +1,41 @@
|
||||
# #1099 — pipefail + early-exit sweep
|
||||
|
||||
## Scope and decisions
|
||||
|
||||
- Baseline `df4c591ab42aa1ae62c12935fdc0e772684864a0`, after #1100 removed its 35 sites.
|
||||
- Split into review-sized non-closing tranches: runtime/general; tmux/git/quality tests; wake validation/tests.
|
||||
- Do not equate class membership with demonstrated risk. Do not use payload size or pipeline stage count as a safety proxy.
|
||||
- Preserve the issue's withdrawn findings for `qa-hook-stdin.sh` and the two fresh-directory `pnpm pack` lookups. Fix `install.sh:312` because malformed multi-root input must reach its named handler.
|
||||
|
||||
## Tranche 1 TDD
|
||||
|
||||
RED-first control: `node --test scripts/pipefail-early-exit.test.mjs` reported exactly 26 non-accepted runtime/general sites, including `install.sh:312`, and exited 1. A checked-in fixture generated from immutable baseline `df4c591a` records all 26 normalized sites; the control passes every fixture entry through the same scanner, asserts exact identity/count/uniqueness, and separately requires zero findings in the current tree. It also inventories accepted sites rather than silently excluding whole files.
|
||||
|
||||
Construction choices:
|
||||
|
||||
- here-string/file redirection for scalar grep assertions;
|
||||
- full capture then parameter expansion for first-line selection;
|
||||
- arrays/`mapfile` for complete populations;
|
||||
- direct jq/awk/grep selection where one tool can express the property;
|
||||
- no `|| true` added to a load-bearing assertion.
|
||||
|
||||
Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`.
|
||||
|
||||
## Tranche 2 TDD
|
||||
|
||||
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
|
||||
|
||||
## Tranche 3 TDD
|
||||
|
||||
Expanded the shared scanner over four wake validation harnesses. RED named 26 occurrences. The wake fixture asserts 26 occurrences / 25 normalized identities through the same scanner; all scalar assertions now use redirection, direct jq selection, complete capture, or consuming diagnostic ranges. Current-tree finding count is zero across the full scoped population.
|
||||
|
||||
## Verification so far
|
||||
|
||||
- `bash -n` on every changed shell script: pass.
|
||||
- structural Node control: pass.
|
||||
- `test-mutate-push-guard.sh`: 8/8 pass.
|
||||
- `test-send-message-verdict.sh`: 3/3 pass.
|
||||
- `test-send-message-socket.sh`: pass.
|
||||
- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded.
|
||||
- Wake static inventory remains aligned at 261/261 after line-neutral rewrites; no static-set mismatch. Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
|
||||
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
|
||||
@@ -0,0 +1,156 @@
|
||||
# #1150 — Pi persistent goal extension
|
||||
|
||||
- **Task ID:** ISSUE-1150 (no `docs/TASKS.md` row; that file is orchestrator-only)
|
||||
- **Issue:** #1150 — `pi: add persistent /goal controller extension to Mosaic framework`
|
||||
- **Branch:** `feat/1150-pi-goal-extension`
|
||||
- **Mode:** Delivery
|
||||
- **Status:** in progress
|
||||
|
||||
## Objective
|
||||
|
||||
Build and locally validate a Mosaic-owned Pi `/goal` extension. Source must ship from
|
||||
`packages/mosaic/framework/runtime/pi/`, framework sync must deploy it under
|
||||
`~/.config/mosaic/runtime/pi/`, and no extension/configuration asset may be written into `~/.pi`.
|
||||
Pi's native session manager remains the owner of session entries.
|
||||
|
||||
## Scope and acceptance source
|
||||
|
||||
- Canonical requirements: `docs/PRD.md`, section **Pi Persistent Goal Loop (#1150)**.
|
||||
- User intent: continuous goal orientation and status checking after each Pi turn and compaction,
|
||||
tested locally before framework delivery.
|
||||
- Documentation target: canonical in-repo user/developer/runtime docs; no external publication.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `ASSUMPTION:` Initial semantic verification uses two consecutive structured, evidence-bearing
|
||||
reports from the working agent rather than a second model request after every turn. This keeps the
|
||||
loop testable and avoids doubling model cost while making the limitation explicit.
|
||||
- `ASSUMPTION:` Default autonomous bounds are 40 turns and 6 repeated no-progress reports, with only
|
||||
bounded numeric environment overrides.
|
||||
- `ASSUMPTION:` A local smoke copy to `~/.config/mosaic/runtime/pi/goal-extension.ts` is authorized by
|
||||
the user's explicit request. Full framework reseed into the live home is not required for the smoke
|
||||
test and would touch unrelated framework-owned files.
|
||||
|
||||
## Budget
|
||||
|
||||
- Working estimate: 30K implementation/review tokens.
|
||||
- Hard user cap: none stated.
|
||||
- Cost control: deterministic fake-Pi tests; no nested evaluator calls; only bounded arithmetic/load
|
||||
smoke workflows against the installed runtime.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Update PRD and create tracking/scratchpad artifacts.
|
||||
2. Read launcher, installer ownership, Pi extension, and documentation surfaces.
|
||||
3. TDD: add fake-Pi behavior tests for commands, state restoration, turn checks, compaction, limits,
|
||||
verification, and continuation deduplication.
|
||||
4. Implement `runtime/pi/goal-extension.ts` and deterministic launcher discovery.
|
||||
5. Add framework-sync/deployment acceptance coverage.
|
||||
6. Update user, developer, runtime, framework README, and sitemap documentation.
|
||||
7. Run focused tests, local Mosaic-path smoke test, then baseline repository gates.
|
||||
8. Run independent review, remediate, commit, push/PR/CI/merge/issue closure per delivery gates.
|
||||
|
||||
## TDD decision
|
||||
|
||||
Applied. The continuation state machine and lifecycle scheduling are control-path logic where a race
|
||||
or false terminal state can cause unbounded work or premature completion.
|
||||
|
||||
## Progress checkpoints
|
||||
|
||||
- [x] Issue #1150 created through Mosaic wrapper.
|
||||
- [x] Isolated worktree created from `origin/main`.
|
||||
- [x] PRD requirements and acceptance criteria added.
|
||||
- [x] Task scratchpad created.
|
||||
- [x] RED controller and security-regression tests written and observed failing before implementation.
|
||||
- [x] Goal controller, launcher discovery, framework deployment coverage, and bounded state machine
|
||||
implemented.
|
||||
- [x] User, admin, developer, runtime, adapter, README, and sitemap documentation updated.
|
||||
- [x] Final source copied additively to `~/.config/mosaic/runtime/pi/goal-extension.ts`; source and
|
||||
deployed SHA-256 are identical.
|
||||
- [x] Live Pi RPC smoke from the exact Mosaic path reached `achieved` with two verification passes and
|
||||
no extension errors.
|
||||
- [x] Baseline and situational checks completed, except the explicitly documented unavailable
|
||||
PostgreSQL-only root integration case.
|
||||
- [x] Independent code and OWASP/security reviews completed; all findings remediated and re-reviewed.
|
||||
- [ ] Commit, push, PR, terminal-green CI, squash merge, and issue closure complete.
|
||||
|
||||
## Tests and evidence
|
||||
|
||||
### Situational
|
||||
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/runtime/pi-goal-extension.spec.ts`
|
||||
- final: 25 passed.
|
||||
- Covers commands, per-turn checks, context injection, two-pass verification, mixed-report
|
||||
rejection, bounded limits, compaction, branch restore, stale timers, credential redaction,
|
||||
typed-field false-positive protection, and append-only legacy-state fail-closed behavior.
|
||||
- Final focused launcher/controller/file-adapter run: 3 files / 67 tests passed.
|
||||
- Final V8 coverage for `framework/runtime/pi/goal-extension.ts`:
|
||||
- 99.17% statements/lines, 93.78% branches, 100% functions.
|
||||
- Installer migration fixture: 24 passed and byte-compared the deployed framework asset.
|
||||
- Standalone extension TypeScript check against installed Pi 0.84.1 types passed:
|
||||
`pnpm --filter @mosaicstack/mosaic exec tsc --noEmit --pretty false --module NodeNext
|
||||
--moduleResolution NodeNext --target ES2022 --skipLibCheck framework/runtime/pi/goal-extension.ts`.
|
||||
- Live deployment/load evidence:
|
||||
- source/deployed SHA-256:
|
||||
`1f0a3806e0948ad5f49684273a7e535e9880c148f7fd16d13ee487fcd601f637`.
|
||||
- `get_commands` identified `/goal` as an extension command sourced from
|
||||
`~/.config/mosaic/runtime/pi/goal-extension.ts`; `/goal help` succeeded; zero extension errors.
|
||||
- live arithmetic goal ended `achieved`, verification `2/2`, with 3 goal reports / 3 agent starts
|
||||
and zero extension errors.
|
||||
- no goal extension exists under `~/.pi` extension paths.
|
||||
|
||||
### Baseline
|
||||
|
||||
- `pnpm build`: passed before the final framework-only redaction remediation; the extension is not a
|
||||
package build input and its final source passed the standalone Pi type check.
|
||||
- `pnpm typecheck`: 45/45 tasks passed.
|
||||
- `pnpm lint`: 25/25 tasks passed.
|
||||
- `pnpm format:check`: passed.
|
||||
- Final Mosaic package components:
|
||||
- Vitest: 82 files / 1,539 tests passed.
|
||||
- full `test:framework-shell` harness passed.
|
||||
- the discovered pre-existing tmux loader-marker race was reproduced with constructor PID
|
||||
evidence, fixed with a pane readiness/FIFO barrier, passed 3 consecutive focused runs, and passed
|
||||
in the full shell harness.
|
||||
- one combined rerun encountered the separate existing real-lease probe TOCTOU in
|
||||
`install-ordering-guard.spec.ts`; an earlier final Vitest run was fully green and the changed
|
||||
focused suites remained green.
|
||||
- Gateway safe baseline excluding the prohibited PostgreSQL-only fixture: 55 files / 600 tests passed
|
||||
(6 files / 12 tests skipped by their existing environment gates).
|
||||
- Root `pnpm test` reached 43 successful workspace tasks and all changed-package Vitest tests, but
|
||||
the unchanged `apps/gateway/src/__tests__/cross-user-isolation.test.ts` afterAll hook retried a
|
||||
PostgreSQL connection and failed authentication (`28P01`). This checkout explicitly forbids local
|
||||
PostgreSQL startup/access; the failure is unrelated to #1150 and cannot be remediated by starting
|
||||
the database. The gateway suite excluding that PostgreSQL-only file and required CI are used as
|
||||
the safe verification paths.
|
||||
|
||||
### Independent review
|
||||
|
||||
- Codex code review: approved, 0 findings across 15 files.
|
||||
- Initial Codex security review: one medium CWE-532/A09 finding for raw report persistence.
|
||||
- Remediation added central credential-pattern redaction, prompt/docs guidance, canary tests, typed
|
||||
field false-positive guards, and sticky fail-closed restore for credential-bearing append-only
|
||||
history.
|
||||
- Codex security re-review: risk `none`, 0 findings, confidence 0.87.
|
||||
- Focused remediation review findings were fixed; final focused re-review verdict: `APPROVE`.
|
||||
- Focused independent review of the tmux readiness barrier: `APPROVE`, no actionable findings.
|
||||
|
||||
## Risks and blockers
|
||||
|
||||
- Live `~/.config/mosaic` is shared by active Pi/fleet processes. Local deployment remained a single
|
||||
additive framework file and did not reload or restart unrelated sessions.
|
||||
- Completion verification is semantic, not mathematical: the active agent supplies structured
|
||||
evidence twice. Operators must still inspect consequential outcomes.
|
||||
- Credential redaction is pattern-based defense-in-depth, not a secret store. It covers
|
||||
controller-owned state/status/tool details, not Pi's separate model-message/tool-call history.
|
||||
Goals and reports must never contain real secrets or raw sensitive output. Because Pi session
|
||||
entries are append-only, a detected credential-bearing legacy branch fails closed and the affected
|
||||
session must be removed.
|
||||
- Current installed Pi is newer than the repository's historical gateway Pi dependency. The
|
||||
extension was checked and smoke-tested against installed Pi 0.84.1 using stable documented APIs.
|
||||
- Local root testing cannot safely execute the unchanged PostgreSQL-only integration fixture under
|
||||
the checkout's explicit database safety constraints. Terminal-green PR CI remains mandatory before
|
||||
merge.
|
||||
- The unchanged real-lease default-probe test can observe different broker availability across its two
|
||||
sequential probes; one combined package rerun hit that existing TOCTOU. The same final Vitest suite
|
||||
passed in a separate run, and CI remains the merge authority.
|
||||
@@ -0,0 +1,89 @@
|
||||
# #1174 — Wrapper guard rounds 10–11
|
||||
|
||||
## Objective
|
||||
|
||||
Make checkout enforcement judge Git placement operands rather than every HOME-shaped word in the command, without reopening `--separate-git-dir` placement under HOME.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Reproduce the four over-blocks and the placement-option control at head `20d86e39`.
|
||||
2. Add RED fixtures before production changes.
|
||||
3. Extract clone/worktree placement operands from the existing shell-aware normalized stream.
|
||||
4. Run the full guard corpus, historical-head discrimination, syntax/static checks, probes, review, and CI.
|
||||
|
||||
## Progress and evidence
|
||||
|
||||
- Reproduced: `NOTE=$HOME`, `--reference=$HOME`, `GIT_DIR=$HOME/x`, and `--template=$HOME/t` all blocked despite explicit `/src/wt` destinations.
|
||||
- RED at `20d86e39`: expanded suite had 8 failures, all HOME-valued non-placement cases.
|
||||
- GREEN: expanded suite passes 242/242.
|
||||
- Round-10 probes: 7/7 placement expectations and 4/4 placement-option controls pass.
|
||||
- Earlier path probes remain green: 60/60, 24/24, and 17/17.
|
||||
- Historical discrimination with the 242-fixture suite:
|
||||
- `3d0a882a`: 216 pass / 26 fail.
|
||||
- `4b8eba95`: 222 pass / 20 fail.
|
||||
- `20d86e39`: 234 pass / 8 fail.
|
||||
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
|
||||
|
||||
## Residual / risk
|
||||
|
||||
- Relative destinations whose effective path depends on cwd are tracked separately by #1197 and remain out of scope.
|
||||
- Unknown future Git options with a separate following value fail closed when that value is HOME-shaped. This may require classification when Git adds an unrelated path-taking option, but prevents a new placement option from silently bypassing the guard.
|
||||
|
||||
## Round 11 objective and intake
|
||||
|
||||
- **Issue / PR:** #1174.
|
||||
- **Objective:** Remove the finite boolean-flag allowlists that turn accepted clone/worktree flags into fake placement operands, while preserving all real HOME placement blocks.
|
||||
- **Scope:** `wrapper-guard.sh`, its hermetic fixtures, and task documentation. Relative cwd-dependent destinations remain in #1197.
|
||||
- **Surfaces:** security-sensitive Bash hook behavior and shell/Git option grammar; no API, DB, UI, auth, deploy, or dependency changes.
|
||||
- **Budget assumption:** 25K working tokens; reduce exploratory matrices before reducing acceptance coverage.
|
||||
|
||||
### Round 11 plan
|
||||
|
||||
1. Use Git itself to classify accepted/rejected clone and worktree options, and Bash itself to resolve path-word expectations.
|
||||
2. Add RED fixtures for all six reported clone flags, generated negations, and equivalent worktree grammar.
|
||||
3. Replace the open-ended unknown-option fail-closed fallback with a parser based on the closed value-taking option surface; keep explicit placement options special.
|
||||
4. Run the full corpus, historical discrimination, shell/static checks, targeted probes, independent code/security review, one push, and exact-head CI.
|
||||
|
||||
### Root-cause evidence
|
||||
|
||||
- Git 2.39.5 accepts all six reported clone flags and the broader generated family measured in the brief: `--bare`, `--mirror`, `--ipv4`, `--ipv6`, `-4`, `-6`, `--no-local`, `--no-reject-shallow`, `--no-bare`, `--no-sparse`, `--no-dissociate`, `--no-shallow-submodules`, `--no-quiet`, `--no-progress`, and `--no-recurse-submodules`; it rejects `--relative-paths` as unknown.
|
||||
- Git 2.39.5 accepts worktree negations including `--no-force`, `--no-detach`, `--no-lock`, `--no-guess-remote`, and `--no-track`; the current finite worktree flag list does not describe that generated family.
|
||||
- `bash -c "printf '%s' <word>"` resolves `$HOME/source`, `${HOME}/source`, and `"$HOME"/source` under HOME while `/src/wt` remains outside it.
|
||||
- **Hypothesis:** only separate-value options need positive classification. Treat every other option token as a no-value flag unless it is the explicit placement option; this matches Git's non-enumerable boolean family and confines the residual to genuinely new future value-taking options.
|
||||
|
||||
### TDD and verification checkpoints
|
||||
|
||||
- RED against the unmodified `91cc37bc` guard: 253 pass / 22 fail in the initial expanded 275-fixture suite. Failures include all 15 accepted clone flags, accepted long abbreviations, short value-taking bundles, abbreviated placement, worktree metadata abbreviation, and both directions of bundled worktree branch parsing.
|
||||
- An exploratory fail-closed residual test drove emission of every worktree positional. Re-review correctly showed that this over-blocked HOME-shaped commit-ish metadata; a new commit-ish fixture failed RED against that intermediate implementation (278 pass / 2 fail, including one transient message assertion) and the parser was restored to emit only the actual path.
|
||||
- GREEN after remediation: 280/280.
|
||||
- Ultron's 13-shape option probe: 13/13 correct, including the six reported over-blocks, HOME destinations, end-of-options, worktree controls, and a later-command placement.
|
||||
- Round-10 probes remain green: 7/7 subject-placement expectations and 4/4 `--separate-git-dir` controls.
|
||||
- Earlier shell/path probes remain green: 60/60, 24/24, and 17/17.
|
||||
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
|
||||
|
||||
### Deliberate residual
|
||||
|
||||
A future Git release could add a new separate-value option absent from the closed value grammar. It defaults to no-value flag parsing, which leaves the following word positional. For clone, this can fail open if that future option itself creates repository state at its value. For worktree, it can shift which word is read as the path. This hypothetical future ambiguity is accepted deliberately because failing closed on every unclassified option is proven to over-block Git's open-ended present-day boolean/`--no-*` family. Every value-taking and placement option Git currently supports is classified, including accepted abbreviations of `--separate-git-dir`. Relative cwd-dependent targets remain in #1197.
|
||||
|
||||
### Independent review checkpoint
|
||||
|
||||
- Initial Codex code/security review raised `--orphan` as value-taking. Upstream Git `master` contradicts that premise: the synopsis is `[--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]`, and the prose derives the branch from the path when `-b`/`-B` is absent. `--orphan` is therefore correctly handled as a boolean flag.
|
||||
- The security review separately identified the generic future worktree shift residual. An attempted fail-closed remediation emitted every positional, but code re-review correctly rejected it because valid grammar has only one placement positional and an optional commit-ish. Final behavior checks only the path and documents the hypothetical future option shift deliberately; paired actual-grammar `--orphan` fixtures cover safe/HOME paths and `-b` metadata.
|
||||
- Security re-review initially had no findings. Code re-review's commit-ish blocker was remediated with a RED fixture and path-only restoration; final code re-review approved with no findings.
|
||||
- Final security review then found non-canonical absolute and symlink aliases. Eight lexical fixtures failed RED against the prior implementation, followed by three symlink fixtures failing RED. Remediation expands only shell-visible HOME tokens, resolves the longest existing directory prefix physically, and lexically normalizes the nonexistent suffix. The suite is now 292/292.
|
||||
- Inherent residual: a symlink can be replaced between pre-tool inspection and Git execution. Existing aliases are resolved; eliminating the race requires enforcement inside the filesystem mutation path rather than a text pre-hook. Security review classified this medium, and architectural closure is tracked in #1199.
|
||||
- Final independent code review: APPROVE, 0 findings. Final security review: no critical/high findings; the single medium TOCTOU residual is explicitly tracked in #1199.
|
||||
|
||||
### Final local evidence
|
||||
|
||||
- Final hermetic suite: 292/292; the same suite against `91cc37bc` discriminates at 256 pass / 36 fail.
|
||||
- Ultron option probe: 13/13; round-10 probes: 7/7 plus 4/4 controls; earlier shell/path probes: 60/60, 24/24, and 17/17.
|
||||
- `bash -n`, ShellCheck warning-or-higher, `git diff --check`, sanitization gate, and test-enumeration gate (population 55; 38 enumerated; 18 signed exclusions): pass.
|
||||
- Independent code review: APPROVE, 0 findings. Security review's remaining medium TOCTOU architecture residual is tracked in #1199; no critical/high findings remain.
|
||||
- Repository-wide TypeScript gates require dependencies absent from this worktree; the canonical Woodpecker pipeline will run them against the pushed exact head.
|
||||
|
||||
### Documentation checklist
|
||||
|
||||
- `docs/PRD.md` updated with WPG requirements, acceptance, canonicalization, and residual risk.
|
||||
- Task scratchpad updated in the same logical change set; `docs/TASKS.md` remains orchestrator-only.
|
||||
- No API, auth, UI, navigation, deployment, user-guide, or admin-guide surface changed; OpenAPI, endpoint index, sitemap, and publishing are not applicable.
|
||||
@@ -0,0 +1,71 @@
|
||||
# #1194 — Installed framework-tool drift detection and refresh analysis
|
||||
|
||||
## Decision
|
||||
|
||||
The reported queue-guard source defect was already fixed on `main` by `58b971ab`; the live failure came from a stale `~/.config/mosaic/tools/git/ci-queue-wait.sh`. The durable fix is therefore a detector, not a duplicate queue-guard patch.
|
||||
|
||||
`mosaic doctor` now compares the framework tools bundled with the executing Mosaic package against the deployed tools tree. Doctor is the selected visibility boundary because it is observational and operator-invoked: unlike session start, it does not add a repository/network scan to every seat launch, and it cannot silently replace identity or messaging tools while seats are active. It reports drift without changing files. `--fail-on-warn` converts detected drift into a non-zero doctor result.
|
||||
|
||||
## Classification
|
||||
|
||||
The existing `framework-manifest.txt` is authoritative. The detector invokes the canonical shared `tools/_lib/manifest.sh classify` implementation over the complete source census and refuses missing, unreadable, malformed, incomplete, or zero-framework ownership output. Policy is therefore read rather than duplicated:
|
||||
|
||||
- Current policy classifies source files under `tools/**` as framework-owned and required in the deployed tools tree.
|
||||
- Current policy explicitly classifies `tools/_lib/credentials.json` operator-owned and excludes it from byte comparison; future policy changes take effect without a detector edit.
|
||||
- A file present only in the deployed tools tree is operator-owned/unknown by the manifest's fail-safe default. The detector reports it as `INSTALLED_ONLY operator-or-unknown` under `--verbose` but does not fail or delete it.
|
||||
- Empty/partial source traversal, unreadable directories/files, symlinked census entries, root aliases, and descendant source aliases all return `CANNOT_ASSERT` rather than manufacturing agreement.
|
||||
|
||||
This means `NOT_INSTALLED` is not suppressed by filename guesses such as “test” or “README”: if it ships below source `tools/**`, the installer contract says it should be installed. Source-only implementation files outside `tools/**` are outside this detector population by construction.
|
||||
|
||||
## Current host analysis (observation only; no refresh performed)
|
||||
|
||||
A direct source-vs-installed census showed broad drift, including identity and messaging behavior:
|
||||
|
||||
- Identity/provider operations: stale `git/detect-platform.sh`, `issue-comment.sh`, `issue-create.sh`, `issue-close.sh`, `issue-view.sh`, `pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `pr-metadata.sh`; missing `pr-edit.sh` and several identity/read-back regression tools.
|
||||
- Messaging/session: stale `tmux/agent-send.sh`, `tmux/send-message.sh`, their regressions, and `fleet/start-agent-session.sh`.
|
||||
- Gate enforcement: stale `git/ci-queue-wait.sh`; missing the queue tri-state/process-level suites and terminal-green verifier.
|
||||
- Lease/QA behavior: stale lease-broker launch/mutation/receipt tools and QA hooks.
|
||||
|
||||
Counts vary with source head and installed local/operator files; the detector prints measured counts every run rather than baking this snapshot into policy.
|
||||
|
||||
## Reviewed refresh command — analyse only, do not run during active seats
|
||||
|
||||
Use the package/release updater's manifest-driven keep-mode sync during a quiet maintenance window:
|
||||
|
||||
```bash
|
||||
MOSAIC_SYNC_ONLY=1 \
|
||||
MOSAIC_INSTALL_MODE=keep \
|
||||
MOSAIC_HOME="$HOME/.config/mosaic" \
|
||||
bash /path/to/reviewed/@mosaicstack/mosaic/framework/install.sh
|
||||
```
|
||||
|
||||
For the globally installed package, resolve the reviewed installer rather than guessing its path:
|
||||
|
||||
```bash
|
||||
PACKAGE_ROOT="$(dirname "$(node -p "require.resolve('@mosaicstack/mosaic/package.json')")")"
|
||||
MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_HOME="$HOME/.config/mosaic" \
|
||||
bash "$PACKAGE_ROOT/framework/install.sh"
|
||||
```
|
||||
|
||||
Do not run this while agent seats are active: the stale set includes identity selection, provider mutation, messaging, queue/merge guards, lease enforcement, and session launch. Syncing those files in place can change behavior between a seat's preflight and mutation.
|
||||
|
||||
## Post-refresh verification
|
||||
|
||||
1. Run `mosaic doctor --fail-on-warn`; require `stale=0 not-installed=0` from the framework drift summary (other unrelated doctor warnings must also be adjudicated).
|
||||
2. Re-run the constructed process-level queue probes against the **installed path**, not the source checkout. Use the source suite while overriding its subject path in a reviewed scratch copy, or reproduce these exact observations:
|
||||
- pending provider payload: guard must print `state=pending`, print the pending context, wait, and exit non-zero/timeout — never return immediately with rc 0;
|
||||
- malformed payload: guard must print `state=malformed` and exit non-zero;
|
||||
- unsupported but valid status vocabulary: guard must print `state=unknown` and exit non-zero.
|
||||
3. Run provider author read-back for one deliberately low-risk wrapper operation before resuming fleet mutation work; wrapper self-report is not identity evidence.
|
||||
4. Relaunch seats only after the quiet-window verification, because existing processes retain loaded environment/context.
|
||||
|
||||
## Probe evidence
|
||||
|
||||
The detector regression constructs a stale installed tool plus a missing shipped tool and observes rc 1 with distinct `STALE` and `NOT_INSTALLED` lines. That case would pass or be invisible before this change because no installed-vs-shipped comparison existed. Additional review-red controls prove:
|
||||
|
||||
- empty and unreadable source censuses return `CANNOT_ASSERT` (they returned clean rc 0 at the first PR head);
|
||||
- deleting the manifest returns `CANNOT_ASSERT`, while changing manifest ownership changes the verdict through the canonical resolver (the first head never opened the manifest);
|
||||
- root and descendant symlink/source aliases cannot return clean (the first head returned clean for a source-backed installed subtree);
|
||||
- a checker hung during doctor is terminated by a bounded watchdog, emits `CANNOT_ASSERT`, and doctor reaches its final warnings line (the first head hung and suppressed the remaining audit).
|
||||
|
||||
Controls retain byte-identical success, exact credential carve-out behavior, and installed-only preservation.
|
||||
@@ -13,7 +13,8 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
||||
|
||||
1. Injects the full runtime contract via `--append-system-prompt`
|
||||
2. Loads Mosaic skills via `--skill` flags
|
||||
3. Loads the Mosaic extension via `--extension` for lifecycle hooks
|
||||
3. Loads framework-owned `mosaic-extension.ts` and `goal-extension.ts` from
|
||||
`~/.config/mosaic/runtime/pi/` via ordered `--extension` flags
|
||||
4. Detects active missions and injects initial prompts
|
||||
|
||||
## Capabilities vs Other Runtimes
|
||||
@@ -22,6 +23,7 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
||||
- Native thinking levels replace sequential-thinking MCP
|
||||
- Native skill discovery compatible with Mosaic SKILL.md format
|
||||
- Native extension system for lifecycle hooks (TypeScript, not bash shims)
|
||||
- Bounded persistent `/goal` loop with per-turn, post-compaction, and two-pass evidence checks
|
||||
- Native session persistence and resume
|
||||
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
||||
|
||||
|
||||
@@ -104,7 +104,14 @@ The launcher:
|
||||
1. Verifies `~/.config/mosaic` exists
|
||||
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
||||
3. Injects `AGENTS.md` into the runtime
|
||||
4. Forwards all arguments to the runtime CLI
|
||||
4. For Pi, loads the framework-owned core and persistent-goal extensions from
|
||||
`~/.config/mosaic/runtime/pi/`
|
||||
5. Forwards all arguments to the runtime CLI
|
||||
|
||||
Inside `mosaic pi`, `/goal set <statement>` starts a bounded persistent goal loop. Use `/goal status`,
|
||||
`/goal pause`, `/goal resume`, or `/goal cancel` to control it. The extension remains part of Mosaic
|
||||
under `~/.config/mosaic/runtime/pi/goal-extension.ts`; it is not installed in Pi's main extension
|
||||
directory.
|
||||
|
||||
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
|
||||
|
||||
@@ -124,7 +131,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
||||
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
||||
│ ├── codex/ ← instructions.md, RUNTIME.md
|
||||
│ ├── opencode/ ← AGENTS.md, RUNTIME.md
|
||||
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts
|
||||
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts, goal-extension.ts
|
||||
│ └── mcp/ ← MCP server configs
|
||||
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
|
||||
├── skills-local/ ← Local cross-runtime skills
|
||||
@@ -136,7 +143,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
||||
|
||||
| Launch method | Injection mechanism |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension |
|
||||
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + Mosaic extensions |
|
||||
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
|
||||
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
||||
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
||||
|
||||
@@ -60,6 +60,52 @@ If a repo does not expose these scripts, run equivalent local workflow commands
|
||||
- Do not auto-resolve data conflicts in shared state files.
|
||||
- Keep commits scoped to a single logical change set.
|
||||
|
||||
## Model Tiering
|
||||
|
||||
Model choice is a standard, not a preference. Delegating a mechanical grep to a
|
||||
frontier reasoning model wastes budget; sending a security review to a cheap tier
|
||||
produces a review that passes and proves nothing. Both are defects.
|
||||
|
||||
Tiers are named by **capability class**, so the standard survives a model
|
||||
generation. An operator binds each class to a concrete model id.
|
||||
|
||||
| Class | Use for |
|
||||
| ------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `search` | grep/glob, file location, status and health checks, one-line mechanical edits |
|
||||
| `build` | feature implementation, test writing, bugfixes, routine refactors |
|
||||
| `judge` | code review, planning, API/compat-sensitive changes |
|
||||
| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive |
|
||||
|
||||
Rules:
|
||||
|
||||
1. **Start at the cheapest class that can do the task; escalate on evidence, not
|
||||
on nerves.** Omitting a tier is not neutral — it inherits the caller's model,
|
||||
which is usually the most expensive one.
|
||||
2. **Compat-sensitive work escalates one class.** A change that must interoperate
|
||||
with an existing contract is judged, not just built.
|
||||
3. **A tier assignment is benchmarked, not asserted.** Move a task class to a
|
||||
cheaper tier only against a blind A/B on real work from this codebase, ranked
|
||||
by someone other than the author. "It seemed fine" is not evidence.
|
||||
4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from
|
||||
the model that wrote the code is not a second opinion (see Constitution gate 16).
|
||||
|
||||
### Where the binding lives
|
||||
|
||||
The class→model map is operator configuration, never framework source: model
|
||||
availability, cost, and quotas differ per operator and per host.
|
||||
|
||||
Resolution order, first hit wins:
|
||||
|
||||
1. the config service (DB-backed, surfaced and editable in the Mosaic webUI)
|
||||
2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime
|
||||
injects it)
|
||||
3. the framework default — the class names above, with no binding
|
||||
|
||||
Only layer 1 is auditable across a fleet, so it is the target end state; layers 2
|
||||
and 3 exist so a host with no config service still runs. A local override that
|
||||
silently disagrees with the config service is drift — the same failure class the
|
||||
tool-index gate exists to catch, and it belongs in `mosaic doctor`.
|
||||
|
||||
## Prompting Contract
|
||||
|
||||
All runtime adapters should inject:
|
||||
|
||||
@@ -11,22 +11,106 @@ All tool suites are located at `~/.config/mosaic/tools/`.
|
||||
|
||||
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||
|
||||
This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh`
|
||||
fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no
|
||||
longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable
|
||||
from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened.
|
||||
|
||||
Every command takes `--help`. All of them accept `--login <account>` to pin the acting identity;
|
||||
supply it explicitly on any host where the provider CLI's default account is an admin.
|
||||
|
||||
| Issues | |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `issue-create.sh` | Create an issue (Gitea or GitHub) |
|
||||
| `issue-view.sh` | Show one issue |
|
||||
| `issue-list.sh` | List issues |
|
||||
| `issue-edit.sh` | Edit title/body/labels/milestone |
|
||||
| `issue-comment.sh` | Add a comment |
|
||||
| `issue-assign.sh` | Assign or unassign |
|
||||
| `issue-close.sh` | Close an issue |
|
||||
| `issue-reopen.sh` | Reopen a closed issue |
|
||||
|
||||
| Pull requests | |
|
||||
| ---------------- | --------------------------------------------------------- |
|
||||
| `pr-create.sh` | Open a pull request |
|
||||
| `pr-edit.sh` | Edit PR title, body, base branch, or draft/ready state |
|
||||
| `pr-view.sh` | Show one PR |
|
||||
| `pr-list.sh` | List PRs |
|
||||
| `pr-diff.sh` | Fetch a PR's diff |
|
||||
| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) |
|
||||
| `pr-review.sh` | **Place a review verdict — see the dialect note below** |
|
||||
| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state |
|
||||
| `pr-merge.sh` | Merge a PR |
|
||||
| `pr-close.sh` | Close a PR without merging |
|
||||
|
||||
| Milestones | |
|
||||
| --------------------- | ------------------ |
|
||||
| `milestone-create.sh` | Create a milestone |
|
||||
| `milestone-list.sh` | List milestones |
|
||||
| `milestone-close.sh` | Close a milestone |
|
||||
|
||||
| Gates and guards | |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
|
||||
| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) |
|
||||
| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard |
|
||||
| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree |
|
||||
|
||||
| Context | |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it |
|
||||
| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider |
|
||||
|
||||
| Workspace | |
|
||||
| -------------------- | ------------------------------------------------------------------------ |
|
||||
| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below |
|
||||
| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand |
|
||||
|
||||
**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new <branch>` takes a branch
|
||||
name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree,
|
||||
repo name, parent dir, then `<parent>/<repo>-worktrees/<branch-slug>`. There is no placement flag
|
||||
because a decision an agent has to make is a decision that drifts: the rule "big work goes on a work
|
||||
filesystem" already existed in prose and 255 GB accumulated in `$HOME` across 842 directories
|
||||
anyway, under five simultaneous conventions on a single host.
|
||||
|
||||
```bash
|
||||
# Issues
|
||||
~/.config/mosaic/tools/git/issue-create.sh
|
||||
~/.config/mosaic/tools/git/issue-close.sh
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> [--from <base>]
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # derived path, no side effect
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones
|
||||
```
|
||||
|
||||
# PRs
|
||||
~/.config/mosaic/tools/git/pr-create.sh
|
||||
~/.config/mosaic/tools/git/pr-merge.sh
|
||||
Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare
|
||||
clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and
|
||||
`gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when
|
||||
`git status --porcelain` is empty _and_ `git rev-list --count HEAD --not --remotes` is 0. Anything
|
||||
else is preserved and reported. `--force` exists and is yours to type deliberately.
|
||||
|
||||
# Milestones
|
||||
~/.config/mosaic/tools/git/milestone-create.sh
|
||||
`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see
|
||||
`runtime/claude/settings.json`). It blocks exactly three things and lets everything else through:
|
||||
a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that
|
||||
already has a wrapper above (reads are untouched — they are how you gather evidence); and the
|
||||
literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix
|
||||
`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has
|
||||
a missing flag — extend the wrapper.
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/issue-create.sh --help
|
||||
~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..."
|
||||
|
||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||
```
|
||||
|
||||
**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is
|
||||
`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP
|
||||
200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the
|
||||
verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the
|
||||
approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider.
|
||||
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
|
||||
placed.
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "^/mosaic-promote$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,7 @@
|
||||
{
|
||||
"model": "opus",
|
||||
"hooks": {
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "^/mosaic-promote$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
@@ -64,6 +11,16 @@
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/git/wrapper-guard.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -91,11 +48,6 @@
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||
"timeout": 15
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||
@@ -315,5 +267,11 @@
|
||||
"cpan",
|
||||
"nohup"
|
||||
],
|
||||
"enableAllMcpTools": true
|
||||
"enableAllMcpTools": true,
|
||||
"mcpServers": {
|
||||
"sequential-thinking": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +51,26 @@ Skills are discovered from:
|
||||
|
||||
### Extensions
|
||||
|
||||
The Mosaic Pi extension (`~/.config/mosaic/runtime/pi/mosaic-extension.ts`) handles:
|
||||
`mosaic pi` loads framework-owned extensions directly from `~/.config/mosaic/runtime/pi/` in this
|
||||
order:
|
||||
|
||||
- Session start/end lifecycle hooks
|
||||
- Active mission detection and context injection
|
||||
- Memory routing to `~/.config/mosaic/memory/`
|
||||
- MACP queue status reporting
|
||||
1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates,
|
||||
and fleet heartbeat reporting.
|
||||
2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction
|
||||
checks.
|
||||
|
||||
The goal extension is deployed by Mosaic and MUST NOT be copied into `~/.pi/agent/extensions/`.
|
||||
Use `/goal set <statement>` (or `/goal <statement>`) to start, then `/goal status`, `/goal pause`,
|
||||
`/goal resume`, or `/goal cancel` to control it. An active goal is injected before every model
|
||||
request, restored from branch-specific session entries, and considered achieved only after two
|
||||
consecutive evidence-bearing reports. Common credential shapes are redacted before controller-owned
|
||||
goal-state entries are persisted or
|
||||
displayed; Pi's own model/tool-call history is separate. Goals and reports must contain references
|
||||
and pass/fail summaries rather than secrets or raw sensitive output.
|
||||
|
||||
- `MOSAIC_GOAL_MAX_TURNS` — autonomous turn limit, default `40`, accepted range `1..500`.
|
||||
- `MOSAIC_GOAL_MAX_NO_PROGRESS` — identical no-progress report limit, default `6`, accepted range
|
||||
`1..100`.
|
||||
|
||||
### Sessions
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,10 @@ if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
|
||||
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
||||
MARKER="$TEST_ROOT/loader-marker"
|
||||
LIBRARY="$TEST_ROOT/marker.so"
|
||||
FIXTURE_READY="$TEST_ROOT/loader-ready"
|
||||
FIXTURE_FIFO="$TEST_ROOT/loader-block"
|
||||
HOLDER_HOME="$TEST_ROOT/holder-home"
|
||||
mkfifo "$FIXTURE_FIFO"
|
||||
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
||||
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||
@@ -87,7 +90,17 @@ __attribute__((constructor)) static void mark_loader(void) {
|
||||
EOF
|
||||
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
||||
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
|
||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder \
|
||||
"touch '$FIXTURE_READY'; read _ < '$FIXTURE_FIFO'"
|
||||
# tmux starts the pane asynchronously. Wait until its contaminated shell has
|
||||
# loaded the constructor and reached a builtin-only FIFO barrier before
|
||||
# clearing the marker; otherwise that expected constructor can race with the
|
||||
# clean holder assertion below and create a false failure.
|
||||
for _attempt in {1..100}; do
|
||||
[ -e "$FIXTURE_READY" ] && break
|
||||
sleep 0.01
|
||||
done
|
||||
[ -e "$FIXTURE_READY" ] || fail "contaminated fixture pane did not become ready"
|
||||
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
||||
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
||||
: > "$MARKER"
|
||||
@@ -112,6 +125,7 @@ EOF
|
||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
||||
MOSAIC_GIT_IDENTITY=$AGENT_NAME
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=
|
||||
@@ -144,7 +158,8 @@ EOF
|
||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
||||
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
||||
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
|
||||
ld_preload_env="$(tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null)" || true
|
||||
if grep -q '^LD_PRELOAD=' <<<"$ld_preload_env"; then
|
||||
fail "fresh holder retained LD_PRELOAD"
|
||||
fi
|
||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||
|
||||
@@ -69,7 +69,7 @@ _manifest_glob_to_ere() {
|
||||
out="$out.*"
|
||||
fi
|
||||
else
|
||||
out="$out[^/]*"
|
||||
out="${out}[^/]*"
|
||||
fi
|
||||
else
|
||||
case "$c" in
|
||||
@@ -87,7 +87,8 @@ _manifest_compile_one() {
|
||||
local norm; norm="$(_manifest_norm "$1")"
|
||||
[[ -n "$norm" ]] || return 0
|
||||
if [[ "$norm" == *"*"* ]]; then
|
||||
local re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
local re
|
||||
re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
if [[ "$2" == F ]]; then
|
||||
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
||||
else
|
||||
@@ -183,7 +184,10 @@ _mo_matches() {
|
||||
for (( i = 0; i < n; i++ )); do
|
||||
if [[ "${_MO_KIND[i]}" == exact ]]; then
|
||||
pat="${_MO_EXACT[i]}"
|
||||
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||
# Operator exact entries are file carve-outs, not implicit directory
|
||||
# prefixes. Subtree ownership must be declared explicitly as `dir/**`;
|
||||
# otherwise one bare directory entry can hide all drift beneath it.
|
||||
[[ "$path" == "$pat" ]] && return 0
|
||||
else
|
||||
re="${_MO_RE[i]}"
|
||||
[[ "$path" =~ $re ]] && return 0
|
||||
|
||||
@@ -153,6 +153,38 @@ warn_if_symlink_tree_present() {
|
||||
|
||||
echo "[mosaic-doctor] Mosaic home: $MOSAIC_HOME"
|
||||
|
||||
# Compare the framework tools that this CLI/package ships with the deployed
|
||||
# ~/.config copy that direct wrappers and systemd units actually execute. Doctor
|
||||
# is the right boundary: observational, operator-invoked, and already designed
|
||||
# to report drift without mutating live tooling or restarting active seats.
|
||||
framework_drift_checker="$(cd -- "$(dirname -- "$0")/../quality/scripts" && pwd)/framework-drift-check.py"
|
||||
if [[ -f "$framework_drift_checker" ]]; then
|
||||
echo "[mosaic-doctor] Checking installed framework-tool drift..."
|
||||
drift_timeout="${MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC:-15}"
|
||||
if ! [[ "$drift_timeout" =~ ^[1-9][0-9]*$ ]]; then
|
||||
warn "Invalid MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC='$drift_timeout' (expected positive integer); using 15s"
|
||||
drift_timeout=15
|
||||
fi
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
set +e
|
||||
timeout -s TERM -k 2 "${drift_timeout}s" \
|
||||
python3 "$framework_drift_checker" --installed-root "$MOSAIC_HOME/tools"
|
||||
drift_rc=$?
|
||||
set -e
|
||||
if [[ "$drift_rc" -eq 0 ]]; then
|
||||
pass "Installed framework tools match shipped source"
|
||||
elif [[ "$drift_rc" -eq 124 || "$drift_rc" -eq 137 || "$drift_rc" -eq 143 ]]; then
|
||||
warn "CANNOT_ASSERT framework drift checker timed out after ${drift_timeout}s; continuing remaining doctor checks"
|
||||
else
|
||||
warn "Installed framework-tool drift detected (checker exit $drift_rc; no files changed)"
|
||||
fi
|
||||
else
|
||||
warn "CANNOT_ASSERT timeout utility unavailable; refusing unbounded framework drift check and continuing remaining doctor checks"
|
||||
fi
|
||||
else
|
||||
warn "Framework drift checker is absent from the shipped tools tree"
|
||||
fi
|
||||
|
||||
# Canonical Mosaic checks
|
||||
expect_file "$MOSAIC_HOME/STANDARDS.md"
|
||||
expect_file "$MOSAIC_HOME/USER.md"
|
||||
|
||||
@@ -5,6 +5,7 @@ MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
MODE="apply"
|
||||
RUNTIME="all"
|
||||
STRICT_CHECK=0
|
||||
CLAUDE_CONFIG_DIR=""
|
||||
|
||||
PKG="@modelcontextprotocol/server-sequential-thinking"
|
||||
|
||||
@@ -29,6 +30,14 @@ while [[ $# -gt 0 ]]; do
|
||||
STRICT_CHECK=1
|
||||
shift
|
||||
;;
|
||||
--claude-config-dir)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
err "--claude-config-dir requires an absolute seat config directory"
|
||||
exit 2
|
||||
fi
|
||||
CLAUDE_CONFIG_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
err "Unknown argument: $1"
|
||||
exit 2
|
||||
@@ -67,11 +76,19 @@ warm_package() {
|
||||
}
|
||||
|
||||
check_claude_config() {
|
||||
python3 - <<'PY'
|
||||
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
p = Path.home() / ".claude" / "settings.json"
|
||||
if not p.exists():
|
||||
# Claude reads MCP definitions from .claude.json, not settings.json. The
|
||||
# settings.json fallback preserves legacy operator flows until their config is migrated.
|
||||
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json"
|
||||
if not p.exists() and not config_dir:
|
||||
p = Path.home() / ".claude" / "settings.json"
|
||||
# Only explicit fleet seats require a private, non-symlink config. Operator
|
||||
# config remains compatible with pre-existing permission conventions.
|
||||
if not p.exists() or p.is_symlink() or (config_dir and (p.stat().st_mode & 0o077) != 0):
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
@@ -92,10 +109,15 @@ PY
|
||||
}
|
||||
|
||||
apply_claude_config() {
|
||||
python3 - <<'PY'
|
||||
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
p = Path.home() / ".claude" / "settings.json"
|
||||
# Claude reads MCP definitions from .claude.json for both operator and
|
||||
# explicitly isolated fleet config dirs. The checker retains a settings.json
|
||||
# fallback only to avoid breaking legacy operator configurations.
|
||||
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if p.exists():
|
||||
try:
|
||||
@@ -117,7 +139,7 @@ PY
|
||||
}
|
||||
|
||||
check_codex_config() {
|
||||
local cfg="$HOME/.codex/config.toml"
|
||||
local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml"
|
||||
[[ -f "$cfg" ]] || return 1
|
||||
grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \
|
||||
grep -q '^command = "npx"' "$cfg" && \
|
||||
@@ -125,7 +147,7 @@ check_codex_config() {
|
||||
}
|
||||
|
||||
apply_codex_config() {
|
||||
local cfg="$HOME/.codex/config.toml"
|
||||
local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml"
|
||||
mkdir -p "$(dirname "$cfg")"
|
||||
[[ -f "$cfg" ]] || touch "$cfg"
|
||||
|
||||
@@ -148,10 +170,11 @@ apply_codex_config() {
|
||||
}
|
||||
|
||||
check_opencode_config() {
|
||||
python3 - <<'PY'
|
||||
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
p = Path.home() / ".config" / "opencode" / "config.json"
|
||||
p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json"
|
||||
if not p.exists():
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
@@ -174,10 +197,11 @@ PY
|
||||
}
|
||||
|
||||
apply_opencode_config() {
|
||||
python3 - <<'PY'
|
||||
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
p = Path.home() / ".config" / "opencode" / "config.json"
|
||||
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)
|
||||
if p.exists():
|
||||
try:
|
||||
|
||||
@@ -69,7 +69,7 @@ if [[ -n "$GROUP" ]]; then
|
||||
group_response=$(curl -sk \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"${AUTHENTIK_URL}/api/v3/core/groups/?search=${GROUP}")
|
||||
group_pk=$(echo "$group_response" | jq -r ".results[] | select(.name == \"$GROUP\") | .pk" | head -1)
|
||||
group_pk=$(jq -r "first(.results[] | select(.name == \"$GROUP\") | .pk) // empty" <<<"$group_response")
|
||||
if [[ -n "$group_pk" ]]; then
|
||||
payload=$(echo "$payload" | jq --arg gk "$group_pk" '. + {groups: [$gk]}')
|
||||
else
|
||||
|
||||
@@ -97,7 +97,7 @@ is_sensitive_key() {
|
||||
|
||||
is_generated_key() {
|
||||
case "$1" in
|
||||
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||
MOSAIC_AGENT_NAME|MOSAIC_GIT_IDENTITY|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -114,6 +114,7 @@ validate_generated_value() {
|
||||
local value="$2"
|
||||
case "$key" in
|
||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
||||
MOSAIC_GIT_IDENTITY) safe_agent_name "$value" || fail_env unsafe-git-identity "$key" "$value" ;;
|
||||
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
||||
MOSAIC_AGENT_RUNTIME)
|
||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
||||
@@ -175,7 +176,7 @@ load_environment_file() {
|
||||
|
||||
load_environment_file "$GENERATED_ENV" generated
|
||||
for required_key in \
|
||||
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||
MOSAIC_AGENT_NAME MOSAIC_GIT_IDENTITY MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
||||
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
||||
done
|
||||
@@ -183,12 +184,15 @@ load_environment_file "$LOCAL_ENV" local
|
||||
|
||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
||||
[ "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}" = "$AGENT_NAME" ] || \
|
||||
fail_env git-identity-mismatch MOSAIC_GIT_IDENTITY "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}"
|
||||
|
||||
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
||||
MOSAIC_GIT_IDENTITY=${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}
|
||||
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
||||
@@ -286,12 +290,24 @@ _build_runtime_bin_prefix() {
|
||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
||||
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# A seat scaffolded by `mosaic fleet agent new` owns its harness home, settings
|
||||
# overlay and auth bundle; launching it through `mosaic fleet launch` is what makes
|
||||
# ~/.mosaic real for a roster-started pane instead of a directory nothing reads.
|
||||
# Detection uses $PANE_HOME/.mosaic because the pane environment is cleared below,
|
||||
# so `mosaic fleet launch` resolves the same root from HOME and the two agree.
|
||||
FLEET_SEAT_DIR="$PANE_HOME/.mosaic/fleet/agents/$AGENT_NAME"
|
||||
FLEET_SEAT=0
|
||||
[ -f "$FLEET_SEAT_DIR/profile.json" ] && FLEET_SEAT=1
|
||||
|
||||
_ensure_claude_workdir_trusted() {
|
||||
local workdir="$1"
|
||||
local claude_json="$2"
|
||||
local resolved
|
||||
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
||||
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
||||
if [ -z "$claude_json" ]; then
|
||||
claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
||||
fi
|
||||
command -v python3 >/dev/null 2>&1 || return 1
|
||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
||||
import json, os, sys, tempfile
|
||||
@@ -325,11 +341,23 @@ PY
|
||||
}
|
||||
|
||||
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
|
||||
# Trust belongs to the home the seat will actually run in. Writing it to the
|
||||
# operator's ~/.claude.json would leave the seat prompting on its first turn.
|
||||
SEAT_CLAUDE_JSON=""
|
||||
if [ "$FLEET_SEAT" = 1 ] && [ -d "$FLEET_SEAT_DIR/.claude" ]; then
|
||||
SEAT_CLAUDE_JSON="$FLEET_SEAT_DIR/.claude/.claude.json"
|
||||
fi
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" "$SEAT_CLAUDE_JSON" || \
|
||||
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
fi
|
||||
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
if [ "$FLEET_SEAT" = 1 ]; then
|
||||
# --dangerous keeps the seat on the same permissions footing `mosaic yolo` gave it;
|
||||
# the composition, not the roster, decides harness home, bundle and settings.
|
||||
LAUNCH_COMMAND=(mosaic fleet launch "$AGENT_NAME" --dangerous)
|
||||
else
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
fi
|
||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||
|
||||
@@ -343,6 +371,7 @@ LAUNCH_ENV=(
|
||||
"PATH=$PANE_PATH"
|
||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
||||
"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
|
||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
||||
|
||||
@@ -14,6 +14,82 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
pane_command_clears_environment() {
|
||||
local calls_file="$1"
|
||||
local -a argv=()
|
||||
local index
|
||||
mapfile -d '' -t argv < "$calls_file"
|
||||
for ((index = 0; index + 1 < ${#argv[@]}; index++)); do
|
||||
if [ "${argv[$index]}" = /usr/bin/env ] && [ "${argv[$((index + 1))]}" = -i ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
print_pane_argv() {
|
||||
local calls_file="$1"
|
||||
local -a argv=()
|
||||
local bytes index
|
||||
mapfile -d '' -t argv < "$calls_file"
|
||||
bytes=$(wc -c < "$calls_file")
|
||||
printf 'observed pane argv: records=%s bytes=%s\n' "${#argv[@]}" "$bytes" >&2
|
||||
for ((index = 0; index < ${#argv[@]}; index++)); do
|
||||
printf ' [%03d] %q\n' "$index" "${argv[$index]}" >&2
|
||||
done
|
||||
}
|
||||
|
||||
check_pane_environment_boundary() {
|
||||
local calls_file="$1"
|
||||
if pane_command_clears_environment "$calls_file"; then
|
||||
return 0
|
||||
fi
|
||||
print_pane_argv "$calls_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
contains_literal() {
|
||||
grep -F -- "$2" <<< "$1" >/dev/null
|
||||
}
|
||||
|
||||
contains_line() {
|
||||
grep -xF -- "$2" <<< "$1" >/dev/null
|
||||
}
|
||||
|
||||
# Portability regression: inspect the authoritative NUL-delimited argv instead
|
||||
# of piping a newline reconstruction through `grep -q` under pipefail. The old
|
||||
# pipeline could report failure after a successful match when an upstream
|
||||
# producer received SIGPIPE. A large trailing argument keeps that failure class
|
||||
# covered without making stream size part of the semantic contract.
|
||||
PORTABILITY_CALLS="$ROOT/portability-calls"
|
||||
printf -v PORTABILITY_PADDING '%*s' 32768 ''
|
||||
PORTABILITY_PADDING=${PORTABILITY_PADDING// /x}
|
||||
printf '%s\0' /usr/bin/env -i "$PORTABILITY_PADDING" > "$PORTABILITY_CALLS"
|
||||
pane_command_clears_environment "$PORTABILITY_CALLS" || \
|
||||
fail "valid large pane argv was rejected by the environment-boundary assertion"
|
||||
|
||||
assert_pane_boundary_rejected() {
|
||||
local case_name="$1"
|
||||
local expected_records="$2"
|
||||
local diagnostic
|
||||
if diagnostic=$(check_pane_environment_boundary "$PORTABILITY_CALLS" 2>&1); then
|
||||
fail "pane boundary accepted invalid $case_name fixture"
|
||||
fi
|
||||
contains_literal "$diagnostic" "records=$expected_records bytes=" || \
|
||||
fail "pane argv diagnostic omitted counts for $case_name fixture"
|
||||
contains_literal "$diagnostic" '[000]' || \
|
||||
fail "pane argv diagnostic omitted indexed arguments for $case_name fixture"
|
||||
}
|
||||
|
||||
printf '%s\0' tmux -i > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected missing-env 2
|
||||
printf '%s\0' /usr/bin/env HOME=/untrusted > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected missing-i 2
|
||||
printf '%s\0' /usr/bin/env HOME=/untrusted -i > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected non-adjacent-i 3
|
||||
printf '%s\0' -i /usr/bin/env > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected reversed-boundary 2
|
||||
|
||||
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -62,6 +138,19 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/mosaic"
|
||||
|
||||
# Freeze numeric epoch reads only when a test arm supplies an observation bound.
|
||||
# Formatting reads still use the real BusyBox/POSIX date implementation.
|
||||
cat > "$FAKE_BIN/date" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ -n "${MOSAIC_TEST_FIXED_EPOCH:-}" ] && [ "${1:-}" = '+%s' ]; then
|
||||
printf '%s\n' "$MOSAIC_TEST_FIXED_EPOCH"
|
||||
exit 0
|
||||
fi
|
||||
exec /bin/date "$@"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/date"
|
||||
|
||||
write_generated() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
@@ -71,6 +160,7 @@ write_generated() {
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_GIT_IDENTITY=$agent
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||
@@ -88,6 +178,7 @@ run_start() {
|
||||
local agent="$2"
|
||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
|
||||
MOSAIC_TEST_HOME="$home" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$home" "$START" "$agent"
|
||||
@@ -100,19 +191,55 @@ AGENT_VALID="coder0"
|
||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
||||
run_start "$HOME_VALID" "$AGENT_VALID"
|
||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
||||
if echo "$valid_args" | grep -qF 'bash -c'; then
|
||||
contains_literal "$valid_args" new-session || fail "valid generated projection did not reach tmux"
|
||||
contains_literal "$valid_args" mosaic || fail "fixed mosaic launcher command missing"
|
||||
contains_literal "$valid_args" yolo || fail "fixed yolo launcher command missing"
|
||||
contains_literal "$valid_args" pi || fail "roster runtime missing"
|
||||
if contains_literal "$valid_args" 'bash -c'; then
|
||||
fail "launcher constructed a shell command payload"
|
||||
fi
|
||||
|
||||
# The pane must start through an absolute clean-environment boundary. Its
|
||||
# runtime command remains an argv vector, but no holder/session environment
|
||||
# control variable can pass through the pane command.
|
||||
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
||||
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||
|
||||
# Git identity is generated authority, not an optional or independently mutable
|
||||
# local value. Each invalid form must fail before fake tmux receives a call.
|
||||
assert_git_identity_rejected() {
|
||||
local case_name="$1"
|
||||
local expected_code="$2"
|
||||
local home="$ROOT/git-identity-$case_name"
|
||||
local agent="coder-git-identity-$case_name"
|
||||
local generated="$home/fleet/agents/$agent.env.generated"
|
||||
write_generated "$home" "$agent"
|
||||
|
||||
case "$case_name" in
|
||||
missing) grep -v '^MOSAIC_GIT_IDENTITY=' "$generated" > "$generated.next" && mv "$generated.next" "$generated" ;;
|
||||
unsafe) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=bad/identity|' "$generated" ;;
|
||||
mismatch) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=other-agent|' "$generated" ;;
|
||||
local-shadow)
|
||||
printf 'MOSAIC_GIT_IDENTITY=%s\n' "$agent" > "$home/fleet/agents/$agent.env.local"
|
||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||
;;
|
||||
*) fail "unknown Git identity rejection case: $case_name" ;;
|
||||
esac
|
||||
chmod 600 "$generated"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
if output=$(run_start "$home" "$agent" 2>&1); then
|
||||
fail "Git identity case $case_name was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before Git identity $case_name rejection"
|
||||
contains_literal "$output" "code=$expected_code" || \
|
||||
fail "Git identity $case_name diagnostic omitted code $expected_code"
|
||||
}
|
||||
|
||||
assert_git_identity_rejected missing missing-key
|
||||
assert_git_identity_rejected unsafe unsafe-git-identity
|
||||
assert_git_identity_rejected mismatch git-identity-mismatch
|
||||
assert_git_identity_rejected local-shadow generated-key-shadow
|
||||
|
||||
# The generated-file parent is a security boundary too: even a private regular
|
||||
# file is untrusted if its parent can be replaced or written by another user.
|
||||
@@ -125,7 +252,7 @@ if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
|
||||
fail "generated file under a world-writable parent was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||
contains_literal "$output" 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
||||
@@ -136,7 +263,7 @@ if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
|
||||
fail "generated file under a symlinked parent was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||
contains_literal "$output" 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||
|
||||
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
||||
# symlink or group/world-writable ancestor must fail before environment parsing,
|
||||
@@ -174,8 +301,8 @@ assert_managed_ancestor_rejected() {
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
||||
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
||||
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
|
||||
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
||||
contains_literal "$output" 'code=unsafe-' || fail "managed ancestor diagnostic missing"
|
||||
if contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND'; then
|
||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||
fi
|
||||
}
|
||||
@@ -196,9 +323,9 @@ if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
|
||||
fail "generated-key shadow was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF 'codex'; then
|
||||
contains_literal "$output" 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||
contains_literal "$output" 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||
if contains_literal "$output" codex; then
|
||||
fail "shadow diagnostic leaked value"
|
||||
fi
|
||||
|
||||
@@ -214,9 +341,9 @@ if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
|
||||
fail "arbitrary command override was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
||||
contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||
contains_literal "$output" 'sha256=' || fail "command diagnostic omitted hash"
|
||||
if contains_literal "$output" "$COMMAND_VALUE"; then
|
||||
fail "command diagnostic leaked command value"
|
||||
fi
|
||||
|
||||
@@ -230,7 +357,7 @@ if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
|
||||
fail "world-readable local input was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||
contains_literal "$output" 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||
|
||||
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
||||
# computed PATH only. The pane command itself must not carry loader, shell
|
||||
@@ -260,25 +387,35 @@ PATH="$PANE_STALE_PATH" \
|
||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||
"$START" coder-pane-boundary
|
||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
contains_line "$pane_args" "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "pane did not restore trusted HOME"
|
||||
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
|
||||
contains_literal "$pane_args" "HOME=$PANE_STALE_HOME" && \
|
||||
fail "pane inherited stale HOME"
|
||||
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||
contains_literal "$pane_args" "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
|
||||
contains_literal "$pane_args" "$blocked" && fail "pane inherited $blocked"
|
||||
done
|
||||
|
||||
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
||||
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
||||
fail "pane command did not clear its environment"
|
||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
||||
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
# Exercise the repository launcher at $START, not the independently installed
|
||||
# host copy. Set-compare every declared generated projection entry with the
|
||||
# launched process environment so a newly declared identity cannot be omitted
|
||||
# by a hand-maintained per-variable assertion.
|
||||
declared_generated_environment=$(sort "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.generated")
|
||||
missing_or_changed_generated_environment=$(comm -23 \
|
||||
<(printf '%s\n' "$declared_generated_environment") \
|
||||
<(printf '%s\n' "$pane_environment" | sort))
|
||||
if [ -n "$missing_or_changed_generated_environment" ]; then
|
||||
missing_or_changed_keys=$(printf '%s\n' "$missing_or_changed_generated_environment" | cut -d= -f1 | paste -sd, -)
|
||||
fail "runtime pane omitted or changed generated environment keys: $missing_or_changed_keys"
|
||||
fi
|
||||
contains_line "$pane_environment" "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "runtime pane did not receive trusted HOME"
|
||||
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||
contains_literal "$pane_environment" "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
||||
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
|
||||
done
|
||||
|
||||
write_interaction_generated() {
|
||||
@@ -290,6 +427,7 @@ write_interaction_generated() {
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_GIT_IDENTITY=$agent
|
||||
MOSAIC_AGENT_CLASS=operator-interaction
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
||||
@@ -352,8 +490,12 @@ write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
||||
touch -d '10 seconds ago' "$STALE_HB.native"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||
touch -t 200001010000.00 "$STALE_HB.native"
|
||||
# Hold the sidecar's observation epoch constant: assertion runtime must not age
|
||||
# a fresh-marker mutant into the stale state that this fixture must distinguish.
|
||||
STALE_OBSERVATION_EPOCH=$(date +%s)
|
||||
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||
wait_for_sidecar_status "$STALE_HB"
|
||||
|
||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
||||
@@ -374,22 +516,22 @@ if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed
|
||||
fail "interaction wrapper accepted malformed generated data"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
||||
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||
contains_literal "$output" 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||
|
||||
# A syntactically valid but policy-incompatible projection reaches the pinned
|
||||
# interaction policy check only after strict parsing and never starts tmux.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
||||
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
|
||||
sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
|
||||
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
||||
fi
|
||||
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
||||
contains_literal "$interaction_policy_args" new-session && \
|
||||
fail "interaction pinned-policy rejection created a tmux session"
|
||||
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
||||
contains_literal "$output" 'operator interaction service requires runtime pi' || \
|
||||
fail "interaction pinned-policy check did not follow strict parsing"
|
||||
|
||||
# Exact stop derives the socket exclusively from the validated generated
|
||||
@@ -402,11 +544,30 @@ HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
||||
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
||||
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
|
||||
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
||||
contains_line "$stop_args" mosaic-test || fail "exact stop did not use the validated generated socket"
|
||||
contains_line "$stop_args" kill-session || fail "exact stop did not request session termination"
|
||||
contains_line "$stop_args" '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||
if contains_literal "$stop_args" ambient-socket; then
|
||||
fail "exact stop trusted an ambient socket"
|
||||
fi
|
||||
|
||||
# A seat scaffolded under ~/.mosaic owns its harness home, so the pane launches
|
||||
# through the composition instead of the operator's own home. --dangerous keeps the
|
||||
# seat on the permissions footing `mosaic yolo` gave it.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SEAT="$ROOT/seat"
|
||||
write_generated "$HOME_SEAT" "coder-seat"
|
||||
mkdir -p "$HOME_SEAT/.mosaic/fleet/agents/coder-seat"
|
||||
printf '{"schema":1,"harness":"pi","bundle":"primary"}\n' \
|
||||
> "$HOME_SEAT/.mosaic/fleet/agents/coder-seat/profile.json"
|
||||
run_start "$HOME_SEAT" "coder-seat"
|
||||
seat_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$seat_args" | grep -qxF 'fleet' || fail "scaffolded seat did not launch through fleet launch"
|
||||
echo "$seat_args" | grep -qxF 'launch' || fail "scaffolded seat did not launch through fleet launch"
|
||||
echo "$seat_args" | grep -qxF 'coder-seat' || fail "fleet launch did not name the seat"
|
||||
echo "$seat_args" | grep -qxF -- '--dangerous' || fail "scaffolded seat lost dangerous permissions"
|
||||
if echo "$seat_args" | grep -qxF 'yolo'; then
|
||||
fail "scaffolded seat still launched through mosaic yolo"
|
||||
fi
|
||||
|
||||
echo 'ok - start-agent-session generated environment boundary'
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
detect_platform() {
|
||||
local remote_url
|
||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
||||
# kills the CALLER before the -z check below can run, so the error message that is
|
||||
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
||||
|
||||
if [[ -z "$remote_url" ]]; then
|
||||
echo "error: not a git repository or no origin remote" >&2
|
||||
@@ -39,7 +42,10 @@ detect_platform() {
|
||||
|
||||
get_repo_info() {
|
||||
local remote_url
|
||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
||||
# kills the CALLER before the -z check below can run, so the error message that is
|
||||
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
||||
|
||||
if [[ -z "$remote_url" ]]; then
|
||||
echo "error: not a git repository or no origin remote" >&2
|
||||
@@ -240,6 +246,21 @@ PY
|
||||
} >&2
|
||||
}
|
||||
|
||||
# Explain tea's most misleading failure. `user does not exist [uid: 0, name: ]` reads
|
||||
# as a missing account; it almost always means a REVOKED OR STALE TOKEN. `tea login`
|
||||
# keeps its OWN COPY of the token, so rotating the credential store does not update it.
|
||||
# Diagnostic only -- stderr, no control flow, no exit.
|
||||
explain_tea_user_does_not_exist() {
|
||||
cat >&2 <<'MSG'
|
||||
NOTE: `user does not exist [uid: 0, name: ]` from tea usually means a REVOKED OR STALE TOKEN,
|
||||
not a missing account. A `tea login` stores its OWN COPY of the token; rotating the
|
||||
credential store does NOT update it.
|
||||
CHECK: the login's cached copy (`tea login list` -- read the FULL table, never `| head`),
|
||||
then re-register that login against the current token.
|
||||
DO NOT probe capability with a mutating request; a POST is the action, not a check.
|
||||
MSG
|
||||
}
|
||||
|
||||
get_gitea_login_for_host() {
|
||||
local host="${1:-}"
|
||||
local login
|
||||
|
||||
@@ -91,13 +91,32 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||
# `tea issue comment` is NOT a subcommand -- tea 0.11.x lists only
|
||||
# list/create/edit/reopen/close under `tea issue`. Comments are the
|
||||
# TOP-LEVEL `tea comment`, which takes the same --repo/--login flags.
|
||||
# The old call therefore always failed, was unchecked, and the script
|
||||
# closed the issue anyway, losing the record of WHY.
|
||||
#
|
||||
# Use `tea comment` rather than the API helper so the comment and the
|
||||
# close are made by the SAME principal ($GITEA_LOGIN_NAME). Routing the
|
||||
# comment through the token-authenticated helper here would attribute the
|
||||
# comment to the token holder and the close to the tea login -- two
|
||||
# principals for one operation.
|
||||
tea comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || {
|
||||
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||
else
|
||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
gitea_issue_comment_api
|
||||
# Fail closed here too: an unchecked comment lets the issue close without its
|
||||
# audit trail, which is the same defect as the tea path above.
|
||||
gitea_issue_comment_api || {
|
||||
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
gitea_issue_close_api
|
||||
fi
|
||||
|
||||
@@ -254,15 +254,32 @@ from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||
#
|
||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||
# provider's own correct answer about a write that landed — a deterministic
|
||||
# false negative on every comment posted against such a deployment. The
|
||||
# scheme is also not what this check defends: the forgeries it exists to
|
||||
# catch (look-alike host, decoy path prefix, wrong owner/repo/number) all
|
||||
# vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||
#
|
||||
# Port: an implicit port and its own scheme's default compare equal, so
|
||||
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||
# because a different port is a different service on the same host.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
if scheme in ("http", "https"):
|
||||
scheme_class = "web"
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = None if parsed.port in (None, default_port) else parsed.port
|
||||
else:
|
||||
scheme_class = scheme
|
||||
port = parsed.port
|
||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -156,6 +156,7 @@ case "$PLATFORM" in
|
||||
exit 0
|
||||
fi
|
||||
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
fi
|
||||
gitea_issue_create_api
|
||||
;;
|
||||
|
||||
@@ -71,6 +71,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
fi
|
||||
gitea_issue_view_api
|
||||
else
|
||||
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env bash
|
||||
# mosaic-worktree.sh — the only supported way to create and dispose of a git
|
||||
# worktree on a fleet host.
|
||||
#
|
||||
# Why this exists as a helper and not as a rule: the rule already existed, in
|
||||
# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in
|
||||
# $HOME across 842 directories anyway. Five placement conventions were live on
|
||||
# one fleet host simultaneously. Every one was a decision an agent had to make,
|
||||
# and a decision an agent has to make is a decision that drifts.
|
||||
#
|
||||
# So this script makes NO placement decision available. The caller supplies a
|
||||
# branch name. Every path is DERIVED:
|
||||
#
|
||||
# main worktree <- git worktree list --porcelain (never cwd, which may
|
||||
# itself already be a worktree)
|
||||
# REPO_NAME <- basename of the main worktree
|
||||
# REPO_PARENT <- dirname of the main worktree
|
||||
# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees
|
||||
# SLUG <- branch with '/' replaced by '-'
|
||||
# WT_PATH <- $WT_ROOT/$SLUG
|
||||
#
|
||||
# The derivation puts the worktree on the same filesystem as the object store
|
||||
# it shares, as a sibling of the repo, under one root per repo. Those are the
|
||||
# properties that make the checkout cheap and — via `git worktree list` —
|
||||
# enumerable, which is the only reason automated cleanup can ever be safe.
|
||||
#
|
||||
# Usage:
|
||||
# mosaic-worktree.sh new <branch> [--from <base>] create (branch may exist)
|
||||
# mosaic-worktree.sh path <branch> print derived path, no side effect
|
||||
# mosaic-worktree.sh list this repo's worktrees + state
|
||||
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
|
||||
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
|
||||
#
|
||||
# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with
|
||||
# commits absent from every remote, or holding ignored files that are not of the
|
||||
# well-known regenerable kind (a `.env` is ignored so it is never committed,
|
||||
# which is also why nothing else holds a copy). That check is by EVIDENCE, never
|
||||
# by size or age. --force overrides it and is yours to type deliberately.
|
||||
#
|
||||
# Run from anywhere inside the repo, or pass --repo <path>.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
REPO_HINT=""
|
||||
ARGS=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--repo) REPO_HINT="${2:-}"; shift 2 ;;
|
||||
*) ARGS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||
|
||||
CMD="${1:-}"
|
||||
[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc"
|
||||
shift || true
|
||||
|
||||
# ---- mechanical derivation -------------------------------------------------
|
||||
# The FIRST entry of `git worktree list --porcelain` is always the main
|
||||
# worktree, regardless of which worktree we are standing in. Deriving from cwd
|
||||
# would nest worktrees inside worktrees.
|
||||
resolve_repo() {
|
||||
local start="${REPO_HINT:-$PWD}"
|
||||
git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \
|
||||
|| die "not inside a git repository: $start"
|
||||
# Take the first entry WITHOUT closing the pipe early. `awk ... exit` on the
|
||||
# first match closes the read end while git is still writing, git takes SIGPIPE,
|
||||
# and under `set -euo pipefail` the command substitution returns 141 and this
|
||||
# function aborts SILENTLY — no message, no worktree, and `new` exits 141 while
|
||||
# printing nothing at all.
|
||||
#
|
||||
# Whether it happens depends on how much git still had to write when awk left,
|
||||
# so the failure is a function of REPO SIZE: fine on a repo with three
|
||||
# worktrees, reliably broken on one with seventy. That is backwards — the repos
|
||||
# this helper exists to serve are exactly the ones that accumulated worktrees,
|
||||
# and it silently did nothing on those while working everywhere it was tried.
|
||||
# Measured on a repo with 73 worktrees (10 KB of porcelain): rc=141, no output.
|
||||
#
|
||||
# The file's own comment block below already names this class for `head -200`
|
||||
# and removed that cap for the same reason. The `exit` here is the same defect
|
||||
# in the same file, so the rule is now uniform: nothing in this script closes a
|
||||
# git pipe early. Dropping `exit` costs one pass over a few KB.
|
||||
MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /&&!seen{print substr($0,10); seen=1}')"
|
||||
[ -n "$MAIN_WT" ] || die "could not resolve the main worktree"
|
||||
REPO_NAME="$(basename -- "$MAIN_WT")"
|
||||
REPO_PARENT="$(dirname -- "$MAIN_WT")"
|
||||
WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees"
|
||||
}
|
||||
|
||||
slugify() { printf '%s' "$1" | tr '/' '-'; }
|
||||
|
||||
derive_path() {
|
||||
local branch="$1"
|
||||
[ -n "$branch" ] || die "branch name required"
|
||||
printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")"
|
||||
}
|
||||
|
||||
# A worktree root under $HOME defeats the entire point: wrong filesystem, and
|
||||
# $HOME is for configuration and state, not work products. Refuse rather than
|
||||
# silently produce the layout we are trying to eliminate.
|
||||
assert_not_home() {
|
||||
local p="$1" home_real repo_real
|
||||
home_real="$(cd "$HOME" && pwd -P)"
|
||||
repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")"
|
||||
case "$repo_real/" in
|
||||
"$home_real"/*)
|
||||
die "refusing: derived path is under \$HOME ($p).
|
||||
The repo itself lives under \$HOME, so its worktrees would too. Move the repo
|
||||
to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds
|
||||
configuration, credentials, state and caches — not checkouts." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- work-loss evidence ----------------------------------------------------
|
||||
# Two independent questions, both answered from git, neither from size or age:
|
||||
# dirty — anything uncommitted in the tree
|
||||
# unpushed — commits reachable from HEAD that no remote ref contains
|
||||
# precious — IGNORED files git will not mention and will not miss
|
||||
#
|
||||
# The third question is not obvious and was missed on the first pass. An
|
||||
# independent reviewer demonstrated it in four commands: a pushed, clean
|
||||
# worktree whose .gitignore covers `*.secret`, holding one `local.secret`.
|
||||
# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is
|
||||
# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The
|
||||
# same shape covers `.env`, credentials, scratch notes, downloaded fixtures:
|
||||
# precisely the files that are ignored BECAUSE they must not be committed, which
|
||||
# is also why nothing else is holding a copy.
|
||||
#
|
||||
# So ignored files count as work unless they are the well-known regenerable
|
||||
# kind. Getting that set wrong is asymmetric: an over-broad list preserves a
|
||||
# worktree that could have been reclaimed (cheap, visible, fixable by --force),
|
||||
# an over-narrow one deletes the only copy of a secret (silent, permanent).
|
||||
# The list stays short and conservative for that reason.
|
||||
DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$'
|
||||
|
||||
# These three run under `set -euo pipefail` inside command substitution, which
|
||||
# makes any nonzero exit ANYWHERE in the pipeline abort the calling function
|
||||
# silently. Two ways that bites, one of which shipped:
|
||||
#
|
||||
# * `grep -v` exits 1 when it filters everything out. A worktree whose only
|
||||
# ignored entry is `node_modules/` is exactly the SAFE case, and it made
|
||||
# `rm` exit 1 with no message and no removal — found by review.
|
||||
# * `head -200` closes the pipe, SIGPIPEs the producer, and turns a worktree
|
||||
# with 201 dirty files into the same silent abort. Not reported; it is the
|
||||
# same defect one step upstream, so the cap is gone. Counting is cheap;
|
||||
# the cap only ever protected output that is now never printed.
|
||||
#
|
||||
# Every one of them therefore ends in a total, and every stage that can
|
||||
# legitimately exit nonzero says so explicitly.
|
||||
wt_dirty() {
|
||||
local out
|
||||
out="$(git -C "$1" status --porcelain 2>/dev/null || true)"
|
||||
if [ -n "$out" ]; then printf '%s\n' "$out" | wc -l; else printf '0'; fi
|
||||
}
|
||||
|
||||
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || printf '?'; }
|
||||
|
||||
# Default --ignored (not =matching) so a 40k-file node_modules collapses to one
|
||||
# directory entry instead of being enumerated and then discarded.
|
||||
wt_precious() {
|
||||
local ignored
|
||||
ignored="$(git -C "$1" status --porcelain --ignored 2>/dev/null \
|
||||
| awk '/^!! /{print substr($0,4)}' || true)"
|
||||
[ -n "$ignored" ] || { printf '0'; return 0; }
|
||||
printf '%s\n' "$ignored" | grep -Ecv "$DISPOSABLE_RE" || true
|
||||
}
|
||||
|
||||
wt_state() {
|
||||
local wt="$1" d u p
|
||||
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")"
|
||||
if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then
|
||||
printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping'
|
||||
else
|
||||
printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- commands --------------------------------------------------------------
|
||||
cmd_path() { resolve_repo; derive_path "${1:-}"; echo; }
|
||||
|
||||
cmd_new() {
|
||||
local branch="${1:-}" base=""
|
||||
shift || true
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac
|
||||
done
|
||||
[ -n "$branch" ] || die "usage: mosaic-worktree.sh new <branch> [--from <base>]"
|
||||
|
||||
resolve_repo
|
||||
local path; path="$(derive_path "$branch")"
|
||||
assert_not_home "$path"
|
||||
|
||||
if [ -e "$path" ]; then
|
||||
echo "exists: $path"
|
||||
echo "(already checked out — reuse it, or 'rm' it first)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$WT_ROOT"
|
||||
|
||||
# Existing branch -> check it out. New branch -> create from base (default:
|
||||
# the remote's default branch if resolvable, else current HEAD).
|
||||
if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \
|
||||
|| git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
||||
git -C "$MAIN_WT" worktree add "$path" "$branch"
|
||||
else
|
||||
if [ -z "$base" ]; then
|
||||
base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
||||
[ -n "$base" ] || base="HEAD"
|
||||
fi
|
||||
git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
worktree: $path
|
||||
branch: $branch
|
||||
|
||||
Removal is part of this task, not a later chore. When the work is pushed:
|
||||
mosaic-worktree.sh rm $branch
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
resolve_repo
|
||||
printf 'repo: %s\nroot: %s\n\n' "$MAIN_WT" "$WT_ROOT"
|
||||
git -C "$MAIN_WT" worktree list --porcelain \
|
||||
| awk '/^worktree /{print substr($0,10)}' \
|
||||
| while read -r wt; do
|
||||
[ "$wt" = "$MAIN_WT" ] && { printf '%-10s %s (main)\n' "-" "$wt"; continue; }
|
||||
printf '%-10s %s\t%s\n' "$(wt_state "$wt" | cut -f1)" "$wt" "$(wt_state "$wt" | cut -f2)"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_rm() {
|
||||
local branch="${1:-}" force=0
|
||||
shift || true
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --force) force=1; shift ;; *) die "unknown flag: $1" ;; esac
|
||||
done
|
||||
[ -n "$branch" ] || die "usage: mosaic-worktree.sh rm <branch> [--force]"
|
||||
|
||||
resolve_repo
|
||||
local path; path="$(derive_path "$branch")"
|
||||
[ -d "$path" ] || die "no worktree at $path"
|
||||
|
||||
local d u p
|
||||
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")"
|
||||
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then
|
||||
die "refusing to remove $path
|
||||
uncommitted files: $d
|
||||
unpushed commits: $u
|
||||
ignored, not disposable: $p
|
||||
Commit and push first — that is the contract. Ignored files are counted because
|
||||
git will neither report them nor miss them: a .env or a *.secret is ignored
|
||||
precisely so it is never committed, which is also why nothing else holds a copy.
|
||||
List them with: git -C $path status --porcelain --ignored | grep '^!!'
|
||||
If this work is genuinely disposable, re-run with --force."
|
||||
fi
|
||||
|
||||
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
|
||||
if [ "$force" -eq 1 ]; then
|
||||
git -C "$MAIN_WT" worktree remove --force "$path"
|
||||
else
|
||||
git -C "$MAIN_WT" worktree remove "$path"
|
||||
fi
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
echo "removed: $path"
|
||||
rmdir "$WT_ROOT" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cmd_gc() {
|
||||
local apply=0
|
||||
[ "${1:-}" = "--apply" ] && apply=1
|
||||
resolve_repo
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
git -C "$MAIN_WT" worktree list --porcelain \
|
||||
| awk '/^worktree /{print substr($0,10)}' \
|
||||
| while read -r wt; do
|
||||
[ "$wt" = "$MAIN_WT" ] && continue
|
||||
local_state="$(wt_state "$wt")"
|
||||
case "$local_state" in
|
||||
SAFE*)
|
||||
if [ "$apply" -eq 1 ]; then
|
||||
git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt"
|
||||
else
|
||||
echo "reclaimable (clean + fully pushed): $wt"
|
||||
fi ;;
|
||||
*) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;;
|
||||
esac
|
||||
done
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
[ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)'
|
||||
}
|
||||
|
||||
case "$CMD" in
|
||||
new) cmd_new "$@" ;;
|
||||
path) cmd_path "$@" ;;
|
||||
list) cmd_list "$@" ;;
|
||||
rm) cmd_rm "$@" ;;
|
||||
gc) cmd_gc "$@" ;;
|
||||
-h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||
*) die "unknown command: $CMD (new | path | list | rm | gc)" ;;
|
||||
esac
|
||||
@@ -84,7 +84,7 @@ cp "$TARGET" "$BAK"
|
||||
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
||||
|
||||
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
||||
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)"
|
||||
PROSE_LO="$(grep -n -m1 '^usage() {' "$BAK" | cut -d: -f1)"
|
||||
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
||||
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
||||
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
||||
|
||||
@@ -219,6 +219,7 @@ case "$PLATFORM" in
|
||||
exit 0
|
||||
fi
|
||||
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
gitea_pr_create_api
|
||||
;;
|
||||
*)
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# pr-edit.sh - Edit a pull request on GitHub or Gitea
|
||||
# Usage: pr-edit.sh -n <pr_number> [-t <title>] [-b <body>] [-B <base>] [--draft|--ready] [--login <name>] [-r owner/repo] [-H host]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
PR_NUMBER=""
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BASE_BRANCH=""
|
||||
DRAFT_MODE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
AUTH_CONFIG=""
|
||||
|
||||
cleanup() {
|
||||
[[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG"
|
||||
}
|
||||
terminate() {
|
||||
local signal="$1"
|
||||
trap - "$signal"
|
||||
cleanup
|
||||
kill -s "$signal" "$$"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'terminate HUP' HUP
|
||||
trap 'terminate INT' INT
|
||||
trap 'terminate TERM' TERM
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
Edit a pull request on the current repository (Gitea or GitHub).
|
||||
|
||||
Options:
|
||||
-n, --number NUMBER Pull request number (required)
|
||||
-t, --title TITLE New title
|
||||
-b, --body BODY New body/description
|
||||
-B, --base BRANCH New base branch
|
||||
--draft Mark the pull request as draft
|
||||
--ready Mark the pull request ready for review
|
||||
-l, --login NAME Gitea login (must authenticate as MOSAIC_GIT_IDENTITY)
|
||||
-r, --repo OWNER/REPO Explicit target repository
|
||||
-H, --host HOST Explicit Gitea host (required with --repo off-host)
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
|
||||
-t|--title) TITLE="${2:-}"; shift 2 ;;
|
||||
-b|--body) BODY="${2:-}"; shift 2 ;;
|
||||
-B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
|
||||
--draft)
|
||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="draft"; shift ;;
|
||||
--ready)
|
||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="ready"; shift ;;
|
||||
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; }
|
||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; }
|
||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
||||
echo "Error: At least one edit option is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
|
||||
echo "Error: --repo must be OWNER/REPO" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
|
||||
PLATFORM="gitea"
|
||||
else
|
||||
PLATFORM=$(detect_platform)
|
||||
fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
|
||||
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
|
||||
CMD=(gh pr edit "$PR_NUMBER")
|
||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
"${CMD[@]}"
|
||||
fi
|
||||
if [[ "$DRAFT_MODE" == "draft" ]]; then
|
||||
gh pr ready "$PR_NUMBER" --undo
|
||||
elif [[ "$DRAFT_MODE" == "ready" ]]; then
|
||||
gh pr ready "$PR_NUMBER"
|
||||
fi
|
||||
;;
|
||||
gitea)
|
||||
IDENTITY="${MOSAIC_GIT_IDENTITY:-}"
|
||||
[[ -n "$IDENTITY" ]] || {
|
||||
echo "Error: MOSAIC_GIT_IDENTITY is required for a mutating Gitea operation" >&2
|
||||
exit 1
|
||||
}
|
||||
HOST="${HOST_OVERRIDE:-}"
|
||||
if [[ -z "$HOST" ]]; then
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not resolve Gitea host; pass --host with --repo" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
HOST="${HOST#http://}"; HOST="${HOST#https://}"; HOST="${HOST%%/*}"
|
||||
REPO_SLUG="${REPO_OVERRIDE:-}"
|
||||
if [[ -z "$REPO_SLUG" ]]; then
|
||||
REPO_SLUG=$(get_repo_slug) || { echo "Error: Could not resolve Gitea repo slug from remote" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
if [[ -n "$LOGIN_OVERRIDE" ]]; then
|
||||
GITEA_LOGIN_NAME="$LOGIN_OVERRIDE"
|
||||
elif [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||
GITEA_LOGIN_NAME="$GITEA_LOGIN"
|
||||
else
|
||||
echo "Error: --login (or GITEA_LOGIN) is required; refusing host-first login selection" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN_NAME" "$HOST") || {
|
||||
echo "Error: login '$GITEA_LOGIN_NAME' is not configured for target host '$HOST'" >&2
|
||||
exit 1
|
||||
}
|
||||
AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || {
|
||||
echo "Error: could not stage private Gitea authentication" >&2
|
||||
exit 1
|
||||
}
|
||||
unset TOKEN
|
||||
|
||||
API_BASE="https://${HOST}/api/v1"
|
||||
# Resolve identity through the SAME private curl config used for the
|
||||
# mutation. Tea login names are globally scoped and can be duplicated
|
||||
# across hosts; a separate `tea api --login NAME` could validate another
|
||||
# credential than this host-bound token.
|
||||
AUTHENTICATED_USER=$(curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \
|
||||
| python3 -c 'import json,sys; value=json.load(sys.stdin).get("login"); print(value) if isinstance(value,str) and value else sys.exit(1)') || {
|
||||
echo "Error: could not authenticate the host-bound credential for '$GITEA_LOGIN_NAME'" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$AUTHENTICATED_USER" == "$IDENTITY" ]] || {
|
||||
echo "Error: host-bound credential authenticates as '$AUTHENTICATED_USER', not MOSAIC_GIT_IDENTITY '$IDENTITY'" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
REPO_API="$API_BASE/repos/${REPO_SLUG}"
|
||||
curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || {
|
||||
echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
PAYLOAD=$(TITLE="$TITLE" BODY="$BODY" BASE_BRANCH="$BASE_BRANCH" DRAFT_MODE="$DRAFT_MODE" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
payload = {}
|
||||
if os.environ["TITLE"]: payload["title"] = os.environ["TITLE"]
|
||||
if os.environ["BODY"]: payload["body"] = os.environ["BODY"]
|
||||
if os.environ["BASE_BRANCH"]: payload["base"] = os.environ["BASE_BRANCH"]
|
||||
if os.environ["DRAFT_MODE"]: payload["draft"] = os.environ["DRAFT_MODE"] == "draft"
|
||||
print(json.dumps(payload))
|
||||
PY
|
||||
)
|
||||
curl -fsS --config "$AUTH_CONFIG" -X PATCH \
|
||||
-H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" "$REPO_API/pulls/${PR_NUMBER}"
|
||||
echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2
|
||||
;;
|
||||
*) echo "Error: Could not detect git platform" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -243,15 +243,35 @@ from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||
#
|
||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||
# provider's own correct answer about a comment that landed — a deterministic
|
||||
# false negative on EVERY review comment posted against such a deployment.
|
||||
# That matters more here than anywhere else: on a host where no seat can
|
||||
# create a review OBJECT, the comment-form review record this path produces
|
||||
# is the only gate-16 evidence available, and this check refuses all of it.
|
||||
# The scheme is also not what the check defends: the forgeries it exists to
|
||||
# catch (look-alike host, decoy path prefix, wrong owner/repo/kind/number)
|
||||
# all vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||
#
|
||||
# Port: an implicit port and its own scheme's default compare equal, so
|
||||
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||
# because a different port is a different service on the same host.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
if scheme in ("http", "https"):
|
||||
scheme_class = "web"
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = None if parsed.port in (None, default_port) else parsed.port
|
||||
else:
|
||||
scheme_class = scheme
|
||||
port = parsed.port
|
||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# Regression: detect_platform / get_repo_info must FAIL LOUDLY outside a git repo,
|
||||
# not kill the caller silently.
|
||||
#
|
||||
# Both functions already contained the right error path:
|
||||
# if [[ -z "$remote_url" ]]; then echo "error: not a git repository..." >&2; return 1; fi
|
||||
# but under `set -e` -- which every wrapper in this directory uses -- the preceding
|
||||
# assignment `remote_url=$(git remote get-url origin 2>/dev/null)` returns git's 128
|
||||
# outside a repo and terminates the CALLER first. The message was unreachable.
|
||||
#
|
||||
# Observed cost: pr-review.sh invoked from a non-repo cwd exits 128 with NO stdout and
|
||||
# NO stderr, even when -r/--repo and -H/--host are supplied -- the flags documented as
|
||||
# "skips git-remote inference". Two reviewer seats hit this and correctly reported
|
||||
# `blocked` with no diagnostic to report.
|
||||
#
|
||||
# The control that matters is the LOUD one: asserting "rc != 0" passes on the broken
|
||||
# build too, because 128 is also non-zero. The test must assert the MESSAGE.
|
||||
set -uo pipefail
|
||||
fail=0
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
run_outside() { # $1=function name -> "rc:sawmessage"
|
||||
local fn="$1" out rc
|
||||
out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$?
|
||||
printf '%s:%s' "$rc" "$(grep -qi 'not a git repository' <<<"$out" && echo yes || echo no)"
|
||||
}
|
||||
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||
|
||||
# $TMP must not be inside a git repo. Do not SKIP on failure: be-coder-07 showed the
|
||||
# original SKIP exited 0, so pointing TMPDIR beneath a git worktree made this test PASS
|
||||
# against unchanged main. A skip that exits 0 is indistinguishable from a pass.
|
||||
# GIT_CEILING_DIRECTORIES stops git walking above $TMP, making the condition hold
|
||||
# regardless of where TMPDIR lives, rather than merely detecting when it does not.
|
||||
# GIT_CEILING_DIRECTORIES is matched against the PHYSICAL path -- a symlinked TMPDIR
|
||||
# (/tmp is commonly one) makes the logical path never match, and the ceiling silently
|
||||
# does nothing. Resolve it before exporting.
|
||||
TMP="$(cd "$TMP" && pwd -P)"
|
||||
export GIT_CEILING_DIRECTORIES="$TMP"
|
||||
if ( cd "$TMP" && git rev-parse --git-dir >/dev/null 2>&1 ); then
|
||||
echo " FAIL scratch dir is inside a git repo even with GIT_CEILING_DIRECTORIES set;"
|
||||
echo " the outside-a-repo precondition cannot be established -- refusing to report a result"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== outside a git repo: rc=1 AND the diagnostic is emitted =="
|
||||
check "detect_platform" "$(run_outside detect_platform)" "1:yes"
|
||||
check "get_repo_info" "$(run_outside get_repo_info)" "1:yes"
|
||||
|
||||
echo "== inside a git repo the functions still work =="
|
||||
git init -q "$TMP/repo" 2>/dev/null
|
||||
git -C "$TMP/repo" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git 2>/dev/null
|
||||
out=$( cd "$TMP/repo" && bash -c "set -e; source '$HERE/detect-platform.sh'; detect_platform" 2>&1 ); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qi 'gitea' <<<"$out"; then echo " PASS detect_platform in-repo (rc=0, $out)"
|
||||
else echo " FAIL detect_platform in-repo: rc=$rc out=$out"; fail=1; fi
|
||||
|
||||
[ "$fail" -eq 0 ] && echo "OK detect-platform fails loudly outside a repo" || echo "FAILED"
|
||||
exit "$fail"
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# Regression: the tea-failure diagnostic must be STATUS-NEUTRAL.
|
||||
#
|
||||
# Found by be-coder-08 reviewing PR #1086. At all three call sites the diagnostic is emitted
|
||||
# immediately BEFORE the Gitea API fallback. Written as the last command of an && list:
|
||||
# declare -F explain_... >/dev/null && explain_...
|
||||
# under `set -e` a FAILING diagnostic exits and the fallback never runs -- a diagnostic that
|
||||
# suppresses the recovery path it exists to explain. It misbehaves ONLY when the helper is
|
||||
# PRESENT, so the helper-absent path (pre-#1086 behaviour) keeps working and reads as a
|
||||
# passing control.
|
||||
#
|
||||
# TWO DEFECTS IN THE FIRST VERSION OF THIS TEST, both found by be-coder-08:
|
||||
# 1. `out=$( ... ) 2>"$errto"` applies the redirection to the ASSIGNMENT, not to the
|
||||
# command substitution, so the probe's stderr was never actually pointed at /dev/full
|
||||
# and the /dev/full rows proved nothing. Verified: `out=$(echo x >&2) 2>/dev/full`
|
||||
# leaks to the terminal and returns 0; the redirect must be INSIDE the substitution.
|
||||
# 2. `eval "$CONSTRUCT"` changes `set -e` semantics for a bare && list, so the probe did
|
||||
# not exercise the construct as the shipped file executes it. It now writes the line
|
||||
# into a real script and runs it -- same parse, same set -e rules, no eval.
|
||||
# The construct is still LIFTED FROM THE SHIPPED FILE: retyping the fixed form makes the
|
||||
# probe pass on a build whose real call sites still carry the bare && form.
|
||||
set -uo pipefail
|
||||
fail=0
|
||||
GIT_DIR_UNDER_TEST="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
probe() { # $1=present|absent $2=stderr target $3=source file -> "rc:fallback"
|
||||
local helper="$1" errto="$2" src="$3" construct script out rc
|
||||
construct=$(grep -m1 'explain_tea_user_does_not_exist' "$GIT_DIR_UNDER_TEST/$src" | sed 's/^[[:space:]]*//')
|
||||
[ -n "$construct" ] || { printf 'no-construct:no'; return; }
|
||||
script="$TMP/probe.sh"
|
||||
{
|
||||
echo '#!/bin/bash'
|
||||
echo 'set -e'
|
||||
echo 'explain_tea_user_does_not_exist() { echo "diagnostic" >&2; }'
|
||||
[ "$helper" = absent ] && echo 'unset -f explain_tea_user_does_not_exist'
|
||||
echo "$construct" # the shipped line, parsed by a real shell
|
||||
echo 'echo FALLBACK_REACHED'
|
||||
} > "$script"
|
||||
# redirect INSIDE the substitution so the subshell's stderr really is $errto
|
||||
out=$( bash "$script" 2>"$errto" ); rc=$?
|
||||
printf '%s:%s' "$rc" "$(grep -q FALLBACK_REACHED <<<"$out" && echo yes || echo no)"
|
||||
}
|
||||
|
||||
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||
|
||||
echo "== diagnostic must not alter exit status or skip the fallback =="
|
||||
# /dev/full makes every stderr write fail -- the real-world shape is a closed or full fd.
|
||||
for src in pr-create.sh issue-view.sh issue-create.sh; do
|
||||
check "$src stderr OK / helper present" "$(probe present /dev/null "$src")" "0:yes"
|
||||
check "$src stderr OK / helper absent " "$(probe absent /dev/null "$src")" "0:yes"
|
||||
check "$src stderr FAILING / helper present" "$(probe present /dev/full "$src")" "0:yes"
|
||||
check "$src stderr FAILING / helper absent " "$(probe absent /dev/full "$src")" "0:yes"
|
||||
done
|
||||
|
||||
echo "== all three call sites use the status-neutral form =="
|
||||
for f in pr-create.sh issue-view.sh issue-create.sh; do
|
||||
p="$GIT_DIR_UNDER_TEST/$f"
|
||||
grep -q '{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true' "$p" \
|
||||
&& echo " PASS $f guarded" || { echo " FAIL $f: diagnostic is not status-neutral"; fail=1; }
|
||||
done
|
||||
|
||||
[ "$fail" -eq 0 ] && echo "OK diagnostic is status-neutral" || echo "FAILED"
|
||||
exit "$fail"
|
||||
@@ -7,14 +7,40 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
LOG_FILE="$WORK_DIR/calls.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credentials below are silently
|
||||
# ignored. This suite is the one where the consequence is not subtle: it FAILS
|
||||
# outright on a provisioned seat (rc=1 bare, rc=0 with $HOME sandboxed, one
|
||||
# variable changed) and passes everywhere else, including CI, which has no
|
||||
# per-agent token to leak.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME in the four run helpers below. It only has to
|
||||
# bound a failure that the pin should already have prevented.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{
|
||||
@@ -86,6 +112,7 @@ run_in_repo() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
@@ -283,6 +310,7 @@ run_in_repo2() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR2:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
@@ -343,7 +371,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
||||
token_fallback() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_token_for_login "$1" "$2"
|
||||
' _ "$1" "$2"
|
||||
@@ -354,7 +382,7 @@ token_fallback() {
|
||||
token_pyyaml() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_token_for_login "$1" "$2"
|
||||
' _ "$1" "$2"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
||||
#
|
||||
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||
# Covers wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
||||
# Asserts, per wrapper:
|
||||
# 1. `--help` exits 0 and prints usage.
|
||||
@@ -18,6 +18,7 @@ WRAPPERS=(
|
||||
issue-list.sh
|
||||
milestone-create.sh
|
||||
pr-create.sh
|
||||
pr-edit.sh
|
||||
pr-list.sh
|
||||
pr-merge.sh
|
||||
)
|
||||
@@ -47,7 +48,7 @@ for wrapper in "${WRAPPERS[@]}"; do
|
||||
done
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "help-exit-code regression passed (7/7 wrappers)"
|
||||
echo "help-exit-code regression passed (8/8 wrappers)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression: issue-close.sh must NOT close an issue when the closing comment could not
|
||||
# be posted, and comment+close must be made by ONE principal.
|
||||
#
|
||||
# Guards two defects fixed together (see #1081):
|
||||
# 1. `tea issue comment` is not a subcommand -- tea exposes comments as the TOP-LEVEL
|
||||
# `tea comment`. The old call always failed, was unchecked, and the issue closed
|
||||
# anyway, losing the record of WHY it was closed.
|
||||
# 2. Routing the comment through the token-authenticated API helper while the close
|
||||
# used --login would attribute one operation to two principals.
|
||||
#
|
||||
# SAFETY (rev-974, #1085 review 130): this test previously ran under `set -uo pipefail`
|
||||
# with unchecked mkdir/redirect/cd, then prepended a possibly-nonexistent $MOCK_BIN to
|
||||
# PATH -- while `git remote add origin` names the REAL repository. Forcing setup failure
|
||||
# with an unwritable AGENT_WORK_ROOT made it `git init` in its CALLER's directory and
|
||||
# invoke the real, provider-mutating issue-close.sh. Setup now fails closed, and both
|
||||
# `tea` and `curl` are asserted to resolve INSIDE $MOCK_BIN before any target run.
|
||||
set -euo pipefail
|
||||
# NOTE: with `set -e`, `grep -q X && fail "..."` is a trap -- the ABSENT case (grep rc=1,
|
||||
# which is the PASSING case for a must-not-appear assertion) is the last command of an &&
|
||||
# list and silently terminates the script with no message. Every must-not-appear check
|
||||
# below is therefore an if-block. This is the same set -e + &&-list defect be-coder-08
|
||||
# found in #1086, reintroduced here by adding `set -e` for the sandbox-safety fix.
|
||||
|
||||
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
||||
SANDBOX="$WORK_ROOT/issue-close-fail-closed-test-$$"
|
||||
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
|
||||
cleanup() { rm -rf "$SANDBOX"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET="$SCRIPT_DIR/issue-close.sh"
|
||||
[ -f "$TARGET" ] || { echo "FAIL: issue-close.sh not found beside this test"; exit 1; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
|
||||
# Every setup step is checked. Under `set -e` these abort; the explicit || fail keeps the
|
||||
# reason legible instead of a bare non-zero exit.
|
||||
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
|
||||
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
|
||||
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
|
||||
git init -q || fail "setup: git init failed"
|
||||
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
|
||||
export PATH="$MOCK_BIN:$PATH" CALLS
|
||||
export GITEA_URL="https://git.mosaicstack.dev"
|
||||
export GITEA_TOKEN="redacted-test-token"
|
||||
|
||||
cat > "$MOCK_BIN/curl" <<'EOF'
|
||||
#!/bin/bash
|
||||
method=GET; url=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-X) method="$2"; shift 2 ;;
|
||||
http*|https*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf 'curl %s %s\n' "$method" "$url" >> "$CALLS"
|
||||
[ "${MOCK_CURL_FAIL:-}" = "1" ] && [ "$method" = "POST" ] && exit 22
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/curl"
|
||||
|
||||
mk_tea() { # $1 = exit code for a comment attempt; $2 = login list (empty => no login)
|
||||
local rc="$1" login="${2-}"
|
||||
cat > "$MOCK_BIN/tea" <<EOF
|
||||
#!/bin/bash
|
||||
printf 'tea %s\n' "\$*" >> "$CALLS"
|
||||
if [[ "\$*" == *"login list"* ]]; then
|
||||
printf '%s\n' '${login}'; exit 0
|
||||
fi
|
||||
# Fail ANY comment attempt -- both the correct top-level \`tea comment\` and the broken
|
||||
# \`tea issue comment\` -- so an unfixed script exercises the DEFECT rather than tripping
|
||||
# a setup assertion.
|
||||
if [[ "\$1" == "comment" || ( "\$1" == "issue" && "\$2" == "comment" ) ]]; then exit $rc; fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
}
|
||||
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||
|
||||
# The mocks must be the ones that run. Without this, a failed setup silently falls through
|
||||
# to the real tea/curl and the "test" mutates the real provider.
|
||||
assert_mocked() {
|
||||
local w
|
||||
for w in tea curl; do
|
||||
p=$(command -v "$w" || true)
|
||||
[ -n "$p" ] || fail "SAFETY: $w does not resolve at all"
|
||||
case "$p" in
|
||||
"$MOCK_BIN"/*) : ;;
|
||||
*) fail "SAFETY: $w resolves to $p, OUTSIDE the sandbox -- refusing to invoke the target" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
run_target() { # never let a target failure abort the test; we assert on rc
|
||||
# Call sites MUST use `rc=0; run_target ... || rc=$?` -- a bare `run_target ...; rc=$?`
|
||||
# lets the non-zero RETURN trip set -e in the CALLER before rc is ever read.
|
||||
set +e; bash "$TARGET" "$@" >/dev/null 2>&1; local rc=$?; set -e; return $rc
|
||||
}
|
||||
|
||||
# ── tea path ────────────────────────────────────────────────────────────────────────
|
||||
# 1. NEGATIVE (the regression): comment fails => must NOT close, must exit non-zero
|
||||
mk_tea 1 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
||||
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||
grep -qE 'tea (issue )?comment' "$CALLS" || fail "no comment attempt -- setup did not reach the tea branch"
|
||||
if grep -q 'tea issue close' "$CALLS"; then fail "ISSUE CLOSED AFTER THE COMMENT FAILED -- the regression"; fi
|
||||
[ "$rc" -ne 0 ] || fail "comment failed but issue-close exited 0 -- FAIL-OPEN"
|
||||
|
||||
# 2. POSITIVE: comment succeeds => close proceeds, exit 0
|
||||
mk_tea 0 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
||||
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "comment succeeded but issue-close exited $rc"
|
||||
grep -q 'tea issue close' "$CALLS" || fail "issue not closed even though the comment succeeded"
|
||||
|
||||
# 3. must use top-level `tea comment`, never `tea issue comment`
|
||||
if grep -q 'tea issue comment' "$CALLS"; then fail "used 'tea issue comment' -- not a valid subcommand"; fi
|
||||
|
||||
# 4. ONE PRINCIPAL: comment and close must carry the SAME --login
|
||||
c=$(grep -m1 '^tea comment' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
||||
k=$(grep -m1 '^tea issue close' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
||||
[ -n "$c" ] || fail "comment carried no --login"
|
||||
[ "$c" = "$k" ] || fail "MIXED PRINCIPALS: comment=$c close=$k"
|
||||
|
||||
# ── no-login / API fallback path ────────────────────────────────────────────────────
|
||||
# rev-974: the delta also adds fail-closed behaviour to this branch, and the suite never
|
||||
# reached it -- replacing the whole fallback contract with an unconditional close still
|
||||
# passed. These assert the POSTCONDITION (which HTTP calls happened, in what order),
|
||||
# not merely that a command ran.
|
||||
# 5. no login + comment FAILS => POST attempted, NO PATCH, non-zero
|
||||
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||
rc=0; MOCK_CURL_FAIL=1 run_target -i 42 -c "closing note" || rc=$?
|
||||
grep -q 'curl POST' "$CALLS" || fail "API path: no comment POST attempted"
|
||||
if grep -q 'curl PATCH' "$CALLS"; then fail "API path: ISSUE CLOSED (PATCH) AFTER THE COMMENT POST FAILED"; fi
|
||||
[ "$rc" -ne 0 ] || fail "API path: comment failed but exited 0 -- FAIL-OPEN"
|
||||
|
||||
# 6. no login + comment SUCCEEDS => POST strictly BEFORE PATCH, exit 0
|
||||
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "API path: comment succeeded but exited $rc"
|
||||
order=$(grep -oE 'curl (POST|PATCH)' "$CALLS" | awk '{print $2}' | paste -sd, -)
|
||||
[ "$order" = "POST,PATCH" ] || fail "API path: expected POST,PATCH -- got '${order:-<none>}'"
|
||||
|
||||
# 7. no login + NO comment => PATCH only, never a POST
|
||||
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||
rc=0; run_target -i 42 || rc=$?
|
||||
[ "$rc" -eq 0 ] || fail "API path: no-comment close exited $rc"
|
||||
if grep -q 'curl POST' "$CALLS"; then fail "API path: posted a comment when none was requested"; fi
|
||||
grep -q 'curl PATCH' "$CALLS" || fail "API path: issue not closed when no comment was requested"
|
||||
|
||||
echo "issue-close.sh fail-closed + single-principal regression passed"
|
||||
@@ -61,15 +61,54 @@ STATE_FILE="$WORK_DIR/comments.json"
|
||||
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
||||
# ignored. The stub curl then rejects the unrecognised bearer, and this suite
|
||||
# fails at its FIRST case with `Gitea authenticated-identity read failed with
|
||||
# HTTP 401`. An empty repo-local value shadows the global one and reads back
|
||||
# empty at rc=0. Measured: without this pin the suite is RED on every seat.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME in run_comment(). It only has to bound a
|
||||
# failure that the pin should already have prevented.
|
||||
#
|
||||
# THIS SUITE WAS THE HARDEST OF THE FIVE TO SEE, and the reason is worth stating
|
||||
# because it generalises: run_comment() sends the wrapper's stdout AND stderr to
|
||||
# $OUTPUT_FILE, and the EXIT trap above deletes $WORK_DIR. So the 401 — the only
|
||||
# thing that says what went wrong — exists only inside a directory that is gone
|
||||
# by the time anyone looks. The suite exits 1 with ZERO bytes on stdout and
|
||||
# stderr. A suite that discards or deletes its own evidence turns any post-hoc
|
||||
# assay into a non-measurement: "nothing found" there means "no surviving
|
||||
# trace", never "clean". It was found by intercepting the identity read at its
|
||||
# SOURCE (a PATH shim over `git` logging every `mosaic.gitIdentity` read to a
|
||||
# file outside $WORK_DIR), which is deletion-proof by construction, rather than
|
||||
# by grepping for the symptom.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To
|
||||
# measure, REPLICATE a seat (a decoy HOME whose .gitconfig sets
|
||||
# mosaic.gitIdentity, with no per-slot token) so step 0 reaches its fail-loud
|
||||
# branch — or intercept the read as described above.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
ISSUE_NUMBER=7
|
||||
REPO_SLUG="mosaicstack/stack"
|
||||
@@ -280,7 +319,10 @@ print("201")
|
||||
print(json.dumps(record))
|
||||
PY
|
||||
)
|
||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||
response_status="${result%%$'\n'*}"
|
||||
response_body=""
|
||||
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
||||
write_response "$response_status" "$response_body"
|
||||
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
||||
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
||||
import json
|
||||
@@ -299,7 +341,10 @@ else:
|
||||
print(json.dumps(match))
|
||||
PY
|
||||
)
|
||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||
response_status="${result%%$'\n'*}"
|
||||
response_body=""
|
||||
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
||||
write_response "$response_status" "$response_body"
|
||||
else
|
||||
echo "Unexpected curl request: $method $url" >&2
|
||||
exit 97
|
||||
@@ -366,6 +411,7 @@ run_comment() {
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
HOME="$HOME_DIR" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||
|
||||
@@ -7,13 +7,38 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
LOG_FILE="$WORK_DIR/calls.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
||||
# ignored and the suite runs against a production credential. An empty repo-local
|
||||
# value shadows the global one and reads back empty at rc=0. Measured: this suite
|
||||
# resolves a per-slot token without it.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME in run_wrapper(). It only has to bound a failure
|
||||
# that the pin should already have prevented.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
||||
@@ -50,6 +75,7 @@ run_wrapper() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
|
||||
@@ -69,7 +69,7 @@ section_nums() { # $1 = output $2 = header-prefix
|
||||
}
|
||||
|
||||
fail() { echo "FAIL: $1" >&2; exit 1; }
|
||||
contains() { printf '%s\n' "$1" | grep -qx "$2"; }
|
||||
contains() { grep -qx "$2" <<<"$1"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixed (current) script behavior
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-mosaic-worktree-large-repo.sh — the helper must work on the repos it exists for.
|
||||
#
|
||||
# resolve_repo() took the first line of `git worktree list --porcelain` with
|
||||
# `awk '/^worktree /{print substr($0,10); exit}'`. The `exit` closes the read end
|
||||
# of the pipe while git is still writing, git takes SIGPIPE, and under
|
||||
# `set -euo pipefail` the command substitution returns 141 — so the assignment
|
||||
# fails, `set -e` aborts the function, and the script dies printing NOTHING. No
|
||||
# message, no path, no worktree, exit 141.
|
||||
#
|
||||
# What makes it worth a dedicated test rather than a fixture line is WHEN it
|
||||
# fires. If git finishes writing before awk leaves, there is no SIGPIPE and
|
||||
# everything works. So the failure is a function of how much porcelain the repo
|
||||
# produces: invisible on a three-worktree repo, reliable on a seventy-worktree
|
||||
# one. It was measured on a repo with 73 worktrees (10 KB of porcelain) — rc=141,
|
||||
# no output — and it had passed every hand-check before that, on small repos.
|
||||
#
|
||||
# A test that ran `git worktree list` against whatever repo it happens to sit in
|
||||
# would inherit that same size dependence and would have PASSED on the tree that
|
||||
# was broken. So git is stubbed on PATH and made to emit a large porcelain
|
||||
# stream, which turns "depends on the repo you are standing in" into "always".
|
||||
#
|
||||
# Exit: 0 = the helper resolved the repo · 1 = it did not
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TOOL="${1:-$HERE/mosaic-worktree.sh}"
|
||||
[ -x "$TOOL" ] || { printf 'test-mosaic-worktree-large-repo: not executable: %s\n' "$TOOL" >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
mkdir -p "$TMP/bin"
|
||||
|
||||
# The stub answers exactly the two calls resolve_repo makes, and answers the
|
||||
# porcelain one with ~450 KB — comfortably past a 64 KB pipe buffer, so the
|
||||
# writer is still writing when a reader that quits early goes away. Anything
|
||||
# else exits non-zero rather than pretending to be git.
|
||||
cat > "$TMP/bin/git" <<'STUB'
|
||||
#!/bin/sh
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in -C) shift 2 ;; *) break ;; esac
|
||||
done
|
||||
case "$*" in
|
||||
"rev-parse --git-dir")
|
||||
echo .git; exit 0 ;;
|
||||
"worktree list --porcelain")
|
||||
# The first entry is the main worktree. That single line is all the helper
|
||||
# needs, and it is exactly what it stopped receiving.
|
||||
printf 'worktree /src/fakerepo\nHEAD %040d\nbranch refs/heads/main\n\n' 0
|
||||
awk 'BEGIN{ for (i = 0; i < 4000; i++)
|
||||
printf "worktree /src/fakerepo-worktrees/w%d\nHEAD %040d\nbranch refs/heads/topic-%d\n\n", i, 0, i }'
|
||||
# NOT `exit 0`. Real git dies of SIGPIPE here and reports 141, and pipefail
|
||||
# in the caller is what turns that into the silent abort. A stub that exits 0
|
||||
# regardless hands the caller a clean status and the probe passes on the
|
||||
# broken tree — which is how this test failed to be a test on its first run.
|
||||
exit $? ;;
|
||||
esac
|
||||
exit 1
|
||||
STUB
|
||||
chmod +x "$TMP/bin/git"
|
||||
|
||||
fail=0
|
||||
check() {
|
||||
local why="$1" want="$2" got="$3"
|
||||
if [ "$want" = "$got" ]; then
|
||||
printf 'ok %s\n' "$why"
|
||||
else
|
||||
printf 'FAIL %s\n want: %s\n got: %s\n' "$why" "$want" "$got"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
out="$(PATH="$TMP/bin:$PATH" "$TOOL" path feat/workspace-hygiene 2>&1)"
|
||||
rc=$?
|
||||
|
||||
# Both halves are asserted. rc alone would pass if the helper started printing a
|
||||
# usage error, and output alone would miss a non-zero exit — and the defect's
|
||||
# signature is precisely a non-zero exit with no output, which only the pair
|
||||
# distinguishes from every other way this could go wrong.
|
||||
check 'resolving a repo with a large worktree list exits 0' 0 "$rc"
|
||||
check 'and derives the path from the main worktree' /src/fakerepo-worktrees/feat-workspace-hygiene "$out"
|
||||
|
||||
printf '\n'
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
printf 'mosaic-worktree: resolves against a large porcelain stream.\n'
|
||||
else
|
||||
cat <<'EOF'
|
||||
mosaic-worktree could not resolve the repository.
|
||||
|
||||
An empty output with a non-zero exit is the SIGPIPE signature: a reader that
|
||||
quits early (`awk ... exit`, `head -n`) kills the producer, and pipefail turns
|
||||
that into a silent abort. Nothing in this script may close a git pipe early.
|
||||
EOF
|
||||
fi
|
||||
exit "$fail"
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for secret-safe, identity-bound PR editing and explicit targets.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit}"
|
||||
REPO_DIR="$WORK_DIR/repo"; BIN_DIR="$WORK_DIR/bin"; HOME_DIR="$WORK_DIR/home"
|
||||
XDG_DIR="$WORK_DIR/xdg"; LOG_FILE="$WORK_DIR/calls.log"
|
||||
rm -rf "$WORK_DIR"; mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR" "$XDG_DIR/tea"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/other/wrong-checkout.git
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
cat > "$XDG_DIR/tea/config.yml" <<'YAML'
|
||||
logins:
|
||||
- name: usc-coder3
|
||||
url: https://git.uscllc.com
|
||||
token: fixture-usc-token
|
||||
- name: same-host-other
|
||||
url: https://git.uscllc.com
|
||||
token: fixture-other-token
|
||||
- name: mosaic-coder3
|
||||
url: https://git.mosaicstack.dev
|
||||
token: fixture-mosaic-token
|
||||
YAML
|
||||
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Deliberately misleading duplicate-name response: the wrapper must never use
|
||||
# tea for identity validation because its name lookup is not host-bound.
|
||||
[[ "$*" == "api --login duplicate /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; }
|
||||
exit 1
|
||||
SH
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'curl' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
|
||||
if [[ "${*: -1}" == */user ]]; then
|
||||
printf '{"login":"%s"}\n' "${MOSAIC_STUB_AUTH_USER:-coder3}"
|
||||
elif [[ "${*: -1}" == */repos/* && " $* " != *" -X PATCH "* ]]; then
|
||||
[[ "${MOSAIC_STUB_SIGNAL:-}" == "TERM" ]] && { kill -TERM "$PPID"; sleep 1; }
|
||||
[[ "${MOSAIC_STUB_SIGNAL:-}" == "INT" ]] && { kill -INT "$PPID"; sleep 1; }
|
||||
printf '{"name":"repo"}\n'
|
||||
else
|
||||
printf '{"number":42,"draft":false}\n'
|
||||
fi
|
||||
SH
|
||||
cat > "$BIN_DIR/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'gh' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" "$BIN_DIR/gh" "$SCRIPT_DIR/pr-edit.sh"
|
||||
|
||||
run_wrapper() {
|
||||
(cd "$REPO_DIR"; PATH="$BIN_DIR:$PATH" HOME="$HOME_DIR" XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" "$SCRIPT_DIR/pr-edit.sh" "$@")
|
||||
}
|
||||
assert_no_secret() {
|
||||
! grep -q 'fixture-.*-token' "$LOG_FILE" || { echo "Credential leaked into curl argv/log" >&2; exit 1; }
|
||||
}
|
||||
|
||||
# The explicit target differs from CWD origin and must govern BOTH host and slug.
|
||||
: > "$LOG_FILE"
|
||||
# shellcheck disable=SC2016 # literal backticks prove argument-array body safety.
|
||||
MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r mosaicstack/stack \
|
||||
-H git.mosaicstack.dev --title 'New title' --body 'Body with `literal` bytes' --base develop --draft >/dev/null
|
||||
python3 - "$LOG_FILE" <<'PY'
|
||||
import json, pathlib, sys
|
||||
lines = pathlib.Path(sys.argv[1]).read_text().splitlines()
|
||||
assert len(lines) == 3, lines
|
||||
assert "https://git.mosaicstack.dev/api/v1/user" in lines[0], lines
|
||||
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack" in lines[1], lines
|
||||
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/42" in lines[2], lines
|
||||
assert all("--config" in line for line in lines), lines
|
||||
assert "Authorization:" not in "\n".join(lines), lines
|
||||
payload = lines[2].split(" <-d> <", 1)[1].split("> <https://", 1)[0]
|
||||
assert json.loads(payload) == {"title":"New title","body":"Body with `literal` bytes","base":"develop","draft":True}
|
||||
PY
|
||||
assert_no_secret
|
||||
|
||||
# Ready maps to false and still preflights before the write.
|
||||
: > "$LOG_FILE"
|
||||
MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 -r USC/uconnect -H git.uscllc.com --ready >/dev/null
|
||||
grep -q '"draft": false' "$LOG_FILE"; assert_no_secret
|
||||
|
||||
# Identity is mandatory; no ambient/first-host login can write.
|
||||
: > "$LOG_FILE"
|
||||
if run_wrapper -n 42 --login usc-coder3 --draft >/dev/null 2>&1; then echo "Unset identity wrote" >&2; exit 1; fi
|
||||
[[ ! -s "$LOG_FILE" ]] || { echo "Unset identity reached curl" >&2; exit 1; }
|
||||
|
||||
# Explicit and ambient same-host wrong principals both refuse after identity
|
||||
# lookup but before repo preflight/PATCH. The /user read is expected curl #1.
|
||||
for mode in explicit ambient; do
|
||||
: > "$LOG_FILE"
|
||||
if [[ "$mode" == explicit ]]; then
|
||||
cmd=(--login same-host-other)
|
||||
else
|
||||
cmd=(); export GITEA_LOGIN=same-host-other
|
||||
fi
|
||||
if MOSAIC_STUB_AUTH_USER=other MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 "${cmd[@]}" -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
||||
echo "$mode wrong identity wrote" >&2; exit 1
|
||||
fi
|
||||
unset GITEA_LOGIN
|
||||
[[ "$(wc -l < "$LOG_FILE")" -eq 1 ]] || { echo "$mode wrong identity passed identity lookup" >&2; exit 1; }
|
||||
! grep -q '/repos/' "$LOG_FILE" || { echo "$mode wrong identity reached repo preflight/PATCH" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# Set identity with no explicit/ambient login refuses rather than selecting first host login.
|
||||
: > "$LOG_FILE"
|
||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
||||
echo "Missing login selected a principal" >&2; exit 1
|
||||
fi
|
||||
[[ ! -s "$LOG_FILE" ]] || { echo "Missing login reached curl" >&2; exit 1; }
|
||||
|
||||
# Split-credential probe for the duplicate-name cross-host seam: tea's
|
||||
# name-only /user would report coder3, while the selected host-bound curl token
|
||||
# reports other. The wrapper must trust only the latter handle used by PATCH.
|
||||
: > "$LOG_FILE"
|
||||
if MOSAIC_STUB_AUTH_USER=other MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 \
|
||||
-r mosaicstack/stack -H git.mosaicstack.dev --draft >/dev/null 2>&1; then
|
||||
echo "Duplicate-name split credential reached PATCH" >&2; exit 1
|
||||
fi
|
||||
[[ "$(wc -l < "$LOG_FILE")" -eq 1 ]] || { echo "Duplicate-name identity mismatch passed /user" >&2; cat "$LOG_FILE" >&2; exit 1; }
|
||||
! grep -q -- '-X> <PATCH' "$LOG_FILE" || { echo "Duplicate-name mismatch mutated" >&2; exit 1; }
|
||||
|
||||
# TERM and INT during repo preflight clean up, do not mutate, and return the
|
||||
# signal status rather than swallowing termination into success.
|
||||
for sig in TERM INT; do
|
||||
: > "$LOG_FILE"
|
||||
set +e
|
||||
MOSAIC_STUB_SIGNAL="$sig" MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 \
|
||||
-r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
[[ "$rc" -ne 0 ]] || { echo "$sig was swallowed into success" >&2; exit 1; }
|
||||
[[ "$rc" -eq 143 || "$rc" -eq 130 ]] || { echo "$sig returned unexpected status $rc" >&2; exit 1; }
|
||||
! grep -q -- '-X> <PATCH' "$LOG_FILE" || { echo "$sig continued into PATCH" >&2; exit 1; }
|
||||
assert_no_secret
|
||||
done
|
||||
|
||||
# Cross-host credential fails before curl; explicit target preflight failure blocks PATCH.
|
||||
: > "$LOG_FILE"
|
||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
||||
echo "Cross-host login wrote" >&2; exit 1
|
||||
fi
|
||||
[[ ! -s "$LOG_FILE" ]] || { echo "Cross-host login reached curl" >&2; exit 1; }
|
||||
|
||||
if run_wrapper -n 42 --draft --ready >/dev/null 2>&1; then echo "Accepted conflicting modes" >&2; exit 1; fi
|
||||
if run_wrapper -n 42 >/dev/null 2>&1; then echo "Accepted no-op edit" >&2; exit 1; fi
|
||||
run_wrapper --help 2>&1 | grep -q '^Usage:'
|
||||
|
||||
# GitHub retains provider-native edit/readiness behavior.
|
||||
git -C "$REPO_DIR" remote set-url origin https://github.com/acme/widgets.git
|
||||
: > "$LOG_FILE"; run_wrapper -n 7 --title 'GitHub title' --draft >/dev/null
|
||||
grep -q 'gh <pr> <edit> <7> <--title> <GitHub title>' "$LOG_FILE"
|
||||
grep -q 'gh <pr> <ready> <7> <--undo>' "$LOG_FILE"
|
||||
echo "PR edit regression harness passed"
|
||||
@@ -8,6 +8,7 @@ WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
|
||||
MOCK_BIN="$SANDBOX/bin"
|
||||
REPO_DIR="$SANDBOX/repo"
|
||||
HOME_DIR="$SANDBOX/home"
|
||||
LOG_FILE="$SANDBOX/mock.log"
|
||||
|
||||
cleanup() {
|
||||
@@ -15,7 +16,7 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$MOCK_BIN" "$REPO_DIR"
|
||||
mkdir -p "$MOCK_BIN" "$REPO_DIR" "$HOME_DIR"
|
||||
: > "$LOG_FILE"
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
@@ -109,7 +110,48 @@ chmod +x "$MOCK_BIN/curl"
|
||||
cd "$REPO_DIR"
|
||||
git init -q
|
||||
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
|
||||
# check, so the `GITEA_TOKEN=redacted-test-token` exported below is silently
|
||||
# overridden and a REAL per-slot token from $HOME is what flows through the
|
||||
# wrapper. Measured on a provisioned seat before this pin: all 5 mock-curl calls
|
||||
# carried the real per-slot token in argv and the fixture token was never used at
|
||||
# ALL. Three consequences specific to this suite:
|
||||
# 1. pr-merge.sh passes the token as `-H "Authorization: token $token"` and the
|
||||
# mock curl logs full argv, so the real credential is written to $LOG_FILE
|
||||
# on disk — transiently: the suite truncates that file between phases and
|
||||
# the EXIT trap removes $SANDBOX, so it leaves NO post-hoc trace. That is
|
||||
# why this suite was the hardest of the three to detect; observing it needs
|
||||
# an instrument that captures argv while the run is live.
|
||||
# 2. Every failure path dumps $OUTPUT/$LOG_FILE to stderr through
|
||||
# `sed 's/redacted-test-token/***REDACTED***/g'` — a redaction pattern that
|
||||
# is the literal fixture string and therefore CANNOT match the token
|
||||
# actually in use.
|
||||
# 3. The leak assertion at "Token leaked to pr-merge.sh output" greps for that
|
||||
# same fixture string, so on a provisioned seat it passes vacuously: it is
|
||||
# searching for a value the run never used.
|
||||
# An empty repo-local value shadows the global one and reads back empty at rc=0.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME exported below. It only has to bound a failure
|
||||
# that the pin should already have prevented.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
# $SANDBOX/$HOME_DIR were derived from the real $HOME above, before this export.
|
||||
export HOME="$HOME_DIR"
|
||||
export PATH="$MOCK_BIN:$PATH"
|
||||
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||
|
||||
@@ -8,12 +8,68 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
FIXTURE_DIR="$WORK_DIR/fixtures"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR"
|
||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR" "$HOME_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
|
||||
# check, so the `GITEA_TOKEN="stub-token"` set in the run helpers below is
|
||||
# silently overridden and a REAL per-slot token from $HOME is what reaches curl.
|
||||
# Measured on a provisioned seat before this pin: both stub-curl calls carried
|
||||
# the real token in argv. An empty repo-local value shadows the global one and
|
||||
# reads back empty at rc=0.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME in the three run helpers below. It only has to
|
||||
# bound a failure that the pin should already have prevented.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||
# per-slot token) so step 0 reaches its fail-loud branch. See
|
||||
# test-gitea-token-identity.sh for the stronger `env -i HOME=…` form used where a
|
||||
# suite's whole subject IS identity resolution.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
# The pin above removes step 0, but this suite has a SECOND, independent
|
||||
# dependency on operator state, and closing only the first would leave the suite
|
||||
# red on any hermetic environment. The `GITEA_TOKEN="stub-token"` /
|
||||
# `GITEA_URL="https://git.example.test"` pair the run helpers set is INERT: step 2
|
||||
# of get_gitea_token accepts GITEA_TOKEN only when GITEA_URL matches the remote
|
||||
# host, and this repo's origin is git.uscllc.com, so that pair can never satisfy
|
||||
# it. Before this fixture the only credential that could reach the authenticated
|
||||
# curl branch was a REAL one — from step 0 on an agent seat, or from step 1
|
||||
# reading the operator's own ~/.config/mosaic/credentials.json. That is why the
|
||||
# "curl success path" case passed: not because the stub credential worked, but
|
||||
# because a production credential was available.
|
||||
#
|
||||
# A fixture is used rather than relying on the sandboxed HOME making step 1 find
|
||||
# nothing: a test that passes because production configuration is ABSENT fails
|
||||
# the moment it is present. Step 1 now resolves deterministically to a value that
|
||||
# is a fixture on every machine.
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{
|
||||
"gitea": {
|
||||
"usc": {
|
||||
"url": "https://git.uscllc.com",
|
||||
"token": "stub-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
|
||||
{
|
||||
@@ -131,6 +187,8 @@ run_curl_success_case() {
|
||||
set +e
|
||||
output=$(cd "$REPO_DIR" && \
|
||||
PATH="$STUB_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
TMPDIR="$tmpdir" \
|
||||
GITEA_TOKEN="stub-token" \
|
||||
GITEA_URL="https://git.example.test" \
|
||||
@@ -170,6 +228,8 @@ run_curl_early_exit_cleanup_case() {
|
||||
set +e
|
||||
output=$(cd "$REPO_DIR" && \
|
||||
PATH="$STUB_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
TMPDIR="$tmpdir" \
|
||||
GITEA_TOKEN="stub-token" \
|
||||
GITEA_URL="https://git.example.test" \
|
||||
@@ -204,7 +264,8 @@ run_curl_early_exit_cleanup_case() {
|
||||
run_case() {
|
||||
local fixture="$1" expected_number="$2" expected_head="$3"
|
||||
local output
|
||||
output=$(cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
||||
output=$(cd "$REPO_DIR" && HOME="$HOME_DIR" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
||||
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -225,7 +225,10 @@ write_response() {
|
||||
emit() {
|
||||
# Split a two-line "status\n<json body>" python result into the response.
|
||||
local result="$1"
|
||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||
response_status="${result%%$'\n'*}"
|
||||
response_body=""
|
||||
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
||||
write_response "$response_status" "$response_body"
|
||||
}
|
||||
|
||||
mode="${PR_REVIEW_TEST_MODE:-}"
|
||||
@@ -436,6 +439,19 @@ elif mode == "comment-url-wrong-repo":
|
||||
elif mode == "comment-url-suffix-injection":
|
||||
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
||||
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
||||
elif mode == "comment-url-wrong-port":
|
||||
# #991 bound: an EXPLICIT non-default port is a different service on the same
|
||||
# host. Relaxing http-vs-https must NOT relax this.
|
||||
pr_url = f"{_p.scheme}://{_p.hostname}:8443{_slug}/pulls/123"
|
||||
elif mode == "comment-url-non-web-scheme":
|
||||
# #991 bound: ONLY http/https collapse; any other scheme stays distinguishing.
|
||||
pr_url = f"ftp://{_p.netloc}{_slug}/pulls/123"
|
||||
elif mode == "comment-url-scheme-downgrade":
|
||||
# #991, and the only URL mode here that must be ACCEPTED. A Gitea whose
|
||||
# ROOT_URL is http:// returns http:// object URLs for a repo reached over
|
||||
# https://. Same host, same path, correct record — a truthful provider
|
||||
# answer about a comment that landed, not a forgery.
|
||||
pr_url = f"http://{_p.netloc}{_slug}/pulls/123"
|
||||
elif mode == "comment-mixed-case-slug":
|
||||
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
||||
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
||||
@@ -890,11 +906,16 @@ fi
|
||||
assert_no_temp_leak "review-body-reuse"
|
||||
|
||||
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
|
||||
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
|
||||
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
|
||||
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
|
||||
# test would wrongly accept the look-alike-host and prefix-injection variants.
|
||||
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
|
||||
# but whose provider-returned pull_request_url does not belong to this PR must
|
||||
# FAIL CLOSED. Verification pins the URL's ORIGIN (scheme-class + host + explicit
|
||||
# non-default port) and FULL path (deployment prefix + exact owner/repo + kind +
|
||||
# number); a bare endswith/suffix test would wrongly accept the look-alike-host
|
||||
# and prefix-injection variants. comment-url-wrong-port and
|
||||
# comment-url-non-web-scheme (#991) bound the scheme relaxation from the other
|
||||
# side: collapsing http/https must not also collapse a different port or a
|
||||
# different scheme family.
|
||||
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo \
|
||||
comment-url-suffix-injection comment-url-wrong-port comment-url-non-web-scheme; do
|
||||
if run_review "$bad_mode" comment durable-body; then
|
||||
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
@@ -920,6 +941,19 @@ run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
assert_no_temp_leak "comment-mixed-case-slug"
|
||||
|
||||
# Case 15c (#991): the deployment's Gitea ROOT_URL is http:// while every client
|
||||
# reaches it over https://, so the provider returns an http:// pull_request_url
|
||||
# for a comment that is otherwise entirely correct. Same class as 15b — a
|
||||
# legitimate provider response, not a spoof — and a scheme-strict compare
|
||||
# rejects it on EVERY comment, deterministically. That is not a cosmetic false
|
||||
# negative here: on a host where no seat can create a review OBJECT, this
|
||||
# comment-form record is the only gate-16 evidence obtainable, and the wrapper
|
||||
# refuses all of it while the comment sits durably on the PR. Host, path, owner,
|
||||
# repo, kind and number stay strict; only http-vs-https is relaxed.
|
||||
run_review comment-url-scheme-downgrade comment durable-body
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
assert_no_temp_leak "comment-url-scheme-downgrade"
|
||||
|
||||
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
||||
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
||||
# review is genuinely created and verified as pinned to the OLD head, but the
|
||||
|
||||
@@ -222,8 +222,8 @@ grep -q 'Unknown action: bogus-action' "$OUTPUT_FILE"
|
||||
|
||||
# --- Case 2: -h/--help documents both overrides.
|
||||
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
|
||||
echo "$HELP_TEXT" | grep -q -- '-r, --repo'
|
||||
echo "$HELP_TEXT" | grep -q -- '-H, --host'
|
||||
grep -q -- '-r, --repo' <<<"$HELP_TEXT"
|
||||
grep -q -- '-H, --host' <<<"$HELP_TEXT"
|
||||
|
||||
# --- Case 3 (comment): a TRUE no-git-origin dir + -r/-H must not silently die
|
||||
# and must not fail with "not a git repository or no origin remote" either.
|
||||
|
||||
+703
@@ -0,0 +1,703 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-wrapper-guard.sh — hermetic behavioural regression for wrapper-guard.sh.
|
||||
#
|
||||
# Resolves no credentials, touches no network, and creates no repository: the
|
||||
# guard reads a hook payload on stdin and answers with an exit code, so the whole
|
||||
# contract is testable from fixtures.
|
||||
#
|
||||
# The fixtures are written to a temp file rather than passed inline, and this is
|
||||
# not stylistic. The guard inspects the literal text of the Bash command it is
|
||||
# handed. A test that embeds `git clone ... $HOME` inside its own command line
|
||||
# trips the guard on the harness instead of on the fixture — which is exactly
|
||||
# what happened the first time this was checked by hand. Substring matching over
|
||||
# whole command text is the guard's deliberate fail-closed posture; a test that
|
||||
# does not account for it silently measures the wrong thing.
|
||||
#
|
||||
# Exit: 0 = every fixture behaved as specified · 1 = at least one did not
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GUARD="${1:-$HERE/wrapper-guard.sh}"
|
||||
[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; }
|
||||
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
FIXTURES="$TMP/fixtures.tsv"
|
||||
|
||||
# Each line: <expected-exit> TAB <hook payload> TAB <what it proves>
|
||||
# [ TAB <substring the block message must contain> ]
|
||||
# 0 = allowed, 2 = blocked. The optional fourth field is how the remediation
|
||||
# itself gets checked; without it a block is only asserted to have happened,
|
||||
# not to have been useful.
|
||||
{
|
||||
printf '2\t{"tool_input":{"command":"git clone https://example.invalid/x ~/wt"}}\tcheckout into $HOME is refused\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add ~/wt topic"}}\tworktree into $HOME is refused\n'
|
||||
printf '2\t{"tool_input":{"command":"g\\"it\\" clone https://example.invalid/x $HOME/wt"}}\ta double quote inside git does not hide a checkout\n'
|
||||
printf '2\t{"tool_input":{"command":"g'"'"'it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta single quote inside git does not hide a checkout\n'
|
||||
printf '2\t{"tool_input":{"command":"g\\\\it clone https://example.invalid/x $HOME/wt"}}\tan unquoted escape inside git does not hide a checkout\n'
|
||||
# Path words use the same quote/escape state machine as names, but preserve
|
||||
# substitutions so HOME remains visible. Quotes do not split the path word.
|
||||
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"/wt"}}\ta closing quote between HOME and slash does not hide the path\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x ${HOME}/wt"}}\tthe braced HOME spelling is the same home path\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x \\"${HOME}\\"/wt"}}\tbraced HOME may also end a quoted span before the slash\n'
|
||||
# Lexically equivalent absolute paths must be compared after shell-known HOME
|
||||
# expansion and dot-segment normalization, without resolving filesystem links.
|
||||
printf '2\t{"tool_input":{"command":"git clone x /var/../$HOME/wt"}}\tHOME expansion after parent traversal is normalized before comparison\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add /var/../${HOME}/wt"}}\tworktree placement also normalizes embedded HOME expansion\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir=/var/../$HOME/gd x /src/wt"}}\tseparate Git state cannot hide behind parent traversal\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME/../outside-home/wt"}}\ta parent segment that leaves HOME is not over-blocked\n'
|
||||
# The target may be HOME itself. End-of-command and whitespace terminate the
|
||||
# token just as a slash does; punctuation that can extend a path does not.
|
||||
printf '2\t{"tool_input":{"command":"git clone x $HOME"}}\tthe unbraced variable may name HOME exactly\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\""}}\tquotes do not change the exact HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x ${HOME}"}}\tthe braced variable may name HOME exactly\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x ~"}}\ttilde may name HOME exactly\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add $HOME topic"}}\twhitespace terminates an exact HOME target before another argument\n'
|
||||
# Unquoted POSIX metacharacters terminate the target word even without spaces.
|
||||
printf '2\t{"tool_input":{"command":"git clone x $HOME;echo x"}}\tsemicolon terminates an exact HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"&& echo x"}}\tand-if terminates a quoted exact HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x ${HOME}| cat"}}\ta pipe terminates a braced exact HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x ~&"}}\tbackground operator terminates a tilde HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x $HOME</dev/null"}}\tinput redirection terminates the target word\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x $HOME>out"}}\toutput redirection terminates the target word\n'
|
||||
printf '2\t{"tool_input":{"command":"( git clone x $HOME)"}}\ta subshell close terminates the exact HOME target\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone x $HOME\\necho x"}}\ta literal newline terminates the target word\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME_BACKUP/wt"}}\ta longer HOME-prefixed variable is a different path\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOMEBREW/wt"}}\tHOMEBREW is not HOME either\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME.bak/wt"}}\ta dot continues the path token into a sibling name\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt"}}\tplus is ordinary sibling filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt"}}\tat-sign is ordinary sibling filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME,bak/wt"}}\tcomma is ordinary sibling filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME:bak/wt"}}\tcolon is ordinary sibling filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME=bak/wt"}}\tequals is ordinary sibling filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x ${HOME}+bak/wt"}}\tbraced HOME plus suffix is still a sibling\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x /home/tester+bak/wt"}}\ta literal plus-suffixed home path is a sibling\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x /home/tester@bak/wt"}}\ta literal at-suffixed home path is a sibling\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt;echo x"}}\ta later terminator does not turn a sibling into HOME\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt&& echo x"}}\tand-if after a sibling preserves the allow\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x \\"$HOME;bak/wt\\""}}\ta quoted semicolon is filename content, not a boundary\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME\\\\;bak/wt"}}\tan escaped semicolon is filename content, not a boundary\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME\\u001b/wt"}}\ta raw internal-marker byte is encoded as filename content\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x /home/tester.bak/wt"}}\ta literal sibling path is not beneath HOME\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x /home/testerx/wt"}}\ta longer literal basename is not HOME\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x ~root/wt"}}\tanother account tilde is not this account HOME\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x $HOME}/wt"}}\ta closing brace without an opening brace is a literal suffix\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x ${HOME/wt"}}\tan opening brace without a close is not a HOME expansion\n'
|
||||
# Quote removal must not create an expansion the shell never performs.
|
||||
printf '0\t{"tool_input":{"command":"git clone x '"'"'$HOME'"'"'/wt"}}\tsingle-quoted HOME is a literal directory name\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x \\\\$HOME/wt"}}\tan escaped dollar makes HOME literal outside quotes\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x \\"\\\\$HOME\\"/wt"}}\tan escaped dollar makes HOME literal inside double quotes\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x \\"~/wt\\""}}\ttilde does not expand inside double quotes\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x '"'"'~/wt'"'"'"}}\ttilde does not expand inside single quotes\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone x \\\\~/wt"}}\tan escaped tilde is literal too\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n'
|
||||
# Round ten: placement is decided by the destination and the one clone option
|
||||
# that creates repository state elsewhere, not by every HOME-valued word in
|
||||
# the command. Sources, templates, references, and environment are not targets.
|
||||
printf '0\t{"tool_input":{"command":"NOTE=$HOME git clone https://example.invalid/x /src/wt"}}\tan unrelated assignment carrying HOME is not checkout placement\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --reference=$HOME https://example.invalid/x /src/wt"}}\ta HOME reference is an object source, not checkout placement\n'
|
||||
printf '0\t{"tool_input":{"command":"GIT_DIR=$HOME/x git clone https://example.invalid/x /src/wt"}}\tclone does not place its destination from ambient GIT_DIR\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --template=$HOME/t https://example.invalid/x /src/wt"}}\ta HOME template source is not checkout placement\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir=$HOME/gd https://example.invalid/x /src/wt"}}\tseparate-git-dir explicitly places repository state under HOME\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir $HOME/gd https://example.invalid/x /src/wt"}}\tthe space-separated placement option is equivalent\n'
|
||||
printf '0\t{"tool_input":{"command":"git worktree add --reason=$HOME/note /src/wt"}}\ta worktree reason is metadata, not its path\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone $HOME/source /src/wt"}}\ta HOME source with an explicit safe destination is not placement\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --reference $HOME https://example.invalid/x /src/wt"}}\ta space-separated HOME reference remains a source\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --template $HOME/t https://example.invalid/x /src/wt"}}\ta space-separated HOME template remains a source\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone 2>/dev/null https://example.invalid/x $HOME/wt"}}\ta redirection before clone arguments does not become the destination\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone --reference $HOME https://example.invalid/x $HOME/wt"}}\ta source option does not hide a later HOME destination\n'
|
||||
# Round eleven: Git accepts boolean options as a rule-generated family,
|
||||
# including --no-* negations. Each command below was checked with Git itself:
|
||||
# `git clone <option> /nonexistent-src /nonexistent-dst` reaches the missing
|
||||
# source instead of reporting an unknown option. The HOME word is the source,
|
||||
# not the explicit /src destination, so Bash expansion is allowed here.
|
||||
printf '0\t{"tool_input":{"command":"git clone --bare $HOME/source /src/wt"}}\tbare clone keeps its HOME source distinct from the safe destination\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --mirror $HOME/source /src/wt"}}\tmirror is an accepted flag and does not consume the HOME source\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --ipv4 $HOME/source /src/wt"}}\tipv4 is an accepted flag and does not consume the HOME source\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --ipv6 $HOME/source /src/wt"}}\tipv6 is an accepted flag and does not consume the HOME source\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --no-local $HOME/source /src/wt"}}\tgenerated no-local remains a flag rather than a placement option\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --no-reject-shallow $HOME/source /src/wt"}}\tgenerated no-reject-shallow remains a flag rather than placement\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone -4 $HOME/source /src/wt"}}\tthe short IPv4 flag leaves the HOME word in source position\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone -6 $HOME/source /src/wt"}}\tthe short IPv6 flag leaves the HOME word in source position\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --no-bare $HOME/source /src/wt"}}\tan unusual generated negation is accepted without enumeration\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --no-sparse $HOME/source /src/wt"}}\tgenerated no-sparse is accepted without enumeration\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --no-dissociate $HOME/source /src/wt"}}\tgenerated no-dissociate is accepted without enumeration\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --no-shallow-submodules $HOME/source /src/wt"}}\ta long generated negation is accepted without enumeration\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --no-quiet $HOME/source /src/wt"}}\tgenerated no-quiet is accepted without enumeration\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --no-progress $HOME/source /src/wt"}}\tgenerated no-progress is accepted without enumeration\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone --no-recurse-submodules $HOME/source /src/wt"}}\tgenerated no-recurse-submodules is accepted without enumeration\n'
|
||||
# Git also generates accepted long abbreviations and short-option bundles.
|
||||
# The closed value-taking option grammar must consume their values correctly.
|
||||
printf '0\t{"tool_input":{"command":"git clone --templ $HOME/t $HOME/source /src/wt"}}\tan accepted template abbreviation consumes metadata rather than the source\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone -qj 1 $HOME/source /src/wt"}}\ta short flag bundle ending in jobs consumes its separate value\n'
|
||||
printf '0\t{"tool_input":{"command":"git clone -qb topic $HOME/source /src/wt"}}\ta short flag bundle ending in branch consumes its separate value\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone --separate-git-d=$HOME/gd https://example.invalid/x /src/wt"}}\tan accepted placement-option abbreviation remains blocked in attached form\n'
|
||||
printf '2\t{"tool_input":{"command":"git clone --separate-git-d $HOME/gd https://example.invalid/x /src/wt"}}\tan accepted placement-option abbreviation remains blocked in separate form\n'
|
||||
# Worktree boolean options have the same generated-negation grammar. The next
|
||||
# positional is its real path, so safe paths allow and HOME paths still block.
|
||||
printf '0\t{"tool_input":{"command":"git worktree add --no-force /src/wt"}}\tgenerated worktree no-force accepts a safe path\n'
|
||||
printf '0\t{"tool_input":{"command":"git worktree add --no-detach /src/wt"}}\tgenerated worktree no-detach accepts a safe path\n'
|
||||
printf '0\t{"tool_input":{"command":"git worktree add --no-lock /src/wt"}}\tgenerated worktree no-lock accepts a safe path\n'
|
||||
printf '0\t{"tool_input":{"command":"git worktree add --no-guess-remote /src/wt"}}\ta long worktree negation accepts a safe path without enumeration\n'
|
||||
printf '0\t{"tool_input":{"command":"git worktree add -d /src/wt"}}\tthe documented short detach flag accepts a safe path\n'
|
||||
printf '0\t{"tool_input":{"command":"git worktree add -q /src/wt"}}\tthe documented short quiet flag accepts a safe path\n'
|
||||
printf '0\t{"tool_input":{"command":"git worktree add --lock --rea $HOME/note /src/wt"}}\tan accepted reason abbreviation consumes metadata rather than the path\n'
|
||||
printf '0\t{"tool_input":{"command":"git worktree add -fb $HOME/topic /src/wt"}}\ta short branch bundle consumes its HOME-valued branch before the safe path\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add -fb topic $HOME/wt"}}\ta short branch bundle does not hide the later HOME path\n'
|
||||
# Upstream Git defines --orphan as a boolean flag; -b still carries the branch.
|
||||
printf '0\t{"tool_input":{"command":"git worktree add --orphan /src/wt"}}\torphan mode accepts a safe path without consuming it as a value\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add --orphan $HOME/wt"}}\torphan mode does not hide its HOME path\n'
|
||||
printf '0\t{"tool_input":{"command":"git worktree add --orphan -b $HOME/topic /src/wt"}}\torphan mode leaves HOME branch metadata to the branch option\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add --orphan -b topic $HOME/wt"}}\torphan mode plus a branch option preserves HOME path blocking\n'
|
||||
# The optional second positional is commit-ish metadata, never placement.
|
||||
# HOME expands here, but the explicit worktree path remains safely under /src.
|
||||
printf '0\t{"tool_input":{"command":"git worktree add /src/wt $HOME/topic"}}\ta HOME-shaped commit-ish is not the worktree path\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add --no-force $HOME/wt"}}\ta generated worktree negation does not hide the HOME path\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add --no-guess-remote $HOME/wt"}}\ta long worktree negation preserves HOME placement blocking\n'
|
||||
# Explicit placement options and later simple commands remain traps.
|
||||
printf '2\t{"tool_input":{"command":"git clone --bare $HOME/source /src/wt && git clone x $HOME/wt"}}\ta boolean flag in one command does not hide a later HOME destination\n'
|
||||
printf '2\t{"tool_input":{"command":"git worktree add --no-force /src/wt; git clone x $HOME/wt"}}\ta worktree flag before a boundary does not hide later HOME placement\n'
|
||||
# Routing this arm through the shared name site also repaired an over-block it
|
||||
# had carried from the start: the old whole-command regex found `git` INSIDE a
|
||||
# longer word, so these two were refused at every head before this commit.
|
||||
# Same class as mycurl and curl-wrapper, and refusing them is how a guard gets
|
||||
# routed around instead of repaired.
|
||||
printf '0\t{"tool_input":{"command":"mygit clone https://example.invalid/x $HOME/wt"}}\tmygit is a different program and its checkout is not ours\n'
|
||||
printf '0\t{"tool_input":{"command":"gitfoo clone https://example.invalid/x $HOME/wt"}}\tthe name has to end where git ends\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -s -X GET https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\treads are never blocked\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\treview write has a wrapper\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tmerge write has a wrapper\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://api.github.com/repos/a/b/issues"}}\tGitHub host is covered too\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/releases"}}\tan endpoint with no wrapper passes\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVE\\"} https://example.invalid/x"}}\tthe APPROVE token is caught anywhere\n'
|
||||
printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n'
|
||||
printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n'
|
||||
# --- bypasses an independent reviewer demonstrated against the first version.
|
||||
# Each of these returned 0 (allowed) and each is a real write. They are pinned
|
||||
# as fixtures rather than fixed-and-forgotten because the class is recurring:
|
||||
# the guard reads text, so every spelling it does not know is a hole.
|
||||
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t-d@body with no space is still a body\n'
|
||||
printf '2\t{"tool_input":{"command":"curl --request=POST -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t--request=POST equals-form is still a method\n'
|
||||
printf '2\t{"tool_input":{"command":"p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@b https://git.example.invalid${p}${q}"}}\ta path split across variables is still that path\n'
|
||||
printf '2\t{"tool_input":{"command":"curl --data-binary @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\t--data-binary is a body\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -F f=@b https://git.example.invalid/api/v1/repos/a/b/issues"}}\t-F multipart is a body\n'
|
||||
# Reads must survive every one of those broadenings, or the guard gets disabled.
|
||||
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tno body and no verb is a read\n'
|
||||
printf '0\t{"tool_input":{"command":"grep -rn /pulls/ src/ | head -20"}}\ta path fragment in a grep is not an API call\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://registry.example.invalid/v2/x/manifests/latest"}}\tan unwrapped API is not this guard'"'"'s business\n'
|
||||
# --- round two of the same review. Splitting the ENDPOINT TOKEN defeats any
|
||||
# amount of fragment matching, because the endpoint does not exist until the
|
||||
# shell expands it. The guard now refuses to clear a write whose URL it cannot
|
||||
# read, rather than pretending it read one.
|
||||
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/iss; b=ues/1/comments; curl -d@body https://git.example.invalid${a}${b}"}}\tan endpoint token split across variables is unreadable, not absent\n'
|
||||
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/pu; b=lls/1/reviews; curl -d@body https://git.example.invalid${a}${b}"}}\tsame split, review endpoint\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d @payload https://hooks.example.invalid/services/${WEBHOOK_ID}"}}\tan opaque URL that is not forge-shaped stays allowed\n'
|
||||
# --- round six changed the contract in this direction, and these fixtures are
|
||||
# where it shows. They used to assert that discussing a call is not making one.
|
||||
# Five rounds proved there is no textual way to tell a quoted example from a
|
||||
# quoted command, so the guard stopped trying: it judges the payload, and a
|
||||
# payload inside quotes is still a payload. Quoting one of these on a Bash
|
||||
# command line is now refused, and the way to write the example is a
|
||||
# file-writing tool. This is the deliberate cost of the mechanism change.
|
||||
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tquoting a wrapped write is refused even in a grep\n'
|
||||
printf '2\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\t...and when written into a file\n'
|
||||
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\t...and when printed from another language\n'
|
||||
# The boundary that keeps this from being "block everything": what is refused
|
||||
# is a WRITE to a WRAPPED endpoint. Mentioning either alone still passes, and
|
||||
# these are asserted as hard as the blocks above.
|
||||
printf '0\t{"tool_input":{"command":"grep -R \\"curl -s https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/"}}\tquoting a READ example is untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"echo \\"the wrapped endpoint is https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tnaming the endpoint without a body flag is untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/releases\\" docs/"}}\tquoting a write to an UNWRAPPED endpoint is untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"issue-comment.sh --repo a/b --issue 1 --body @msg.md"}}\tthe wrapper itself carries a body flag and must never trip its own guard\n'
|
||||
# Command position must still catch the real thing behind operators and env.
|
||||
printf '2\t{"tool_input":{"command":"cd /tmp && GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\ta real call behind && and an assignment is still a call\n'
|
||||
# --- the case the AUTHOR hit twice while chasing the above: sending a message
|
||||
# that QUOTED one of these fixtures. Under the old contract that was a defect
|
||||
# to be parsed away; under this one it is the documented cost, and the message
|
||||
# gets composed with a file-writing tool instead.
|
||||
printf '2\t{"tool_input":{"command":"send.sh -m \\"repro was: cd /tmp && curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tquoting the repro in a message is refused too\n'
|
||||
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues\\nEOF"}}\ta heredoc body carrying the payload is refused with it\n'
|
||||
# ...but quotes stop being data the moment something executes them.
|
||||
printf '2\t{"tool_input":{"command":"bash -c \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tbash -c makes the quoted text code again\n'
|
||||
# --- round three. Each of these four is a real write that a bare-name match
|
||||
# for the client could not see, because an ordinary word sat in front of it.
|
||||
# They are kept as fixtures after the mechanism change even though the guard no
|
||||
# longer looks for a client at all: they are the evidence for WHY it stopped,
|
||||
# and a future re-narrowing that reintroduced position would fail here first.
|
||||
printf '2\t{"tool_input":{"command":"env GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tenv VAR=... in front of the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"command curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tcommand in front of the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ttimeout N in front of the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan absolute path to the client is still the client\n'
|
||||
printf '2\t{"tool_input":{"command":"echo timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments >> notes.md"}}\tnaming the call after echo carries the payload, so it is refused\n'
|
||||
# A shell standing between quoted data and execution makes that data code,
|
||||
# and the pipe is the form agents actually use. Filing it as data allowed the
|
||||
# call to vanish from the skeleton while still running.
|
||||
printf '2\t{"tool_input":{"command":"printf '"'"'%%s\\\\n'"'"' '"'"'curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments'"'"' | sh"}}\tquoted code piped to a shell is code\n'
|
||||
printf '2\t{"tool_input":{"command":"cat <<EOF | sh\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\ta heredoc piped to a shell is code\n'
|
||||
printf '2\t{"tool_input":{"command":"sh -s <<EOF\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\tsh -s reads its script from the heredoc\n'
|
||||
# ...and the questions that used to follow — is the pipe target a shell, does a
|
||||
# shell on one line execute a string on another — no longer have to be answered
|
||||
# at all. Both of these carry the payload, both are refused, and neither
|
||||
# outcome depends on parsing what the pipe or the other line does.
|
||||
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/ | wc -l"}}\tpiping the payload to wc is refused without asking what wc is\n'
|
||||
printf '2\t{"tool_input":{"command":"docker run --rm alpine sh -c '"'"'echo hi'"'"'\\necho \\"example: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tan unrelated shell on another line no longer changes the answer either way\n'
|
||||
# --- round four. The guard was still reading the command as typed rather than
|
||||
# as the shell will run it: a backslash before a newline is removed before
|
||||
# anything else happens, so the endpoint token can be split across the join.
|
||||
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments"}}\ta line continuation inside the endpoint token is still that endpoint\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pu\\\\\\nlls/1/reviews"}}\tsame join, review endpoint\n'
|
||||
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments\\nEOF"}}\tthe join still runs first, and the joined payload is refused in a document too\n'
|
||||
# Transparent prefixes take option VALUES, and the value was a word the list
|
||||
# did not know — so the client went missing again behind an ordinary `sudo -u`.
|
||||
printf '2\t{"tool_input":{"command":"sudo -u root curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option value after a prefix does not hide the client\n'
|
||||
printf '2\t{"tool_input":{"command":"timeout --signal TERM 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option pair plus a duration does not hide the client\n'
|
||||
printf '2\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe payload behind xargs echo is refused rather than adjudicated\n'
|
||||
printf '0\t{"tool_input":{"command":"sudo apt-get install curl"}}\tinstalling the client is not calling it\n'
|
||||
# Execution through another command needed its own case under the old design.
|
||||
printf '2\t{"tool_input":{"command":"find . -maxdepth 0 -exec curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments ;"}}\tfind -exec runs the client\n'
|
||||
# --- round five, and the finding that ended the parser. Command substitution
|
||||
# inside a double-quoted span EXECUTES, while the skeleton was discarding that
|
||||
# span as inert prose. The unquoted and process-substitution forms already
|
||||
# blocked, which is what made it a classification defect rather than a spelling
|
||||
# one: the same call was refused or allowed depending on a quote character.
|
||||
printf '2\t{"tool_input":{"command":"echo \\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tcommand substitution inside double quotes executes\n'
|
||||
printf '2\t{"tool_input":{"command":"echo \\"`curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments`\\""}}\tso does the backtick form\n'
|
||||
printf '2\t{"tool_input":{"command":"msg=\\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tand an assignment RHS is not data either\n'
|
||||
printf '2\t{"tool_input":{"command":"echo $(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tthe unquoted form, which blocked before and must keep blocking\n'
|
||||
printf '2\t{"tool_input":{"command":"cat <(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tprocess substitution, same\n'
|
||||
printf '2\t{"tool_input":{"command":"bash --command \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\""}}\tthe long-option spelling of bash -c needs no entry in any list now\n'
|
||||
# A client the guard was never taught is the point of dropping client
|
||||
# detection: neither of these names curl at all.
|
||||
printf '2\t{"tool_input":{"command":"python3 -c '"'"'import requests; requests.post(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\", json={})'"'"'"}}\ta library call is a write with no flag and no curl\n'
|
||||
printf '2\t{"tool_input":{"command":"wget --post-data=x https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\twget spells its body differently and is still a write\n'
|
||||
|
||||
# Round six scoped the guard on `https?://`, and review found the absence shape
|
||||
# had simply moved to that new boundary: a raw provider CLI carries no scheme,
|
||||
# so the guard never reached the write question. These are the reported repros.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues -f title=x -f body=y"}}\tgh api is a raw write with no URL scheme at all\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE"}}\tand it reaches the endpoint the review wrapper owns\n'
|
||||
printf '2\t{"tool_input":{"command":"tea api -X POST repos/a/b/issues/1/comments -f body=x"}}\ttea api, same shape, different CLI\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues"}}\ta scheme-less host path is still an API write\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues -f title=x"}}\tgh POSTs implicitly when handed a field, exactly as curl does with -d\n'
|
||||
# ...and the boundary that stops a broader scope gate becoming block-everything.
|
||||
printf '0\t{"tool_input":{"command":"gh api repos/a/b/pulls/1"}}\treading through a provider CLI stays untouched\n'
|
||||
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/releases -f tag_name=v1"}}\tno wrapper owns releases, whoever calls it\n'
|
||||
printf '0\t{"tool_input":{"command":"tea pulls create --title x --repo a/b"}}\tprovider PORCELAIN is out of scope by decision, not by accident\n'
|
||||
printf '0\t{"tool_input":{"command":"rm -f /var/tmp/api/v1-issues-notes.txt"}}\t-f is only a body when it carries key=value\n'
|
||||
printf '0\t{"tool_input":{"command":"grep -f patterns.txt /src/api/v1/repos/a/b/issues.log"}}\tsame, on the flag agents actually collide with\n'
|
||||
|
||||
# Wrong remediation is its own defect: /issues/1/labels used to block with
|
||||
# "use issue-create.sh", which is not the wrapper for that call. Round seven
|
||||
# answered that by letting EVERY path under a numbered issue or PR through,
|
||||
# and review showed the reasoning ("no wrapper owns these") was false in this
|
||||
# tree. These are the reported repros, all rc 0 before round eight, and each
|
||||
# asserts the wrapper the advice must name — not merely that a block happened.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f title=x"}}\tan issue edit is issue-edit.sh, not a wrapper gap\tissue-edit.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1"}}\tsame call through curl, same wrapper\tissue-edit.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1/labels -f labels[]=bug"}}\tlabels are wrapped, and the advice says by which\tissue-edit.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/assignees -f assignees[]=u"}}\tassignees are issue-assign.sh\tissue-assign.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues/1/assignees -f assignees[]=u"}}\tthe array field spelling is a body with no -X at all\tissue-assign.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/labels"}}\ta PR is an issue where labels live, so the issue wrapper owns them\tissue-edit.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\tPR state is pr-close.sh, and the gap in that arm is stated\tpr-close.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/4"}}\ta milestone state change is milestone-close.sh, not the create wrapper\tmilestone-close.sh\n'
|
||||
# SPAN. A wrapper that owns a slice of an endpoint must not be advertised as
|
||||
# owning the endpoint. milestone-close.sh takes only -t <title> and sends
|
||||
# state=closed, so a title/description/due-date edit is a gap and the message
|
||||
# has to say so — round eight named the wrapper and stopped there.
|
||||
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/1"}}\ta milestone edit blocks, but the advice states the close-only span\towns the CLOSE only\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f assignee=u"}}\tissue-edit.sh cannot set an assignee, so the message names the one that can\tissue-assign.sh owns the assignee\n'
|
||||
# The residue: still genuinely owned by nothing, and still flowing through.
|
||||
# Requesting a reviewer is not submitting one; pr-review.sh files verdicts and
|
||||
# nothing in the tree adds a requested reviewer.
|
||||
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/requested_reviewers -f reviewers[]=u"}}\tno wrapper requests a reviewer, so it is not refused with pr-review.sh\n'
|
||||
# SPAN, applied to the guard's OWN fail-closed rule rather than to a wrapper.
|
||||
# The scope gate admits three shapes; the unreadable-endpoint rule asked only
|
||||
# for `https?://`, so a split endpoint in the other two was in scope to block,
|
||||
# produced no readable endpoint, and fell through to allow. Same defect class
|
||||
# as the milestone arm, one layer up. Each shape gets its own fixture, because
|
||||
# a single one would have passed on the arm that already worked.
|
||||
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x"}}\ta split endpoint in a provider-CLI api call is unreadable, not absent\n'
|
||||
printf '2\t{"tool_input":{"command":"p=repos/a/b/issues/1/comm; q=ents; gh api -X POST ${p}${q} -f body=x"}}\tsame, comments\n'
|
||||
printf '2\t{"tool_input":{"command":"p=repos/a/b/pulls/1/rev; q=iews; gh api -X POST ${p}${q} -f event=APPROVED"}}\tsame, and a verdict is the costliest one to lose\n'
|
||||
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; tea api -X POST ${p}${q} -f title=x"}}\tevery CLI the scope gate admits, not just gh\n'
|
||||
printf '2\t{"tool_input":{"command":"p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q}"}}\ta schemeless forge host with a split path is unreadable too\n'
|
||||
printf '2\t{"tool_input":{"command":"h=git.example.invalid; q=ues; curl -X POST -d x ${h}/api/v1/repos/a/b/iss${q}"}}\tthe expansion may come first; the token is what matters\n'
|
||||
# And the reason this is not "any variable blocks a write": a payload in a
|
||||
# variable is the SAFE way to pass one and leaves the endpoint fully legible.
|
||||
printf '0\t{"tool_input":{"command":"gh api repos/a/b/git/refs -f sha=$SHA"}}\tan expansion in a body value leaves the endpoint readable\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d \\"$BODY\\" https://git.example.invalid/api/v1/repos/a/b/git/refs"}}\tsame for a quoted body on an unwrapped endpoint\n'
|
||||
printf '0\t{"tool_input":{"command":"gh api repos/${OWNER}/${REPO}/git/refs"}}\ta read with a split endpoint is still a read\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/comments/5"}}\tediting a comment has no wrapper; only creating one does\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/stopwatch/start"}}\tno wrapper owns a stopwatch, and none is invented for it\n'
|
||||
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/times -f time=60"}}\tnor time tracking\n'
|
||||
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=+1"}}\tnor reactions\n'
|
||||
# ...and the residue must be decided by the SEGMENT, never by a stray slash.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=see-/docs/x"}}\ta slash inside the body is not a subresource\tissue-edit.sh\n'
|
||||
# The arms above the numbered ones must keep blocking, with their own wrappers.
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe wrapped subresource must not fall through\tissue-comment.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues"}}\tnor may issue creation\tissue-create.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tPR creation is wrapped and must not fall through with them\tpr-create.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tand a review still names the review wrapper\tpr-review.sh\n'
|
||||
|
||||
# SPAN a third time, now in the scope gate itself: it asked for `/api/v[0-9]`,
|
||||
# which is Gitea's spelling. GitHub's API carries no version segment at all
|
||||
# (`api.github.com/repos/...`), so the schemeless Gitea write was in scope and
|
||||
# the schemeless GitHub one was not — a gate calibrated to one dialect rather
|
||||
# than to what identifies a provider API. `/repos/` is the marker both share.
|
||||
# These endpoints are READABLE, so each asserts the wrapper it must name; a
|
||||
# rc-only fixture here would pass on the unreadable arm and prove nothing.
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues"}}\ta schemeless GitHub host is a provider API even with no version segment\tissue-create.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues/1/comments"}}\tsame, and the subresource still names its own wrapper\tissue-comment.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues"}}\tthe host may be a variable; the path is what the guard reads\tissue-create.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/pulls/1/reviews"}}\ta verdict is the costliest call to lose to a spelling\tpr-review.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"p=/repos/a/b/iss; q=ues; curl -X POST -d x api.github.com${p}${q}"}}\tand the split form of it is unreadable, not absent\n'
|
||||
# The end-of-options marker, which is the one option not spelled like one.
|
||||
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST -- ${p}${q} -f title=x"}}\ta bare -- must not walk the endpoint past the scanner\n'
|
||||
# Widening a scope gate may not create a block. Reads and unwrapped endpoints
|
||||
# in the newly admitted shape have to stay allowed, or this is a regression
|
||||
# wearing a fix'"'"'s clothes.
|
||||
printf '0\t{"tool_input":{"command":"curl api.github.com/repos/a/b/issues"}}\tadmitting a shape to the gate does not make a read a write\n'
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/git/refs"}}\tno wrapper owns git refs, on GitHub'"'"'s spelling either\n'
|
||||
printf '0\t{"tool_input":{"command":"gh api -- repos/a/b/issues"}}\tthe marker in a read is still a read\n'
|
||||
|
||||
# The APPROVE trap, in the spelling a provider CLI uses, and the value that
|
||||
# must never trip it.
|
||||
printf '0\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVED\\"} https://git.example.invalid/api/v1/repos/a/b/releases"}}\tAPPROVED is the correct value and is never the trap\n'
|
||||
# Documented over-block, pinned so it is a known boundary and not a surprise.
|
||||
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments .post(\\")'"'"'"}}\tprose carrying .post( near a wrapped URL is refused, by the same payload rule\n'
|
||||
|
||||
# --- round nine, all four from one adversarial pass, and three of them are
|
||||
# the same shape: a test written over the WHOLE command text deciding an
|
||||
# ALLOW. That is the fail-open form this file keeps rediscovering, and it had
|
||||
# reached the break-glass itself.
|
||||
#
|
||||
# BREAK-GLASS. `case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*)` cleared the entire
|
||||
# command if that string appeared anywhere in it — so quoting the override in a
|
||||
# note, or naming a variable after it, disabled the guard for the call sitting
|
||||
# beside it. The override is now read POSITIONALLY: leading `NAME=value`
|
||||
# assignments only, exactly where the shell would honour one.
|
||||
printf '2\t{"tool_input":{"command":"echo \\"MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews\\" >> notes.md"}}\tquoting the override in a document does not arm it\n'
|
||||
printf '2\t{"tool_input":{"command":"NOTES=MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tan assignment whose VALUE is the override is not the override\n'
|
||||
printf '2\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t=10 matched the old substring test and is not the value 1\n'
|
||||
printf '0\t{"tool_input":{"command":"GITEA_TOKEN=$T MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tthe override still works behind other assignments, as the shell reads it\n'
|
||||
# The cost, pinned rather than discovered later: positional means positional.
|
||||
printf '2\t{"tool_input":{"command":"cd /tmp && MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tan override after && is not in command position and does not arm\n'
|
||||
|
||||
# SUBRESOURCE REFINEMENT, same defect one arm lower. It asked whether a
|
||||
# subresource appears ANYWHERE in the command, so a numbered-object write was
|
||||
# cleared on the strength of text in its own BODY. Inverted: clear only when
|
||||
# EVERY numbered-object occurrence carries a subresource.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=cf-/pulls/2/files"}}\ta subresource in the body does not clear a write to the numbered issue\tissue-edit.sh\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=cf-/issues/3/reactions"}}\tsame, quoting a subresource of the same object type\tissue-edit.sh\n'
|
||||
# ...and its documented cost, in the safe direction.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=cf-/issues/2"}}\tan unwrapped subresource write that quotes a bare issue is refused\n'
|
||||
|
||||
# -K/--config. curl reads the method, the body, the headers AND the URL from
|
||||
# that file, so none of them are in the command: every write test above read 0
|
||||
# and the call went through. An unreadable request is not a cleared one.
|
||||
printf '2\t{"tool_input":{"command":"curl --config /tmp/req https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tthe request in a config file is unreadable, so it is refused\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -K /tmp/req https://git.example.invalid/api/v1/repos/a/b/issues"}}\tthe short spelling, same answer\t--config/-K\n'
|
||||
# Cost, stated: this refuses a --config read against a host that has nothing
|
||||
# to do with a forge. The alternative is to require a forge marker in a
|
||||
# command whose URL may itself be in the file, which is the hole again.
|
||||
printf '2\t{"tool_input":{"command":"curl --config /tmp/req https://example.invalid/anything"}}\tan unrelated https URL with --config is refused too, by decision\t--config/-K\n'
|
||||
printf '0\t{"tool_input":{"command":"eslint --config .eslintrc.json src/"}}\t--config on a command that is not curl is nobody'"'"'s business\n'
|
||||
|
||||
# ROUND TEN. The --config check above was first written INSIDE the API-shape
|
||||
# gate, so it was guarded by a condition that the capability it guards against
|
||||
# removes. A config file can carry the URL; delete the URL from the command and
|
||||
# nothing is API-shaped, the branch is never entered, and the guard reports
|
||||
# clean on precisely the call it exists to refuse. It is now asked of any curl.
|
||||
printf '2\t{"tool_input":{"command":"curl --config /tmp/provider-write.cfg"}}\ta config file can own the URL, so there is nothing API-shaped left to gate on\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -K/tmp/provider-write.cfg"}}\tcurl accepts the value attached to the short flag\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -sK /tmp/provider-write.cfg"}}\tand inside a bundle, which a space-separated test does not see\t--config/-K\n'
|
||||
printf '0\t{"tool_input":{"command":"tar -K /tmp/archive.tar"}}\t-K on a command that is not curl is not this hook'"'"'s business\n'
|
||||
# curl by any ordinary path spelling. The first version of the config check
|
||||
# matched the bare word only, so these three executed the same wrapped write
|
||||
# while the guard reported clean. Recognizing only the unqualified name is
|
||||
# caller-name parsing, and that is the class this file exists to refuse.
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/curl --config /tmp/provider-write.cfg"}}\tan absolute path is the same invocation, not a different one\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"env /usr/bin/curl -K/tmp/provider-write.cfg"}}\tand it is still curl behind env, with the value attached\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"./curl --config /tmp/provider-write.cfg"}}\ta relative path costs two characters and used to be enough\t--config/-K\n'
|
||||
# The prefix must end at a slash: a basename that merely ENDS in curl is a
|
||||
# different program, and blocking it would be the over-block that gets a guard
|
||||
# routed around rather than fixed.
|
||||
printf '0\t{"tool_input":{"command":"mycurl --config /tmp/provider-write.cfg"}}\tmycurl is not curl, and over-blocking is its own failure\n'
|
||||
printf '0\t{"tool_input":{"command":"/opt/x/curl-wrapper --config /tmp/provider-write.cfg"}}\tnor is curl-wrapper, whose name only starts the same way\n'
|
||||
|
||||
# And the same name once it is punctuated. The basename repair above fixed the
|
||||
# UNQUOTED path spelling and nothing else, so two quote characters restored the
|
||||
# bypass it had just closed: the check was still modelling one presentation of
|
||||
# a shell word instead of the word. Every one of these executes the real curl.
|
||||
printf '2\t{"tool_input":{"command":"\\"/usr/bin/curl\\" --config /tmp/provider-write.cfg"}}\tquoting a path does not make it a different program\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"'"'"'./curl'"'"' --config /tmp/provider-write.cfg"}}\tnor does quoting a relative one\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"$(which curl) --config /tmp/provider-write.cfg"}}\tthe name is in the text even when a substitution supplies the path\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"`which curl` --config /tmp/provider-write.cfg"}}\tand in the older spelling of the same substitution\t--config/-K\n'
|
||||
|
||||
# The provider-CLI SCOPE gate had the identical defect, untouched while the
|
||||
# curl arm was repaired twice. It decides whether write detection runs at all,
|
||||
# so failing to admit these is indistinguishable from allowing them — and no
|
||||
# URL marker rescues them, because provider CLI paths carry no leading slash.
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/issues -f title=x"}}\tan absolute path to a provider CLI is still a provider CLI\n'
|
||||
printf '2\t{"tool_input":{"command":"./gh api -X POST repos/a/b/issues -f title=x"}}\tand a relative one still is too\n'
|
||||
printf '2\t{"tool_input":{"command":"/usr/local/bin/tea api -X POST repos/a/b/issues -f title=x"}}\tthe same is true of every CLI the gate names, not just the first\n'
|
||||
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/issues -f title=x"}}\tmygh is not gh, and the scope gate must not over-admit either\n'
|
||||
printf '0\t{"tool_input":{"command":"/usr/bin/gh api repos/a/b/issues"}}\ta read through an absolute path is still a read\n'
|
||||
|
||||
# Quotes and backslashes INSIDE the word. The previous repair replaced quote
|
||||
# characters with whitespace, which is token separation and not quote removal:
|
||||
# a shell removes a quote without splitting the word around it, so `cu"rl"` is
|
||||
# one word naming curl while whitespace made it two words naming neither.
|
||||
# `"/usr/bin/curl"` passed under that version only because the inserted space
|
||||
# happened to land after a slash, which established nothing.
|
||||
printf '2\t{"tool_input":{"command":"cu\\"rl\\" --config /tmp/provider-write.cfg"}}\ta quote inside the word does not make it another program\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"cu'"'"'rl'"'"' --config /tmp/provider-write.cfg"}}\tand a single quote inside it is the same word again\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/cu\\\\rl --config /tmp/provider-write.cfg"}}\tescaping is ordinary word formation, not a disguise\t--config/-K\n'
|
||||
# A backslash is NOT uniformly removed. It is literal inside single quotes,
|
||||
# and inside double quotes when it precedes anything other than $, `, ",
|
||||
# backslash, or newline. These spell a different program and must stay allowed.
|
||||
printf '0\t{"tool_input":{"command":"'"'"'cu\\\\rl'"'"' --config /tmp/provider-write.cfg"}}\ta backslash inside single quotes remains literal\n'
|
||||
printf '0\t{"tool_input":{"command":"\\"cu\\\\rl\\" --config /tmp/provider-write.cfg"}}\ta backslash before r inside double quotes remains literal\n'
|
||||
printf '0\t{"tool_input":{"command":"'"'"'g\\\\it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta literal backslash in a single-quoted non-git name is not a checkout\n'
|
||||
printf '0\t{"tool_input":{"command":"\\"g\\\\it\\" clone https://example.invalid/x $HOME/wt"}}\ta literal backslash in a double-quoted non-git name is not a checkout\n'
|
||||
# The other branch of the same rule: OUTSIDE quotes a backslash escapes the
|
||||
# next character, so an escaped quote is a literal quote IN the name and the
|
||||
# program is not curl. Held separately from the cases above because it is a
|
||||
# different arm of the state machine, and an arm without a fixture is a rule
|
||||
# that is not held.
|
||||
printf '0\t{"tool_input":{"command":"cu\\\\\\"rl\\\\\\" --config /tmp/provider-write.cfg"}}\tan escaped quote is a literal quote in the name\n'
|
||||
# Three quoted segments concatenate into ONE word. This is the shape that
|
||||
# distinguishes quote removal from token separation, so it is worth its own line.
|
||||
printf '2\t{"tool_input":{"command":"\\"cu\\"'"'"'r'"'"'\\"l\\" --config /tmp/provider-write.cfg"}}\tadjacent quoted segments are one word, and that word is curl\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/issues -f title=x"}}\tthe CLI name is a word on the same terms\n'
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/g\\\\h api -X POST repos/a/b/issues -f title=x"}}\tincluding when it is escaped behind a path\n'
|
||||
# The FLAG is the same recognition problem as the name, and is read the same
|
||||
# way. No review raised this one; the name was simply the easier half to reach.
|
||||
printf '2\t{"tool_input":{"command":"curl --con\\"fig\\" /tmp/provider-write.cfg"}}\tone word spelling --config is still --config\t--config/-K\n'
|
||||
|
||||
# The unreadable-endpoint arm is the THIRD name consumer. It kept a private
|
||||
# bare-name copy of the scope gate's regex, so a caller could be admitted by
|
||||
# the repaired gate and then go unrecognized by the fail-closed refinement —
|
||||
# a gate and its own refinement disagreeing about who the caller is.
|
||||
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/$EP -f title=x"}}\ta path-qualified CLI with an assembled endpoint is still unreadable\n'
|
||||
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/$EP -f title=x"}}\tand so is a quoted one, which is where the two halves disagreed\n'
|
||||
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/$EP -f title=x"}}\tmygh is still not gh, in the refinement as well as the gate\n'
|
||||
|
||||
# Shapes nobody raised. Written down because reasoning that they were already
|
||||
# covered is precisely what produced two of the rounds above; each one below
|
||||
# was measured, and the three that fail at df83a9ee are here on that evidence.
|
||||
# `\curl` is the ordinary way to bypass a shell alias and is a thing people
|
||||
# actually type, which makes it the least hypothetical entry in the file.
|
||||
printf '2\t{"tool_input":{"command":"\\\\curl --config /tmp/provider-write.cfg"}}\tescaping the leading character to dodge an alias still names curl\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"cur\\"l\\" --config /tmp/provider-write.cfg"}}\tthe quote may sit at any offset in the word\t--config/-K\n'
|
||||
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST \\"repos/a/b/$EP\\" -f title=x"}}\tboth halves dressed at once, which is where they last disagreed\n'
|
||||
|
||||
# Over-blocking is a real failure and not a safe direction: a guard that
|
||||
# refuses legitimate work gets routed around instead of repaired. These four
|
||||
# pass at both heads, which is what a regression guard is for.
|
||||
printf '0\t{"tool_input":{"command":"gh api \\"repos/a/b/issues\\""}}\ta quoted read is still a read\n'
|
||||
printf '0\t{"tool_input":{"command":"curl https://example.com/file.txt -o /tmp/f"}}\tan ordinary download is not a provider write\n'
|
||||
printf '0\t{"tool_input":{"command":"echo \\"$EP\\" && gh --version"}}\tno api subcommand, so nothing to refuse\n'
|
||||
printf '0\t{"tool_input":{"command":"echo \\"not a curl call\\""}}\tthe word inside a string, with no flag, is prose\n'
|
||||
|
||||
# Percent-encoded endpoints. Not hypothetical: /issues/1174 and /iss%%75es/1174
|
||||
# both returned HTTP 200 with the same object from the live forge, so the
|
||||
# encoded spelling IS the wrapped endpoint and the literal comparison below it
|
||||
# sees a segment matching nothing. Refused rather than decoded — a decoder has
|
||||
# to be exactly right about depth and normalization, which is the parser
|
||||
# mistake this file declines everywhere else.
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/iss%%75es/1/comments -f body=x"}}\tan encoded path segment reaches the wrapped endpoint\tpercent-escape\n'
|
||||
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/revi%%65ws"}}\tsame for the review endpoint, which is the one that matters most\tpercent-escape\n'
|
||||
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/iss%%2575es/1/comments -f body=x"}}\tdouble-encoded too, which is why this refuses instead of decoding\tpercent-escape\n'
|
||||
# Scoped to writes, deliberately. Reads are never blocked by this guard and a
|
||||
# query string carrying %%20 is an ordinary URL, not a hazard.
|
||||
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/issues?q=a%%20b"}}\ta percent-escape in a READ is not this hook'"'"'s business\n'
|
||||
} > "$FIXTURES"
|
||||
|
||||
fail=0 n=0
|
||||
while IFS=$'\t' read -r want payload why remedy; do
|
||||
[ -n "${want:-}" ] || continue
|
||||
n=$((n + 1))
|
||||
out="$(printf '%s' "$payload" | "$GUARD" 2>&1)"
|
||||
got=$?
|
||||
if [ "$got" != "$want" ]; then
|
||||
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
# A block that names the wrong wrapper is a defect in its own right, and until
|
||||
# now it was invisible here: the harness read the exit code and nothing else,
|
||||
# so /issues/1/labels blocking with "use issue-create.sh" passed every run for
|
||||
# six rounds. Where a fixture states the remediation it expects, assert it.
|
||||
if [ -n "${remedy:-}" ] && ! printf '%s' "$out" | grep -Fq -- "$remedy"; then
|
||||
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
printf 'ok %s\n' "$why"
|
||||
done < "$FIXTURES"
|
||||
|
||||
# ---- $HOME resolution ------------------------------------------------------
|
||||
# These cannot be fixtures. Every case above varies the COMMAND; this defect
|
||||
# varies the ENVIRONMENT, and the loop has no way to express that.
|
||||
#
|
||||
# The checkout arm built its pattern from "$HOME" without asking whether $HOME
|
||||
# was a usable value. Three values it is not: unset (which is a crash under
|
||||
# `set -u`, not a decision), empty (the pattern collapses to `/`, so `~|\$HOME|`
|
||||
# matches whatever the empty alternative touches), and "/" (every absolute path
|
||||
# is under it, so the comparison stops discriminating). An agent seat running
|
||||
# with no HOME — a systemd unit without one, a container, `env -i` — got the
|
||||
# checkout question answered by accident rather than on the merits.
|
||||
#
|
||||
# The fix resolves $HOME once, rejects all three, and BLOCKS the checkout it
|
||||
# cannot adjudicate. A guard may not clear a question it was unable to ask. The
|
||||
# blast radius of that fail-closed arm is asserted below to be one command shape
|
||||
# and not the session: with no HOME at all, ordinary commands still pass and the
|
||||
# API arms still block.
|
||||
home_case() {
|
||||
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
|
||||
local out got
|
||||
n=$((n + 1))
|
||||
local payload
|
||||
payload="$(jq -nc --arg command "$cmd" '{tool_input:{command:$command}}')"
|
||||
if [ "$homeval" = "@unset" ]; then
|
||||
out="$(printf '%s' "$payload" | env -u HOME "$GUARD" 2>&1)"
|
||||
else
|
||||
out="$(printf '%s' "$payload" | env HOME="$homeval" "$GUARD" 2>&1)"
|
||||
fi
|
||||
got=$?
|
||||
if [ "$got" != "$want" ]; then
|
||||
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
|
||||
printf 'FAIL %s (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
printf 'ok %s\n' "$why"
|
||||
}
|
||||
|
||||
home_case 'HOME unset: a checkout is refused, not adjudicated' \
|
||||
2 '@unset' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||
home_case 'HOME empty: same, and it is not the same thing as unset' \
|
||||
2 '' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||
home_case 'HOME=/ : every path is under it, so it discriminates nothing' \
|
||||
2 '/' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||
home_case 'a usable HOME still allows a checkout onto a work filesystem' \
|
||||
0 '/home/tester' 'git clone https://example.invalid/x /src/wt'
|
||||
home_case 'a usable HOME still catches the literal path' \
|
||||
2 '/home/tester' 'git clone https://example.invalid/x /home/tester/wt' 'checks a repository out under'
|
||||
home_case 'a usable HOME catches the exact literal path without a trailing slash' \
|
||||
2 '/home/tester' 'git clone https://example.invalid/x /home/tester' 'checks a repository out under'
|
||||
home_case 'quotes around the exact literal HOME path do not change the target' \
|
||||
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"' 'checks a repository out under'
|
||||
home_case 'a quoted literal HOME segment remains contiguous with the suffix' \
|
||||
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"/wt' 'checks a repository out under'
|
||||
home_case 'a repeated leading slash is the same absolute HOME path' \
|
||||
2 '/home/tester' 'git clone https://example.invalid/x //home/tester/wt' 'checks a repository out under'
|
||||
home_case 'dot segments cannot disguise the literal HOME path' \
|
||||
2 '/home/tester' 'git worktree add /var/../home/tester/./wt' 'checks a repository out under'
|
||||
home_case 'parent traversal into HOME is normalized for separate Git state' \
|
||||
2 '/home/tester' 'git clone --separate-git-dir=/home/other/../tester/gd x /src/wt' 'checks a repository out under'
|
||||
home_case 'normalization still permits a literal HOME sibling' \
|
||||
0 '/home/tester' 'git clone x /home/tester/../tester-sibling/wt'
|
||||
home_case 'and the unexpanded $HOME spelling, which needs no resolution at all' \
|
||||
2 '/home/tester' 'git worktree add $HOME/wt topic' 'checks a repository out under'
|
||||
|
||||
# Resolve the longest existing parent physically before appending a nonexistent
|
||||
# destination. Lexical normalization alone cannot see a symlink into HOME, and
|
||||
# it applies `..` in the wrong order when the preceding component is a symlink.
|
||||
SYMLINK_HOME="$TMP/symlink-home"
|
||||
SYMLINK_SAFE="$TMP/symlink-safe"
|
||||
mkdir -p "$SYMLINK_HOME/nested" "$SYMLINK_SAFE"
|
||||
ln -s "$SYMLINK_HOME" "$TMP/home-link"
|
||||
ln -s "$SYMLINK_HOME/nested" "$TMP/home-nested-link"
|
||||
ln -s "$SYMLINK_SAFE" "$TMP/safe-link"
|
||||
home_case 'a clone path through a symlink into HOME is refused' \
|
||||
2 "$SYMLINK_HOME" "git clone x $TMP/home-link/wt" 'checks a repository out under'
|
||||
home_case 'a worktree path through a symlink into HOME is refused' \
|
||||
2 "$SYMLINK_HOME" "git worktree add $TMP/home-link/wt" 'checks a repository out under'
|
||||
home_case 'symlink resolution occurs before a following parent segment' \
|
||||
2 "$SYMLINK_HOME" "git clone x $TMP/home-nested-link/../wt" 'checks a repository out under'
|
||||
home_case 'a symlink to a physical path outside HOME remains allowed' \
|
||||
0 "$SYMLINK_HOME" "git clone x $TMP/safe-link/wt"
|
||||
# The fail-closed arm is scoped to checkouts. If it were not, a seat with no
|
||||
# HOME would have every command it runs refused, which is how a guard gets
|
||||
# disabled rather than fixed.
|
||||
home_case 'HOME unset does not block an ordinary command' \
|
||||
0 '@unset' 'ls -la /src'
|
||||
home_case 'HOME unset does not stop the API arms doing their job' \
|
||||
2 '@unset' 'curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews' 'pr-review.sh'
|
||||
|
||||
# ---- the guard standing on its own -----------------------------------------
|
||||
# Every case above runs the guard from the directory holding its siblings, so
|
||||
# `[ -x "$W/pr-review.sh" ]` succeeds and the $HOME fallback beside it never
|
||||
# evaluates. That is a property of the HARNESS, not of the guard, and it hid a
|
||||
# live fail-open: with the guard copied somewhere alone AND no HOME, the
|
||||
# fallback expanded an unset variable under `set -u` and the script died at
|
||||
# rc=1 — on EVERY arm, before any adjudication. A PreToolUse hook exiting
|
||||
# nonzero-but-not-2 is a non-blocking error, so that seat ran with no guard and
|
||||
# nothing reported it.
|
||||
#
|
||||
# The first remediation moved that expansion four lines earlier and called it
|
||||
# closed. It was not closed, because the test could not reach it. So the guard
|
||||
# is copied ALONE here — no siblings, no installed mosaic home — which is the
|
||||
# deployment this file already claims to support ("still works from a repo
|
||||
# checkout with no installed mosaic home").
|
||||
LONE="$TMP/lone"; mkdir -p "$LONE"
|
||||
cp "$GUARD" "$LONE/wrapper-guard.sh"; chmod +x "$LONE/wrapper-guard.sh"
|
||||
|
||||
lone_case() {
|
||||
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
|
||||
local out got
|
||||
n=$((n + 1))
|
||||
if [ "$homeval" = "@unset" ]; then
|
||||
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env -u HOME "$LONE/wrapper-guard.sh" 2>&1)"
|
||||
else
|
||||
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env HOME="$homeval" "$LONE/wrapper-guard.sh" 2>&1)"
|
||||
fi
|
||||
got=$?
|
||||
if [ "$got" != "$want" ]; then
|
||||
printf 'FAIL %s [standalone] (want exit %s, got %s)\n' "$why" "$want" "$got"
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
|
||||
printf 'FAIL %s [standalone] (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
||||
fail=1
|
||||
return
|
||||
fi
|
||||
printf 'ok %s [standalone]\n' "$why"
|
||||
}
|
||||
|
||||
lone_case 'no siblings and no HOME: an ordinary command still passes, not rc=1' \
|
||||
0 '@unset' 'ls -la /src'
|
||||
lone_case 'no siblings and no HOME: a wrapped write is still refused' \
|
||||
2 '@unset' 'curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews' 'pr-review.sh'
|
||||
lone_case 'no siblings and no HOME: a checkout is refused, not adjudicated' \
|
||||
2 '@unset' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
||||
# Spelled without quotes on purpose. The payload is interpolated into a JSON
|
||||
# string by the helper, so a fixture carrying bare double quotes produces
|
||||
# malformed JSON, jq returns empty, and the guard exits 0 on an empty command —
|
||||
# a PASS that measures nothing. That is what the first version of this case did.
|
||||
lone_case 'no siblings and no HOME: the APPROVE trap still fires' \
|
||||
2 '@unset' 'gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE'
|
||||
lone_case 'no siblings, usable HOME: ordinary commands unaffected' \
|
||||
0 '/home/tester' 'ls -la /src'
|
||||
|
||||
printf '\n'
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
printf 'wrapper-guard: %d/%d fixtures behaved as specified.\n' "$n" "$n"
|
||||
else
|
||||
cat <<'EOF'
|
||||
wrapper-guard drifted from its contract.
|
||||
|
||||
A guard that blocks too much gets routed around, and a guard that blocks too
|
||||
little is decoration. Both directions are failures here, which is why the
|
||||
allowed cases are asserted as hard as the blocked ones.
|
||||
EOF
|
||||
fi
|
||||
exit "$fail"
|
||||
+1161
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,20 @@ EXPECTED_ACTIVATION_CAPABILITY: Final[ActivationCapability] = {
|
||||
# capability as compact JSON.
|
||||
LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability"
|
||||
|
||||
PROBE_TIMEOUT_SECONDS: Final = 2.0
|
||||
# Running the probe boots the whole Node CLI; it does not merely exec a binary.
|
||||
# Measured: 3.0-3.7 s on an idle 4-core VM and 3.55-3.61 s on web1, against
|
||||
# `node -e 0` at 0.05 s. The former 2.0 s budget therefore expired on every
|
||||
# call on both hosts. Because the probe is fail-closed, an expiry is
|
||||
# indistinguishable from "no capability", so every seat launch was denied with
|
||||
# a version-skew message that no upgrade could fix. Sized well above the
|
||||
# measured range: the gate still fails closed, it just no longer fails closed
|
||||
# on a stopwatch.
|
||||
PROBE_TIMEOUT_SECONDS: Final = 20.0
|
||||
|
||||
# Override hook: seconds to wait for the probe, for hosts slow or loaded enough
|
||||
# that even the default is tight. Non-numeric or non-positive values are
|
||||
# ignored in favour of the default rather than disabling the bound.
|
||||
PROBE_TIMEOUT_OVERRIDE_VAR: Final = "MOSAIC_LEASE_VERSION_PROBE_TIMEOUT_SECONDS"
|
||||
|
||||
# Override hook: a full shell-style command line (parsed with `shlex.split`)
|
||||
# to run INSTEAD of resolving `mosaic` on PATH and appending the probe
|
||||
@@ -83,12 +96,28 @@ class VersionCouplingError(Exception):
|
||||
silent pass, and never let its absence be treated as compatible."""
|
||||
|
||||
|
||||
def _resolve_probe_timeout(environ: Mapping[str, str]) -> float:
|
||||
raw = environ.get(PROBE_TIMEOUT_OVERRIDE_VAR)
|
||||
if not raw:
|
||||
return PROBE_TIMEOUT_SECONDS
|
||||
try:
|
||||
seconds = float(raw)
|
||||
except ValueError:
|
||||
return PROBE_TIMEOUT_SECONDS
|
||||
if seconds <= 0 or seconds != seconds or seconds == float("inf"):
|
||||
return PROBE_TIMEOUT_SECONDS
|
||||
return seconds
|
||||
|
||||
|
||||
def _resolve_probe_command(environ: Mapping[str, str]) -> list[str] | None:
|
||||
override = environ.get(MOSAIC_COMMAND_OVERRIDE_VAR)
|
||||
if override:
|
||||
parsed = shlex.split(override)
|
||||
return parsed or None
|
||||
resolved = shutil.which("mosaic")
|
||||
# Resolve against the caller's PATH, not the ambient process one. The
|
||||
# function is handed an `environ` and honoured it only for the override
|
||||
# var, so a caller passing an explicit PATH was silently ignored here.
|
||||
resolved = shutil.which("mosaic", path=environ.get("PATH"))
|
||||
if resolved is None:
|
||||
return None
|
||||
return [resolved, LEASE_CAPABILITY_PROBE_COMMAND]
|
||||
@@ -116,7 +145,7 @@ def default_probe_activation_capability(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=PROBE_TIMEOUT_SECONDS,
|
||||
timeout=_resolve_probe_timeout(source_environment),
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired, ValueError):
|
||||
|
||||
@@ -91,10 +91,12 @@ fi
|
||||
|
||||
if [[ -n "$dirty_files" ]]; then
|
||||
echo " Modified files:"
|
||||
echo "$dirty_files" | head -20 | while IFS= read -r line; do
|
||||
echo " $line"
|
||||
mapfile -t dirty_lines <<<"$dirty_files"
|
||||
file_count="${#dirty_lines[@]}"
|
||||
display_count=$((file_count < 20 ? file_count : 20))
|
||||
for ((i = 0; i < display_count; i++)); do
|
||||
echo " ${dirty_lines[$i]}"
|
||||
done
|
||||
file_count="$(echo "$dirty_files" | wc -l)"
|
||||
if (( file_count > 20 )); then
|
||||
echo " ... and $(( file_count - 20 )) more"
|
||||
fi
|
||||
|
||||
@@ -64,12 +64,12 @@ if jq -e '.next_task == "T-001"' "$capsule_file" >/dev/null 2>&1; then pass_case
|
||||
if grep -Fq 'Target runtime:** codex' <<< "$codex_continue_output"; then pass_case "continue prompt contains target runtime codex"; else fail_case "continue prompt contains target runtime codex"; fi
|
||||
|
||||
codex_run_prompt="$(MOSAIC_COORD_RUNTIME=codex bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
|
||||
if [[ "$(printf '%s\n' "$codex_run_prompt" | head -n1)" == "Now initiating Orchestrator mode..." ]]; then pass_case "codex run prompt first line is mode declaration"; else fail_case "codex run prompt first line is mode declaration"; fi
|
||||
if [[ "${codex_run_prompt%%$'\n'*}" == "Now initiating Orchestrator mode..." ]]; then pass_case "codex run prompt first line is mode declaration"; else fail_case "codex run prompt first line is mode declaration"; fi
|
||||
if grep -Fq 'Do NOT ask clarifying questions before your first tool actions' <<< "$codex_run_prompt"; then pass_case "codex run prompt includes no-questions hard gate"; else fail_case "codex run prompt includes no-questions hard gate"; fi
|
||||
if grep -Fq '"next_task": "T-001"' <<< "$codex_run_prompt"; then pass_case "codex run prompt embeds capsule json"; else fail_case "codex run prompt embeds capsule json"; fi
|
||||
|
||||
claude_run_prompt="$(MOSAIC_COORD_RUNTIME=claude bash "$SCRIPT_DIR/session-run.sh" --project "$tmp_project" --print)"
|
||||
if [[ "$(printf '%s\n' "$claude_run_prompt" | head -n1)" == "## Continuation Mission" ]]; then pass_case "claude run prompt remains continuation prompt format"; else fail_case "claude run prompt remains continuation prompt format"; fi
|
||||
if [[ "${claude_run_prompt%%$'\n'*}" == "## Continuation Mission" ]]; then pass_case "claude run prompt remains continuation prompt format"; else fail_case "claude run prompt remains continuation prompt format"; fi
|
||||
|
||||
echo ""
|
||||
echo "Smoke test summary: pass=$PASS fail=$FAIL"
|
||||
|
||||
@@ -96,8 +96,8 @@ L="$WORK/live5.md"; G="$WORK/ledger5.md"; echo "# LEDGER" > "$G"
|
||||
make_board "$L" 6 1 400
|
||||
before_l=$(cat "$L"); before_g=$(cat "$G")
|
||||
out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 --dry-run 2>&1) || note "dry-run exited nonzero: $out"
|
||||
echo "$out" | grep -qi "dry run" || note "dry-run did not announce itself"
|
||||
echo "$out" | grep -q "would roll" || note "dry-run did not report a plan"
|
||||
grep -qi "dry run" <<<"$out" || note "dry-run did not announce itself"
|
||||
grep -q "would roll" <<<"$out" || note "dry-run did not report a plan"
|
||||
[[ "$(cat "$L")" == "$before_l" ]] || note "dry-run modified LIVE"
|
||||
[[ "$(cat "$G")" == "$before_g" ]] || note "dry-run modified LEDGER"
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ present=0
|
||||
|
||||
for entry in "${PRDY_REQUIRED_SECTIONS[@]}"; do
|
||||
pattern="${entry#*|}"
|
||||
if echo "$PRD_CONTENT" | grep -qiE "$pattern"; then
|
||||
if grep -qiE "$pattern" <<<"$PRD_CONTENT"; then
|
||||
present=$((present + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -169,13 +169,13 @@ main() {
|
||||
# classify_surface PATH → surface name (highest-risk match wins, mirrors TS)
|
||||
classify_surface() {
|
||||
local p="$1"
|
||||
if printf '%s' "$p" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi
|
||||
if printf '%s' "$p" | grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed'; then echo data; return; fi
|
||||
if printf '%s' "$p" | grep -qiE 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; return; fi
|
||||
if printf '%s' "$p" | grep -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite'; then echo build; return; fi
|
||||
if printf '%s' "$p" | grep -qE '\.tsx|\.css|components/|apps/web/'; then echo ui; return; fi
|
||||
if printf '%s' "$p" | grep -qE '\.spec\.|\.test\.|__tests__/'; then echo test; return; fi
|
||||
if printf '%s' "$p" | grep -qE '\.md$|docs/'; then echo docs; return; fi
|
||||
if grep -qiE 'auth|login|session|token|permission|rbac|credential|secret' <<<"$p"; then echo auth; return; fi
|
||||
if grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed' <<<"$p"; then echo data; return; fi
|
||||
if grep -qiE 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform' <<<"$p"; then echo infra; return; fi
|
||||
if grep -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite' <<<"$p"; then echo build; return; fi
|
||||
if grep -qE '\.tsx|\.css|components/|apps/web/' <<<"$p"; then echo ui; return; fi
|
||||
if grep -qE '\.spec\.|\.test\.|__tests__/' <<<"$p"; then echo test; return; fi
|
||||
if grep -qE '\.md$|docs/' <<<"$p"; then echo docs; return; fi
|
||||
echo none
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ JSON_INPUT=$(cat)
|
||||
if command -v jq &>/dev/null; then
|
||||
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // .tool_response.filePath // .file_path // empty' 2>/dev/null || echo "")
|
||||
else
|
||||
FILE_PATH=$(echo "$JSON_INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -1)
|
||||
file_path_pattern='"file_path"[[:space:]]*:[[:space:]]*"([^"]*)"'
|
||||
if [[ "$JSON_INPUT" =~ $file_path_pattern ]]; then
|
||||
FILE_PATH="${BASH_REMATCH[1]}"
|
||||
else
|
||||
FILE_PATH=""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Only check TypeScript files
|
||||
@@ -53,7 +58,7 @@ OUTPUT=$(npx tsc --noEmit --pretty --maxNodeModuleJsDepth 0 2>&1) || STATUS=$?
|
||||
if [ "${STATUS:-0}" -ne 0 ]; then
|
||||
# Filter output to only show errors related to the edited file (if possible)
|
||||
BASENAME=$(basename "$FILE_PATH")
|
||||
RELEVANT=$(echo "$OUTPUT" | grep -A2 "$BASENAME" 2>/dev/null || echo "$OUTPUT" | head -20)
|
||||
RELEVANT=$(grep -A2 "$BASENAME" <<<"$OUTPUT" 2>/dev/null || sed -n '1,20p' <<<"$OUTPUT")
|
||||
|
||||
echo "TypeScript type errors detected after editing $FILE_PATH:"
|
||||
echo "$RELEVANT"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user