Compare commits

..
Author SHA1 Message Date
be-coder-06andops-03 513b6b0d01 fix: fail closed on invalid launch inputs
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/manual/ci Pipeline was successful
2026-08-20 11:14:44 -05:00
27 changed files with 560 additions and 545 deletions
-4
View File
@@ -1,4 +0,0 @@
{
"integration_trunk": "next",
"release_branch": "main"
}
@@ -1,332 +0,0 @@
import { type Type } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { describe, expect, it, vi } from 'vitest';
import { AgentService, type AgentSession } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
import { AppModule } from '../app.module.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { CommandExecutorService } from '../commands/command-executor.service.js';
import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { PreferencesModule } from '../preferences/preferences.module.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
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 fakeProviderService = {
onModuleInit: async (): Promise<void> => {},
onModuleDestroy: (): void => {},
getRegistry: () => ({ getAvailable: () => [], getAll: () => [], find: () => undefined }),
getDefaultModel: () => undefined,
listAvailableModels: () => [],
listProviders: () => [],
getAdapter: () => undefined,
getProvidersHealth: () => [],
};
function compileRealAppGraph(): Promise<TestingModule> {
return Test.createTestingModule({ imports: [AppModule] })
.overrideProvider('DB_HANDLE')
.useValue({ db: fakeDb, close: async (): Promise<void> => {} })
.overrideProvider('DB')
.useValue(fakeDb)
.overrideProvider('STORAGE_ADAPTER')
.useValue({
name: 'required-security-wiring-test',
migrate: async (): Promise<void> => {},
close: async (): Promise<void> => {},
})
.overrideProvider('AUTH')
.useValue({})
.overrideProvider('BRAIN')
.useValue({ conversations: {}, agents: {} })
.overrideProvider('LOG_SERVICE')
.useValue({})
.overrideProvider('MEMORY')
.useValue({})
.overrideProvider('MEMORY_ADAPTER')
.useValue({})
.overrideProvider(ProviderService)
.useValue(fakeProviderService)
.compile();
}
function providerToken(provider: unknown): unknown {
return typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
}
interface MaskingConsumer {
moduleType: Type<unknown>;
token: Type<unknown>;
useValue: object;
}
async function compileWithoutProvider(
moduleType: Type<unknown>,
missingToken: Type<unknown>,
maskingConsumer: MaskingConsumer,
): Promise<{ error: unknown; moduleRef: TestingModule | undefined }> {
const touchedModules = new Set([moduleType, maskingConsumer.moduleType]);
const originals = Array.from(touchedModules, (touchedModule: Type<unknown>) => ({
moduleType: touchedModule,
providers: (Reflect.getMetadata('providers', touchedModule) ?? []) as unknown[],
exports: (Reflect.getMetadata('exports', touchedModule) ?? []) as unknown[],
}));
for (const original of originals) {
const providers = original.providers.flatMap((provider: unknown): unknown[] => {
const token = providerToken(provider);
if (original.moduleType === moduleType && token === missingToken) return [];
if (original.moduleType === maskingConsumer.moduleType && token === maskingConsumer.token) {
return [{ provide: maskingConsumer.token, useValue: maskingConsumer.useValue }];
}
return [provider];
});
const exports = original.exports.filter(
(exported: unknown): boolean =>
original.moduleType !== moduleType || providerToken(exported) !== missingToken,
);
Reflect.defineMetadata('providers', providers, original.moduleType);
Reflect.defineMetadata('exports', exports, original.moduleType);
}
let moduleRef: TestingModule | undefined;
let error: unknown;
try {
moduleRef = await compileRealAppGraph();
} catch (caught: unknown) {
error = caught;
} finally {
for (const original of originals) {
Reflect.defineMetadata('providers', original.providers, original.moduleType);
Reflect.defineMetadata('exports', original.exports, original.moduleType);
}
}
return { error, moduleRef };
}
async function closeIfCompiled(moduleRef: TestingModule | undefined): Promise<void> {
if (moduleRef) await moduleRef.close();
}
describe('required security wiring — real AppModule startup refusal', () => {
it('FL-01 positive control: the real graph compiles when CommandAuthorizationService is bound', async () => {
const moduleRef = await compileRealAppGraph();
try {
expect(moduleRef.get(CommandAuthorizationService, { strict: false })).toBeInstanceOf(
CommandAuthorizationService,
);
} finally {
await moduleRef.close();
}
});
it('FL-01 negative control: absence read as permission is refused at module compilation', async () => {
const { error, moduleRef } = await compileWithoutProvider(
CommandsModule,
CommandAuthorizationService,
{
moduleType: CommandsModule,
token: CommandRuntimeApprovalVerifier,
useValue: {},
},
);
await closeIfCompiled(moduleRef);
expect(
error,
'absence read as permission: AppModule compilation accepted a missing CommandAuthorizationService binding',
).toBeInstanceOf(Error);
if (!(error instanceof Error)) return;
expect(error.message).toContain('CommandExecutorService');
expect(error.message).toContain('CommandAuthorizationService');
});
it('FL-11 positive control: the real graph compiles when SystemOverrideService is bound', async () => {
const moduleRef = await compileRealAppGraph();
try {
expect(moduleRef.get(SystemOverrideService, { strict: false })).toBeInstanceOf(
SystemOverrideService,
);
} finally {
await moduleRef.close();
}
});
it('FL-11 negative control: absence read as permission is refused at module compilation', async () => {
const { error, moduleRef } = await compileWithoutProvider(
PreferencesModule,
SystemOverrideService,
{
moduleType: CommandsModule,
token: CommandExecutorService,
useValue: {},
},
);
await closeIfCompiled(moduleRef);
expect(
error,
'absence read as permission: AppModule compilation accepted a missing SystemOverrideService binding',
).toBeInstanceOf(Error);
if (!(error instanceof Error)) return;
expect(error.message).toContain('AgentService');
expect(error.message).toContain('SystemOverrideService');
});
});
const actorScope = { userId: 'security-user', tenantId: 'security-tenant' };
const conversationId = 'security-conversation';
function directExecutorWithoutAuthorization(systemOverrideSet: ReturnType<typeof vi.fn>) {
const registry = {
getManifest: vi.fn(() => ({
version: 1,
commands: [
{
name: 'system',
aliases: [],
description: 'Set instruction authority',
scope: 'agent' as const,
execution: 'socket' as const,
available: true,
},
],
skills: [],
})),
};
return new CommandExecutorService(
registry as never,
{ getSession: vi.fn() } as never,
{ set: systemOverrideSet, clear: vi.fn() } as never,
{ collect: vi.fn() } as never,
null,
{ agents: {} } as never,
null,
null,
{ getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []) } as never,
undefined as never,
);
}
function directAgentWithoutSystemOverride(piPrompt: ReturnType<typeof vi.fn>): {
service: AgentService;
session: AgentSession;
} {
const service = new AgentService(
{
getDefaultModel: vi.fn(() => null),
getRegistry: vi.fn(() => ({})),
findModel: vi.fn(),
listAvailableModels: vi.fn(() => []),
} as never,
{} as never,
{} as never,
{ available: false } as never,
{} as never,
{ getToolDefinitions: vi.fn(() => []) } as never,
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
undefined as never,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
null,
);
const session = {
id: conversationId,
provider: 'test-provider',
modelId: 'test-model',
piSession: { prompt: piPrompt },
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: process.cwd(),
allowedTools: null,
userId: actorScope.userId,
tenantId: actorScope.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date(0).toISOString(),
},
} as unknown as AgentSession;
const internals = service as unknown as { sessions: Map<string, AgentSession> };
internals.sessions.set(conversationId, session);
return { service, session };
}
describe('required security wiring — malformed direct absence has zero effects', () => {
it('FL-01 refuses command execution before any command effect when authorization is absent', async () => {
const systemOverrideSet = vi.fn().mockResolvedValue(undefined);
const executor = directExecutorWithoutAuthorization(systemOverrideSet);
const payload: SlashCommandPayload = {
command: 'system',
args: 'authority that must not be stored',
conversationId,
};
let error: unknown;
try {
await executor.execute(payload, actorScope);
} catch (caught: unknown) {
error = caught;
}
expect
.soft(
error,
'absence read as permission: direct executor accepted missing command authorization',
)
.toBeInstanceOf(Error);
expect
.soft(
systemOverrideSet,
'absence read as permission: command effect occurred without command authorization',
)
.not.toHaveBeenCalled();
});
it('FL-11 refuses prompt execution before any provider or session effect when system override authority is absent', async () => {
const piPrompt = vi.fn().mockResolvedValue(undefined);
const { service, session } = directAgentWithoutSystemOverride(piPrompt);
let error: unknown;
try {
await service.prompt(conversationId, 'must not reach provider', actorScope);
} catch (caught: unknown) {
error = caught;
}
expect
.soft(
error,
'absence read as permission: direct session accepted missing system override authority',
)
.toBeInstanceOf(Error);
expect
.soft(
piPrompt,
'absence read as permission: provider prompt occurred without system override authority',
)
.not.toHaveBeenCalled();
expect
.soft(
session.promptCount,
'absence read as permission: session state changed without system override authority',
)
.toBe(0);
});
});
@@ -26,7 +26,7 @@ function makeService(operatorMemory: unknown = null): AgentService {
{} as never,
{ getToolDefinitions: vi.fn(() => []) } as never,
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
+11 -9
View File
@@ -132,8 +132,9 @@ export class AgentService implements OnModuleDestroy {
@Inject(CoordService) private readonly coordService: CoordService,
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
@Optional()
@Inject(SystemOverrideService)
private readonly systemOverride: SystemOverrideService,
private readonly systemOverride: SystemOverrideService | null,
@Optional()
@Inject(PreferencesService)
private readonly preferencesService: PreferencesService | null,
@@ -708,22 +709,23 @@ export class AgentService implements OnModuleDestroy {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.promptCount += 1;
// Channel attachments are untrusted URI references. Preserve exact,
// authenticated metadata for the agent without treating it as authority.
const attachmentContext = this.attachmentContext(attachments);
// Prepend session-scoped system override if present (renew TTL on each turn).
// Required instruction-authority wiring is consulted before session/provider effects.
// Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = `${message}${attachmentContext}`;
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}
}
session.promptCount += 1;
try {
await session.piSession.prompt(effectiveMessage);
} catch (err) {
+14 -5
View File
@@ -451,15 +451,24 @@ describe('AppModule federation gating', (): void => {
);
it(
'attributes an invalid monorepo-root dotenv tier to the default',
'rejects an invalid explicit monorepo-root dotenv tier with a typed startup refusal',
async (): Promise<void> => {
const graph = await loadModuleGraphFromDotenv({
const failure = await loadModuleGraphFromDotenv({
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
expectedProcessTier: 'invalid',
});
}).then(
(): undefined => undefined,
(error: unknown): unknown => error,
);
expect(graph.imports).not.toContain(graph.federationModule);
expectBootLogLine(graph.bootLogLines, 'local', 'default');
expect(failure).toBeInstanceOf(Error);
expect(failure).toMatchObject({
name: 'MosaicConfigEnvironmentError',
code: 'invalid_storage_tier',
});
expect((failure as Error).message).toBe(
'Invalid MOSAIC_STORAGE_TIER; expected "local", "standalone", or "federated".',
);
},
MODULE_IMPORT_TIMEOUT_MS,
);
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { resolveChatRuntimeMode } from './chat-runtime.js';
interface TypedSelectionFailure {
readonly name: string;
readonly code: string;
}
describe('#1182 fail closed — a wrong answer must not be read as no answer', () => {
it('FL-07 rejects an invalid explicit CHAT_HARNESS_RUNTIME before embedded construction', () => {
let embeddedConstructionCount = 0;
let failure: unknown;
try {
const mode = resolveChatRuntimeMode({ CHAT_HARNESS_RUNTIME: 'pi-rpc-typo' });
if (mode === 'legacy') {
embeddedConstructionCount += 1;
}
} catch (error: unknown) {
failure = error;
}
expect
.soft(failure, 'invalid explicit runtime must produce a typed selection failure')
.toMatchObject({
name: 'ChatRuntimeConfigurationError',
code: 'invalid_chat_harness_runtime',
} satisfies TypedSelectionFailure);
expect(
embeddedConstructionCount,
'invalid explicit runtime must fail before the embedded runtime is constructed',
).toBe(0);
});
it.each([{}, { CHAT_HARNESS_RUNTIME: '' }])(
'preserves the documented transitional legacy default for true absence: %j',
(env) => {
expect(resolveChatRuntimeMode(env)).toBe('legacy');
},
);
it('anti-drift: runtime selection has no invalid-enum-to-legacy catch-all', () => {
const source = readFileSync(new URL('./chat-runtime.ts', import.meta.url), 'utf8');
expect(
source.includes("env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy'"),
'closed runtime enums must distinguish invalid explicit input from absence',
).toBe(false);
});
});
+17 -3
View File
@@ -41,14 +41,28 @@ export class ChatRuntimeUnavailableError extends Error {
}
}
/** Typed startup refusal for an invalid explicit chat-runtime selection. */
export class ChatRuntimeConfigurationError extends Error {
readonly code = 'invalid_chat_harness_runtime' as const;
constructor() {
super('Invalid CHAT_HARNESS_RUNTIME; expected "legacy" or "pi-rpc".');
this.name = 'ChatRuntimeConfigurationError';
}
}
/**
* 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.
* Resolves the process-wide chat runtime mode from the environment. Only true
* absence (unset or empty) retains the documented transitional legacy default;
* any other explicit value must be a member of the closed runtime enum.
*/
export function resolveChatRuntimeMode(
env: Record<string, string | undefined> = process.env,
): ChatRuntimeMode {
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
const runtime = env['CHAT_HARNESS_RUNTIME'];
if (runtime === undefined || runtime === '') return 'legacy';
if (runtime === 'legacy' || runtime === 'pi-rpc') return runtime;
throw new ChatRuntimeConfigurationError();
}
// ---------------------------------------------------------------------------
@@ -80,10 +80,6 @@ const mockMcpClient = {
getToolDefinitions: vi.fn(() => []),
};
const allowAuthorization = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
function buildService(
redis: typeof mockRedis | null = mockRedis,
mcpClient: {
@@ -102,7 +98,6 @@ function buildService(
null,
mockChatGateway as never,
mcpClient as never,
allowAuthorization as never,
);
}
@@ -35,8 +35,9 @@ export class CommandExecutorService {
@Inject(forwardRef(() => ChatGateway))
private readonly chatGateway: ChatGateway | null,
@Inject(McpClientService) private readonly mcpClient: McpClientService,
@Optional()
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService,
private readonly authorization: CommandAuthorizationService | null = null,
) {}
async execute(
@@ -56,13 +57,13 @@ export class CommandExecutorService {
};
}
const authorization = await this.authorization.authorize(
const authorization = await this.authorization?.authorize(
def,
payload,
userId,
payload.approvalId,
);
if (!authorization.allowed) {
if (authorization && !authorization.allowed) {
return { command, conversationId, success: false, message: authorization.reason };
}
@@ -170,7 +171,7 @@ export class CommandExecutorService {
const def = this.registry
.getManifest()
.commands.find((command) => command.name === payload.command);
if (!def) return null;
if (!def || !this.authorization) return null;
return this.authorization.createApproval(def, payload, scope.userId);
}
@@ -55,10 +55,6 @@ const mockMcpClient = {
reconnectServer: vi.fn().mockResolvedValue(undefined),
};
const allowAuthorization = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function buildRegistry(): CommandRegistryService {
@@ -78,7 +74,6 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
null, // reloadService (optional)
null, // chatGateway (optional)
mockMcpClient as never,
allowAuthorization as never,
);
}
@@ -159,7 +159,6 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
reloadService,
mockChatGateway as never,
mockMcpClient as never,
{ authorize: vi.fn().mockResolvedValue({ allowed: true }) } as never,
);
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
@@ -1,77 +0,0 @@
# #1179 — Required security DI wiring
## Objective
Eliminate the shared fail-open defect class **absence read as permission**:
- FL-01: missing `CommandAuthorizationService` must refuse Nest startup and must not permit command effects.
- FL-11: missing `SystemOverrideService` must refuse Nest startup and must not omit stored instruction authority while allowing provider/session effects.
## Tracking
- Issue: #1179, child of #1156
- Branch: `fix/1179-required-security-di`
- Base: `origin/next` at `216cd72226cd9ee17eea461cfe7cd0e010a22f02`
## Plan
1. RED: compile the real `AppModule` graph with each required provider independently removed, with a positive control for each intact binding.
2. RED: directly exercise each malformed absence path and assert zero command/provider/session effects.
3. Stop and report RED to the coordinator before production implementation.
4. After authorization, make both constructor injections required, remove absence-as-permission branches, and update explicit legitimate optional test seams.
5. Run focused Gateway tests, typecheck, lint, format, build, independent exact-head verification, and focused security review.
## Immutable path fence
Production changes are confined to:
- `apps/gateway/src/commands/command-executor.service.ts`
- `apps/gateway/src/agent/agent.service.ts`
Tests and task evidence are confined to:
- `apps/gateway/src/__tests__/required-security-wiring.test.ts`
- existing direct-constructor specs that require explicit required arguments
- `docs/scratchpads/1179-required-security-di.md`
No files in #1178, #1072, #1080, or #1054 lanes are in scope. `docs/TASKS.md` is orchestrator-owned and will not be modified.
## Budget
No explicit token ceiling was provided. Working assumption: one narrow Gateway security packet; split and stop if either arm requires unrelated module rewiring.
## Progress
- Intake read from #1179 and parent #1156.
- Base independently resolved from the issue's pre-native-stage ordering and repository `origin/next` ref; branch HEAD verified byte-for-byte against the remote ref.
- Real consumers and direct constructors inventoried.
## Tests
### RED
- `required-security-wiring.test.ts`: 4 failed, 2 passed before implementation.
- Both real-graph negative controls showed module compilation accepted the missing target binding.
- Direct FL-01 showed one unauthorized command effect; direct FL-11 showed one provider prompt and one session counter mutation.
### GREEN
- `required-security-wiring.test.ts`: 6/6 passed.
- FL-01-only production revert: exactly the two FL-01 test cases failed; all four other cases, including FL-11, passed.
- FL-11-only production revert: exactly the two FL-11 test cases failed; all four other cases, including FL-01, passed.
- Full Gateway suite: 74 files passed, 7 skipped; 831 tests passed, 17 skipped.
- Gateway typecheck: passed.
- Gateway lint: passed.
- Gateway build: passed.
- Changed-file Prettier check: passed.
### Review
- Codex code review: APPROVE, 0 findings.
- Codex focused security review: risk `none`, 0 findings.
- Independent exact-head review remains assigned to Scrappy through the coordinator.
## Risks / blockers
- `AgentModule` / `CommandsModule` / `ChatModule` contain a production cycle; the module test therefore uses the real top-level `AppModule` and replaces only storage/network leaves, preserving the target service in each arm while isolating the separate required consumer that would otherwise mask that arm's defect.
- No broad module rewrite was required.
@@ -0,0 +1,59 @@
# #1182 — fail closed when a wrong answer is read as no answer
## Objective
Implement FL-07 through FL-10 as one narrow fail-closed change: explicit invalid runtime/storage enum values and launcher failures must never be interpreted as absence or success.
## Tracking
- Issue: #1182 (child of #1156; W-F1 prerequisite)
- Branch: `fix/1182-fail-closed-launch`
- Verified base: `origin/next` at `216cd72226cd9ee17eea461cfe7cd0e010a22f02`
## Scope and fence
- Gateway chat runtime enum resolution and focused tests.
- Config storage-tier enum resolution and focused tests.
- Mosaic launcher spawn/provenance failure handling and focused integration/anti-drift tests.
- Narrow operator documentation in `packages/mosaic/README.md` if required by the behavior change.
- Preserve the #1109 lease broker without refactor; do not implement W-F1 composition.
- Do not touch #1178, #1179, #1072, #1080, #1054, `docs/TASKS.md`, or unrelated source/docs.
## Plan
1. RED: add one independent negative control per FL finding plus exact anti-drift checks.
2. Pause and report BASE / BRANCH / FENCE / SPLIT / RED to the coordinator.
3. After coordinator confirmation, implement each minimal fail-closed fix and verify each negative control independently.
4. Run affected package and repository test/typecheck/lint/build/format gates, focused security review, then commit/push/PR for independent exact-head verification.
## Split assessment
One PR remains reviewable: three narrowly bounded decision boundaries, no shared abstraction, no lease-broker refactor, and four independent tests naming the single defect class. Splitting would separate the same fail-closed invariant without reducing implementation coupling. If RED reveals material launcher harness expansion, split before production edits; the Gateway config half is the direct W-F1 selection prerequisite and would gate first.
## Budget
No explicit token or monetary cap supplied. Keep scope to the three production files, focused tests, this scratchpad, and one existing README.
## Progress
- Issue #1182 and parent #1156 read directly through Mosaic wrappers.
- Base derived from issue dependency plus repository topology: FL-07 exists on `origin/next` (introduced by #1172) and is absent from `origin/main`; branch reset before edits to the exact `origin/next` head above.
## Verification evidence
- RED observed independently for FL-07 through FL-10 before implementation.
- Focused GREEN: Gateway runtime 4/4, config 5/5, launcher 6/6.
- Four final per-finding production reverts discriminated: FL-07 made only FL-07 red; FL-08 made its unit and real Gateway boundary controls red after rebuilding config; FL-09 made only its abnormal-spawn controls red; FL-10 made all three provenance controls (`opencode`, `claudex`, and `yolo claudex`) red while sibling findings stayed green.
- Public config export negative control: removing only the `packages/config/src/index.ts` export caused TS2305 and `MosaicConfigEnvironmentError is not a constructor`; restoring it passed 5/5.
- Gateway full package: 74 files passed, 7 skipped; 829 tests passed, 17 skipped.
- Config full test/typecheck/lint/build: green.
- Mosaic typecheck/lint/build: green. Vitest is qualified-red only on the exact three update-notice stderr assertions tracked by #1190 — bare `--source`, bare `--decisions`, and bare `--observations`; 1528 other tests pass.
- The separate `test:framework-shell` command is red and is not attributed to #1190: `invariant_r_unittest.py` reports the host Pi runtime changed from measured 0.84.1 to 0.80.7. A clean archive of `origin/next@216cd722` reproduces the same single failure. The base test hardcodes the W-B measurement as `PI_VERSION = "0.84.1"`, then resolves `pi` via `shutil.which` and executes `--version`; on this host that is `/home/hermes/.npm-global/bin/pi`, whose global package reports 0.80.7. Issue #1191 tracks the immediate host drift and hardcoded-version design defect; #1184 tracks pinning/approving native Pi 0.84.1. No related source or test is changed here.
- Repository typecheck/lint/format/build: green.
- Codex security review: no findings. Initial code-review blocker (claudex provenance bypass) remediated with normal/yolo claudex coverage; subsequent public-export finding remediated.
## Risks / blockers
- Coordinated branch: no merge authority; coordinator routes independent exact-head verification.
- #1179 currently owns `apps/gateway/src/__tests__/required-security-wiring.test.ts`; this change does not touch it.
- #1190 independently tracks the pre-existing CLI smoke/update-notice stderr collision; no #1190 source or test is included here.
+1
View File
@@ -3,6 +3,7 @@ export {
DEFAULT_LOCAL_CONFIG,
DEFAULT_STANDALONE_CONFIG,
DEFAULT_FEDERATED_CONFIG,
MosaicConfigEnvironmentError,
loadConfig,
validateConfig,
detectFromEnv,
@@ -0,0 +1,72 @@
import { readFileSync } from 'node:fs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { MosaicConfigEnvironmentError as PublicMosaicConfigEnvironmentError } from '@mosaicstack/config';
import { detectFromEnv } from './mosaic-config.js';
interface TypedSelectionFailure {
readonly name: string;
readonly code: string;
}
describe('#1182 fail closed — a wrong answer must not be read as no answer', () => {
const originalEnv = process.env;
it('exports the typed storage-tier refusal from the public @mosaicstack/config entry point', () => {
expect(new PublicMosaicConfigEnvironmentError()).toBeInstanceOf(Error);
});
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env['DATABASE_URL'];
delete process.env['VALKEY_URL'];
delete process.env['MOSAIC_STORAGE_TIER'];
});
afterEach(() => {
process.env = originalEnv;
});
it('FL-08 rejects an invalid explicit MOSAIC_STORAGE_TIER before local PGlite selection', () => {
process.env['MOSAIC_STORAGE_TIER'] = 'federatd';
let pgliteSelectionCount = 0;
let failure: unknown;
try {
const config = detectFromEnv();
if (config.storage.type === 'pglite') {
pgliteSelectionCount += 1;
}
} catch (error: unknown) {
failure = error;
}
expect
.soft(failure, 'invalid explicit storage tier must produce a typed selection failure')
.toMatchObject({
name: 'MosaicConfigEnvironmentError',
code: 'invalid_storage_tier',
} satisfies TypedSelectionFailure);
expect(
pgliteSelectionCount,
'invalid explicit storage tier must fail before local PGlite is selected',
).toBe(0);
});
it.each([undefined, ''])('preserves the local default for true absence: %j', (tier) => {
if (tier === undefined) delete process.env['MOSAIC_STORAGE_TIER'];
else process.env['MOSAIC_STORAGE_TIER'] = tier;
const config = detectFromEnv();
expect(config.tier).toBe('local');
expect(config.storage.type).toBe('pglite');
});
it('anti-drift: storage selection validates a non-empty tier before the local default', () => {
const source = readFileSync(new URL('./mosaic-config.ts', import.meta.url), 'utf8');
expect(
/if \(tier !== undefined && tier !== ''[^]*throw new [A-Za-z]+Error/.test(source),
'closed storage enums must reject invalid explicit input before DEFAULT_LOCAL_CONFIG',
).toBe(true);
});
});
+14
View File
@@ -20,6 +20,16 @@ export interface MosaicConfig {
memory: MemoryConfigRef;
}
/** Typed startup refusal for an invalid explicit storage-tier selection. */
export class MosaicConfigEnvironmentError extends Error {
readonly code = 'invalid_storage_tier' as const;
constructor() {
super('Invalid MOSAIC_STORAGE_TIER; expected "local", "standalone", or "federated".');
this.name = 'MosaicConfigEnvironmentError';
}
}
/* ------------------------------------------------------------------ */
/* Defaults */
/* ------------------------------------------------------------------ */
@@ -126,6 +136,10 @@ export function validateConfig(raw: unknown): MosaicConfig {
export function detectFromEnv(): MosaicConfig {
const tier = process.env['MOSAIC_STORAGE_TIER'];
if (tier !== undefined && tier !== '' && !VALID_TIERS.has(tier)) {
throw new MosaicConfigEnvironmentError();
}
if (tier === 'federated') {
if (process.env['DATABASE_URL']) {
return {
+16
View File
@@ -26,6 +26,15 @@ Set `MOSAIC_ASSUME_YES=1` (or ensure stdin is not a TTY) to skip all interactive
| `MOSAIC_ANTHROPIC_API_KEY` | _(none)_ | No |
| `MOSAIC_CORS_ORIGIN` | `http://localhost:3000` | No |
`MOSAIC_STORAGE_TIER` is a closed enum: `local`, `standalone`, or `federated`.
Unset or empty input selects the documented local default. Any other non-empty
value is a typed startup error and is rejected before a storage adapter is
selected.
The Gateway process also accepts `CHAT_HARNESS_RUNTIME=legacy|pi-rpc`. Unset or
empty input retains the transitional `legacy` default. Any other non-empty value
is a typed startup error and is rejected before either runtime is selected.
### Admin user bootstrap
| Variable | Default | Required |
@@ -55,6 +64,13 @@ mosaic yolo claude # …with --dangerously-skip-permissions
mosaic codex | opencode | pi
```
Every runtime launch requires its immutable `session.launch` provenance record
to be written before spawn. A provenance-write failure exits nonzero, starts no
runtime, and propagates no `MOSAIC_LAUNCH_ID`. Likewise, a spawn error, signal,
or missing numeric child status is reported with the fixed
`runtime_launch_failed` code and exits nonzero rather than being interpreted as
success.
### `mosaic claudex` (EXPERIMENTAL)
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
+1 -2
View File
@@ -84,7 +84,6 @@ is re-seeded a genuinely missing core file is a stop-and-report condition — no
Confirm: required + situational tests passed (primary gate); aligned to `docs/PRD.md`; acceptance
criteria mapped to evidence; independent code review passed (if code changed); required docs updated;
scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on the integration
trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), terminal-green
scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on `main`, terminal-green
CI, linked issue closed (or `docs/TASKS.md` equivalent). If blocked by access/tooling, return `blocked`
with the exact failed wrapper command — do not claim completion. Full checklist: `guides/E2E-DELIVERY.md`.
@@ -21,25 +21,11 @@ guard"), the runtime adapter binds it to a concrete tool and states whether abse
## Hard Gates
The **integration trunk** is the branch a project declares in its `.mosaic/repo.json` under the
key `integration_trunk`; `release_branch` names the release target when one exists (`null` for
single-branch projects). Absent a declaration, the trunk is `main`. The declaration is policy
data, never shell text: values must be valid local branch names under `git check-ref-format
--branch` semantics — no remote refs, no revision expressions, no option-like values (leading `-`),
no path traversal or control characters. A declaration file that fails to parse, an unknown or
misspelled key, or an invalid value is a hard stop (`blocked`) — never a silent fallback to `main`.
Prose that mentions branch names designates nothing; only the declaration file does. A project
declares exactly ONE trunk. **Changing an existing declaration is operator-owned:** a trunk
redeclaration redirects merge target and branch-protection target at once, so it requires an
explicit operator action above ordinary PR review. The designation relaxes nothing:
reviewed-PR-only delivery, squash merge, independent review, queue guards, and terminal-green CI
bind to the declared trunk exactly as they bind to `main`.
1. Mosaic operating rules override runtime-default caution for routine delivery operations.
2. Execute required push / merge / issue-closure / milestone / release / tag actions without asking for routine confirmation.
3. Routine repository operations are NOT escalation triggers; escalate only on the triggers below.
4. For source-code delivery, completion is forbidden at the PR-open stage.
5. Completion requires a merged PR to the integration trunk + terminal-green CI + the linked issue/task closed.
5. Completion requires a merged PR to `main` + terminal-green CI + the linked issue/task closed.
6. Before any push or merge, run the CI queue guard.
7. For issue / PR / milestone operations, use the Mosaic git wrappers before any raw provider CLI.
8. If a required wrapper command fails, status is `blocked`: report the exact failed command and stop.
@@ -49,7 +35,7 @@ bind to the declared trunk exactly as they bind to `main`.
12. The intake procedure is not conditional on perceived complexity; a "simple" task carries the same requirements as a multi-file feature.
13. **Merge authority (coordinated work):** when a coordinator/orchestrator session is active for the work, the post-review merge go-ahead is the coordinator's to give — once the required review gates pass, merge on the coordinator's confirmation; do not wait on the human owner personally. Solo (uncoordinated) delivery keeps the default: merge per gates 2 and 9. A "No self-merge" note on a PR means no UNREVIEWED self-merge — it does not suspend coordinator-authorized merges.
14. Never hardcode secrets; never emit credential values in any output (not even partially, not "to confirm").
15. Trunk-based git only: branch from the integration trunk, merge via a reviewed PR (squash), never push directly to the trunk.
15. Trunk-based git only: branch from `main`, merge via a reviewed PR (squash), never push directly to `main`.
16. If you modify source code, an independent review (author ≠ reviewer) must pass before completion.
## Integrity (quality gates are never bypassed)
+10 -12
View File
@@ -12,7 +12,7 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
4. Issue tracking is consistent across projects
5. Documentation standards and API contracts are enforced from day one
6. PRD requirements are established before coding begins
7. Branching/merging is consistent: branch -> integration trunk (default `main`) via PR with squash-only merges
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
## Agent Host Prerequisites
@@ -206,7 +206,7 @@ Every runtime context file should contain:
6. **Issue tracking** — Issue and commit conventions
7. **Code review** — Required review process
8. **Runtime notes** — Runtime-specific behavior references
9. **Branch and merge policy** — Trunk workflow (branch -> integration trunk via PR, squash-only)
9. **Branch and merge policy** — Trunk workflow (`branch -> main` via PR, squash-only)
10. **Autonomy and escalation policy** — Agent owns coding/review/PR/release/deploy lifecycle
---
@@ -288,17 +288,15 @@ Reserve `0.1.0` for the MVP release milestone.
---
## Step 5b: Configure Trunk Branch Protection (Hard Rule)
## Step 5b: Configure Main Branch Protection (Hard Rule)
Apply equivalent settings in Gitea, GitHub, or GitLab, targeting the project's integration trunk
(the branch its `.mosaic/repo.json` declares under `integration_trunk`; default `main` — see
`CONSTITUTION.md` Hard Gates):
Apply equivalent settings in Gitea, GitHub, or GitLab:
1. Protect the integration trunk from direct pushes.
2. Require pull requests to merge into the integration trunk.
1. Protect `main` from direct pushes.
2. Require pull requests to merge into `main`.
3. Require required CI/status checks to pass before merge.
4. Require code review approval before merge.
5. Allow **squash merge only** for PRs into the integration trunk (disable merge commits and rebase merges for it).
5. Allow **squash merge only** for PRs into `main` (disable merge commits and rebase merges for `main`).
This enforces one merge strategy across human and agent workflows.
@@ -515,9 +513,9 @@ After bootstrapping, verify:
- [ ] Git labels created (epic, feature, bug, task, etc.)
- [ ] Initial pre-MVP milestone created (0.0.1)
- [ ] MVP milestone reserved for release (0.1.0)
- [ ] The integration trunk is protected from direct pushes
- [ ] PRs into the integration trunk are required
- [ ] Merge method for the integration trunk is squash-only
- [ ] `main` is protected from direct pushes
- [ ] PRs into `main` are required
- [ ] Merge method for `main` is squash-only
- [ ] Quality gates run successfully
- [ ] `.env.example` exists (if project uses env vars)
- [ ] CI/CD pipeline configured (if using Woodpecker/GitHub Actions)
@@ -4,11 +4,6 @@
## Overview
> **Integration trunk:** the YAML examples in this guide use the default integration trunk `main`
> in branch conditions and version rules. A project that declares a different trunk in its
> `.mosaic/repo.json` under `integration_trunk` (see `CONSTITUTION.md` Hard Gates) substitutes its
> declared trunk wherever `main` appears as the trunk branch.
This guide covers the canonical CI/CD pattern used across projects. The pipeline runs in Woodpecker CI and follows this flow:
```
@@ -870,7 +865,7 @@ steps:
```yaml
image: git.example.com/org/service@${IMAGE_DIGEST}
```
7. **Test on a short-lived non-trunk branch first** — open a PR and verify quality gates before merging to the integration trunk
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
8. **Verify images appear** in Gitea Packages tab after successful pipeline
## Terminal-Green Full-Step Contract
@@ -911,7 +906,7 @@ For source-code delivery, completion is not allowed at "PR opened" stage.
Required sequence:
1. Merge PR to the integration trunk (squash) via Mosaic wrapper.
1. Merge PR to `main` (squash) via Mosaic wrapper.
2. Monitor CI to terminal status:
```bash
~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>
@@ -1117,5 +1112,5 @@ If a project currently uses Verdaccio (e.g., U-Connect at `npm.uscllc.net`), fol
### Pipeline runs Docker builds on pull requests
- Verify `when` clause on Docker build steps restricts to the integration trunk (`branch: [main]` by default)
- Verify `when` clause on Docker build steps restricts to `branch: [main]`
- Pull requests should only run quality gates, not build/push images
@@ -10,10 +10,9 @@ If implementation diverges from `docs/PRD.md` or `docs/PRD.json` without PRD upd
Merge strategy enforcement (HARD RULE):
- The integration trunk is the branch the project's `.mosaic/repo.json` declares under `integration_trunk` (default: `main`) — see `CONSTITUTION.md` Hard Gates.
- PR target for delivery is the integration trunk.
- Direct pushes to the integration trunk are prohibited.
- Merge to the integration trunk MUST be squash-only.
- PR target for delivery is `main`.
- Direct pushes to `main` are prohibited.
- Merge to `main` MUST be squash-only.
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
An estate MAY carry a documented exception for a repository whose gates are commit hooks rather
@@ -66,19 +65,6 @@ Each of these produced a wrong conclusion before it was written down.
conclusion drawn from it describes the wrong tree. Confirm `git rev-parse --show-toplevel`
is the tree you think it is before trusting any git output.
13. **Run the repository's PINNED tool version.** `npx <tool>` resolves a local `node_modules`
install when one is present and fetches the latest release when one is not, so the same
command answers differently depending on where it ran. A reviewer measuring in a fresh clone
or a detached worktree — which is exactly where reviewers measure — has no `node_modules` and
silently gets the latest release instead of the pinned one. Measured on mosaicstack#1313: the
lockfile pins prettier 3.8.1, under which three guides pass; a version-less `npx` in a
worktree resolved 3.9.6, under which the same three fail; and 3.0.0, the floor of the declared
`^3.0.0` range, fails a different one. Three versions, three verdicts, identical bytes. Use
`node_modules/.bin/<tool>`, or name the version the lockfile pins.
14. **A formatter or linter declared as a range is a dated verdict, not a fact.** If a lockfile
pins it, the gate is reproducible today and will disagree with itself the day the pin moves.
Report a formatting failure with the version that produced it, always.
### Feedback Categories
- **Blocker**: must fix before merge (security, bugs, test failures)
@@ -198,8 +184,8 @@ Use `~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md` whenever code/A
# List the issue being addressed
~/.config/mosaic/tools/git/issue-list.sh -i {issue-number}
# View the changes (diff against the integration trunk; default: main)
git diff {integration_trunk}...HEAD
# View the changes
git diff main...HEAD
```
### Providing Feedback
@@ -228,4 +214,4 @@ This pattern appears in 3 places. A shared helper would reduce duplication.
2. If changes requested, assign back to author
3. If approved, note approval in issue comments
4. For merges, ensure CI passes first
5. Merge PR to the integration trunk with squash strategy only
5. Merge PR to `main` with squash strategy only
@@ -78,7 +78,7 @@ For implementation work, you MUST run this cycle in order:
7. `commit` - commit only when the logical unit passes tests and review.
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. `push` - push immediately after queue guard passes.
10. `PR integration` - if external git provider is available, create/update PR to the integration trunk (the project's declared trunk, default `main`) and merge with required strategy via Mosaic wrappers.
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
@@ -199,7 +199,7 @@ Before running this checklist, pause and self-interrogate: did I fulfill the use
10. No unresolved blocker hidden.
11. If deployment is in scope, deployment target, release version, and post-deploy verification evidence are documented.
12. `docs/TASKS.md` status and issue/internal references are updated to match delivered work.
13. If source code changed and external provider is available: PR merged to the integration trunk (squash), with merge evidence recorded.
13. If source code changed and external provider is available: PR merged to `main` (squash), with merge evidence recorded.
14. CI/pipeline status is terminal green for the merged PR/head commit.
15. Linked external issue is closed (or internal task ref is closed when no provider exists).
16. If any of items 13-15 fail due access/tooling, report `blocked` with exact failed wrapper command and do not claim completion.
@@ -253,7 +253,7 @@ status → mission → run → repeat
- [ ] All milestone tasks in TASKS.md are `done`
- [ ] CI/pipeline green
- [ ] PR merged to the integration trunk
- [ ] PR merged to `main`
- [ ] Issues closed
- [ ] Update manifest: milestone status → completed
- [ ] Update scratchpad: session log entry
@@ -15,7 +15,7 @@ mosaic claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md th
- You MUST keep the TASKS.md file updated with agent and tasks statuses.
- You MUST keep `docs/` root clean. Reports and working artifacts MUST be stored in scoped folders (`docs/reports/`, `docs/tasks/`, `docs/releases/`, `docs/scratchpads/`).
- You MUST enforce plan/token usage budgets when provided, and adapt orchestration strategy to remain within limits.
- You MUST enforce trunk workflow: workers branch from the integration trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), PR target is the integration trunk, direct push to the trunk is forbidden, and PR merges to the trunk are squash-only.
- You MUST enforce trunk workflow: workers branch from `main`, PR target is `main`, direct push to `main` is forbidden, and PR merges to `main` are squash-only.
- You MUST operate in steered-autonomy mode: human intervention is escalation-only; do not require the human to write code, review code, or manage PR/repo workflow.
- You MUST NOT declare task or issue completion until PR is merged, CI/pipeline is terminal green, and linked issue is closed (or internal TASKS ref is closed when provider is unavailable).
- Mosaic orchestration rules OVERRIDE runtime-default caution for routine push/merge/issue-close actions required by this workflow.
@@ -133,10 +133,10 @@ Milestone versioning (HARD RULE):
Branch and merge strategy (HARD RULE):
- Workers use short-lived task branches from `origin/{integration_trunk}` (default `main`).
- Worker task branches merge back via PR to the integration trunk only.
- Direct pushes to the integration trunk are prohibited.
- PR merges to the integration trunk MUST use squash merge.
- Workers use short-lived task branches from `origin/main`.
- Worker task branches merge back via PR to `main` only.
- Direct pushes to `main` are prohibited.
- PR merges to `main` MUST use squash merge.
**Available templates:**
@@ -427,7 +427,7 @@ git push
- Before merging, run queue guard:
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
- Ensure PR exists for the task branch (create/update via wrappers if needed):
`~/.config/mosaic/tools/git/pr-create.sh ... -B {integration_trunk}` (default `main`)
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
- Merge via wrapper:
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- Wait for terminal CI status:
@@ -619,7 +619,7 @@ Construct this from the task row and pass to worker via Task tool:
## Workflow
1. Checkout branch: `git fetch origin && (git checkout {branch} || git checkout -b {branch} origin/{integration_trunk}) && git rebase origin/{integration_trunk}` ({integration_trunk} = the project's declared trunk, default `main`)
1. Checkout branch: `git fetch origin && (git checkout {branch} || git checkout -b {branch} origin/main) && git rebase origin/main`
2. Read `docs/PRD.md` or `docs/PRD.json` and align implementation with PRD requirements
3. Read the finding details from the report
4. Implement the fix following existing code patterns
@@ -637,7 +637,7 @@ Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIR
For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B {integration_trunk}`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
@@ -994,13 +994,13 @@ mv docs/reports/qa-automation/pending/*failing-file* docs/reports/qa-automation/
---
## Merge-to-Trunk Candidate Protocol (Container Deployments)
## Merge-to-Main Candidate Protocol (Container Deployments)
If deployment is in scope and container images are used, every merge to the integration trunk MUST execute this protocol:
If deployment is in scope and container images are used, every merge to `main` MUST execute this protocol:
1. Build and push immutable candidate image tags:
- `sha-<shortsha>` (always)
- `v{base-version}-rc.{build}` (for integration-trunk merges)
- `v{base-version}-rc.{build}` (for `main` merges)
- `testing` mutable pointer to the same digest
2. Resolve and record the image digest for each service.
3. Deploy by digest to testing environment (never deploy by mutable tag alone).
@@ -0,0 +1,193 @@
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { createRequire } from 'node:module';
import { delimiter, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const require = createRequire(import.meta.url);
const TSX_LOADER_URL = pathToFileURL(require.resolve('tsx')).href;
const COMMANDER_MODULE_URL = pathToFileURL(require.resolve('commander')).href;
const LAUNCH_MODULE_URL = pathToFileURL(join(import.meta.dirname, 'launch.ts')).href;
const DRIVER = `
const launch = await import(${JSON.stringify(LAUNCH_MODULE_URL)});
if (process.env['TEST_CLAUDEX_PROVENANCE'] === '1') {
const execute = launch.execRecordedClaudexRuntime;
if (typeof execute !== 'function') {
process.stderr.write('[missing_recorded_claudex_runtime]\\n');
process.exit(70);
}
execute([], process.env, process.env['TEST_DANGEROUS'] === '1');
} else {
const { Command } = await import(${JSON.stringify(COMMANDER_MODULE_URL)});
const program = new Command();
program.exitOverride();
launch.registerLaunchCommands(program);
await program.parseAsync(['node', 'mosaic', 'opencode']);
}
`;
interface LaunchFixture {
readonly root: string;
readonly home: string;
readonly bin: string;
readonly runtimePath: string;
}
function createFixture(): LaunchFixture {
const root = mkdtempSync('/var/tmp/mosaic-launch-fail-closed-');
const home = join(root, 'mosaic-home');
const bin = join(root, 'bin');
const runtimePath = join(bin, 'opencode');
mkdirSync(join(home, 'runtime', 'opencode'), { recursive: true });
mkdirSync(bin, { recursive: true });
writeFileSync(join(home, 'AGENTS.md'), '# test agents\n');
writeFileSync(join(home, 'SOUL.md'), '# test soul\n');
writeFileSync(join(home, 'USER.md'), '# test user\n');
writeFileSync(join(home, 'TOOLS.md'), '# test tools\n');
writeFileSync(join(home, 'runtime', 'opencode', 'RUNTIME.md'), '# test runtime\n');
return { root, home, bin, runtimePath };
}
function installRuntime(fixture: LaunchFixture, source: string): void {
writeFileSync(fixture.runtimePath, source);
chmodSync(fixture.runtimePath, 0o755);
}
function runLauncher(
fixture: LaunchFixture,
extraEnv: NodeJS.ProcessEnv = {},
): SpawnSyncReturns<string> {
const env: NodeJS.ProcessEnv = {
...process.env,
...extraEnv,
MOSAIC_HOME: fixture.home,
PATH: `${fixture.bin}${delimiter}${process.env['PATH'] ?? ''}`,
};
delete env['MOSAIC_AGENT_NAME'];
delete env['MOSAIC_AGENT_CLASS'];
delete env['MOSAIC_AGENT_TOOL_POLICY'];
return spawnSync(
process.execPath,
['--import', TSX_LOADER_URL, '--input-type=module', '--eval', DRIVER],
{
cwd: fixture.root,
encoding: 'utf8',
env,
},
);
}
describe('#1182 fail closed — a wrong answer must not be read as no answer', () => {
let fixture: LaunchFixture;
beforeEach(() => {
fixture = createFixture();
});
afterEach(() => {
rmSync(fixture.root, { recursive: true, force: true });
});
it.each([
{
condition: 'spawn error',
runtime: '#!/definitely/missing/interpreter\n',
},
{
condition: 'signal with null status',
runtime: '#!/usr/bin/env bash\nkill -TERM $$\n',
},
])('FL-09 converts $condition into a sanitized nonzero launch failure', ({ runtime }) => {
installRuntime(fixture, runtime);
const result = runLauncher(fixture);
expect.soft(result.status, 'spawn failure must never be converted into exit 0').not.toBe(0);
expect
.soft(result.stderr, 'spawn failure must report the fixed typed runtime_launch_failed code')
.toContain('[runtime_launch_failed]');
expect(
result.stderr,
'spawn diagnostics must not disclose the runtime fixture path',
).not.toContain(fixture.runtimePath);
});
it.each([
{ launcher: 'opencode', extraEnv: {} },
{
launcher: 'claudex',
extraEnv: { TEST_CLAUDEX_PROVENANCE: '1' },
},
{
launcher: 'yolo claudex',
extraEnv: { TEST_CLAUDEX_PROVENANCE: '1', TEST_DANGEROUS: '1' },
},
])(
'FL-10 refuses $launcher spawn when mandatory provenance cannot be recorded and fabricates no launch ID',
({ extraEnv }) => {
const spawnMarker = join(fixture.root, 'runtime-spawned');
const launchIdMarker = join(fixture.root, 'runtime-saw-launch-id');
installRuntime(
fixture,
`#!/usr/bin/env bash\nprintf 'spawned' > "$TEST_SPAWN_MARKER"\nif [[ -n "\${MOSAIC_LAUNCH_ID:-}" ]]; then printf '%s' "$MOSAIC_LAUNCH_ID" > "$TEST_LAUNCH_ID_MARKER"; fi\n`,
);
const ledgerPath = join(fixture.home, 'fleet', 'run', 'sessions', 'events.ndjson');
mkdirSync(ledgerPath, { recursive: true });
const result = runLauncher(fixture, {
...extraEnv,
TEST_SPAWN_MARKER: spawnMarker,
TEST_LAUNCH_ID_MARKER: launchIdMarker,
});
expect.soft(result.status, 'mandatory provenance failure must exit nonzero').not.toBe(0);
expect
.soft(existsSync(spawnMarker), 'mandatory provenance failure must prevent spawn')
.toBe(false);
expect
.soft(
existsSync(launchIdMarker),
'a failed provenance write must not fabricate or propagate a launch ID',
)
.toBe(false);
expect
.soft(
result.stderr,
'provenance refusal must report the fixed typed launch_provenance_failed code',
)
.toContain('[launch_provenance_failed]');
expect(
result.stderr,
'provenance diagnostics must not disclose filesystem details',
).not.toContain(fixture.root);
},
);
it('anti-drift: launcher contains neither null-status success nor mandatory warn-and-run', () => {
const source = readFileSync(new URL('./launch.ts', import.meta.url), 'utf8');
expect
.soft(
source.includes('result.status ?? 0'),
'spawnSync null status must never default to success',
)
.toBe(false);
expect
.soft(
source.includes('[mosaic] WARNING: launch record not written:'),
'mandatory provenance failures must never warn and continue',
)
.toBe(false);
});
});
+70 -27
View File
@@ -200,13 +200,24 @@ function redactArgv(argv: string[]): string[] {
);
}
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
interface LaunchRecordSuccess {
readonly ok: true;
readonly launchId: string;
}
interface LaunchRecordFailure {
readonly ok: false;
readonly code: 'launch_provenance_failed';
}
type LaunchRecordResult = LaunchRecordSuccess | LaunchRecordFailure;
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): LaunchRecordResult {
// Never let a stale or caller-supplied correlation id masquerade as this launch.
delete process.env['MOSAIC_LAUNCH_ID'];
try {
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
// Correlation id for the lease.register half. Set into process.env so it
// propagates through every `...process.env` / `...baseEnv` spread below.
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
process.env['MOSAIC_LAUNCH_ID'] = launchId;
const record = {
seq: Date.now(),
kind: 'session.launch',
@@ -233,14 +244,26 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
mode: 0o600,
});
} catch (err) {
// Never block a launch on bookkeeping — but never fail silently either.
console.error(
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
);
return { ok: true, launchId };
} catch {
return { ok: false, code: 'launch_provenance_failed' };
}
}
function requireLaunchRecord(runtime: RuntimeName, cliArgs: string[], yolo: boolean): string {
const result = recordLaunch(runtime, cliArgs, yolo);
if (!result.ok) {
console.error(
`[mosaic] ERROR [${result.code}]: mandatory launch provenance could not be recorded; runtime was not started.`,
);
process.exit(1);
}
// Propagate correlation authority only after its immutable provenance exists.
process.env['MOSAIC_LAUNCH_ID'] = result.launchId;
return result.launchId;
}
// ─── Pre-flight checks ──────────────────────────────────────────────────────
function checkMosaicHome(): void {
@@ -976,7 +999,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('claude', cliArgs, yolo);
requireLaunchRecord('claude', cliArgs, yolo);
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
break;
}
@@ -990,7 +1013,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('codex', cliArgs, yolo);
requireLaunchRecord('codex', cliArgs, yolo);
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
break;
}
@@ -999,7 +1022,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
console.log(`[mosaic] Launching ${label}${modeStr}...`);
recordLaunch('opencode', args, yolo);
requireLaunchRecord('opencode', args, yolo);
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
break;
}
@@ -1015,7 +1038,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('pi', cliArgs, yolo);
requireLaunchRecord('pi', cliArgs, yolo);
execLeaseGatedRuntime('pi', cliArgs);
break;
}
@@ -1059,19 +1082,31 @@ function execLeaseGatedRuntime(
);
}
/** exec into the runtime, replacing the current process. */
type RuntimeLaunchFailureReason = 'spawn_error' | 'signal' | 'missing_status';
interface RuntimeLaunchFailure {
readonly code: 'runtime_launch_failed';
readonly reason: RuntimeLaunchFailureReason;
}
function refuseRuntimeLaunch(reason: RuntimeLaunchFailureReason): never {
const failure: RuntimeLaunchFailure = { code: 'runtime_launch_failed', reason };
console.error(
`[mosaic] ERROR [${failure.code}]: runtime process did not produce a successful exit result (${failure.reason}).`,
);
process.exit(1);
}
/** Spawn the runtime and preserve only a real numeric exit status as success. */
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
try {
// Use execFileSync with inherited stdio to replace the process
const result = spawnSync(cmd, args, {
stdio: 'inherit',
env,
});
process.exit(result.status ?? 0);
} catch (err) {
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
process.exit(1);
}
const result = spawnSync(cmd, args, {
stdio: 'inherit',
env,
});
if (result.error !== undefined) refuseRuntimeLaunch('spawn_error');
if (result.signal !== null) refuseRuntimeLaunch('signal');
if (result.status === null) refuseRuntimeLaunch('missing_status');
process.exit(result.status);
}
/**
@@ -1081,6 +1116,15 @@ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = proce
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
* logic lives in the DI module, not here.
*/
export function execRecordedClaudexRuntime(
args: string[],
env: NodeJS.ProcessEnv,
dangerous: boolean,
): void {
const launchId = requireLaunchRecord('claude', args, dangerous);
execLeaseGatedRuntime('claude', args, { ...env, MOSAIC_LAUNCH_ID: launchId }, dangerous);
}
function launchClaudexProduction(args: string[], yolo: boolean): void {
writeSessionLock('claude');
const adapter: ClaudexHarnessAdapter = {
@@ -1092,8 +1136,7 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
checkSequentialThinking('claude');
},
composePrompt: () => buildRuntimePrompt('claude'),
execLeaseGated: (cmdArgs, env, dangerous) =>
execLeaseGatedRuntime('claude', cmdArgs, env, dangerous),
execLeaseGated: execRecordedClaudexRuntime,
};
void launchClaudex(args, yolo, adapter);
}