Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
284b2e3c23 | ||
|
|
69efad2f9a | ||
|
|
587cb19641 | ||
|
|
9b6869fab7 |
@@ -0,0 +1,332 @@
|
|||||||
|
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,
|
||||||
null,
|
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
null,
|
null,
|
||||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
operatorMemory as never,
|
operatorMemory as never,
|
||||||
|
|||||||
@@ -132,9 +132,8 @@ 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 | null,
|
private readonly systemOverride: SystemOverrideService,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
@@ -709,23 +708,22 @@ 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}`;
|
||||||
if (this.systemOverride) {
|
const override = await this.systemOverride.get(sessionId, scope);
|
||||||
const override = await this.systemOverride.get(sessionId, scope);
|
if (override) {
|
||||||
if (override) {
|
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
||||||
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
await this.systemOverride.renew(sessionId, scope);
|
||||||
await this.systemOverride.renew(sessionId, scope);
|
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||||
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) {
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ 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: {
|
||||||
@@ -98,6 +102,7 @@ function buildService(
|
|||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mcpClient as never,
|
mcpClient as never,
|
||||||
|
allowAuthorization as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ 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 | null = null,
|
private readonly authorization: CommandAuthorizationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(
|
||||||
@@ -57,13 +56,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 && !authorization.allowed) {
|
if (!authorization.allowed) {
|
||||||
return { command, conversationId, success: false, message: authorization.reason };
|
return { command, conversationId, success: false, message: authorization.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +170,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 || !this.authorization) return null;
|
if (!def) return null;
|
||||||
return this.authorization.createApproval(def, payload, scope.userId);
|
return this.authorization.createApproval(def, payload, scope.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ 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 {
|
||||||
@@ -74,6 +78,7 @@ 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,6 +159,7 @@ 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' };
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# #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.
|
||||||
@@ -53,21 +53,23 @@ sends, it does not auto-reply.
|
|||||||
|
|
||||||
### Exit codes
|
### Exit codes
|
||||||
|
|
||||||
| rc | Meaning |
|
| rc | Meaning |
|
||||||
| --- | ---------------------------------------------- |
|
| --- | -------------------------------------------------------------------------------------------- |
|
||||||
| 0 | delivered or queued |
|
| 0 | delivered or queued |
|
||||||
| 1 | target session not found |
|
| 1 | target session not found |
|
||||||
| 2 | text reached the pane but is **still a draft** |
|
| 2 | submission unconfirmed: draft still on the input line, or no positive evidence of submission |
|
||||||
| 3 | usage error (bad class, missing `-s`) |
|
| 3 | usage error (bad class, missing `-s`) |
|
||||||
|
|
||||||
**Never retry on rc=2.** The message is in the target pane; retrying double-sends it. Confirm
|
**Never retry on rc=2.** The message may be in the target pane, and a retry can double-send it.
|
||||||
instead:
|
Confirm instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tmux capture-pane -p -t <session>:0.0 | tail -20
|
tmux capture-pane -p -t <session>:0.0 | tail -20
|
||||||
```
|
```
|
||||||
|
|
||||||
rc=2 is the normal result when the target is an idle pi seat.
|
rc=0 is the normal result for both idle and busy pi seats (submission confirmed by draft
|
||||||
|
transition, not by prompt glyph). rc=2 on a healthy seat is exceptional — treat it as a real
|
||||||
|
report and investigate the pane.
|
||||||
|
|
||||||
## Durable comms
|
## Durable comms
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured i
|
|||||||
# --- tools/tmux: require a live tmux server ---
|
# --- tools/tmux: require a live tmux server ---
|
||||||
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
|
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
|
||||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||||
|
packages/mosaic/framework/tools/tmux/test-send-message-glyph-agnostic.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its siblings) — signed at adoption of #1262 (rev-code-02 F5), red-first verified on sb-it-1-dt
|
||||||
|
|
||||||
# --- single-suite directories: unmeasured in CI ---
|
# --- single-suite directories: unmeasured in CI ---
|
||||||
|
|
||||||
|
|||||||
@@ -97,13 +97,34 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
|||||||
# would otherwise accumulate forever.
|
# would otherwise accumulate forever.
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
|
|
||||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
# 2) Submit, then POSITIVELY confirm submission by DRAFT TRANSITION, not by prompt
|
||||||
# still a draft. Success requires positive evidence — the queued banner, OR the
|
# glyph. The historical bug was treating ABSENCE of a draft as delivery; the
|
||||||
# REPL input box located AND clear of our message tail. The historical bug was
|
# 2026-08 fix over-corrected to glyph inference (grep '❯|^>|│ >'), which locates
|
||||||
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched
|
# only Claude Code's box and false-NEGATIVES every glyphless REPL (pi renders a
|
||||||
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
# U+2500 rule, no glyph) — a delivered message reported "UNDELIVERED", driving a
|
||||||
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
# retry that duplicates it. Runtime-agnostic evidence: our message tail sits on
|
||||||
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
# the INPUT line (located by the cursor row, not a glyph) BEFORE Enter, and has
|
||||||
|
# LEFT it AFTER — that transition is positive proof of submission and needs no
|
||||||
|
# glyph. Absence alone still never means delivered: if we never saw our draft on
|
||||||
|
# the input line we stay UNCONFIRMED (wrong/dead pane), and a draft that never
|
||||||
|
# leaves the input line stays a DRAFT (exit 2), preserving both historical guards.
|
||||||
|
_cursor_line() { # echo the pane's current input (cursor) line, glyph-free
|
||||||
|
local cy line
|
||||||
|
cy=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" -F '#{cursor_y}' 2>/dev/null) || return 1
|
||||||
|
[ -n "$cy" ] || return 1
|
||||||
|
"${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null | sed -n "$((cy + 1))p"
|
||||||
|
}
|
||||||
|
_draft_on_input() { # true iff our message tail is sitting on the input line now
|
||||||
|
[ -n "$snippet" ] || return 1
|
||||||
|
grep -qF "$snippet" <<<"$(_cursor_line)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Baseline: after the paste, our draft must be on the input line. This is positive
|
||||||
|
# proof we are on the right pane and the paste landed — the anchor the transition
|
||||||
|
# check measures against.
|
||||||
|
saw_draft=0
|
||||||
|
_draft_on_input && saw_draft=1
|
||||||
|
|
||||||
status="unconfirmed"
|
status="unconfirmed"
|
||||||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||||||
@@ -113,20 +134,26 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
|
|||||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||||
status="queued"; break
|
status="queued"; break
|
||||||
fi
|
fi
|
||||||
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
|
# POSITIVE draft evidence from a located prompt box, when one exists. This is the
|
||||||
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery.
|
# cursor-row check's blind spot: a pane in COOKED mode (a plain shell whose
|
||||||
|
# foreground process never reads stdin) echoes our paste via the kernel line
|
||||||
|
# discipline and moves the cursor off it on Enter, which is indistinguishable from
|
||||||
|
# a real submit by cursor row alone. If a prompt box IS locatable and still carries
|
||||||
|
# our tail, that is affirmative proof the message was not consumed. Absence of a
|
||||||
|
# glyph is still never used for anything — that inference is the original E7 bug.
|
||||||
promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||||
if [ -z "$promptline" ]; then
|
if [ -n "$promptline" ] && [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
|
||||||
status="unconfirmed"; continue
|
|
||||||
fi
|
|
||||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
|
||||||
# (Submitted messages scroll up into history; a draft stays on the ❯ line.)
|
|
||||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
|
|
||||||
status="draft"; continue
|
status="draft"; continue
|
||||||
fi
|
fi
|
||||||
# Input box located AND clear of our tail => positively submitted. This is the
|
if [ "$saw_draft" = 1 ]; then
|
||||||
# only path to success besides the queued banner.
|
if _draft_on_input; then
|
||||||
status="delivered"; break
|
status="draft"; continue # still on the input line => not submitted; flush + retry
|
||||||
|
fi
|
||||||
|
status="delivered"; break # left the input line => positively submitted
|
||||||
|
fi
|
||||||
|
# No confirmed baseline yet: try to (re)acquire it; never infer delivery from absence.
|
||||||
|
if _draft_on_input; then saw_draft=1; status="draft"; continue; fi
|
||||||
|
status="unconfirmed"; continue
|
||||||
done
|
done
|
||||||
|
|
||||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Red-first regression test for E7 (#1017 task 2): the confirm-check must bind
|
||||||
|
# "delivered" to WHETHER THE MESSAGE WAS SUBMITTED, not to which runtime's prompt
|
||||||
|
# glyph is present. A pi seat renders a U+2500 rule input box with no ❯/^>/│ >
|
||||||
|
# glyph; send-message.sh:118 locates the box only by glyph, so a genuinely
|
||||||
|
# delivered message on a glyphless REPL falsely reports exit 2 "may be UNDELIVERED",
|
||||||
|
# and the operator's rc=2-driven retry duplicates it.
|
||||||
|
#
|
||||||
|
# Parameterized on $SEND: RED against the shipping blob (B and D fail), GREEN
|
||||||
|
# against a candidate patch. No pi; no fake HOME; hermetic throwaway socket.
|
||||||
|
#
|
||||||
|
# Submission counting is EXACT and terminal-echo-independent: the fixture message
|
||||||
|
# is `echo <tok> >>SINK`; each real submission appends one line. wc -l SINK ==
|
||||||
|
# number of times the REPL actually executed the send. This does not depend on how
|
||||||
|
# many times the marker string is painted on screen.
|
||||||
|
set -u
|
||||||
|
SEND="${SEND:?set SEND=/path/to/send-message.sh}"
|
||||||
|
SOCKET="glyphagnostic-$$"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
tmux() { command tmux -L "$SOCKET" "$@"; }
|
||||||
|
cleanup() { command tmux -L "$SOCKET" kill-server 2>/dev/null; rm -rf "$TMP"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
pass=0; fail=0
|
||||||
|
ok() { printf 'ok %s\n' "$1"; pass=$((pass+1)); }
|
||||||
|
no() { printf 'FAIL %s -- %s\n' "$1" "$2"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
mk() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" "PS1='$2' exec bash --noprofile --norc -i"; sleep 0.5; }
|
||||||
|
subs() { [ -f "$1" ] && wc -l <"$1" | tr -d ' ' || echo 0; } # exact submission count
|
||||||
|
|
||||||
|
echo "SEND=$SEND tmux $(command tmux -V | awk '{print $2}')"
|
||||||
|
|
||||||
|
# --- A (control): glyph box (❯) that submits => exit 0, exactly one submission.
|
||||||
|
mk ctl '❯ '
|
||||||
|
SINK="$TMP/sink.ctl"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t ctl -m "echo x >>'$SINK'" 2>"$TMP/e.ctl"); rc=$?; sleep 0.4
|
||||||
|
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
|
||||||
|
ok "control: ❯-box submits => exit 0, exactly one submission"
|
||||||
|
else no "control: ❯-box submits => exit 0, one submission" "rc=$rc subs=$(subs "$SINK") err=[$(cat "$TMP/e.ctl")]"; fi
|
||||||
|
|
||||||
|
# --- B (THE false-rc regression): glyphless U+2500 box that SUBMITS. Message lands
|
||||||
|
# (subs==1) yet shipping reports exit 2. Must be exit 0.
|
||||||
|
mk sub $'──────── \n'
|
||||||
|
SINK="$TMP/sink.sub"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t sub -m "echo x >>'$SINK'" 2>"$TMP/e.sub"); rc=$?; sleep 0.4
|
||||||
|
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
|
||||||
|
ok "glyphless: U+2500 box that submits => exit 0 (delivered, not 'UNDELIVERED')"
|
||||||
|
else no "glyphless: U+2500 box that submits => exit 0" \
|
||||||
|
"rc=$rc subs=$(subs "$SINK")(delivered=$([ "$(subs "$SINK")" -ge 1 ] && echo yes||echo no)) err=[$(cat "$TMP/e.sub")]"; fi
|
||||||
|
|
||||||
|
# --- D (duplicate arm): operator follows the rc=2 stderr and retries once. On the
|
||||||
|
# glyphless box, shipping => two submissions (the reported duplicate). The
|
||||||
|
# property: one logical send => exactly one submission. Same fix closes it.
|
||||||
|
mk dup $'──────── \n'
|
||||||
|
SINK="$TMP/sink.dup"
|
||||||
|
tries=0
|
||||||
|
for attempt in 1 2; do
|
||||||
|
tries=$((tries+1))
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t dup -m "echo x >>'$SINK'" 2>/dev/null); rc=$?
|
||||||
|
sleep 0.4
|
||||||
|
[ "$rc" = 0 ] && break # operator stops retrying only when told delivered
|
||||||
|
done
|
||||||
|
if [ "$(subs "$SINK")" = 1 ]; then
|
||||||
|
ok "duplicate: one logical send (rc-driven retry) => exactly one submission (tries=$tries)"
|
||||||
|
else no "duplicate: one logical send => exactly one submission" "submissions=$(subs "$SINK") tries=$tries"; fi
|
||||||
|
|
||||||
|
# --- E (faithful hung managed TUI, NOT a cooked shell): raw/no-echo, paints nothing.
|
||||||
|
# A cooked `sleep infinity` echoes the paste via the kernel line discipline and
|
||||||
|
# false-passes a cursor-row fix that is correct on real seats (measured). So: raw.
|
||||||
|
mk_rawstuck() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
|
||||||
|
"bash --noprofile --norc -c 'stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity'"; sleep 0.5; }
|
||||||
|
mk_rawstuck estuck
|
||||||
|
SINK="$TMP/sink.estuck"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t estuck -r 1 -m "this stuck draft was never submitted" 2>/dev/null); rc=$?
|
||||||
|
sleep 0.3
|
||||||
|
if [ "$rc" != 0 ] && [ "$(subs "$SINK")" = 0 ]; then
|
||||||
|
ok "raw/no-echo stuck TUI (not submitted) => non-zero (no false delivered)"
|
||||||
|
else no "raw stuck TUI must NOT report delivered" "rc=$rc subs=$(subs "$SINK")"; fi
|
||||||
|
|
||||||
|
# --- F (busy/queued branch, your BUSY-not-runtime finding): glyphless pane rendering the
|
||||||
|
# queued banner, never consuming. QUEUED_RE :113 fires before the glyph grep => rc=0.
|
||||||
|
mk_busy() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
|
||||||
|
"bash --noprofile --norc -c 'printf \"Press up to edit queued messages\n\"; exec sleep infinity'"; sleep 0.5; }
|
||||||
|
mk_busy ebusy
|
||||||
|
SINK="$TMP/sink.ebusy"
|
||||||
|
out=$("$SEND" -L "$SOCKET" -t ebusy -m "echo x >>'$SINK'" 2>/dev/null); rc=$?; sleep 0.3
|
||||||
|
if [ "$rc" = 0 ]; then
|
||||||
|
ok "busy/queued-banner glyphless => exit 0 (queued is delivery; runtime owns custody)"
|
||||||
|
else no "busy/queued-banner must report delivered" "rc=$rc"; fi
|
||||||
|
|
||||||
|
# --- C (historical-bug guard): unresolvable target. No pane ever carried our draft
|
||||||
|
# => must fail, never infer delivered from absence of a glyph/snippet.
|
||||||
|
if out=$("$SEND" -L "$SOCKET" -t "nonexistent-$$" -m "echo x >>'$TMP/sink.wrong'" 2>/dev/null); then
|
||||||
|
no "wrong-pane: unresolvable target must NOT report success" "expected non-zero, got 0"
|
||||||
|
else ok "wrong-pane: unresolvable target => non-zero (no false delivered)"; fi
|
||||||
|
|
||||||
|
echo "---"; echo "pass=$pass fail=$fail"
|
||||||
|
[ "$fail" = 0 ]
|
||||||
@@ -4,16 +4,19 @@
|
|||||||
#
|
#
|
||||||
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
||||||
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
||||||
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
|
# 2. DELIVERED — a pane with NO prompt glyph that DOES submit => exit 0. A pi
|
||||||
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
|
# seat is this fixture (U+2500 rule, no glyph). Reshaped for
|
||||||
# exit 0; post-patch it MUST fail loud (exit 2, stderr
|
# #1257; see the note at the fixture for why the old exit-2
|
||||||
# "could not confirm submission").
|
# assertion was wrong.
|
||||||
|
# 2b. UNCONFIRMED— a glyphless pane that never submits (raw/no-echo hung TUI)
|
||||||
|
# => must fail loud. This carries the historical
|
||||||
|
# false-positive guard that fixture 2 used to be credited with.
|
||||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||||
# input line) => exit 2, stderr "unsubmitted draft".
|
# input line) => exit 2, stderr "unsubmitted draft".
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
SEND="$HERE/send-message.sh"
|
SEND="${SEND:-$HERE/send-message.sh}"
|
||||||
SOCKET="verdict-test-$RANDOM-$$"
|
SOCKET="verdict-test-$RANDOM-$$"
|
||||||
TMP=$(mktemp -d)
|
TMP=$(mktemp -d)
|
||||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
||||||
@@ -37,19 +40,44 @@ else
|
|||||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
|
# --- Fixture 2: NO prompt glyph, and the pane DOES submit (interactive bash).
|
||||||
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
|
# RESHAPED 2026-08-16 (#1257), deliberately. This fixture previously asserted
|
||||||
|
# exit 2 here and was labelled "false-positive FIXED". That assertion was wrong,
|
||||||
|
# and locking it in is what kept E7 alive: the pane submits, so "delivered" is
|
||||||
|
# the truth, and a pi seat — whose input box is a bare U+2500 rule with no glyph
|
||||||
|
# — IS this fixture. Reporting exit 2 for it told operators a delivered message
|
||||||
|
# may be undelivered, and the retry that advice invites is the duplicate.
|
||||||
|
#
|
||||||
|
# The guard this fixture was reaching for is real and is NOT dropped: "never
|
||||||
|
# infer delivered from absence" is now enforced positively by fixture 2b below
|
||||||
|
# (glyphless AND not submitting => must fail) and by fixture 3 (locatable box
|
||||||
|
# still carrying our tail => draft). Absence alone decides nothing either way.
|
||||||
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
||||||
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
|
out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); rc=$?
|
||||||
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||||
|
ok "delivered: glyphless pane that submits => exit 0 (runtime-agnostic, E7 FIXED)"
|
||||||
|
else
|
||||||
|
no "delivered: glyphless pane that submits => exit 0" "rc=$rc out=[$out] err=[$(cat "$TMP/e2")]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Fixture 2b: NO prompt glyph AND never submits — a hung managed TUI holding the
|
||||||
|
# terminal in raw/no-echo, which is what a stuck agent seat actually is (measured
|
||||||
|
# on live pi: stty -echo -icanon). Nothing is echoed, nothing is consumed, so
|
||||||
|
# there is no positive evidence of submission and the tool MUST fail loud. This
|
||||||
|
# is the historical false-positive guard, kept as a positive test.
|
||||||
|
tmux -L "$SOCKET" new-session -d -s rawstuck -c "$TMP" \
|
||||||
|
'bash --noprofile --norc -c "stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity"'
|
||||||
|
sleep 0.3
|
||||||
|
if out=$("$SEND" -L "$SOCKET" -t "=rawstuck" -r 1 -m "verdict fixture two-b never submitted" 2>"$TMP/e2b"); then
|
||||||
|
no "unconfirmed: glyphless hung TUI must NOT report success" "expected non-zero, got 0 (out=[$out])"
|
||||||
else
|
else
|
||||||
rc=$?
|
rc=$?
|
||||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
|
if [ "$rc" -ne 0 ] && grep -qF "could not confirm submission" "$TMP/e2b"; then
|
||||||
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
|
ok "unconfirmed: glyphless hung TUI (raw/no-echo) => non-zero + 'could not confirm submission'"
|
||||||
else
|
else
|
||||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
no "unconfirmed: glyphless hung TUI => non-zero + stderr" "rc=$rc err=[$(cat "$TMP/e2b")]"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
/**
|
|
||||||
* setupPath profile management (issue #1327, MOSAIC-IMPROVEMENTS 4c / D25).
|
|
||||||
*
|
|
||||||
* The profile append used to be guarded on the binDir value it was about to
|
|
||||||
* write, which is blind to accumulation across different Mosaic homes: every
|
|
||||||
* wizard run against a fresh temp home appended a permanent block to the
|
|
||||||
* operator's real shell profile (1,061 measured appends on sb-it-1-dt).
|
|
||||||
*
|
|
||||||
* Arms below map to the requirements:
|
|
||||||
* S1 sentinel-managed block, rewritten in place
|
|
||||||
* S2 a non-default target home never touches the operator profile
|
|
||||||
* S3 byte-identical profile across repeated runs
|
|
||||||
* S4 legacy unmarked `# Mosaic` blocks collapse into the managed block
|
|
||||||
* S5 the Windows ($env:Path) arm shares the same block logic
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { tmpdir, homedir } from 'node:os';
|
|
||||||
|
|
||||||
let profilePathMock: string | null = null;
|
|
||||||
|
|
||||||
vi.mock('../platform/detect.js', () => ({
|
|
||||||
getShellProfilePath: (): string | null => profilePathMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { setupPath, managedBlockFor, stripLegacyPathBlocks } from './finalize.js';
|
|
||||||
|
|
||||||
// The real resolved default on this host. Tests use it as the comparator a
|
|
||||||
// non-default home must fail against, exactly as the wizard would.
|
|
||||||
const REAL_DEFAULT_HOME = join(homedir(), '.config', 'mosaic');
|
|
||||||
|
|
||||||
function tempHome(prefix: string): string {
|
|
||||||
const dir = join(tmpdir(), prefix);
|
|
||||||
mkdirSync(join(dir, 'bin'), { recursive: true });
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('setupPath profile management (#1327)', () => {
|
|
||||||
let workDir: string;
|
|
||||||
let profileFile: string;
|
|
||||||
let defaultLikeHome: string;
|
|
||||||
let otherHome: string;
|
|
||||||
const baseline = '# existing operator content\nexport EDITOR=vim\n';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
workDir = mkdtempSync(join(tmpdir(), 'setuppath-spec-'));
|
|
||||||
profileFile = join(workDir, '.bashrc');
|
|
||||||
writeFileSync(profileFile, baseline, 'utf-8');
|
|
||||||
profilePathMock = profileFile;
|
|
||||||
defaultLikeHome = tempHome(join(workDir, 'home-a', '.config', 'mosaic'));
|
|
||||||
otherHome = tempHome(join(workDir, 'home-b', '.config', 'mosaic'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
profilePathMock = null;
|
|
||||||
rmSync(workDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// S2 — the arm that MUST fail against the pre-fix code: a home that is not
|
|
||||||
// the resolved default may not modify the operator profile at all.
|
|
||||||
it('does not touch the operator profile when the target home is not the resolved default', () => {
|
|
||||||
const action = setupPath(otherHome, REAL_DEFAULT_HOME);
|
|
||||||
expect(action).toBe('skipped');
|
|
||||||
expect(readFileSync(profileFile, 'utf-8')).toBe(baseline);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns skipped when no shell profile can be resolved', () => {
|
|
||||||
profilePathMock = null;
|
|
||||||
const action = setupPath(defaultLikeHome, defaultLikeHome);
|
|
||||||
expect(action).toBe('skipped');
|
|
||||||
});
|
|
||||||
|
|
||||||
// S1 + S3 — two distinct homes (each run as the resolved default in turn,
|
|
||||||
// the shape of two legitimate installs against one operator profile) and
|
|
||||||
// repeated runs against the same home both leave exactly one block.
|
|
||||||
it('leaves exactly one managed block after runs against two distinct homes', () => {
|
|
||||||
const first = setupPath(defaultLikeHome, defaultLikeHome);
|
|
||||||
expect(first).toBe('added');
|
|
||||||
|
|
||||||
const second = setupPath(otherHome, otherHome);
|
|
||||||
expect(second).toBe('added');
|
|
||||||
|
|
||||||
const content = readFileSync(profileFile, 'utf-8');
|
|
||||||
const beginCount = content.split('# >>> mosaic begin >>>').length - 1;
|
|
||||||
const endCount = content.split('# <<< mosaic end <<<').length - 1;
|
|
||||||
expect(beginCount).toBe(1);
|
|
||||||
expect(endCount).toBe(1);
|
|
||||||
expect(content).toContain(join(otherHome, 'bin'));
|
|
||||||
expect(content).toContain(baseline);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is byte-identical across repeated runs against the same home', () => {
|
|
||||||
setupPath(defaultLikeHome, defaultLikeHome);
|
|
||||||
const afterFirst = readFileSync(profileFile, 'utf-8');
|
|
||||||
|
|
||||||
const again = setupPath(defaultLikeHome, defaultLikeHome);
|
|
||||||
expect(again).toBe('already');
|
|
||||||
expect(readFileSync(profileFile, 'utf-8')).toBe(afterFirst);
|
|
||||||
});
|
|
||||||
|
|
||||||
// S4 — pre-existing unmarked blocks from the old append logic collapse
|
|
||||||
// into the single managed block instead of accumulating beside it.
|
|
||||||
it('collapses legacy unmarked # Mosaic blocks into the managed block', () => {
|
|
||||||
const legacy =
|
|
||||||
'# existing operator content\n' +
|
|
||||||
'# Mosaic\n' +
|
|
||||||
'export PATH="/tmp/mosaic-dead-wizard-1/bin:$PATH"\n' +
|
|
||||||
'export EDITOR=vim\n' +
|
|
||||||
'# Mosaic\n' +
|
|
||||||
'export PATH="/tmp/mosaic-dead-wizard-2/bin:$PATH"\n';
|
|
||||||
writeFileSync(profileFile, legacy, 'utf-8');
|
|
||||||
|
|
||||||
const action = setupPath(defaultLikeHome, defaultLikeHome);
|
|
||||||
expect(action).toBe('added');
|
|
||||||
|
|
||||||
const content = readFileSync(profileFile, 'utf-8');
|
|
||||||
expect(content).not.toContain('/tmp/mosaic-dead-wizard-1/bin');
|
|
||||||
expect(content).not.toContain('/tmp/mosaic-dead-wizard-2/bin');
|
|
||||||
expect(content).toContain('export EDITOR=vim');
|
|
||||||
expect(content.split('# >>> mosaic begin >>>').length - 1).toBe(1);
|
|
||||||
expect(content).toContain(join(defaultLikeHome, 'bin'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('managed block helpers (#1327)', () => {
|
|
||||||
// S5 — the Windows arm shares markers and shape with the POSIX arm.
|
|
||||||
it('builds the $env:Path variant inside the same markers', () => {
|
|
||||||
const block = managedBlockFor('C:\\Users\\op\\.config\\mosaic\\bin', true);
|
|
||||||
expect(block).toContain('# >>> mosaic begin >>>');
|
|
||||||
expect(block).toContain('# <<< mosaic end <<<');
|
|
||||||
expect(block).toContain('$env:Path = "C:\\Users\\op\\.config\\mosaic\\bin;$env:Path"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds the POSIX export variant inside the same markers', () => {
|
|
||||||
const block = managedBlockFor('/home/op/.config/mosaic/bin', false);
|
|
||||||
expect(block).toContain('# >>> mosaic begin >>>');
|
|
||||||
expect(block).toContain('export PATH="/home/op/.config/mosaic/bin:$PATH"');
|
|
||||||
expect(block).toContain('# <<< mosaic end <<<');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('strips legacy $env:Path pairs on the Windows arm', () => {
|
|
||||||
const legacy =
|
|
||||||
'# Mosaic\n$env:Path = "C:\\tmp\\dead\\bin;$env:Path"\n' +
|
|
||||||
'# Mosaic\n$env:Path = "C:\\tmp\\dead2\\bin;$env:Path"\n' +
|
|
||||||
'Write-Host hi\n';
|
|
||||||
const stripped = stripLegacyPathBlocks(legacy, true);
|
|
||||||
expect(stripped).not.toContain('C:\\tmp\\dead');
|
|
||||||
expect(stripped).toContain('Write-Host hi');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { platform } from 'node:os';
|
import { platform } from 'node:os';
|
||||||
import type { WizardPrompter } from '../prompter/interface.js';
|
import type { WizardPrompter } from '../prompter/interface.js';
|
||||||
import type { ConfigService } from '../config/config-service.js';
|
import type { ConfigService } from '../config/config-service.js';
|
||||||
import type { WizardState } from '../types.js';
|
import type { WizardState } from '../types.js';
|
||||||
import { getShellProfilePath } from '../platform/detect.js';
|
import { getShellProfilePath } from '../platform/detect.js';
|
||||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
|
||||||
import { ManifestError } from '../framework/manifest.js';
|
import { ManifestError } from '../framework/manifest.js';
|
||||||
import {
|
import {
|
||||||
getDefaultSkillPaths,
|
getDefaultSkillPaths,
|
||||||
@@ -145,87 +144,32 @@ function runDoctor(mosaicHome: string): DoctorResult {
|
|||||||
|
|
||||||
type PathAction = 'already' | 'added' | 'skipped';
|
type PathAction = 'already' | 'added' | 'skipped';
|
||||||
|
|
||||||
const PATH_BLOCK_BEGIN = '# >>> mosaic begin >>>';
|
function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
|
||||||
const PATH_BLOCK_END = '# <<< mosaic end <<<';
|
|
||||||
const PATH_BLOCK_NOTE = '# Managed by the Mosaic installer; this block is rewritten on install.';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The managed PATH block written into the operator's shell profile.
|
|
||||||
*
|
|
||||||
* The block is delimited by begin/end sentinels so any number of installs,
|
|
||||||
* against any homes, collapse to exactly one block: the writer replaces the
|
|
||||||
* region between the sentinels instead of appending a second copy (#1327).
|
|
||||||
*/
|
|
||||||
export function managedBlockFor(binDir: string, isWindows: boolean): string {
|
|
||||||
const exportLine = isWindows
|
|
||||||
? `$env:Path = "${binDir};$env:Path"`
|
|
||||||
: `export PATH="${binDir}:$PATH"`;
|
|
||||||
return `${PATH_BLOCK_BEGIN}\n${PATH_BLOCK_NOTE}\n${exportLine}\n${PATH_BLOCK_END}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove legacy unmarked `# Mosaic` PATH pairs appended by pre-#1327
|
|
||||||
* installs. Only the exact two-line shape this installer used to write is
|
|
||||||
* removed; any other `# Mosaic` comment line is left alone.
|
|
||||||
*/
|
|
||||||
export function stripLegacyPathBlocks(content: string, isWindows: boolean): string {
|
|
||||||
const legacyExport = isWindows ? /^\$env:Path = ".*;\$env:Path"$/ : /^export PATH=".*:\$PATH"$/;
|
|
||||||
const lines = content.split('\n');
|
|
||||||
const kept: string[] = [];
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i] ?? '';
|
|
||||||
const next = i + 1 < lines.length ? lines[i + 1] : undefined;
|
|
||||||
if (line === '# Mosaic' && next !== undefined && legacyExport.test(next)) {
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
kept.push(line);
|
|
||||||
}
|
|
||||||
return kept.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the region between the managed-block sentinels, first occurrence. */
|
|
||||||
function withoutManagedBlock(content: string): string {
|
|
||||||
const beginIdx = content.indexOf(PATH_BLOCK_BEGIN);
|
|
||||||
if (beginIdx < 0) return content;
|
|
||||||
const endIdx = content.indexOf(PATH_BLOCK_END, beginIdx);
|
|
||||||
if (endIdx < 0) return content;
|
|
||||||
return content.slice(0, beginIdx) + content.slice(endIdx + PATH_BLOCK_END.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupPath(mosaicHome: string, resolvedDefaultHome: string): PathAction {
|
|
||||||
// Never write outside the home under test (#1327 S2): a wizard run against
|
|
||||||
// a non-default home (test harnesses, throwaway installs) must not mutate
|
|
||||||
// the operator's real shell profile.
|
|
||||||
if (mosaicHome !== resolvedDefaultHome) {
|
|
||||||
return 'skipped';
|
|
||||||
}
|
|
||||||
|
|
||||||
const binDir = join(mosaicHome, 'bin');
|
const binDir = join(mosaicHome, 'bin');
|
||||||
|
const currentPath = process.env['PATH'] ?? '';
|
||||||
|
|
||||||
|
if (currentPath.includes(binDir)) {
|
||||||
|
return 'already';
|
||||||
|
}
|
||||||
|
|
||||||
const profilePath = getShellProfilePath();
|
const profilePath = getShellProfilePath();
|
||||||
if (!profilePath) return 'skipped';
|
if (!profilePath) return 'skipped';
|
||||||
|
|
||||||
const isWindows = platform() === 'win32';
|
const isWindows = platform() === 'win32';
|
||||||
const block = managedBlockFor(binDir, isWindows);
|
const exportLine = isWindows
|
||||||
|
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
|
||||||
|
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
|
||||||
|
|
||||||
let content = '';
|
// Check if already in profile
|
||||||
if (existsSync(profilePath)) {
|
if (existsSync(profilePath)) {
|
||||||
content = readFileSync(profilePath, 'utf-8');
|
const content = readFileSync(profilePath, 'utf-8');
|
||||||
}
|
if (content.includes(binDir)) {
|
||||||
|
return 'already';
|
||||||
// Migration (#1327 S4): legacy unmarked blocks collapse into the managed
|
}
|
||||||
// block, and an existing managed block is rewritten in place rather than
|
|
||||||
// appended beside itself (S1/S3).
|
|
||||||
const base = stripLegacyPathBlocks(withoutManagedBlock(content), isWindows);
|
|
||||||
const trimmed = base.replace(/\n+$/, '');
|
|
||||||
const next = trimmed.length === 0 ? block : `${trimmed}\n${block}`;
|
|
||||||
|
|
||||||
if (next === content) {
|
|
||||||
return 'already';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(profilePath, next, 'utf-8');
|
appendFileSync(profilePath, exportLine, 'utf-8');
|
||||||
return 'added';
|
return 'added';
|
||||||
} catch {
|
} catch {
|
||||||
return 'skipped';
|
return 'skipped';
|
||||||
@@ -342,7 +286,7 @@ export async function finalizeStage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. PATH setup
|
// 7. PATH setup
|
||||||
const pathAction = setupPath(state.mosaicHome, DEFAULT_MOSAIC_HOME);
|
const pathAction = setupPath(state.mosaicHome, p);
|
||||||
|
|
||||||
let summaryShown = false;
|
let summaryShown = false;
|
||||||
const showSummary = () => {
|
const showSummary = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user