diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index bffa2311b1..636c36b3dc 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -1499,6 +1499,102 @@ test('provider discovery failure preserves the existing catalog and returns no s }); }); +test('commits an authoritative empty GitHub Copilot catalog', async () => { + await withFixture(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('copilot-empty', 'github-copilot'), + ); + const storedCredential = serializeOAuthSubscriptionTokens({ + access_token: 'gho_copilot_empty', + refresh_token: 'ghr_copilot_empty', + expires_at: Number.MAX_SAFE_INTEGER, + token_type: 'Bearer', + base_url: 'https://api.githubcopilot.com', + }); + const enrollment = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'connection-effect-copilot-empty', + target: { kind: 'existing', connectionId: connection.connectionId }, + }); + assert.equal(enrollment.kind, 'ready'); + if (enrollment.kind !== 'ready') throw new Error('OAuth enrollment did not start'); + const credential = await stores.operations.completeInteractiveOAuthLogin( + enrollment.ticket, + storedCredential, + ); + assert.equal(credential.kind, 'committed'); + + const seedCoordinator = new HostConnectionEffectCoordinator({ + stores, + activation: new RuntimePolicyActivationGate(), + oauthCredentials: new HostOAuthExecutionAuthority(stores), + now: () => 122, + createTransport: () => recordingTransport(() => undefined), + runModelDiscovery: async () => ({ ok: true, models: [{ id: 'account-available' }] }), + }); + const seeded = await seedCoordinator.handlers['connection.models.fetch']( + { connectionId: connection.connectionId }, + context, + ); + assert.deepEqual(seeded, { + ok: true, + result: { + kind: 'committed', + catalogRevision: 2, + connection: { connectionId: connection.connectionId, revision: 2 }, + modelCount: 1, + source: 'fetched', + fetchedAt: 122, + }, + }); + const seededSnapshot = await stores.connectionCatalog.getSnapshot(); + const seededConnection = seededSnapshot.connections.find( + ({ connectionId }) => connectionId === connection.connectionId, + ); + assert.ok(seededConnection); + assert.deepEqual(seededConnection.enabledModelIds, ['account-available']); + assert.deepEqual( + seededConnection.models.map((model) => model.id), + ['account-available'], + ); + + const coordinator = new HostConnectionEffectCoordinator({ + stores, + activation: new RuntimePolicyActivationGate(), + oauthCredentials: new HostOAuthExecutionAuthority(stores), + now: () => 123, + createTransport: () => recordingTransport(() => undefined), + runModelDiscovery: async () => ({ ok: true, models: [] }), + }); + + const result = await coordinator.handlers['connection.models.fetch']( + { connectionId: connection.connectionId }, + context, + ); + assert.deepEqual(result, { + ok: true, + result: { + kind: 'committed', + catalogRevision: 3, + connection: { connectionId: connection.connectionId, revision: 3 }, + modelCount: 0, + source: 'fetched', + fetchedAt: 123, + }, + }); + + const snapshot = await stores.connectionCatalog.getSnapshot(); + const updated = snapshot.connections.find( + ({ connectionId }) => connectionId === connection.connectionId, + ); + assert.ok(updated); + assert.deepEqual(updated.models, []); + assert.deepEqual(updated.enabledModelIds, []); + assert.equal(snapshot.defaultTarget, null); + }); +}); + test('OAuth connection effects resolve the canonical access token instead of sending the vault payload', async () => { await withFixture(async ({ stores }) => { const connection = await createConnection( diff --git a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts index dd17dec549..e239b9d81f 100644 --- a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts @@ -203,6 +203,11 @@ describe('Runtime Host connection effects protocol', () => { }; const committed = response('connection.models.fetch', committedResult); assert.deepEqual(decodeHostFrame(committed), committed); + const emptyCommitted = response('connection.models.fetch', { + ...committedResult, + modelCount: 0, + }); + assert.deepEqual(decodeHostFrame(emptyCommitted), emptyCommitted); for (const result of [ { kind: 'failed', errorClass: 'timeout' }, diff --git a/packages/runtime-host/src/protocol/connection-effects.ts b/packages/runtime-host/src/protocol/connection-effects.ts index c57656f9ba..8d1a0e7646 100644 --- a/packages/runtime-host/src/protocol/connection-effects.ts +++ b/packages/runtime-host/src/protocol/connection-effects.ts @@ -467,7 +467,7 @@ export function decodeConnectionModelFetchResult(value: unknown): ConnectionMode modelCount: boundedInteger( committed.modelCount, 'model count', - 1, + 0, CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION, ), source: modelDiscoverySource(committed.source), diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 416ee0ab62..1f44e1e4b1 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 147 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 148 as const; +// 148: GitHub Copilot model discovery may commit an authoritative empty +// catalog, so `connection.models.fetch` accepts `modelCount: 0`. Older peers +// reject that frame because their decoder requires at least one model. // 147: OAuth create targets may carry a caller-selected Connection name and // slug, and slug collisions remain a closed typed error before or after // authorization. Older peers reject those strict input and output shapes. diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index c1ca8a60f4..373f6f278d 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -152,7 +152,12 @@ export class HostConnectionEffectCoordinator { const effect = await this.#withTransport(prepared, (fetch, secret) => this.#runModelDiscovery(prepared.connection, secret, { fetch }), ); - if (!effect.ok || effect.models.length === 0) { + // Copilot's account catalog is authoritative: an empty successful + // response means the account currently has no selectable models. + if ( + !effect.ok || + (effect.models.length === 0 && prepared.connection.providerType !== 'github-copilot') + ) { return { kind: 'failed', errorClass: effect.ok ? 'invalid_response' : effect.error.kind, diff --git a/packages/runtime/src/__tests__/provider-contract-overrides.ts b/packages/runtime/src/__tests__/provider-contract-overrides.ts index a3b1af6310..9564f991f8 100644 --- a/packages/runtime/src/__tests__/provider-contract-overrides.ts +++ b/packages/runtime/src/__tests__/provider-contract-overrides.ts @@ -363,6 +363,14 @@ async function runGitHubCopilotDiscovery(): Promise { ...copilotModel('policy-not-accepted', ['/chat/completions']), policy: { state: 'unconfigured' }, }, + { + ...copilotModel('null-policy', ['/chat/completions']), + policy: null, + }, + { + ...copilotModel('malformed-policy', ['/chat/completions']), + policy: 'enabled', + }, { ...copilotModel('hidden-from-picker', ['/chat/completions']), model_picker_enabled: false, diff --git a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts index 5788a95ecd..e3315d6eea 100644 --- a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { RuntimePolicyCoordinator } from '../runtime-policy/coordinator.js'; +import { ConnectionCatalogDocumentOwner } from '../runtime-policy/connection-catalog-document.js'; test('runtime policy catalog overlays enabled custom model facts without changing the raw catalog', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-')); @@ -259,6 +260,171 @@ test('model fetch keeps enabled facts-backed models when provider inventory fill } }); +test('github copilot first fetch seeds account models instead of keeping bootstrap ids', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-copilot-first-fetch-')); + try { + const catalog = new ConnectionCatalogDocumentOwner(); + const connectionId = '00000000-0000-4000-8000-00000000c0de'; + const current = { + schemaVersion: 1 as const, + revision: 1, + defaultTarget: { connectionId, modelId: 'copilot-fallback' }, + connections: [ + { + connectionId, + revision: 1, + slug: 'github-copilot', + name: 'GitHub Copilot', + providerType: 'github-copilot' as const, + enabled: true, + enabledModelIds: ['copilot-fallback'], + models: [], + modelSource: 'fallback' as const, + }, + ], + }; + + const refreshed = await catalog.writeModelFetchResult( + root, + current, + { connectionId, revision: 1 }, + { models: [{ id: 'live-model' }], source: 'fetched', fetchedAt: 1 }, + ); + + const projected = refreshed.connections[0]; + assert.deepEqual(projected?.enabledModelIds, ['live-model']); + assert.deepEqual( + projected?.models.map((model) => model.id), + ['live-model'], + ); + assert.deepEqual(refreshed.defaultTarget, { connectionId, modelId: 'live-model' }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github copilot later refresh prunes fallback ids outside the live catalog', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-copilot-refresh-')); + try { + const catalog = new ConnectionCatalogDocumentOwner(); + const connectionId = '00000000-0000-4000-8000-00000000c0e1'; + const current = { + schemaVersion: 1 as const, + revision: 1, + defaultTarget: { connectionId, modelId: 'copilot-fallback' }, + connections: [ + { + connectionId, + revision: 1, + slug: 'github-copilot', + name: 'GitHub Copilot', + providerType: 'github-copilot' as const, + enabled: true, + enabledModelIds: ['copilot-fallback'], + models: [{ id: 'copilot-fallback' }], + modelSource: 'fetched' as const, + }, + ], + }; + + const refreshed = await catalog.writeModelFetchResult( + root, + current, + { connectionId, revision: 1 }, + { models: [{ id: 'live-model' }], source: 'fetched', fetchedAt: 1 }, + ); + + const projected = refreshed.connections[0]; + assert.deepEqual(projected?.enabledModelIds, []); + assert.deepEqual( + projected?.models.map((model) => model.id), + ['live-model'], + ); + assert.equal(refreshed.defaultTarget, null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github copilot later refresh keeps remaining live selections when the default is withdrawn', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-copilot-default-')); + try { + const catalog = new ConnectionCatalogDocumentOwner(); + const connectionId = '00000000-0000-4000-8000-00000000c0df'; + const current = { + schemaVersion: 1 as const, + revision: 1, + defaultTarget: { connectionId, modelId: 'copilot-fallback' }, + connections: [ + { + connectionId, + revision: 1, + slug: 'github-copilot', + name: 'GitHub Copilot', + providerType: 'github-copilot' as const, + enabled: true, + enabledModelIds: ['copilot-fallback', 'retained-live'], + models: [{ id: 'copilot-fallback' }, { id: 'retained-live' }], + modelSource: 'fetched' as const, + }, + ], + }; + + const refreshed = await catalog.writeModelFetchResult( + root, + current, + { connectionId, revision: 1 }, + { models: [{ id: 'retained-live' }], source: 'fetched', fetchedAt: 1 }, + ); + + const projected = refreshed.connections[0]; + assert.deepEqual(projected?.enabledModelIds, ['retained-live']); + assert.deepEqual(refreshed.defaultTarget, { connectionId, modelId: 'retained-live' }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github copilot model fetch commits an authoritative empty catalog', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-copilot-empty-')); + try { + const catalog = new ConnectionCatalogDocumentOwner(); + const connectionId = '00000000-0000-4000-8000-00000000c0e0'; + const current = { + schemaVersion: 1 as const, + revision: 1, + defaultTarget: { connectionId, modelId: 'copilot-fallback' }, + connections: [ + { + connectionId, + revision: 1, + slug: 'github-copilot', + name: 'GitHub Copilot', + providerType: 'github-copilot' as const, + enabled: true, + enabledModelIds: ['copilot-fallback'], + models: [{ id: 'copilot-fallback' }], + modelSource: 'fallback' as const, + }, + ], + }; + + const refreshed = await catalog.writeModelFetchResult( + root, + current, + { connectionId, revision: 1 }, + { models: [], source: 'fetched', fetchedAt: 1 }, + ); + + const projected = refreshed.connections[0]; + assert.deepEqual(projected?.enabledModelIds, []); + assert.deepEqual(projected?.models, []); + assert.equal(refreshed.defaultTarget, null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('protocol model facts edits clear verification, supersede tickets, and warn on malformed input', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-external-edit-')); const emitWarning = process.emitWarning; diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 0cf9a14dd5..f4faf70ee7 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -447,14 +447,16 @@ export class ConnectionCatalogDocumentOwner { rawResult: ConnectionModelDiscoveryResult, ): Promise { const result = decodeConnectionInput(() => normalizeConnectionModelDiscoveryResult(rawResult)); - if (result.models.length === 0) { - throw codecError('invalid_connection_input', 'Model discovery result must not be empty'); - } const index = findConnectionIndex(current, expected); const previous = current.connections[index]; if (!previous || previous.revision !== expected.revision) { throw codecError('invalid_document', 'Coordinator admitted a stale model discovery result'); } + // Copilot's account inventory can be authoritative even when empty; every + // other provider still needs a non-empty discovery result to commit. + if (result.models.length === 0 && previous.providerType !== 'github-copilot') { + throw codecError('invalid_connection_input', 'Model discovery result must not be empty'); + } const currentDefaultTarget = current.defaultTarget?.connectionId === previous.connectionId ? current.defaultTarget