Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55f2ec3dbc |
@@ -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,
|
{} as never,
|
||||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||||
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
|
null,
|
||||||
null,
|
null,
|
||||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
operatorMemory as never,
|
operatorMemory as never,
|
||||||
|
|||||||
@@ -132,8 +132,9 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(CoordService) private readonly coordService: CoordService,
|
@Inject(CoordService) private readonly coordService: CoordService,
|
||||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||||
|
@Optional()
|
||||||
@Inject(SystemOverrideService)
|
@Inject(SystemOverrideService)
|
||||||
private readonly systemOverride: SystemOverrideService,
|
private readonly systemOverride: SystemOverrideService | null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
@@ -708,22 +709,23 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
this.assertSessionScope(session, scope);
|
this.assertSessionScope(session, scope);
|
||||||
|
session.promptCount += 1;
|
||||||
|
|
||||||
// Channel attachments are untrusted URI references. Preserve exact,
|
// Channel attachments are untrusted URI references. Preserve exact,
|
||||||
// authenticated metadata for the agent without treating it as authority.
|
// authenticated metadata for the agent without treating it as authority.
|
||||||
const attachmentContext = this.attachmentContext(attachments);
|
const attachmentContext = this.attachmentContext(attachments);
|
||||||
|
|
||||||
// Prepend session-scoped system override if present (renew TTL on each turn).
|
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||||
// Required instruction-authority wiring is consulted before session/provider effects.
|
|
||||||
let effectiveMessage = `${message}${attachmentContext}`;
|
let effectiveMessage = `${message}${attachmentContext}`;
|
||||||
const override = await this.systemOverride.get(sessionId, scope);
|
if (this.systemOverride) {
|
||||||
if (override) {
|
const override = await this.systemOverride.get(sessionId, scope);
|
||||||
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
if (override) {
|
||||||
await this.systemOverride.renew(sessionId, scope);
|
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
||||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
await this.systemOverride.renew(sessionId, scope);
|
||||||
|
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
session.promptCount += 1;
|
|
||||||
try {
|
try {
|
||||||
await session.piSession.prompt(effectiveMessage);
|
await session.piSession.prompt(effectiveMessage);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -451,15 +451,24 @@ describe('AppModule federation gating', (): void => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it(
|
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> => {
|
async (): Promise<void> => {
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
const failure = await loadModuleGraphFromDotenv({
|
||||||
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
||||||
expectedProcessTier: 'invalid',
|
expectedProcessTier: 'invalid',
|
||||||
});
|
}).then(
|
||||||
|
(): undefined => undefined,
|
||||||
|
(error: unknown): unknown => error,
|
||||||
|
);
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
expect(failure).toBeInstanceOf(Error);
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
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,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
* Resolves the process-wide chat runtime mode from the environment. Only true
|
||||||
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
|
* 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(
|
export function resolveChatRuntimeMode(
|
||||||
env: Record<string, string | undefined> = process.env,
|
env: Record<string, string | undefined> = process.env,
|
||||||
): ChatRuntimeMode {
|
): 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(() => []),
|
getToolDefinitions: vi.fn(() => []),
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowAuthorization = {
|
|
||||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildService(
|
function buildService(
|
||||||
redis: typeof mockRedis | null = mockRedis,
|
redis: typeof mockRedis | null = mockRedis,
|
||||||
mcpClient: {
|
mcpClient: {
|
||||||
@@ -102,7 +98,6 @@ function buildService(
|
|||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mcpClient as never,
|
mcpClient as never,
|
||||||
allowAuthorization as never,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ export class CommandExecutorService {
|
|||||||
@Inject(forwardRef(() => ChatGateway))
|
@Inject(forwardRef(() => ChatGateway))
|
||||||
private readonly chatGateway: ChatGateway | null,
|
private readonly chatGateway: ChatGateway | null,
|
||||||
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
||||||
|
@Optional()
|
||||||
@Inject(CommandAuthorizationService)
|
@Inject(CommandAuthorizationService)
|
||||||
private readonly authorization: CommandAuthorizationService,
|
private readonly authorization: CommandAuthorizationService | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(
|
||||||
@@ -56,13 +57,13 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorization = await this.authorization.authorize(
|
const authorization = await this.authorization?.authorize(
|
||||||
def,
|
def,
|
||||||
payload,
|
payload,
|
||||||
userId,
|
userId,
|
||||||
payload.approvalId,
|
payload.approvalId,
|
||||||
);
|
);
|
||||||
if (!authorization.allowed) {
|
if (authorization && !authorization.allowed) {
|
||||||
return { command, conversationId, success: false, message: authorization.reason };
|
return { command, conversationId, success: false, message: authorization.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ export class CommandExecutorService {
|
|||||||
const def = this.registry
|
const def = this.registry
|
||||||
.getManifest()
|
.getManifest()
|
||||||
.commands.find((command) => command.name === payload.command);
|
.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);
|
return this.authorization.createApproval(def, payload, scope.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,6 @@ const mockMcpClient = {
|
|||||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowAuthorization = {
|
|
||||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRegistry(): CommandRegistryService {
|
function buildRegistry(): CommandRegistryService {
|
||||||
@@ -78,7 +74,6 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
|||||||
null, // reloadService (optional)
|
null, // reloadService (optional)
|
||||||
null, // chatGateway (optional)
|
null, // chatGateway (optional)
|
||||||
mockMcpClient as never,
|
mockMcpClient as never,
|
||||||
allowAuthorization as never,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
|||||||
reloadService,
|
reloadService,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mockMcpClient as never,
|
mockMcpClient as never,
|
||||||
{ authorize: vi.fn().mockResolvedValue({ allowed: true }) } as never,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
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.
|
||||||
@@ -3,6 +3,7 @@ export {
|
|||||||
DEFAULT_LOCAL_CONFIG,
|
DEFAULT_LOCAL_CONFIG,
|
||||||
DEFAULT_STANDALONE_CONFIG,
|
DEFAULT_STANDALONE_CONFIG,
|
||||||
DEFAULT_FEDERATED_CONFIG,
|
DEFAULT_FEDERATED_CONFIG,
|
||||||
|
MosaicConfigEnvironmentError,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
validateConfig,
|
validateConfig,
|
||||||
detectFromEnv,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,16 @@ export interface MosaicConfig {
|
|||||||
memory: MemoryConfigRef;
|
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 */
|
/* Defaults */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -126,6 +136,10 @@ export function validateConfig(raw: unknown): MosaicConfig {
|
|||||||
export function detectFromEnv(): MosaicConfig {
|
export function detectFromEnv(): MosaicConfig {
|
||||||
const tier = process.env['MOSAIC_STORAGE_TIER'];
|
const tier = process.env['MOSAIC_STORAGE_TIER'];
|
||||||
|
|
||||||
|
if (tier !== undefined && tier !== '' && !VALID_TIERS.has(tier)) {
|
||||||
|
throw new MosaicConfigEnvironmentError();
|
||||||
|
}
|
||||||
|
|
||||||
if (tier === 'federated') {
|
if (tier === 'federated') {
|
||||||
if (process.env['DATABASE_URL']) {
|
if (process.env['DATABASE_URL']) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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_ANTHROPIC_API_KEY` | _(none)_ | No |
|
||||||
| `MOSAIC_CORS_ORIGIN` | `http://localhost:3000` | 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
|
### Admin user bootstrap
|
||||||
|
|
||||||
| Variable | Default | Required |
|
| Variable | Default | Required |
|
||||||
@@ -55,6 +64,13 @@ mosaic yolo claude # …with --dangerously-skip-permissions
|
|||||||
mosaic codex | opencode | pi
|
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)
|
### `mosaic claudex` (EXPERIMENTAL)
|
||||||
|
|
||||||
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
|
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -162,13 +162,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 {
|
try {
|
||||||
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
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')}`;
|
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
||||||
process.env['MOSAIC_LAUNCH_ID'] = launchId;
|
|
||||||
const record = {
|
const record = {
|
||||||
seq: Date.now(),
|
seq: Date.now(),
|
||||||
kind: 'session.launch',
|
kind: 'session.launch',
|
||||||
@@ -193,14 +204,26 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v
|
|||||||
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
return { ok: true, launchId };
|
||||||
// Never block a launch on bookkeeping — but never fail silently either.
|
} catch {
|
||||||
console.error(
|
return { ok: false, code: 'launch_provenance_failed' };
|
||||||
`[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ──────────────────────────────────────────────────────
|
// ─── Pre-flight checks ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function checkMosaicHome(): void {
|
function checkMosaicHome(): void {
|
||||||
@@ -925,7 +948,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('claude', cliArgs, yolo);
|
requireLaunchRecord('claude', cliArgs, yolo);
|
||||||
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -939,7 +962,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('codex', cliArgs, yolo);
|
requireLaunchRecord('codex', cliArgs, yolo);
|
||||||
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -948,7 +971,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
|
||||||
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}...`);
|
||||||
recordLaunch('opencode', args, yolo);
|
requireLaunchRecord('opencode', args, yolo);
|
||||||
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -964,7 +987,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('pi', cliArgs, yolo);
|
requireLaunchRecord('pi', cliArgs, yolo);
|
||||||
execLeaseGatedRuntime('pi', cliArgs);
|
execLeaseGatedRuntime('pi', cliArgs);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1008,19 +1031,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 {
|
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||||
try {
|
const result = spawnSync(cmd, args, {
|
||||||
// Use execFileSync with inherited stdio to replace the process
|
stdio: 'inherit',
|
||||||
const result = spawnSync(cmd, args, {
|
env,
|
||||||
stdio: 'inherit',
|
});
|
||||||
env,
|
if (result.error !== undefined) refuseRuntimeLaunch('spawn_error');
|
||||||
});
|
if (result.signal !== null) refuseRuntimeLaunch('signal');
|
||||||
process.exit(result.status ?? 0);
|
if (result.status === null) refuseRuntimeLaunch('missing_status');
|
||||||
} catch (err) {
|
process.exit(result.status);
|
||||||
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1030,6 +1065,15 @@ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = proce
|
|||||||
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
|
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
|
||||||
* logic lives in the DI module, not here.
|
* 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 {
|
function launchClaudexProduction(args: string[], yolo: boolean): void {
|
||||||
writeSessionLock('claude');
|
writeSessionLock('claude');
|
||||||
const adapter: ClaudexHarnessAdapter = {
|
const adapter: ClaudexHarnessAdapter = {
|
||||||
@@ -1041,8 +1085,7 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
|
|||||||
checkSequentialThinking('claude');
|
checkSequentialThinking('claude');
|
||||||
},
|
},
|
||||||
composePrompt: () => buildRuntimePrompt('claude'),
|
composePrompt: () => buildRuntimePrompt('claude'),
|
||||||
execLeaseGated: (cmdArgs, env, dangerous) =>
|
execLeaseGated: execRecordedClaudexRuntime,
|
||||||
execLeaseGatedRuntime('claude', cmdArgs, env, dangerous),
|
|
||||||
};
|
};
|
||||||
void launchClaudex(args, yolo, adapter);
|
void launchClaudex(args, yolo, adapter);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user