From e752cbd586af5b88c8bb9a16c1d2a57c8f19ab09 Mon Sep 17 00:00:00 2001 From: seekskyworld Date: Mon, 7 Sep 2026 11:23:51 +0800 Subject: [PATCH 1/3] fix(copilot): persist authoritative empty model catalogs Signed-off-by: seekskyworld --- .../connection-effect-coordinator.test.ts | 62 +++++++++ .../connection-effects-protocol.test.ts | 5 + .../src/protocol/connection-effects.ts | 2 +- .../server/connection-effect-coordinator.ts | 7 +- .../__tests__/provider-contract-overrides.ts | 8 ++ .../runtime-policy-model-facts.test.ts | 123 ++++++++++++++++++ .../connection-catalog-document.ts | 8 +- 7 files changed, 210 insertions(+), 5 deletions(-) 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..cee991eab8 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,68 @@ 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 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: 2, + connection: { connectionId: connection.connectionId, revision: 2 }, + 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/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..66b3aa110b 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,128 @@ test('model fetch keeps enabled facts-backed models when provider inventory fill } }); +test('github copilot model fetch 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-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: [{ id: 'copilot-fallback' }], + 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, []); + 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 model fetch clears a withdrawn default without picking a replacement', 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: 'fallback' 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.equal(refreshed.defaultTarget, null); + } 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 From 03dff02e630700e072d88ce1354c951f31fe5150 Mon Sep 17 00:00:00 2001 From: seekskyworld Date: Mon, 7 Sep 2026 12:50:21 +0800 Subject: [PATCH 2/3] fix(runtime-host): advance protocol epoch for empty catalogs Signed-off-by: seekskyworld --- packages/runtime-host/src/protocol/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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. From cc51a134b192088d3ec43341a92ea61f45241a3e Mon Sep 17 00:00:00 2001 From: seekskyworld Date: Sat, 12 Sep 2026 22:19:34 +0800 Subject: [PATCH 3/3] test(copilot): seed first account catalog before empty refresh Distinguish a first Copilot fetch from later refreshes in the authoritative catalog tests. The first successful account inventory replaces bootstrap ids; later empty or withdrawn refreshes still clear unavailable selections. Signed-off-by: seekskyworld Co-Authored-By: Claude Code --- .../connection-effect-coordinator.test.ts | 38 ++++++++++++- .../runtime-policy-model-facts.test.ts | 55 +++++++++++++++++-- 2 files changed, 85 insertions(+), 8 deletions(-) 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 cee991eab8..636c36b3dc 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -1525,6 +1525,40 @@ test('commits an authoritative empty GitHub Copilot catalog', async () => { ); 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(), @@ -1542,8 +1576,8 @@ test('commits an authoritative empty GitHub Copilot catalog', async () => { ok: true, result: { kind: 'committed', - catalogRevision: 2, - connection: { connectionId: connection.connectionId, revision: 2 }, + catalogRevision: 3, + connection: { connectionId: connection.connectionId, revision: 3 }, modelCount: 0, source: 'fetched', fetchedAt: 123, 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 66b3aa110b..e3315d6eea 100644 --- a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -260,8 +260,8 @@ test('model fetch keeps enabled facts-backed models when provider inventory fill } }); -test('github copilot model fetch prunes fallback ids outside the live catalog', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-copilot-refresh-')); +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'; @@ -278,7 +278,7 @@ test('github copilot model fetch prunes fallback ids outside the live catalog', providerType: 'github-copilot' as const, enabled: true, enabledModelIds: ['copilot-fallback'], - models: [{ id: 'copilot-fallback' }], + models: [], modelSource: 'fallback' as const, }, ], @@ -291,6 +291,49 @@ test('github copilot model fetch prunes fallback ids outside the live catalog', { 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( @@ -303,7 +346,7 @@ test('github copilot model fetch prunes fallback ids outside the live catalog', } }); -test('github copilot model fetch clears a withdrawn default without picking a replacement', async () => { +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(); @@ -322,7 +365,7 @@ test('github copilot model fetch clears a withdrawn default without picking a re enabled: true, enabledModelIds: ['copilot-fallback', 'retained-live'], models: [{ id: 'copilot-fallback' }, { id: 'retained-live' }], - modelSource: 'fallback' as const, + modelSource: 'fetched' as const, }, ], }; @@ -336,7 +379,7 @@ test('github copilot model fetch clears a withdrawn default without picking a re const projected = refreshed.connections[0]; assert.deepEqual(projected?.enabledModelIds, ['retained-live']); - assert.equal(refreshed.defaultTarget, null); + assert.deepEqual(refreshed.defaultTarget, { connectionId, modelId: 'retained-live' }); } finally { await rm(root, { recursive: true, force: true }); }