smoke: reconcile assertions with ungated-global + ambient clock #140

Merged
michal merged 1 commits from fix/smoke-ungated-global-reconcile into main 2026-10-09 15:06:40 +00:00
4 changed files with 41 additions and 13 deletions
Showing only changes of commit ed54aa1464 - Show all commits

View File

@@ -155,7 +155,7 @@ describe('Smoke: Audit events', () => {
try { try {
await chat.initialize(); await chat.initialize();
const tools = await chat.listTools(); const tools = await chat.listTools();
chat.check('Gated (only begin_session)', tools.length, (v) => v >= 1); chat.check('Gated tools/list responds', tools.length, (v) => v >= 1);
// Trigger gate decision // Trigger gate decision
await chat.callTool('begin_session', { description: 'audit smoke test' }); await chat.callTool('begin_session', { description: 'audit smoke test' });

View File

@@ -167,11 +167,21 @@ describe('Smoke: Backup completeness', () => {
} }
}); });
it('backup servers have command field for docker-image STDIO servers', () => { it('backup preserves the command of docker-image STDIO servers', async () => {
if (!available) return; if (!available) return;
const dockerStdio = bundle.servers.filter((s) => s.dockerImage && s.transport === 'STDIO'); // `command` is optional for dockerImage servers (the image entrypoint is
for (const s of dockerStdio) { // used when it is empty — calcmcp, dav-michal, jmap-michal all run that
expect(s.command, `${s.name} (dockerImage STDIO) should have command`).toBeTruthy(); // way), so requiring it universally asserted a false invariant. What the
// backup MUST do is carry the command through for servers that have one.
const live = (await mcpdRequest<Server[]>('GET', '/api/v1/servers')).data;
const withCommand = live.filter(
(s) => s.dockerImage && s.transport === 'STDIO' && Array.isArray(s.command) && s.command.length > 0,
);
expect(withCommand.length, 'no live dockerImage STDIO server has a command — fidelity unverifiable').toBeGreaterThan(0);
for (const s of withCommand) {
const backed = bundle.servers.find((b) => b.name === s.name);
expect(backed, `${s.name} missing from backup`).toBeTruthy();
expect(backed!.command, `${s.name}: backup dropped its command`).toEqual(s.command);
} }
}); });

View File

@@ -108,23 +108,38 @@ describe('Smoke: ProxyModel pipeline', () => {
expect(true).toBe(true); expect(true).toBe(true);
}); });
it('gated session: tools/list returns only begin_session', async () => { it('gated session: tools/list returns begin_session plus only gate-exempt tools', async () => {
if (!serverResponding) return; if (!serverResponding) return;
// Since the ungated-global system project (PR #137), servers attached to
// it ride every project PRE-gate: a fresh gated session sees their tools
// alongside begin_session. Anything else leaking through is a gate bug.
let exemptServers: string[] = [];
try {
const raw = await mcpctl('get project ungated-global -o json');
const parsed = JSON.parse(raw) as Array<{ servers?: string[] }>;
exemptServers = parsed[0]?.servers ?? [];
} catch {
// Project deleted by an admin (it is deletable) — nothing is exempt.
}
const session = new SmokeMcpSession(PROJECT_NAME); const session = new SmokeMcpSession(PROJECT_NAME);
const chat = new ChatReporter(session); const chat = new ChatReporter(session);
chat.section('Gating: fresh session sees only begin_session'); chat.section('Gating: fresh session sees begin_session + gate-exempt tools only');
try { try {
await chat.initialize(); await chat.initialize();
await chat.sendNotification('notifications/initialized'); await chat.sendNotification('notifications/initialized');
const tools = await chat.listTools(); const tools = await chat.listTools();
const names = tools.map((t) => t.name);
const isExempt = (n: string): boolean => exemptServers.some((s) => n.startsWith(`${s}_`));
const leaked = names.filter((n) => n !== 'begin_session' && !isExempt(n));
chat.check('Exactly 1 tool', tools.length, (v) => v === 1); chat.check('begin_session present', names.includes('begin_session'), (v) => v === true);
chat.check('Tool is begin_session', tools[0]?.name ?? '', (v) => v === 'begin_session'); chat.check('No gated tools leaked', leaked.length, (v) => v === 0);
chat.check('Has inputSchema', !!tools[0]?.inputSchema, (v) => v === true); chat.check('Has inputSchema', !!tools.find((t) => t.name === 'begin_session')?.inputSchema, (v) => v === true);
expect(tools).toHaveLength(1); expect(names).toContain('begin_session');
expect(tools[0]!.name).toBe('begin_session'); expect(leaked, 'tools visible before begin_session that are not gate-exempt').toEqual([]);
} finally { } finally {
await chat.close(); await chat.close();
} }

View File

@@ -30,7 +30,10 @@ interface Tool {
/** Tools served by the gate plugin — intercepted before the pipeline, so exempt. */ /** Tools served by the gate plugin — intercepted before the pipeline, so exempt. */
const GATE_TOOLS = new Set(['begin_session', 'read_prompts', 'propose_prompt', 'propose_skill']); // mcplocal-virtual tools: dispatched by the router before the pipeline, so
// they never produce paginatable results and rightly skip the drill-down
// contract. get_current_time is the ambient-context clock.
const GATE_TOOLS = new Set(['begin_session', 'read_prompts', 'propose_prompt', 'propose_skill', 'get_current_time']);
describe('Smoke: tool drill-down contract', () => { describe('Smoke: tool drill-down contract', () => {
let available = false; let available = false;