From 2751319d5a6e865267bcb4a28adbe9f043c18839 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 21:28:07 +0800 Subject: [PATCH 1/5] feat(storage): allocate onboarding connection identity Generated-by: OpenAI Codex --- packages/core/src/runtime-policy.ts | 10 + .../__tests__/onboarding-transaction.test.ts | 14 +- .../__tests__/runtime-policy-stores.test.ts | 177 ++++++++++++ .../connection-catalog-document.ts | 33 +-- .../storage/src/runtime-policy/coordinator.ts | 268 ++++++++++++------ .../runtime-policy/onboarding-transaction.ts | 35 ++- .../storage/src/runtime-policy/operations.ts | 33 +-- 7 files changed, 435 insertions(+), 135 deletions(-) diff --git a/packages/core/src/runtime-policy.ts b/packages/core/src/runtime-policy.ts index a6da3087af..9da541133f 100644 --- a/packages/core/src/runtime-policy.ts +++ b/packages/core/src/runtime-policy.ts @@ -246,6 +246,16 @@ export interface ConnectionCatalogEntry extends ConnectionConfiguration { readonly lastTest?: ConnectionTestSummary; } +export type ConnectionOnboardingTarget = + | { + readonly kind: 'create'; + readonly providerType: ProviderType; + } + | { + readonly kind: 'existing'; + readonly connectionId: EntityId; + }; + export type ConnectionCatalogEntryDraft = ConnectionConfiguration; export interface ConnectionCatalogEntryUpdate { diff --git a/packages/storage/src/__tests__/onboarding-transaction.test.ts b/packages/storage/src/__tests__/onboarding-transaction.test.ts index 12c7f160ed..2154e872b2 100644 --- a/packages/storage/src/__tests__/onboarding-transaction.test.ts +++ b/packages/storage/src/__tests__/onboarding-transaction.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, test } from 'node:test'; @@ -42,6 +42,7 @@ after(async () => { const BASE = { connectionId: '00000000-0000-4000-8000-000000000001', + slug: 'openai-compatible-2', providerType: 'openai-compatible', suppliedSecret: 'relay-secret', enabledModelIds: ['relay/model'], @@ -57,17 +58,26 @@ test('an onboarding intent round-trips its endpoint override through the journal }); await writeConnectionOnboardingIntent(directory, intent); assert.deepEqual(await readConnectionOnboardingIntent(directory), intent); + const persisted = JSON.parse( + await readFile(join(directory, 'runtime-policy-onboarding.json'), 'utf8'), + ) as { schemaVersion: number; slug: string }; + assert.deepEqual(persisted, { ...intent }); + assert.equal(persisted.schemaVersion, 2); + assert.equal(persisted.slug, 'openai-compatible-2'); }); test('a journal written before the baseUrl field replays as no override', async () => { const directory = await root(); // The exact persisted shape an older build leaves behind on crash: no // `baseUrl` key at all. Recovery must replay it, not reject the document. + const { slug: _slug, ...legacyBase } = BASE; await writeFile( join(directory, 'runtime-policy-onboarding.json'), - JSON.stringify({ schemaVersion: 1, ...BASE }), + JSON.stringify({ schemaVersion: 1, ...legacyBase }), ); const replayed = await readConnectionOnboardingIntent(directory); + assert.equal(replayed?.schemaVersion, 1); + assert.equal(replayed?.slug, null); assert.equal(replayed?.baseUrl, null); assert.deepEqual(replayed?.enabledModelIds, ['relay/model']); }); diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 2d1bbfa26f..65ddab5445 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -3469,6 +3469,183 @@ describe('runtime policy stores', () => { }); }); + test('recovers a v1 onboarding intent by identity before deriving a canonical slug', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + let connectionId = ''; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const connection = await createConnection(stores, 0, { + ...connectionDraft('my-relay', 'openai-compatible', 'Custom relay'), + baseUrl: 'https://relay.example.test/v1', + }); + connectionId = connection.connectionId; + await writeFile( + join(root, 'runtime-policy-onboarding.json'), + `${JSON.stringify({ + schemaVersion: 1, + connectionId, + providerType: connection.providerType, + suppliedSecret: null, + baseUrl: connection.baseUrl, + enabledModelIds: ['relay/new'], + discovery: { + models: [{ id: 'relay/new' }], + source: 'fetched', + fetchedAt: 1_800_000_000_001, + }, + invalidateLastTest: false, + })}\n`, + ); + } finally { + await owner.close(); + } + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(successor.lease); + const catalog = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual( + catalog.connections.map(({ connectionId: id, slug }) => ({ id, slug })), + [{ id: connectionId, slug: 'my-relay' }], + ); + // Recovery follows the ordinary onboarding merge rule: the newly + // selected model is enabled while a declaration the wizard never + // offered remains intact. + assert.deepEqual(catalog.connections[0]?.enabledModelIds, ['relay/new', 'gpt-5']); + assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), false); + } finally { + await successor.close(); + } + }); + }); + + test('recovers a v2 create intent with its preallocated dynamic identity', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const connectionId = '22222222-2222-4222-8222-222222222222'; + let firstConnectionId = ''; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + firstConnectionId = ( + await createConnection(stores, 0, connectionDraft('openai', 'openai', 'OpenAI')) + ).connectionId; + await writeFile( + join(root, 'runtime-policy-onboarding.json'), + `${JSON.stringify({ + schemaVersion: 2, + connectionId, + slug: 'openai-2', + providerType: 'openai', + suppliedSecret: 'second-account-secret', + baseUrl: null, + enabledModelIds: ['gpt-5'], + discovery: { + models: [{ id: 'gpt-5' }], + source: 'fetched', + fetchedAt: 1_800_000_000_002, + }, + invalidateLastTest: false, + })}\n`, + ); + } finally { + await owner.close(); + } + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(successor.lease); + const catalog = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual( + catalog.connections.map(({ connectionId: id, slug }) => ({ id, slug })), + [ + { id: firstConnectionId, slug: 'openai' }, + { id: connectionId, slug: 'openai-2' }, + ], + ); + assert.equal( + ( + await stores.operations.exportCredentialMaterial({ + scope: 'connection', + connectionId, + kind: 'api_key', + }) + )?.secret, + 'second-account-secret', + ); + assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), false); + } finally { + await successor.close(); + } + }); + }); + + test('fails closed when a v2 onboarding intent rebinds an existing id to another slug', async () => { + await withInteractiveRoot(async ({ root, capability }) => { + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + let vaultBefore = ''; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const connection = await createConnection( + stores, + 0, + connectionDraft('my-relay', 'openai-compatible', 'Custom relay'), + ); + const credential = await stores.credentialVault.set({ + locator: connectionCredential(connection, 'api_key'), + expected: null, + secret: 'original-secret', + }); + assert.equal(credential.kind, 'committed'); + vaultBefore = await readFile(join(root, 'credential-vault.json'), 'utf8'); + await writeFile( + join(root, 'runtime-policy-onboarding.json'), + `${JSON.stringify({ + schemaVersion: 2, + connectionId: connection.connectionId, + slug: 'openai-compatible', + providerType: connection.providerType, + suppliedSecret: 'must-not-replace-original', + baseUrl: connection.baseUrl, + enabledModelIds: ['gpt-5'], + discovery: { + models: [{ id: 'gpt-5' }], + source: 'fetched', + fetchedAt: 1_800_000_000_002, + }, + invalidateLastTest: false, + })}\n`, + ); + } finally { + await owner.close(); + } + + const successor = await tryAcquireInteractiveRootOwner(capability); + assert.ok(successor); + if (!successor) return; + try { + await assert.rejects( + openInteractiveRuntimePolicyStoresForWrite(successor.lease), + isStoreError('commit_outcome_unknown'), + ); + assert.equal(existsSync(join(root, 'runtime-policy-onboarding.json')), true); + assert.equal(await readFile(join(root, 'credential-vault.json'), 'utf8'), vaultBefore); + } finally { + await successor.close(); + } + }); + }); + test('interactive OAuth login commits only against its frozen connection and credential basis', async () => { await withInteractiveOwner(async ({ root, stores }) => { const claude = await createConnection( diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 60245c940d..3cd7e38dad 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -47,11 +47,7 @@ import { type MigrateSystemSeedInput, type UpdateCatalogConnectionInput, } from '@maka/core/runtime-policy'; -import { - deriveConnectionSlug, - PROVIDER_DEFAULTS, - reconcileConnectionAfterModelFetch, -} from '@maka/core/llm-connections'; +import { PROVIDER_DEFAULTS, reconcileConnectionAfterModelFetch } from '@maka/core/llm-connections'; import { modelIdAliasesForProvider } from '@maka/core/model-metadata'; import { isRetiredProvider } from '@maka/core/provider-registry'; import { pruneRelayModelProfiles } from '@maka/core/model-thinking'; @@ -516,40 +512,39 @@ export class ConnectionCatalogDocumentOwner { prepareOnboardingUpsert( current: ConnectionCatalogDocument, rawConnectionId: string, + rawSlug: string, rawProviderType: unknown, rawBaseUrl: string | null, rawEnabledModelIds: readonly string[], rawResult: ConnectionModelDiscoveryResult, invalidateLastTest: boolean, - ): PreparedOnboardingResult | { readonly kind: 'slug_conflict' } { + ): + | PreparedOnboardingResult + | { readonly kind: 'slug_conflict' } + | { readonly kind: 'catalog_full' } { const connectionId = decodeConnectionInput(() => decodeRuntimePolicyEntityId(rawConnectionId)); + const slug = decodeConnectionInput(() => decodeConnectionSlug(rawSlug)); const providerType = decodeConnectionInput(() => decodeProviderType(rawProviderType)); const definition = PROVIDER_DEFAULTS[providerType]; // Identity first: the intent's connectionId names the connection being // edited, whatever slug it lives under — a relay created in Desktop under // a custom slug is updated in place, never duplicated at the canonical // slug. Only a genuinely new connection lands at the derived slug. - let index = current.connections.findIndex( + const index = current.connections.findIndex( (connection) => connection.connectionId === connectionId, ); - if (index < 0) { - index = current.connections.findIndex( - (connection) => connection.slug === deriveConnectionSlug(providerType), - ); - } const previous = current.connections[index]; if (previous && previous.providerType !== providerType) { return { kind: 'slug_conflict' }; } - if (previous && previous.connectionId !== connectionId) { - throw codecError('invalid_document', 'Onboarding intent conflicts with the connection id'); + if (previous && previous.slug !== slug) { + throw codecError('invalid_document', 'Onboarding intent conflicts with the connection slug'); + } + if (!previous && current.connections.some((connection) => connection.slug === slug)) { + return { kind: 'slug_conflict' }; } - const slug = previous?.slug ?? deriveConnectionSlug(providerType); if (!previous && current.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS) { - throw codecError( - 'invalid_connection_input', - `Connection catalog cannot exceed ${CONNECTION_CATALOG_MAX_CONNECTIONS} entries`, - ); + return { kind: 'catalog_full' }; } const result = decodeConnectionInput(() => normalizeConnectionModelDiscoveryResult(rawResult)); // Non-empty is the requirement; `source` is write provenance, not a diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index f4a3418b95..ea87df952d 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -19,8 +19,10 @@ import { randomUUID } from 'node:crypto'; import { + CONNECTION_CATALOG_MAX_CONNECTIONS, decodeConnectionModelId, decodeConnectionSlug, + decodeProviderType, decodeRuntimePolicyEntityId, decodeCredentialLocator, normalizeDeleteCredentialInput, @@ -32,6 +34,7 @@ import { serializeRequestHeaders, RequestCustomizationValidationError, normalizeCredentialSecret, + normalizeCatalogConnectionBaseUrl, type ConnectionCatalogEntry, type ConnectionCatalogSnapshot, type ConnectionVersionBasis, @@ -192,10 +195,24 @@ interface ConnectionTicketRecord { * the connection revision stands in for every catalog-visible property of an * existing target — a swapped endpoint bumps it. */ -interface ConnectionOnboardingBasis { - readonly providerType: ProviderType; +interface ConnectionOnboardingCandidateIdentity { + readonly connectionId: string; readonly slug: string; - readonly target: { readonly connectionId: string; readonly revision: number } | null; + readonly providerType: ProviderType; +} + +interface ConnectionOnboardingBasis { + readonly target: + | { + readonly kind: 'create'; + readonly candidate: ConnectionOnboardingCandidateIdentity; + } + | { + readonly kind: 'existing'; + readonly candidate: ConnectionOnboardingCandidateIdentity; + readonly revision: number; + }; + readonly baseUrl: string | null; readonly credential: CredentialStatus | null; readonly requestHeadersCredential: CredentialStatus | null; readonly effectiveProxy: EffectiveProxyConfigurationBasis; @@ -983,19 +1000,62 @@ export class RuntimePolicyCoordinator { input: BeginConnectionOnboardingInput, ): Promise { return this.inLane(async (root) => { + const catalog = await this.catalog.read(root); + let existing: ConnectionCatalogEntry | undefined; + let target: ConnectionOnboardingBasis['target']; + const requestedTarget = input.target; + if (requestedTarget.kind === 'create') { + const providerType = decodeConnectionInput(() => + decodeProviderType(requestedTarget.providerType), + ); + target = { + kind: 'create', + candidate: { + connectionId: randomUUID(), + slug: deriveConnectionSlug( + providerType, + catalog.connections.map((connection) => connection.slug), + ), + providerType, + }, + }; + } else if (requestedTarget.kind === 'existing') { + const connectionId = decodeConnectionInput(() => + decodeRuntimePolicyEntityId(requestedTarget.connectionId), + ); + existing = findConnection(catalog, { connectionId }); + if (!existing) return deepFreeze({ kind: 'target_missing' as const }); + target = { + kind: 'existing', + candidate: { + connectionId: existing.connectionId, + slug: existing.slug, + providerType: existing.providerType, + }, + revision: existing.revision, + }; + } else { + throw codecError('invalid_connection_input', 'Unknown connection onboarding target'); + } + const providerType = target.candidate.providerType; // Onboarding guards the api_key credential slot; a provider whose auth // never uses one has no business here (the Host gates on the same // predicate, this keeps the storage API honest on its own). - if (!providerAuthSupportsApiKey(input.providerType)) { - throw codecError( - 'invalid_connection_input', - 'Connection onboarding requires an API-key provider', - ); + if (!providerAuthSupportsApiKey(providerType)) { + return deepFreeze({ kind: 'provider_unsupported' as const }); } - const catalog = await this.catalog.read(root); - const located = locateOnboardingTarget(catalog, input.providerType, input.connectionId); - if (located.kind !== 'ready') return deepFreeze({ kind: located.kind }); - const existing = located.existing; + if ( + target.kind === 'create' && + catalog.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS + ) { + return deepFreeze({ kind: 'catalog_full' as const }); + } + const baseUrl = + input.baseUrl === null + ? null + : (decodeConnectionInput(() => + normalizeCatalogConnectionBaseUrl(input.baseUrl, providerType), + ) ?? null); const policy = await this.policy.read(root); const networkProxy = structuredClone(policy.policy.networkProxy); const vault = await this.vault.read(root); @@ -1003,20 +1063,21 @@ export class RuntimePolicyCoordinator { let storedSecret: string | null = null; let requestHeadersCredential: CredentialStatus | null = null; let requestHeadersSecret: string | null = null; - if (existing) { - const locator = connectionCredentialLocator( - existing.connectionId, - PROVIDER_DEFAULTS[existing.providerType].authKind, - ); - if (locator) { - credential = credentialStatus(vault, locator); + const locator = connectionCredentialLocator( + target.candidate.connectionId, + PROVIDER_DEFAULTS[providerType].authKind, + ); + if (locator) { + credential = credentialStatus(vault, locator); + if (existing) { storedSecret = findCredential(vault, locator)?.secret ?? null; } - // Discovery must probe with the same header customization the models - // path applies, so the secret is pinned for the probe and its status - // joins the basis the commit revalidates. - const headersLocator = connectionRequestHeadersLocator(existing.connectionId); - requestHeadersCredential = credentialStatus(vault, headersLocator); + } + // Discovery must probe with the same header customization the models + // path applies, so even absence is pinned for a new candidate. + const headersLocator = connectionRequestHeadersLocator(target.candidate.connectionId); + requestHeadersCredential = credentialStatus(vault, headersLocator); + if (existing) { requestHeadersSecret = findCredential(vault, headersLocator)?.secret ?? null; } // The proxy discovery will run through is pinned HERE, like @@ -1034,11 +1095,8 @@ export class RuntimePolicyCoordinator { this.tickets.set(ticket, { kind: 'connection_onboarding', basis: { - providerType: input.providerType, - slug: deriveConnectionSlug(input.providerType), - target: existing - ? { connectionId: existing.connectionId, revision: existing.revision } - : null, + target, + baseUrl, credential, requestHeadersCredential, effectiveProxy: effectiveProxyConfigurationBasis(networkProxy), @@ -1049,7 +1107,9 @@ export class RuntimePolicyCoordinator { return deepFreeze({ kind: 'ready' as const, ticket: ticket as ConnectionOnboardingTicket, - connection: existing ? structuredClone(existing) : null, + candidate: structuredClone(target.candidate), + existingConnection: existing ? structuredClone(existing) : null, + baseUrl, storedSecret, requestHeadersSecret, networkProxy, @@ -1079,13 +1139,16 @@ export class RuntimePolicyCoordinator { // from, not whatever a concurrent policy update left behind. const checked = await this.checkOnboardingBasis(root, catalog, record.basis); if (checked.kind !== 'unchanged') { + if (checked.kind === 'catalog_full') { + return deepFreeze({ kind: 'catalog_full' as const }); + } return deepFreeze( checked.kind === 'target_missing' ? { kind: 'target_missing' as const } : { kind: 'superseded' as const, changed: checked.changed }, ); } - return this.commitConnectionOnboardingInLane(root, catalog, input); + return this.commitConnectionOnboardingInLane(root, catalog, record.basis, input); }), ); } @@ -1097,19 +1160,26 @@ export class RuntimePolicyCoordinator { ): Promise< | { readonly kind: 'unchanged' } | { readonly kind: 'target_missing' } + | { readonly kind: 'catalog_full' } | { readonly kind: 'superseded'; readonly changed: ConnectionEffectChangedDomain[] } > { const changed: ConnectionEffectChangedDomain[] = []; // One vault read serves every credential-status compare below. const vault = await this.vault.read(root); - if (basis.target) { - const connection = findConnection(catalog, { connectionId: basis.target.connectionId }); + if (basis.target.kind === 'existing') { + const connection = findConnection(catalog, { + connectionId: basis.target.candidate.connectionId, + }); // A vanished target is its own answer — "the connection is gone" beats // "the connection changed" — while a survived one is compared by // revision, which covers every catalog-visible property, endpoint // included. if (!connection) return { kind: 'target_missing' }; - if (connection.revision !== basis.target.revision) { + if ( + connection.revision !== basis.target.revision || + connection.slug !== basis.target.candidate.slug || + connection.providerType !== basis.target.candidate.providerType + ) { changed.push('connection'); } else if ( basis.credential && @@ -1117,10 +1187,16 @@ export class RuntimePolicyCoordinator { ) { changed.push('credential'); } - } else if (catalog.connections.some((connection) => connection.slug === basis.slug)) { - // Discovery ran for a first-time creation; any connection that appeared - // at the canonical slug since supersedes it. + } else if ( + catalog.connections.some( + (connection) => + connection.connectionId === basis.target.candidate.connectionId || + connection.slug === basis.target.candidate.slug, + ) + ) { changed.push('connection'); + } else if (catalog.connections.length >= CONNECTION_CATALOG_MAX_CONNECTIONS) { + return { kind: 'catalog_full' }; } if ( basis.requestHeadersCredential && @@ -1159,12 +1235,15 @@ export class RuntimePolicyCoordinator { private async commitConnectionOnboardingInLane( root: string, catalog: Awaited>, + basis: ConnectionOnboardingBasis, input: CommitConnectionOnboardingInput, ): Promise { - const located = locateOnboardingTarget(catalog, input.providerType, input.connectionId); - if (located.kind !== 'ready') return deepFreeze({ kind: located.kind }); - const existing = located.existing; - const connectionId = existing?.connectionId ?? randomUUID(); + const candidate = basis.target.candidate; + const existing = + basis.target.kind === 'existing' + ? findConnection(catalog, { connectionId: candidate.connectionId }) + : undefined; + const connectionId = candidate.connectionId; let invalidateLastTest = false; if (input.suppliedSecret !== null) { const locator = { @@ -1194,11 +1273,15 @@ export class RuntimePolicyCoordinator { const intent = prepareConnectionOnboardingIntent({ ...input, connectionId, + slug: candidate.slug, + providerType: candidate.providerType, + baseUrl: basis.baseUrl, invalidateLastTest, }); const catalogPreflight = this.catalog.prepareOnboardingUpsert( catalog, intent.connectionId, + intent.slug, intent.providerType, intent.baseUrl, intent.enabledModelIds, @@ -1206,7 +1289,10 @@ export class RuntimePolicyCoordinator { intent.invalidateLastTest, ); if (catalogPreflight.kind === 'slug_conflict') { - return deepFreeze({ kind: 'slug_conflict' as const }); + return deepFreeze({ kind: 'superseded' as const, changed: ['connection'] as const }); + } + if (catalogPreflight.kind === 'catalog_full') { + return deepFreeze({ kind: 'catalog_full' as const }); } try { await writeConnectionOnboardingIntent(root, intent); @@ -1567,8 +1653,43 @@ export class RuntimePolicyCoordinator { private async applyConnectionOnboarding( root: string, intent: ConnectionOnboardingIntent, - ): Promise<{ readonly snapshot: ConnectionCatalogSnapshot; readonly changed: boolean }> { + ): Promise<{ + readonly snapshot: ConnectionCatalogSnapshot; + readonly changed: boolean; + readonly connection: Pick< + ConnectionCatalogEntry, + 'connectionId' | 'slug' | 'providerType' | 'revision' + >; + }> { let changed = false; + const catalog = await this.catalog.read(root); + const existingConnection = findConnection(catalog, { connectionId: intent.connectionId }); + const slug = + intent.slug ?? existingConnection?.slug ?? deriveConnectionSlug(intent.providerType); + // Validate the durable identity and final catalog shape before touching + // the vault. A damaged v2 intent must not rotate a real connection's + // credential before discovering that its ID/slug pair cannot commit. + const prepared = this.catalog.prepareOnboardingUpsert( + catalog, + intent.connectionId, + slug, + intent.providerType, + intent.baseUrl, + intent.enabledModelIds, + intent.discovery, + intent.invalidateLastTest, + ); + if (prepared.kind === 'slug_conflict') { + throw codecError( + 'invalid_document', + intent.schemaVersion === 1 + ? 'Legacy onboarding intent conflicts with the connection id' + : 'Onboarding intent conflicts with the connection slug', + ); + } + if (prepared.kind === 'catalog_full') { + throw codecError('invalid_document', 'Onboarding intent exceeds the connection catalog'); + } if (intent.suppliedSecret !== null) { const locator = { scope: 'connection', @@ -1596,21 +1717,22 @@ export class RuntimePolicyCoordinator { } } - const catalog = await this.catalog.read(root); - const prepared = this.catalog.prepareOnboardingUpsert( - catalog, - intent.connectionId, - intent.providerType, - intent.baseUrl, - intent.enabledModelIds, - intent.discovery, - intent.invalidateLastTest, - ); - if (prepared.kind === 'slug_conflict') { - throw codecError('invalid_document', 'Onboarding intent conflicts with the connection slug'); - } const snapshot = await this.catalog.commitPreparedOnboarding(root, prepared); - return { snapshot, changed: changed || prepared.changed }; + const connection = snapshot.connections.find( + (candidate) => candidate.connectionId === intent.connectionId, + ); + if (!connection) + throw codecError('invalid_document', 'Onboarding commit omitted its connection'); + return { + snapshot, + changed: changed || prepared.changed, + connection: { + connectionId: connection.connectionId, + slug: connection.slug, + providerType: connection.providerType, + revision: connection.revision, + }, + }; } private inLane(operation: (root: string) => Promise): Promise { @@ -1629,40 +1751,10 @@ function isObsoleteConnectionOnboardingIntent(error: unknown): boolean { return ( error instanceof RuntimePolicyStoreError && error.code === 'invalid_document' && - error.message === 'Onboarding intent conflicts with the connection id' + error.message === 'Legacy onboarding intent conflicts with the connection id' ); } -/** - * The single target-location rule onboarding begin and commit share: an - * explicit connectionId names the connection to edit in place (any slug); - * null targets the canonical slug, creating there when free. - */ -function locateOnboardingTarget( - catalog: { readonly connections: readonly ConnectionCatalogEntry[] }, - providerType: ProviderType, - connectionId: string | null, -): - | { readonly kind: 'target_missing' } - | { readonly kind: 'slug_conflict' } - | { readonly kind: 'ready'; readonly existing: ConnectionCatalogEntry | undefined } { - if (connectionId) { - const existing = catalog.connections.find( - (connection) => connection.connectionId === connectionId, - ); - if (!existing || existing.providerType !== providerType) { - return { kind: 'target_missing' }; - } - return { kind: 'ready', existing }; - } - const slug = deriveConnectionSlug(providerType); - const existing = catalog.connections.find((connection) => connection.slug === slug); - if (existing && existing.providerType !== providerType) { - return { kind: 'slug_conflict' }; - } - return { kind: 'ready', existing }; -} - function commonSemanticConnectionBasis( prepared: PreparedConnectionMaterial, ): CommonSemanticConnectionBasis { diff --git a/packages/storage/src/runtime-policy/onboarding-transaction.ts b/packages/storage/src/runtime-policy/onboarding-transaction.ts index d6f94c0429..1694d044c0 100644 --- a/packages/storage/src/runtime-policy/onboarding-transaction.ts +++ b/packages/storage/src/runtime-policy/onboarding-transaction.ts @@ -21,6 +21,7 @@ import { unlink } from 'node:fs/promises'; import { join } from 'node:path'; import { decodeProviderType, + decodeConnectionSlug, decodeRuntimePolicyEntityId, normalizeCatalogConnectionBaseUrl, normalizeConnectionCatalogEntryUpdateForProvider, @@ -29,6 +30,7 @@ import { type ConnectionModelDiscoveryResult, } from '@maka/core/runtime-policy'; import { + deriveConnectionSlug, PROVIDER_DEFAULTS, providerAuthSupportsApiKey, type ProviderType, @@ -45,11 +47,12 @@ import { } from './errors.js'; import { readBoundedJsonDocument, writeJsonDocument } from './document-io.js'; const FILE = 'runtime-policy-onboarding.json'; -const SCHEMA_VERSION = 1 as const; +const SCHEMA_VERSION = 2 as const; const MAX_BYTES = 5 * 1024 * 1024; export interface ConnectionOnboardingTransactionInput { readonly connectionId: unknown; + readonly slug: unknown; readonly providerType: unknown; readonly suppliedSecret: unknown; readonly baseUrl: unknown; @@ -59,8 +62,10 @@ export interface ConnectionOnboardingTransactionInput { } export interface ConnectionOnboardingIntent { - readonly schemaVersion: typeof SCHEMA_VERSION; + readonly schemaVersion: 1 | typeof SCHEMA_VERSION; readonly connectionId: string; + /** Absent only while replaying a schema-v1 identity-first intent. */ + readonly slug: string | null; readonly providerType: ProviderType; readonly suppliedSecret: string | null; readonly baseUrl: string | null; @@ -69,10 +74,15 @@ export interface ConnectionOnboardingIntent { readonly invalidateLastTest: boolean; } +export type CurrentConnectionOnboardingIntent = ConnectionOnboardingIntent & { + readonly schemaVersion: 2; + readonly slug: string; +}; + export function prepareConnectionOnboardingIntent( input: ConnectionOnboardingTransactionInput, source: 'input' | 'persisted' = 'input', -): ConnectionOnboardingIntent { +): CurrentConnectionOnboardingIntent { const decode = source === 'persisted' ? decodePersistedDomain : decodeConnectionInput; const providerType = decode(() => decodeProviderType(input.providerType)); if (!providerAuthSupportsApiKey(providerType)) { @@ -135,6 +145,7 @@ export function prepareConnectionOnboardingIntent( return { schemaVersion: SCHEMA_VERSION, connectionId: decode(() => decodeRuntimePolicyEntityId(input.connectionId)), + slug: decode(() => decodeConnectionSlug(input.slug)), providerType, suppliedSecret, baseUrl, @@ -149,8 +160,7 @@ export async function readConnectionOnboardingIntent( ): Promise { const value = await readBoundedJsonDocument(root, FILE, MAX_BYTES); if (value === undefined) return undefined; - // `baseUrl` is allowed but not required: an intent journaled by a build - // that predates the field must still replay. + // `baseUrl` is allowed but not required for the oldest v1 journal shape. const raw = record( value, FILE, @@ -158,6 +168,7 @@ export async function readConnectionOnboardingIntent( [ 'schemaVersion', 'connectionId', + 'slug', 'providerType', 'suppliedSecret', 'baseUrl', @@ -175,13 +186,15 @@ export async function readConnectionOnboardingIntent( 'invalidateLastTest', ], ); - if (raw.schemaVersion !== SCHEMA_VERSION) { + if (raw.schemaVersion !== 1 && raw.schemaVersion !== SCHEMA_VERSION) { throw codecError('invalid_document', `${FILE} has an unsupported schema version`); } - return prepareConnectionOnboardingIntent( + const prepared = prepareConnectionOnboardingIntent( { providerType: raw.providerType, connectionId: raw.connectionId, + slug: + raw.schemaVersion === 1 ? deriveLegacyIntentPlaceholderSlug(raw.providerType) : raw.slug, suppliedSecret: raw.suppliedSecret, baseUrl: raw.baseUrl, enabledModelIds: raw.enabledModelIds, @@ -190,15 +203,21 @@ export async function readConnectionOnboardingIntent( }, 'persisted', ); + return raw.schemaVersion === 1 ? { ...prepared, schemaVersion: 1, slug: null } : prepared; } export function writeConnectionOnboardingIntent( root: string, - intent: ConnectionOnboardingIntent, + intent: CurrentConnectionOnboardingIntent, ): Promise { return writeJsonDocument(root, FILE, intent, MAX_BYTES); } +function deriveLegacyIntentPlaceholderSlug(rawProviderType: unknown): string { + const providerType = decodePersistedDomain(() => decodeProviderType(rawProviderType)); + return deriveConnectionSlug(providerType); +} + export async function clearConnectionOnboardingIntent(root: string): Promise { try { await unlink(join(root, FILE)); diff --git a/packages/storage/src/runtime-policy/operations.ts b/packages/storage/src/runtime-policy/operations.ts index f947af87fa..f5f5bf7a41 100644 --- a/packages/storage/src/runtime-policy/operations.ts +++ b/packages/storage/src/runtime-policy/operations.ts @@ -21,6 +21,7 @@ import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot, ConnectionModelDiscoveryResult, + ConnectionOnboardingTarget, ConnectionTestSummary, CredentialMutationResult, CredentialLocator, @@ -218,12 +219,8 @@ export interface ConnectionOnboardingTicket { } export interface BeginConnectionOnboardingInput { - readonly providerType: ConnectionCatalogEntry['providerType']; - /** - * The existing connection to edit in place (any slug); null targets the - * canonical-slug connection, creating it when absent. - */ - readonly connectionId: string | null; + readonly target: ConnectionOnboardingTarget; + readonly baseUrl: string | null; } /** @@ -237,12 +234,16 @@ export interface BeginConnectionOnboardingInput { export type BeginConnectionOnboardingResult = // The explicitly targeted connection does not exist or changed provider type. | { readonly kind: 'target_missing' } - | { readonly kind: 'slug_conflict' } + | { readonly kind: 'provider_unsupported' } + | { readonly kind: 'catalog_full' } | { readonly kind: 'ready'; readonly ticket: ConnectionOnboardingTicket; - /** The targeted connection, or null when onboarding creates one. */ - readonly connection: ConnectionCatalogEntry | null; + readonly candidate: Pick; + /** The targeted persisted connection, or null when onboarding creates one. */ + readonly existingConnection: ConnectionCatalogEntry | null; + /** Provider-normalized endpoint override pinned into the ticket. */ + readonly baseUrl: string | null; /** The target's stored API key, for blank-key reuse during discovery. */ readonly storedSecret: string | null; /** @@ -262,15 +263,7 @@ export type BeginConnectionOnboardingResult = }; export interface CommitConnectionOnboardingInput { - readonly providerType: ConnectionCatalogEntry['providerType']; - /** - * The existing connection to edit in place (any slug); null targets the - * canonical-slug connection, creating it when absent. - */ - readonly connectionId: string | null; readonly suppliedSecret: string | null; - /** Endpoint override; null keeps the existing entry's persisted URL or the registry default. */ - readonly baseUrl: string | null; readonly enabledModelIds: readonly string[]; readonly discovery: ConnectionModelDiscoveryResult; } @@ -280,8 +273,12 @@ export type CommitConnectionOnboardingResult = readonly kind: 'committed'; readonly snapshot: ConnectionCatalogSnapshot; readonly changed: boolean; + readonly connection: Pick< + ConnectionCatalogEntry, + 'connectionId' | 'slug' | 'providerType' | 'revision' + >; } - | { readonly kind: 'slug_conflict' } + | { readonly kind: 'catalog_full' } // The explicitly targeted connection no longer exists (or changed provider // type) between the caller's snapshot and this commit. | { readonly kind: 'target_missing' } From afb4bbc2b90603040f1f474cb1d33ff478da0df6 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 21:29:47 +0800 Subject: [PATCH 2/5] feat(runtime-host): own multi-account onboarding Generated-by: OpenAI Codex --- .../connection-effect-coordinator.test.ts | 330 +++++++++++++----- .../connection-effects-protocol.test.ts | 65 +++- .../src/__tests__/execution-host.test.ts | 76 ++++ .../src/__tests__/protocol.test.ts | 6 + .../src/protocol/connection-effects.ts | 102 ++++-- packages/runtime-host/src/protocol/index.ts | 5 +- .../server/connection-effect-coordinator.ts | 64 ++-- 7 files changed, 475 insertions(+), 173 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 a9f10d071a..cd7444ddc4 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -27,6 +27,7 @@ import type { ConnectionCatalogEntryDraft, CredentialStatus, } from '@maka/core/runtime-policy'; +import { CONNECTION_CATALOG_MAX_CONNECTIONS } from '@maka/core/runtime-policy'; import { serializeOAuthSubscriptionTokens } from '@maka/runtime/subscription-credentials'; import { type ConnectionEffectFetchTransport } from '@maka/runtime/network/scoped-fetch-transport'; import { type ConnectionTestEffectOutcome } from '@maka/runtime/connection-effect-outcome'; @@ -39,6 +40,7 @@ import { HostConnectionEffectCoordinator } from '../server/connection-effect-coo import { HostOAuthExecutionAuthority } from '../server/oauth-execution-authority.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; +import type { ConnectionOnboardingSaveResult, OperationOutcome } from '../protocol/index.js'; const context: ConnectionContext = { hostEpoch: 'connection-effect-test-epoch', @@ -62,7 +64,11 @@ test('verifies a first-run API key without persisting a connection or credential }); const result = await coordinator.handlers['connection.onboarding.verify']( - { providerType: 'openai', connectionId: null, apiKey: 'first-run-secret', baseUrl: null }, + { + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'first-run-secret', + baseUrl: null, + }, context, ); @@ -76,6 +82,154 @@ test('verifies a first-run API key without persisting a connection or credential }); }); +test('rejects a semantically invalid onboarding endpoint in Storage before discovery', async () => { + await withFixture(async ({ stores }) => { + let discoveryRuns = 0; + const coordinator = new HostConnectionEffectCoordinator({ + stores, + activation: new RuntimePolicyActivationGate(), + oauthCredentials: new HostOAuthExecutionAuthority(stores), + createTransport: () => recordingTransport(() => undefined), + runModelDiscovery: async () => { + discoveryRuns += 1; + return { ok: true, models: [{ id: 'must-not-be-observed' }] }; + }, + }); + + assert.deepEqual( + await coordinator.handlers['connection.onboarding.verify']( + { + target: { kind: 'create', providerType: 'openai-compatible' }, + apiKey: 'relay-secret', + baseUrl: 'ftp://relay.example.test/v1', + }, + context, + ), + { + ok: false, + error: { + code: 'invalid_request', + message: 'Connection effect request is invalid', + }, + }, + ); + assert.equal(discoveryRuns, 0); + assert.deepEqual((await stores.connectionCatalog.getSnapshot()).connections, []); + }); +}); + +test('creates multiple accounts with Host-owned identities without changing the default', async () => { + await withFixture(async ({ stores }) => { + const coordinator = onboardingCoordinator(stores, () => undefined, 'gpt-5'); + const save = () => + coordinator.handlers['connection.onboarding.save']( + { + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'account-secret', + baseUrl: null, + enabledModelIds: ['gpt-5'], + }, + context, + ); + + const first = await save(); + const second = await save(); + assertSaved(first); + assertSaved(second); + assert.notEqual(first.result.connection.connectionId, second.result.connection.connectionId); + assert.equal(first.result.connection.slug, 'openai'); + assert.equal(second.result.connection.slug, 'openai-2'); + + const catalog = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual( + catalog.connections.map(({ connectionId, slug }) => ({ connectionId, slug })), + [ + { connectionId: first.result.connection.connectionId, slug: 'openai' }, + { connectionId: second.result.connection.connectionId, slug: 'openai-2' }, + ], + ); + assert.equal(catalog.defaultTarget?.connectionId, first.result.connection.connectionId); + }); +}); + +test('two create tickets cannot commit the same planned slug', async () => { + await withFixture(async ({ stores }) => { + const input = { + target: { kind: 'create', providerType: 'openai' } as const, + baseUrl: null, + }; + const first = await stores.operations.beginConnectionOnboarding(input); + const second = await stores.operations.beginConnectionOnboarding(input); + assert.equal(first.kind, 'ready'); + assert.equal(second.kind, 'ready'); + if (first.kind !== 'ready' || second.kind !== 'ready') return; + assert.equal(first.candidate.slug, 'openai'); + assert.equal(second.candidate.slug, 'openai'); + assert.notEqual(first.candidate.connectionId, second.candidate.connectionId); + + const completion = { + suppliedSecret: 'secret', + enabledModelIds: ['gpt-5'], + discovery: { models: [{ id: 'gpt-5' }], source: 'fetched' as const, fetchedAt: 1 }, + }; + const committed = await stores.operations.completeConnectionOnboarding( + first.ticket, + completion, + ); + assert.equal(committed.kind, 'committed'); + const superseded = await stores.operations.completeConnectionOnboarding( + second.ticket, + completion, + ); + assert.deepEqual(superseded, { kind: 'superseded', changed: ['connection'] }); + }); +}); + +test('reports catalog_full both before discovery and when the last slot fills before commit', async () => { + await withFixture(async ({ root, stores }) => { + const connections = Array.from( + { length: CONNECTION_CATALOG_MAX_CONNECTIONS - 1 }, + (_value, index) => ({ + connectionId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, + revision: 1, + slug: `occupied-${index + 1}`, + name: `Occupied ${index + 1}`, + providerType: 'openai' as const, + enabled: false, + enabledModelIds: [], + models: [], + }), + ); + await writeFile( + join(root, 'connection-catalog.json'), + JSON.stringify({ schemaVersion: 1, revision: 1, defaultTarget: null, connections }), + ); + + const begun = await stores.operations.beginConnectionOnboarding({ + target: { kind: 'create', providerType: 'openai' }, + baseUrl: null, + }); + assert.equal(begun.kind, 'ready'); + if (begun.kind !== 'ready') return; + await createConnection(stores, 1, connectionDraft('last-slot', 'openai')); + + const completion = await stores.operations.completeConnectionOnboarding(begun.ticket, { + suppliedSecret: 'secret', + enabledModelIds: ['gpt-5'], + discovery: { models: [{ id: 'gpt-5' }], source: 'fetched', fetchedAt: 1 }, + }); + assert.deepEqual(completion, { kind: 'catalog_full' }); + + assert.deepEqual( + await stores.operations.beginConnectionOnboarding({ + target: { kind: 'create', providerType: 'openai' }, + baseUrl: null, + }), + { kind: 'catalog_full' }, + ); + }); +}); + test('onboards a custom relay end to end: rejects a missing endpoint, discovers and persists a supplied one', async () => { await withFixture(async ({ stores }) => { let observedBaseUrl: string | undefined; @@ -96,8 +250,7 @@ test('onboards a custom relay end to end: rejects a missing endpoint, discovers assert.deepEqual( await coordinator.handlers['connection.onboarding.verify']( { - providerType: 'openai-compatible', - connectionId: null, + target: { kind: 'create', providerType: 'openai-compatible' }, apiKey: 'relay-secret', baseUrl: null, }, @@ -109,15 +262,15 @@ test('onboards a custom relay end to end: rejects a missing endpoint, discovers const saved = await coordinator.handlers['connection.onboarding.save']( { - providerType: 'openai-compatible', + target: { kind: 'create', providerType: 'openai-compatible' }, apiKey: 'relay-secret', - connectionId: null, baseUrl: 'https://relay.example.test/v1', enabledModelIds: ['relay/model'], }, context, ); - assert.deepEqual(saved, { ok: true, result: { kind: 'saved' } }); + assertSaved(saved); + assert.equal(saved.result.connection.slug, 'openai-compatible'); assert.equal(observedBaseUrl, 'https://relay.example.test/v1'); const connection = (await stores.connectionCatalog.getSnapshot()).connections.find( @@ -127,7 +280,14 @@ test('onboards a custom relay end to end: rejects a missing endpoint, discovers // Re-verifying with a blank endpoint now reuses the persisted one. assert.deepEqual( await coordinator.handlers['connection.onboarding.verify']( - { providerType: 'openai-compatible', connectionId: null, apiKey: '', baseUrl: null }, + { + target: { + kind: 'existing', + connectionId: saved.result.connection.connectionId, + }, + apiKey: '', + baseUrl: null, + }, context, ), { ok: true, result: { kind: 'verified', models: [{ id: 'relay/model' }] } }, @@ -166,8 +326,7 @@ test('re-onboarding by connection identity edits a Desktop custom-slug relay in assert.deepEqual( await coordinator.handlers['connection.onboarding.verify']( { - providerType: 'openai-compatible', - connectionId: connection.connectionId, + target: { kind: 'existing', connectionId: connection.connectionId }, apiKey: '', baseUrl: null, }, @@ -178,19 +337,18 @@ test('re-onboarding by connection identity edits a Desktop custom-slug relay in assert.equal(observedBaseUrl, 'https://relay-a.example.test/v1'); assert.equal(observedSecret, 'old-secret'); - assert.deepEqual( - await coordinator.handlers['connection.onboarding.save']( - { - providerType: 'openai-compatible', - connectionId: connection.connectionId, - apiKey: 'new-secret', - baseUrl: 'https://relay-b.example.test/v1', - enabledModelIds: ['relay/model'], - }, - context, - ), - { ok: true, result: { kind: 'saved' } }, + const edited = await coordinator.handlers['connection.onboarding.save']( + { + target: { kind: 'existing', connectionId: connection.connectionId }, + apiKey: 'new-secret', + baseUrl: 'https://relay-b.example.test/v1', + enabledModelIds: ['relay/model'], + }, + context, ); + assertSaved(edited); + assert.equal(edited.result.connection.connectionId, connection.connectionId); + assert.equal(edited.result.connection.slug, 'my-relay'); const catalog = await stores.connectionCatalog.getSnapshot(); // Edited in place: still exactly one connection, same identity, custom // slug preserved, endpoint replaced. @@ -213,8 +371,7 @@ test('re-onboarding by connection identity edits a Desktop custom-slug relay in assert.deepEqual( await coordinator.handlers['connection.onboarding.verify']( { - providerType: 'openai-compatible', - connectionId: '00000000-0000-4000-8000-00000000dead', + target: { kind: 'existing', connectionId: '00000000-0000-4000-8000-00000000dead' }, apiKey: 'x', baseUrl: null, }, @@ -261,8 +418,7 @@ test('a save whose connection changed between discovery and commit is superseded const saving = coordinator.handlers['connection.onboarding.save']( { - providerType: 'openai-compatible', - connectionId: connection.connectionId, + target: { kind: 'existing', connectionId: connection.connectionId }, apiKey: '', baseUrl: null, enabledModelIds: ['model-from-relay-a'], @@ -322,15 +478,14 @@ test('a save whose connection changed between discovery and commit is superseded // commits cleanly. const retried = await coordinator.handlers['connection.onboarding.save']( { - providerType: 'openai-compatible', - connectionId: connection.connectionId, + target: { kind: 'existing', connectionId: connection.connectionId }, apiKey: '', baseUrl: null, enabledModelIds: ['model-from-relay-a'], }, context, ); - assert.deepEqual(retried, { ok: true, result: { kind: 'saved' } }); + assertSaved(retried); }); }); @@ -403,8 +558,7 @@ test('onboarding probes with the custom request headers the models path sends, a const verified = await coordinator.handlers['connection.onboarding.verify']( { - providerType: 'openai-compatible', - connectionId: connection.connectionId, + target: { kind: 'existing', connectionId: connection.connectionId }, apiKey: '', baseUrl: null, }, @@ -419,8 +573,7 @@ test('onboarding probes with the custom request headers the models path sends, a // connection would fetch, and that changed under the probe. const saving = coordinator.handlers['connection.onboarding.save']( { - providerType: 'openai-compatible', - connectionId: connection.connectionId, + target: { kind: 'existing', connectionId: connection.connectionId }, apiKey: '', baseUrl: null, enabledModelIds: ['relay/model'], @@ -473,16 +626,15 @@ test('saves a verified first-run target through the canonical Host authorities', const result = await coordinator.handlers['connection.onboarding.save']( { - providerType: 'openai', + target: { kind: 'create', providerType: 'openai' }, apiKey: 'first-run-secret', - connectionId: null, baseUrl: null, enabledModelIds: ['second-model'], }, context, ); - assert.deepEqual(result, { ok: true, result: { kind: 'saved' } }); + assertSaved(result); const catalog = await stores.connectionCatalog.getSnapshot(); assert.equal(catalog.connections.length, 1); assert.deepEqual(catalog.connections[0]?.models, [ @@ -535,16 +687,16 @@ test('re-enables an existing connection without replacing another default target const result = await coordinator.handlers['connection.onboarding.save']( { - providerType: 'openai', + target: { kind: 'existing', connectionId: disabledConnection.connectionId }, apiKey: null, - connectionId: null, baseUrl: null, enabledModelIds: ['restored-model'], }, context, ); - assert.deepEqual(result, { ok: true, result: { kind: 'saved' } }); + assertSaved(result); + assert.equal(result.result.connection.connectionId, disabledConnection.connectionId); const catalog = await stores.connectionCatalog.getSnapshot(); const restored = catalog.connections.find( ({ connectionId }) => connectionId === disabledConnection.connectionId, @@ -574,9 +726,8 @@ test('leaves canonical onboarding state unchanged when the durable intent cannot assert.deepEqual( await coordinator.handlers['connection.onboarding.save']( { - providerType: 'openai', + target: { kind: 'existing', connectionId: connection.connectionId }, apiKey: 'new-secret', - connectionId: null, baseUrl: null, enabledModelIds: ['new-model'], }, @@ -624,9 +775,8 @@ test('recovers a durable onboarding intent instead of rolling back a partial pub assert.deepEqual( await coordinator.handlers['connection.onboarding.save']( { - providerType: 'openai', + target: { kind: 'existing', connectionId: connection.connectionId }, apiKey: 'new-secret', - connectionId: null, baseUrl: null, enabledModelIds: ['new-model'], }, @@ -669,19 +819,16 @@ test('invalidates a verified result when onboarding rotates only the credential' await recordVerifiedConnection(stores, connection); const coordinator = onboardingCoordinator(stores, () => undefined, 'gpt-5'); - assert.deepEqual( - await coordinator.handlers['connection.onboarding.save']( - { - providerType: 'openai', - apiKey: 'new-secret', - connectionId: null, - baseUrl: null, - enabledModelIds: ['gpt-5'], - }, - context, - ), - { ok: true, result: { kind: 'saved' } }, + const saved = await coordinator.handlers['connection.onboarding.save']( + { + target: { kind: 'existing', connectionId: connection.connectionId }, + apiKey: 'new-secret', + baseUrl: null, + enabledModelIds: ['gpt-5'], + }, + context, ); + assertSaved(saved); const updated = (await stores.connectionCatalog.getSnapshot()).connections.find( ({ connectionId }) => connectionId === connection.connectionId, @@ -714,19 +861,16 @@ test('onboarding keeps what its wizard never offered and prunes what it did', as await setConnectionCredential(stores, connection, 'old-secret'); const coordinator = onboardingCoordinator(stores, () => undefined, 'kept-model'); - assert.deepEqual( - await coordinator.handlers['connection.onboarding.save']( - { - providerType: 'openai-compatible', - apiKey: 'new-secret', - connectionId: null, - baseUrl: null, - enabledModelIds: ['kept-model'], - }, - context, - ), - { ok: true, result: { kind: 'saved' } }, + const firstSave = await coordinator.handlers['connection.onboarding.save']( + { + target: { kind: 'existing', connectionId: connection.connectionId }, + apiKey: 'new-secret', + baseUrl: null, + enabledModelIds: ['kept-model'], + }, + context, ); + assertSaved(firstSave); const updated = (await stores.connectionCatalog.getSnapshot()).connections.find( ({ connectionId }) => connectionId === connection.connectionId, @@ -747,19 +891,16 @@ test('onboarding keeps what its wizard never offered and prunes what it did', as // Declarations are endpoint-keyed, like the update path enforces: a // re-onboarding that swaps the relay URL must not carry the old relay's // profile table onto the new one. - assert.deepEqual( - await coordinator.handlers['connection.onboarding.save']( - { - providerType: 'openai-compatible', - apiKey: '', - connectionId: null, - baseUrl: 'https://relay-b.example.test/v1', - enabledModelIds: ['kept-model'], - }, - context, - ), - { ok: true, result: { kind: 'saved' } }, + const secondSave = await coordinator.handlers['connection.onboarding.save']( + { + target: { kind: 'existing', connectionId: connection.connectionId }, + apiKey: '', + baseUrl: 'https://relay-b.example.test/v1', + enabledModelIds: ['kept-model'], + }, + context, ); + assertSaved(secondSave); const swapped = (await stores.connectionCatalog.getSnapshot()).connections.find( ({ connectionId }) => connectionId === connection.connectionId, ); @@ -795,19 +936,16 @@ test('onboarding drops a declaration for a model the wizard offered and the user }), }); - assert.deepEqual( - await coordinator.handlers['connection.onboarding.save']( - { - providerType: 'openai-compatible', - apiKey: 'new-secret', - connectionId: null, - baseUrl: null, - enabledModelIds: ['kept-model'], - }, - context, - ), - { ok: true, result: { kind: 'saved' } }, + const saved = await coordinator.handlers['connection.onboarding.save']( + { + target: { kind: 'existing', connectionId: connection.connectionId }, + apiKey: 'new-secret', + baseUrl: null, + enabledModelIds: ['kept-model'], + }, + context, ); + assertSaved(saved); const updated = (await stores.connectionCatalog.getSnapshot()).connections.find( ({ connectionId }) => connectionId === connection.connectionId, @@ -844,9 +982,8 @@ test('rejects an oversized final catalog before publishing a recovery intent', a assert.deepEqual( await coordinator.handlers['connection.onboarding.save']( { - providerType: 'openai', + target: { kind: 'create', providerType: 'openai' }, apiKey: 'capacity-secret', - connectionId: null, baseUrl: null, enabledModelIds: [discovered[0]!.id], }, @@ -1411,3 +1548,12 @@ function assertRedacted(value: unknown, forbidden: readonly string[]): void { const serialized = JSON.stringify(value); for (const text of forbidden) assert.equal(serialized.includes(text), false); } + +function assertSaved(value: OperationOutcome<'connection.onboarding.save'>): asserts value is { + readonly ok: true; + readonly result: Extract; +} { + assert.equal(value.ok, true); + if (!value.ok) return; + assert.equal(value.result.kind, 'saved'); +} 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 ef3bd06012..abb27c2c62 100644 --- a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts @@ -30,14 +30,15 @@ const EXPECTED = { describe('Runtime Host connection effects protocol', () => { test('bounds transient onboarding secrets, models, and save selections', () => { const verify = request('connection.onboarding.verify', { - providerType: 'openrouter', - connectionId: null, + target: { kind: 'create', providerType: 'openrouter' }, apiKey: 'transient-secret', baseUrl: null, }); const save = request('connection.onboarding.save', { - providerType: 'openai-compatible', - connectionId: '00000000-0000-4000-8000-000000000002', + target: { + kind: 'existing', + connectionId: '00000000-0000-4000-8000-000000000002', + }, apiKey: 'transient-secret', baseUrl: 'https://relay.example.test/v1', enabledModelIds: ['relay/model'], @@ -57,8 +58,26 @@ describe('Runtime Host connection effects protocol', () => { }), ); assert.deepEqual( - decodeHostFrame(response('connection.onboarding.save', { kind: 'saved' })), - response('connection.onboarding.save', { kind: 'saved' }), + decodeHostFrame( + response('connection.onboarding.save', { + kind: 'saved', + connection: { + connectionId: '00000000-0000-4000-8000-000000000002', + revision: 2, + slug: 'relay-2', + providerType: 'openai-compatible', + }, + }), + ), + response('connection.onboarding.save', { + kind: 'saved', + connection: { + connectionId: '00000000-0000-4000-8000-000000000002', + revision: 2, + slug: 'relay-2', + providerType: 'openai-compatible', + }, + }), ); // A save whose discovery basis was concurrently changed is superseded. assert.deepEqual( @@ -68,29 +87,34 @@ describe('Runtime Host connection effects protocol', () => { response('connection.onboarding.save', { kind: 'rejected', reason: 'superseded' }), ); assertInvalidRequest('connection.onboarding.save', { - providerType: 'openrouter', - connectionId: null, + target: { kind: 'create', providerType: 'openrouter' }, apiKey: null, baseUrl: null, enabledModelIds: [], }); - // The endpoint override goes through the shared catalog normalizer, so a - // non-http(s) or credentialed URL never reaches discovery. + // Provider-specific URL semantics are resolved after an existing target's + // canonical provider is loaded; the wire still bounds the raw value. assertInvalidRequest('connection.onboarding.verify', { - providerType: 'openai-compatible', - connectionId: null, + target: { kind: 'create', providerType: 'openai-compatible' }, apiKey: 'transient-secret', - baseUrl: 'ftp://relay.example.test/v1', + baseUrl: 'x'.repeat(2_049), }); assertInvalidRequest('connection.onboarding.verify', { providerType: 'openai-compatible', connectionId: null, apiKey: 'transient-secret', - baseUrl: 'https://user:pass@relay.example.test/v1', + baseUrl: null, }); assertInvalidRequest('connection.onboarding.verify', { - providerType: 'openai-compatible', - connectionId: 42, + target: { + kind: 'existing', + connectionId: 42, + }, + apiKey: 'transient-secret', + baseUrl: null, + }); + assertInvalidRequest('connection.onboarding.verify', { + target: { kind: 'create', providerType: 'openai-compatible', slug: 'surface-owned' }, apiKey: 'transient-secret', baseUrl: null, }); @@ -103,6 +127,15 @@ describe('Runtime Host connection effects protocol', () => { errorClass: 'auth', secret: 'forbidden', }); + assertInvalidResponse('connection.onboarding.save', { + kind: 'saved', + connection: { + connectionId: '00000000-0000-4000-8000-000000000002', + revision: 0, + slug: 'relay-2', + providerType: 'openai-compatible', + }, + }); }); test('requires a stable connection identity and an explicit nullable test model', () => { diff --git a/packages/runtime-host/src/__tests__/execution-host.test.ts b/packages/runtime-host/src/__tests__/execution-host.test.ts index 662f2530d2..de2c0c7ac6 100644 --- a/packages/runtime-host/src/__tests__/execution-host.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host.test.ts @@ -492,6 +492,82 @@ test('two UDS Clients share one Runtime Policy authority and CAS winner', async }); }); +test('two UDS Clients serialize same-provider account creation through one Host lane', async () => { + const provider = await startConnectionEffectProvider({ responseDelayMs: 50 }); + try { + await withExecutionRoot(async (fixture) => { + const host = await fixture.startHost(); + const desktop = await connectClient(fixture.root); + const tui = await connectClient(fixture.root); + const secrets = ['desktop-account-secret', 'tui-account-secret'] as const; + let identities: Array<{ connectionId: string; slug: string }> = []; + try { + const results = await Promise.all( + [desktop, tui].map((client, index) => + client.request('connection.onboarding.save', { + target: { kind: 'create', providerType: 'openai-compatible' }, + apiKey: secrets[index]!, + baseUrl: provider.baseUrl, + enabledModelIds: [CONNECTION_EFFECT_MODEL_IDS[0]!], + }), + ), + ); + assert.ok(results.every((result) => result.kind === 'saved')); + identities = results.map((result) => { + if (result.kind !== 'saved') throw new Error('Onboarding did not save'); + return { + connectionId: result.connection.connectionId, + slug: result.connection.slug, + }; + }); + assert.notEqual(identities[0]?.connectionId, identities[1]?.connectionId); + assert.deepEqual(identities.map(({ slug }) => slug).sort(), [ + 'openai-compatible', + 'openai-compatible-2', + ]); + } finally { + await Promise.allSettled([desktop.close(), tui.close()]); + await fixture.stopHost(host); + } + + const owner = await tryAcquireInteractiveRootOwner(fixture.capability); + assert.ok(owner); + if (!owner) return; + try { + const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const catalog = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual( + catalog.connections + .filter(({ providerType }) => providerType === 'openai-compatible') + .map(({ connectionId, slug }) => ({ connectionId, slug })) + .sort((left, right) => left.slug.localeCompare(right.slug)), + [...identities].sort((left, right) => left.slug.localeCompare(right.slug)), + ); + for (const [index, identity] of identities.entries()) { + assert.equal( + ( + await stores.operations.exportCredentialMaterial({ + scope: 'connection', + connectionId: identity.connectionId, + kind: 'api_key', + }) + )?.secret, + secrets[index], + ); + } + } finally { + await owner.close(); + } + assert.deepEqual( + provider.requests.map(({ authorization }) => authorization).sort(), + secrets.map((secret) => `Bearer ${secret}`).sort(), + ); + }); + } finally { + await provider.close(); + } +}); + test('two UDS Clients await slow connection effects against one canonical catalog', async () => { const provider = await startConnectionEffectProvider({ responseDelayMs: 2_100 }); try { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 24c14a6f89..bc348ac164 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -209,6 +209,12 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 44); }); + test('publishes a new compatibility epoch for explicit onboarding targets', () => { + // Epoch 51 peers require nullable connectionId targeting and decode a + // successful save without its committed Connection identity. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 52); + }); + test('publishes a new compatibility epoch for queued message editing', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 45); }); diff --git a/packages/runtime-host/src/protocol/connection-effects.ts b/packages/runtime-host/src/protocol/connection-effects.ts index add65435a1..69b017a2b1 100644 --- a/packages/runtime-host/src/protocol/connection-effects.ts +++ b/packages/runtime-host/src/protocol/connection-effects.ts @@ -21,12 +21,13 @@ import { CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION, decodeConnectionModelId, decodeConnectionModel, + decodeConnectionSlug, decodeProviderType, decodeConnectionTestSummary, decodeConnectionVersionBasis, - normalizeCatalogConnectionBaseUrl, RuntimePolicyDomainDecodeError, type ConnectionVersionBasis, + type ConnectionOnboardingTarget, type ModelDiscoverySource, } from '@maka/core/runtime-policy'; import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; @@ -85,15 +86,7 @@ export interface ConnectionTestRunInput { } export interface ConnectionOnboardingVerifyInput { - readonly providerType: ProviderType; - /** - * The existing connection this onboarding edits, when the client resolved - * one — connection identity stays authoritative instead of being re-derived - * from the provider type, so a relay created under a custom slug is updated - * in place rather than duplicated at the canonical slug. `null` targets the - * canonical-slug connection, creating it if absent. - */ - readonly connectionId: string | null; + readonly target: ConnectionOnboardingTarget; readonly apiKey: string | null; /** * Endpoint override for providers whose registry entry carries none (the @@ -116,12 +109,20 @@ export type ConnectionOnboardingVerifyResult = | 'connection_not_found' | 'credential_not_configured' | 'base_url_not_configured' - | 'slug_conflict'; + | 'catalog_full'; } | { readonly kind: 'failed'; readonly errorClass: ConnectionEffectFailureClass }; export type ConnectionOnboardingSaveResult = - | { readonly kind: 'saved' } + | { + readonly kind: 'saved'; + readonly connection: { + readonly connectionId: string; + readonly revision: number; + readonly slug: string; + readonly providerType: ProviderType; + }; + } | { readonly kind: 'rejected'; readonly reason: @@ -129,7 +130,7 @@ export type ConnectionOnboardingSaveResult = | 'connection_not_found' | 'credential_not_configured' | 'base_url_not_configured' - | 'slug_conflict' + | 'catalog_full' | 'model_unavailable' // The connection changed between model discovery and the commit; the // discovered inventory no longer describes it. Re-run the wizard. @@ -240,15 +241,13 @@ export const CONNECTION_EFFECT_OPERATION_SPECS = { export function decodeConnectionOnboardingSaveInput(value: unknown): ConnectionOnboardingSaveInput { const input = requireExactRecord(value, 'connection onboarding save input', [ - 'providerType', - 'connectionId', + 'target', 'apiKey', 'baseUrl', 'enabledModelIds', ]); const verified = decodeConnectionOnboardingVerifyInput({ - providerType: input.providerType, - connectionId: input.connectionId, + target: input.target, apiKey: input.apiKey, baseUrl: input.baseUrl, }); @@ -273,8 +272,29 @@ export function decodeConnectionOnboardingSaveResult( ): ConnectionOnboardingSaveResult { const result = requireRecord(value, 'connection onboarding save result'); if (result.kind === 'saved') { - requireExactRecord(result, 'saved connection onboarding result', ['kind']); - return { kind: 'saved' }; + const saved = requireExactRecord(result, 'saved connection onboarding result', [ + 'kind', + 'connection', + ]); + const connection = requireExactRecord( + saved.connection, + 'saved connection onboarding identity', + ['connectionId', 'revision', 'slug', 'providerType'], + ); + const basis = decodeDomain(() => + decodeConnectionVersionBasis({ + connectionId: connection.connectionId, + revision: connection.revision, + }), + ); + return { + kind: 'saved', + connection: { + ...basis, + slug: decodeDomain(() => decodeConnectionSlug(connection.slug)), + providerType: decodeDomain(() => decodeProviderType(connection.providerType)), + }, + }; } if (result.kind === 'failed') { const failed = requireExactRecord(result, 'failed connection onboarding save result', [ @@ -293,7 +313,7 @@ export function decodeConnectionOnboardingSaveResult( rejected.reason !== 'connection_not_found' && rejected.reason !== 'credential_not_configured' && rejected.reason !== 'base_url_not_configured' && - rejected.reason !== 'slug_conflict' && + rejected.reason !== 'catalog_full' && rejected.reason !== 'model_unavailable' && rejected.reason !== 'superseded') ) { @@ -306,32 +326,48 @@ export function decodeConnectionOnboardingVerifyInput( value: unknown, ): ConnectionOnboardingVerifyInput { const input = requireExactRecord(value, 'connection onboarding verification input', [ - 'providerType', - 'connectionId', + 'target', 'apiKey', 'baseUrl', ]); - const providerType = decodeDomain(() => decodeProviderType(input.providerType)); return { - providerType, - connectionId: - input.connectionId === null ? null : requireEntityId(input.connectionId, 'connectionId'), + target: decodeConnectionOnboardingTarget(input.target), apiKey: input.apiKey === null ? null : requireString(input.apiKey, 'connection onboarding API key', 64 * 1024), - // The shared catalog normalizer owns the URL rules (http/https, no - // credentials/query/fragment, 2048-byte cap) and collapses a value equal - // to the provider default back to null, so the wire never carries a - // redundant override. baseUrl: input.baseUrl === null ? null - : (decodeDomain(() => normalizeCatalogConnectionBaseUrl(input.baseUrl, providerType)) ?? - null), + : requireString(input.baseUrl, 'connection onboarding base URL', 2048), }; } +function decodeConnectionOnboardingTarget(value: unknown): ConnectionOnboardingTarget { + const target = requireRecord(value, 'connection onboarding target'); + if (target.kind === 'create') { + const exact = requireExactRecord(target, 'create connection onboarding target', [ + 'kind', + 'providerType', + ]); + return { + kind: 'create', + providerType: decodeDomain(() => decodeProviderType(exact.providerType)), + }; + } + if (target.kind === 'existing') { + const exact = requireExactRecord(target, 'existing connection onboarding target', [ + 'kind', + 'connectionId', + ]); + return { + kind: 'existing', + connectionId: requireEntityId(exact.connectionId, 'connectionId'), + }; + } + throw invalidProtocolFrame('Invalid connection onboarding target'); +} + export function decodeConnectionOnboardingVerifyResult( value: unknown, ): ConnectionOnboardingVerifyResult { @@ -366,7 +402,7 @@ export function decodeConnectionOnboardingVerifyResult( rejected.reason !== 'connection_not_found' && rejected.reason !== 'credential_not_configured' && rejected.reason !== 'base_url_not_configured' && - rejected.reason !== 'slug_conflict') + rejected.reason !== 'catalog_full') ) { throw invalidProtocolFrame('Invalid connection onboarding rejection'); } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index a359bcdefd..5c208bf3d8 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -94,7 +94,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 = 67 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 68 as const; +// 68: Connection onboarding replaces nullable canonical-slug targeting with +// explicit create/existing identity and returns the committed Connection. +// Older peers reject the closed target and saved-result shapes. // 67: Message lifecycle queries expose durable execution ownership and // cancellation. Older peers cannot decode or provide the closed proof list. // 66: Peer Mesh queries expose one canonical transit selection and runtime metrics. diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index 5133a7903d..6328c896f1 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -24,11 +24,7 @@ import type { ConnectionTestSummary, } from '@maka/core/runtime-policy'; import { parseRequestHeaders } from '@maka/core/runtime-policy'; -import { - PROVIDER_DEFAULTS, - deriveConnectionSlug, - providerAuthSupportsApiKey, -} from '@maka/core/llm-connections'; +import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; import { createConnectionEffectFetchTransport, type ConnectionEffectFetchTransport, @@ -187,9 +183,10 @@ export class HostConnectionEffectCoordinator { #verifyOnboarding( input: ConnectionOnboardingVerifyInput, ): Promise> { - // Same lane a models.fetch on the targeted connection would use; the - // derived slug only keys the create-at-canonical-slug flow. - const lane = input.connectionId ?? deriveConnectionSlug(input.providerType); + // Existing targets share their connection lane with models.fetch. Create + // attempts share a provider lane until Storage assigns and commits the + // next authoritative identity. + const lane = onboardingLane(input); return this.#admit(lane, 'connection.onboarding.verify', async () => { const prepared = await this.#discoverOnboarding(input); return prepared.kind === 'ready' ? { kind: 'verified', models: prepared.models } : prepared; @@ -199,7 +196,7 @@ export class HostConnectionEffectCoordinator { #saveOnboarding( input: ConnectionOnboardingSaveInput, ): Promise> { - const lane = input.connectionId ?? deriveConnectionSlug(input.providerType); + const lane = onboardingLane(input); return this.#admit(lane, 'connection.onboarding.save', async () => { const prepared = await this.#discoverOnboarding(input); if (prepared.kind !== 'ready') return prepared; @@ -212,30 +209,31 @@ export class HostConnectionEffectCoordinator { } async #discoverOnboarding(input: ConnectionOnboardingVerifyInput): Promise { - if (!providerAuthSupportsApiKey(input.providerType)) { - return { kind: 'rejected', reason: 'provider_unsupported' }; - } // The begin/complete ticket pair binds this discovery to the connection // revision, credential, and proxy it observed: a concurrent policy update // between the remote probe and the commit supersedes the save instead of // pairing the new endpoint with an inventory it never produced. Verify // simply abandons its ticket (they are WeakMap-held one-shots). const begun = await this.#stores.operations.beginConnectionOnboarding({ - providerType: input.providerType, - connectionId: input.connectionId, + target: input.target, + baseUrl: input.baseUrl, }); if (begun.kind === 'target_missing') { // Identity supplied by the client names a connection that is gone or // changed provider type: reject instead of deriving a duplicate. return { kind: 'rejected', reason: 'connection_not_found' }; } - if (begun.kind === 'slug_conflict') { - return { kind: 'rejected', reason: 'slug_conflict' }; + if (begun.kind === 'provider_unsupported') { + return { kind: 'rejected', reason: 'provider_unsupported' }; } - const candidate = begun.connection ?? undefined; + if (begun.kind === 'catalog_full') { + return { kind: 'rejected', reason: 'catalog_full' }; + } + const providerType = begun.candidate.providerType; + const candidate = begun.existingConnection ?? undefined; const supplied = input.apiKey?.trim() ?? ''; const secret = supplied || begun.storedSecret || ''; - if (PROVIDER_DEFAULTS[input.providerType].authKind === 'api_key' && secret.length === 0) { + if (PROVIDER_DEFAULTS[providerType].authKind === 'api_key' && secret.length === 0) { return { kind: 'rejected', reason: 'credential_not_configured' }; } // Mirrors the blank-key contract above: a null baseUrl reuses the @@ -243,9 +241,9 @@ export class HostConnectionEffectCoordinator { // A relay provider with no endpoint from any of those sources cannot // run discovery — reject up front instead of probing an empty URL. const base = candidate - ? { ...candidate, ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}) } - : transientConnection(input.providerType, input.baseUrl); - if (!base.baseUrl && !PROVIDER_DEFAULTS[input.providerType].baseUrl) { + ? { ...candidate, ...(begun.baseUrl ? { baseUrl: begun.baseUrl } : {}) } + : transientConnection(begun.candidate, begun.baseUrl); + if (!base.baseUrl && !PROVIDER_DEFAULTS[providerType].baseUrl) { return { kind: 'rejected', reason: 'base_url_not_configured' }; } // The ticket's basis certifies this exact proxy, so discovery must use @@ -292,10 +290,7 @@ export class HostConnectionEffectCoordinator { const committed = await this.#stores.operations.completeConnectionOnboarding( prepared.ticket, { - providerType: input.providerType, - connectionId: input.connectionId, suppliedSecret: prepared.suppliedSecret || null, - baseUrl: input.baseUrl, enabledModelIds: input.enabledModelIds, discovery: { models: prepared.models, @@ -304,8 +299,8 @@ export class HostConnectionEffectCoordinator { }, }, ); - if (committed.kind === 'slug_conflict') { - return { kind: 'rejected', reason: 'slug_conflict' }; + if (committed.kind === 'catalog_full') { + return { kind: 'rejected', reason: 'catalog_full' }; } if (committed.kind === 'target_missing') { return { kind: 'rejected', reason: 'connection_not_found' }; @@ -314,7 +309,7 @@ export class HostConnectionEffectCoordinator { return { kind: 'rejected', reason: 'superseded' }; } if (committed.changed) this.#onCommittedMutation(); - return { kind: 'saved' }; + return { kind: 'saved', connection: committed.connection }; } catch (error) { if (error instanceof RuntimePolicyStoreError && error.code === 'commit_outcome_unknown') { this.#onCommittedMutation(); @@ -484,7 +479,7 @@ type OnboardingDiscovery = | 'connection_not_found' | 'credential_not_configured' | 'base_url_not_configured' - | 'slug_conflict'; + | 'catalog_full'; } | { readonly kind: 'failed'; readonly errorClass: ConnectionEffectFailureClass }; @@ -598,15 +593,16 @@ function operationFailure< } function transientConnection( - providerType: ConnectionOnboardingVerifyInput['providerType'], + identity: Pick, baseUrl: string | null = null, ): ConnectionCatalogEntry { + const { providerType } = identity; const definition = PROVIDER_DEFAULTS[providerType]; const models = definition.fallbackModels.map((id) => ({ id })); return { - connectionId: '00000000-0000-4000-8000-000000000000', + connectionId: identity.connectionId, revision: 0, - slug: deriveConnectionSlug(providerType), + slug: identity.slug, name: definition.label, providerType, ...((baseUrl ?? definition.baseUrl) ? { baseUrl: baseUrl ?? definition.baseUrl } : {}), @@ -617,3 +613,9 @@ function transientConnection( modelsFetchedAt: 0, }; } + +function onboardingLane(input: ConnectionOnboardingVerifyInput): string { + return input.target.kind === 'existing' + ? input.target.connectionId + : `onboarding:create:${input.target.providerType}`; +} From 620ee94f1fb1741790d05048b7c4e9b4aa21259c Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 21:32:20 +0800 Subject: [PATCH 3/5] feat(cli): expose multi-account onboarding Generated-by: OpenAI Codex --- .../cli/src/__tests__/pi-tui-runner.test.ts | 160 ++++++++++++++++-- .../__tests__/runtime-host-onboarding.test.ts | 40 +++-- packages/cli/src/pi-tui-contracts.ts | 27 +-- packages/cli/src/pi-tui-pickers.ts | 82 +++++++-- packages/cli/src/pi-tui-runner.ts | 81 ++++----- packages/cli/src/runtime-host-onboarding.ts | 54 +++--- 6 files changed, 308 insertions(+), 136 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index c75a66cb43..5377cd16f2 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -33,6 +33,7 @@ import { type SessionEvent, type ShellRunUpdate } from '@maka/core/events'; import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import { type SessionSummary, type StoredMessage } from '@maka/core/session'; import { type ThinkingLevel } from '@maka/core/model-thinking'; +import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; import { type UserQuestionResponse } from '@maka/core/user-question'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { @@ -60,15 +61,18 @@ import type { import { skillInvocationBlockedMessage } from '../session-driver.js'; import { SafeBoundaryResumeParkedError } from '../runtime-host-session-driver.js'; import { listApiKeyOnboardableProviders } from '../onboarding-catalog.js'; +import { projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js'; +import { modelChoiceConnectionLabels } from '../pi-tui-pickers.js'; import type { MakaOnboardingSurface, MakaPiTuiTurnActivitySurface, ModelChoice, OnboardingProviderEntry, + OnboardingSaveInput, OnboardingSaveResult, + OnboardingVerifyInput, OnboardingVerifyResult, } from '../pi-tui-contracts.js'; -import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; import { resolveTaskbarProgress, runMakaPiTui as runMakaPiTuiImpl, @@ -181,25 +185,16 @@ function historicalGraphSnapshot(graphId: string): AgentGraphClientSnapshot { function defaultOnboardingProviders(): OnboardingProviderEntry[] { return listApiKeyOnboardableProviders().map((provider) => ({ ...provider, - hasConnection: false, + target: { kind: 'create', providerType: provider.providerType }, + label: `${provider.label} · 添加账号`, enabledModelIds: [], })); } interface FakeOnboardingOpts { providers?: OnboardingProviderEntry[]; - verify?: (input: { - providerType: ProviderType; - apiKey?: string; - baseUrl?: string; - }) => Promise; - save?: (input: { - providerType: ProviderType; - apiKey?: string; - baseUrl?: string; - enabledModelIds: readonly string[]; - models: readonly ModelInfo[]; - }) => Promise; + verify?: (input: OnboardingVerifyInput) => Promise; + save?: (input: OnboardingSaveInput) => Promise; } /** A controllable `/setup` surface: the wizard calls `listProviders` to open, @@ -3495,7 +3490,7 @@ describe('Maka Pi TUI runner', () => { assert.deepEqual(driver.modelConnections, ['zai']); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes( - 'Model changed: gpt-5.5 (OpenAI) → glm-5.2 (Z.ai)', + 'Model changed: gpt-5.5 (OpenAI · openai) → glm-5.2 (Z.ai · zai)', ), ); // The status line now reflects both the new model and the new connection. @@ -3567,7 +3562,6 @@ describe('Maka Pi TUI runner', () => { plainTerminalOutput(terminal.screenOutput()), /切换模型可能需要重建提示缓存/, ); - // Each query isolates exactly one of the five match criteria named by #1098 // (model id, connection name, connection slug, provider type, provider // label) and keeps only its matching choice. The fixture's three distinct @@ -3650,7 +3644,6 @@ describe('Maka Pi TUI runner', () => { plainTerminalOutput(terminal.screenOutput()), /切换模型可能需要重建提示缓存/, ); - terminal.input('\x1b[B'); terminal.input('\r'); await waitFor(() => driver.models.length === 1); @@ -3669,6 +3662,7 @@ describe('Maka Pi TUI runner', () => { test('names both connections when the model id stays the same', async () => { const terminal = new FakeTerminal(); + terminal.resize(120, 40); const driver = new SlashCommandDriver(); const run = runMakaPiTui({ title: 'Maka', @@ -3711,7 +3705,7 @@ describe('Maka Pi TUI runner', () => { assert.deepEqual(driver.modelConnections, ['relay']); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes( - 'Model changed: shared-model (Primary) → shared-model (Relay)', + 'Model changed: shared-model (Primary · primary) → shared-model (Relay · relay)', ), ); @@ -3724,6 +3718,136 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('disambiguates same-name onboarded accounts and selects the exact slug', async () => { + const terminal = new FakeTerminal(); + terminal.resize(180, 40); + const driver = new SlashCommandDriver(); + const catalog: ConnectionCatalogSnapshot = { + revision: 2, + defaultTarget: { connectionId: 'openai-account-1', modelId: 'shared-model' }, + connections: [ + { + connectionId: 'openai-account-1', + revision: 1, + slug: 'openai', + name: 'OpenAI', + providerType: 'openai', + enabled: true, + enabledModelIds: ['shared-model'], + models: [{ id: 'shared-model' }], + }, + { + connectionId: 'openai-account-2', + revision: 1, + slug: 'openai-2', + name: 'OpenAI', + providerType: 'openai', + enabled: true, + enabledModelIds: ['shared-model'], + models: [{ id: 'shared-model' }], + }, + ], + }; + const modelChoices = projectRuntimeHostModelChoices(catalog); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'shared-model', + connectionSlug: 'openai', + providerType: 'openai', + modelChoices, + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('/model'); + terminal.input('\r'); + await waitFor(() => { + const output = plainTerminalOutput(terminal.screenOutput()); + return output.includes('OpenAI · openai') && output.includes('OpenAI · openai-2'); + }); + terminal.input('\x1b[B'); + terminal.input('\r'); + + await waitFor(() => driver.modelConnections.length === 1); + assert.deepEqual(driver.modelConnections, ['openai-2']); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes( + 'Model changed: shared-model (OpenAI · openai) → shared-model (OpenAI · openai-2)', + ), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('disambiguates a connection name that collides with another fallback slug', () => { + assert.deepEqual( + [ + ...modelChoiceConnectionLabels([ + { + connectionSlug: 'openai', + connectionName: 'openai-2', + providerType: 'openai', + model: 'model-a', + isDefaultConnection: true, + }, + { + connectionSlug: 'openai-2', + connectionName: ' ', + providerType: 'openai', + model: 'model-b', + isDefaultConnection: false, + }, + ]), + ], + [ + ['openai-2', 'openai-2'], + ['openai', 'openai-2 · openai'], + ], + ); + }); + + test('keeps final account labels globally unique after formatting collisions', () => { + const labels = modelChoiceConnectionLabels([ + { + connectionId: 'connection-a', + connectionSlug: 'openai', + connectionName: 'OpenAI', + providerType: 'openai', + model: 'model-a', + isDefaultConnection: true, + }, + { + connectionId: 'connection-b', + connectionSlug: 'openai-2', + connectionName: 'OpenAI', + providerType: 'openai', + model: 'model-b', + isDefaultConnection: false, + }, + { + connectionId: 'connection-c', + connectionSlug: 'relay', + connectionName: 'OpenAI · openai', + providerType: 'openai', + model: 'model-c', + isDefaultConnection: false, + }, + ]); + + assert.equal(new Set(labels.values()).size, 3); + assert.equal(labels.get('openai'), 'OpenAI · openai'); + assert.equal(labels.get('relay'), 'OpenAI · openai · relay'); + }); + test('ignores a delayed title refresh after switching sessions', async () => { const terminal = new FakeTerminal(); const driver = new DeferredListSessionsDriver([ diff --git a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts index b1758babb3..dbac01dc0f 100644 --- a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts +++ b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts @@ -92,31 +92,39 @@ describe('projectProviders', () => { models: [{ id: 'relay/model' }], } as const; - test('a Desktop-created relay under a custom slug reads as the existing connection', () => { - // Identity must survive the projection: a sole connection of the provider - // type is "the" one to edit even off the canonical slug, or saving would - // duplicate it there (#3467 review). - const entry = projectProviders(catalog([relay])).find( + test('a Desktop-created relay and add-account action are both explicit', () => { + const entries = projectProviders(catalog([relay])).filter( ({ providerType }) => providerType === 'openai-compatible', ); - assert.equal(entry?.hasConnection, true); - assert.equal(entry?.connectionId, 'relay-custom-id'); + const entry = entries.find(({ target }) => target.kind === 'existing'); + assert.deepEqual(entry?.target, { kind: 'existing', connectionId: 'relay-custom-id' }); + assert.equal(entry && 'connectionSlug' in entry ? entry.connectionSlug : undefined, 'my-relay'); assert.deepEqual(entry?.enabledModelIds, ['relay/model']); + assert.deepEqual(entries.find(({ target }) => target.kind === 'create')?.target, { + kind: 'create', + providerType: 'openai-compatible', + }); }); - test('several non-canonical connections resolve to none — the wizard offers a fresh setup', () => { - const entry = projectProviders( + test('several non-canonical connections remain independently editable', () => { + const entries = projectProviders( catalog([relay, { ...relay, connectionId: 'relay-2-id', slug: 'my-relay-2' }]), - ).find(({ providerType }) => providerType === 'openai-compatible'); - assert.equal(entry?.hasConnection, false); - assert.equal(entry?.connectionId, undefined); + ).filter(({ providerType }) => providerType === 'openai-compatible'); + assert.deepEqual( + entries.flatMap(({ target }) => (target.kind === 'existing' ? [target.connectionId] : [])), + ['relay-custom-id', 'relay-2-id'], + ); }); - test('the canonical-slug connection wins over other connections of the type', () => { + test('a canonical connection does not hide another account', () => { const canonical = { ...relay, connectionId: 'canonical-id', slug: 'openai-compatible' }; - const entry = projectProviders(catalog([relay, canonical])).find( - ({ providerType }) => providerType === 'openai-compatible', + const entries = projectProviders(catalog([relay, canonical])).filter( + ({ providerType, target }) => + providerType === 'openai-compatible' && target.kind === 'existing', + ); + assert.deepEqual( + entries.flatMap(({ target }) => (target.kind === 'existing' ? [target.connectionId] : [])), + ['relay-custom-id', 'canonical-id'], ); - assert.equal(entry?.connectionId, 'canonical-id'); }); }); diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index 8dcb537771..608a80439c 100644 --- a/packages/cli/src/pi-tui-contracts.ts +++ b/packages/cli/src/pi-tui-contracts.ts @@ -20,6 +20,7 @@ import type { ForeignSessionDigest, ForeignSessionSummary } from '@maka/core/foreign-session'; import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; +import type { ConnectionOnboardingTarget } from '@maka/core/runtime-policy'; import type { MakaPiTuiTurnActivity } from './pi-tui-turn.js'; export interface ModelChoice { @@ -52,17 +53,21 @@ export interface OnboardableProvider { fallbackModels: readonly string[]; } -export interface OnboardingProviderEntry extends OnboardableProvider { - hasConnection: boolean; - /** The existing connection's identity, so saving edits it in place. */ - connectionId?: string; - enabledModelIds: readonly string[]; -} +export type OnboardingProviderEntry = OnboardableProvider & + ( + | { + target: Extract; + enabledModelIds: readonly string[]; + } + | { + target: Extract; + connectionSlug: string; + enabledModelIds: readonly string[]; + } + ); export interface OnboardingVerifyInput { - providerType: ProviderType; - /** The existing connection this edit targets; absent creates/updates the canonical-slug one. */ - connectionId?: string; + target: ConnectionOnboardingTarget; apiKey?: string; /** Endpoint for `requiresBaseUrl` providers; blank reuses the persisted one. */ baseUrl?: string; @@ -80,9 +85,7 @@ export type OnboardingVerifyResult = }; export interface OnboardingSaveInput { - providerType: ProviderType; - /** The existing connection this edit targets; absent creates/updates the canonical-slug one. */ - connectionId?: string; + target: ConnectionOnboardingTarget; apiKey?: string; /** Endpoint for `requiresBaseUrl` providers; blank reuses the persisted one. */ baseUrl?: string; diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 74d7ad4c9a..2ced0e9a23 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -591,16 +591,57 @@ export function modelPickerItems( * caller maps it back to the {@link ModelChoice}. The description carries the * owning connection so identical model ids on different providers are readable. */ +export function modelChoiceConnectionLabels(choices: readonly ModelChoice[]): Map { + const connectionBySlug = new Map< + string, + Pick + >(); + for (const choice of choices) { + if (!connectionBySlug.has(choice.connectionSlug)) { + connectionBySlug.set(choice.connectionSlug, choice); + } + } + const connections = [...connectionBySlug.values()] + .map((choice) => ({ + ...choice, + base: choice.connectionName.trim() + ? `${choice.connectionName.trim()} · ${choice.connectionSlug}` + : choice.connectionSlug, + })) + .sort( + (left, right) => + left.base.localeCompare(right.base) || + left.connectionSlug.localeCompare(right.connectionSlug), + ); + const used = new Set(); + const labels = new Map(); + for (const connection of connections) { + let label = connection.base; + if (used.has(label) && connection.connectionId) { + label = `${connection.base} · ${connection.connectionId}`; + } + let suffix = 2; + while (used.has(label)) { + label = `${connection.base} · ${connection.connectionId ?? connection.connectionSlug} · ${suffix}`; + suffix += 1; + } + used.add(label); + labels.set(connection.connectionSlug, label); + } + return labels; +} + function modelChoicePickerItems( choices: readonly ModelChoice[], current: { model: string; connectionId?: string; connectionSlug: string }, ): SelectItem[] { + const connectionLabels = modelChoiceConnectionLabels(choices); return choices.map((choice, index) => { const isCurrent = choice.model === current.model && choice.connectionId === current.connectionId && choice.connectionSlug === current.connectionSlug; - const tags = [choice.connectionName || choice.connectionSlug]; + const tags = [connectionLabels.get(choice.connectionSlug) ?? choice.connectionSlug]; if (isCurrent) tags.push('current'); else if (choice.isDefaultConnection) tags.push('default'); return { @@ -797,14 +838,21 @@ export function onboardingProviderPickerItems( providers: readonly OnboardingProviderEntry[], ): SelectItem[] { return providers.map((provider) => ({ - value: provider.providerType, + value: onboardingProviderKey(provider), label: provider.label, - description: provider.hasConnection - ? `${provider.providerType} · 已设置` - : provider.providerType, + description: + 'connectionSlug' in provider + ? `${provider.providerType} · ${provider.connectionSlug} · 已设置` + : `${provider.providerType} · 添加账号`, })); } +function onboardingProviderKey(provider: OnboardingProviderEntry): string { + return provider.target.kind === 'existing' + ? provider.target.connectionId + : `create:${provider.target.providerType}`; +} + const THINKING_LEVEL_LABELS: Record = { off: '关', minimal: '最小', @@ -857,7 +905,7 @@ export interface OnboardingWizardInput { /** search→key: the user picked a provider. The runner records it — and the * existing connection's identity, when the catalog resolved one — for * verify/save, so saving edits that connection in place. */ - onPickProvider: (providerType: ProviderType, existingConnectionId: string | undefined) => void; + onPickProvider: (provider: OnboardingProviderEntry) => void; /** baseUrl submit (only for `requiresBaseUrl` providers). Empty means "reuse * the existing connection's persisted endpoint"; the wizard has already * rejected an empty value for a provider with no connection. */ @@ -943,7 +991,9 @@ export class OnboardingWizard implements Component { { minPrimaryColumnWidth: 16, maxPrimaryColumnWidth: 32 }, ); list.onSelect = (item) => { - const provider = this.filtered.find((p) => p.providerType === item.value); + const provider = this.filtered.find( + (candidate) => onboardingProviderKey(candidate) === item.value, + ); if (!provider) return; this.enterKeyPhase(provider); }; @@ -968,7 +1018,7 @@ export class OnboardingWizard implements Component { this.modelHighlight = 0; this.modelScroll = 0; this.modelsSearchEditor.setText(''); - this.input.onPickProvider(provider.providerType, provider.connectionId); + this.input.onPickProvider(provider); } private submitBaseUrl(value: string): void { @@ -991,7 +1041,7 @@ export class OnboardingWizard implements Component { */ private validateBaseUrl(trimmed: string): string | null { if (!trimmed) { - return this.picked?.hasConnection ? null : '需要填写 Base URL'; + return this.picked?.target.kind === 'existing' ? null : '需要填写 Base URL'; } let parsed: URL; try { @@ -1286,9 +1336,10 @@ export class OnboardingWizard implements Component { this.keyEditor.focused = false; this.modelsSearchEditor.focused = false; const label = this.picked?.label ?? ''; - const hint = this.picked?.hasConnection - ? '留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商' - : '输入中转站的 Base URL(http/https)· Esc 返回选择服务商'; + const hint = + this.picked?.target.kind === 'existing' + ? '留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商' + : '输入中转站的 Base URL(http/https)· Esc 返回选择服务商'; return [ padLine(`Set Up Provider ${ansi.dim(`· ${this.step(2)}`)} ${ansi.accent(label)}`, width), padLine(ansi.dim(hint), width), @@ -1331,9 +1382,10 @@ export class OnboardingWizard implements Component { this.modelsSearchEditor.focused = false; const label = this.picked?.label ?? ''; const backTarget = this.picked?.requiresBaseUrl ? 'Esc 返回 Base URL' : 'Esc 返回选择服务商'; - const hint = this.picked?.hasConnection - ? `留空复用已保存的 key,或输入新 key 轮换 · ${backTarget}` - : `输入 API key · 仅本机存储 · ${backTarget}`; + const hint = + this.picked?.target.kind === 'existing' + ? `留空复用已保存的 key,或输入新 key 轮换 · ${backTarget}` + : `输入 API key · 仅本机存储 · ${backTarget}`; return [ padLine( `Set Up Provider ${ansi.dim(`· ${this.step(this.picked?.requiresBaseUrl ? 3 : 2)}`)} ${ansi.accent(label)}`, diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 1ec9656c44..1db5866e82 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -149,6 +149,7 @@ import { OnboardingWizard, PickerOverlay, UserQuestionOverlay, + modelChoiceConnectionLabels, modelPickerItems, permissionModePickerItems, skillPickerItems, @@ -1159,7 +1160,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // success notice beside the input field instead of the transcript entry flow. let wizardOverlay: OverlayHandle | undefined; let wizard: OnboardingWizard | undefined; - let wizardProviderType: ProviderType | undefined; // The user's supplied key from the key step ('' reuses the stored secret for an // existing connection) and the models from the last verify (cached on save). // The runner holds them so the wizard stays UI-only; the secret never crosses @@ -1169,7 +1169,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let wizardBaseUrl = ''; // The existing connection the picked provider resolved to, so saving edits // it in place (a Desktop-created relay may live under a custom slug). - let wizardConnectionId: string | undefined; + let wizardTarget: OnboardingProviderEntry['target'] | undefined; let wizardModels: readonly ModelInfo[] = []; // Authoritative ready model choices for `/model`. A startup snapshot refreshed // in place after `/setup` saves so newly configured models are immediately @@ -1551,10 +1551,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } const previousModel = transcriptLastUsedModel ?? model; const previousConnectionSlug = connectionSlug; - const previousChoice = modelChoices?.find( - (candidate) => - candidate.model === previousModel && candidate.connectionSlug === previousConnectionSlug, - ); + const connectionLabels = modelChoiceConnectionLabels(modelChoices ?? [choice]); await input.driver.setModel(choice.model, choice.connectionSlug, choice.connectionId); model = choice.model; connectionId = choice.connectionId; @@ -1570,7 +1567,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { text: previousConnectionSlug === choice.connectionSlug ? `Model changed: ${previousModel} → ${choice.model}` - : `Model changed: ${previousModel} (${previousChoice?.connectionName || previousConnectionSlug}) → ${choice.model} (${choice.connectionName || choice.connectionSlug})`, + : `Model changed: ${previousModel} (${connectionLabels.get(previousConnectionSlug) ?? previousConnectionSlug}) → ${choice.model} (${connectionLabels.get(choice.connectionSlug) ?? choice.connectionSlug})`, }); requestRender(); }; @@ -2101,10 +2098,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { wizardOverlay?.hide(); wizardOverlay = undefined; wizard = undefined; - wizardProviderType = undefined; wizardApiKey = ''; wizardBaseUrl = ''; - wizardConnectionId = undefined; + wizardTarget = undefined; wizardModels = []; }; @@ -2112,8 +2108,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // still escapes the wizard) instead of being stored as an API key; every // in-flight state stays inside the wizard overlay, never the transcript. const submitWizardKey = (apiKey: string): void => { - const providerType = wizardProviderType; - if (!providerType || !wizard) return; + const target = wizardTarget; + if (!target || !wizard) return; if (apiKey.startsWith('/')) { closeWizard(); handleSlashCommand(apiKey, 0); @@ -2129,32 +2125,30 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const attempt = ++wizardAttempt; targetWizard.setVerifying(); requestRender(); - void input.onboarding - .verify({ providerType, connectionId: wizardConnectionId, apiKey, baseUrl: wizardBaseUrl }) - .then( - (result) => { - if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - if (result.kind === 'error') { - // Probe failed: re-arm the key field in place. The host stores nothing - // during verify, so retrying with a corrected key is clean. - // A stale snapshot (the targeted connection is gone) is not a key - // problem — retyping cannot fix it, so skip that framing. - wizard.setKeyError( - result.stale ? result.text : `API key 验证失败:${result.text}。请检查后重新输入。`, - ); - requestRender(); - return; - } - wizardModels = result.models; - wizard.setModels(result.models); // advance to the models step - requestRender(); - }, - (error) => { - if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - wizard.setKeyError(`配置失败:${error instanceof Error ? error.message : String(error)}`); + void input.onboarding.verify({ target, apiKey, baseUrl: wizardBaseUrl }).then( + (result) => { + if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; + if (result.kind === 'error') { + // Probe failed: re-arm the key field in place. The host stores nothing + // during verify, so retrying with a corrected key is clean. + // A stale snapshot (the targeted connection is gone) is not a key + // problem — retyping cannot fix it, so skip that framing. + wizard.setKeyError( + result.stale ? result.text : `API key 验证失败:${result.text}。请检查后重新输入。`, + ); requestRender(); - }, - ); + return; + } + wizardModels = result.models; + wizard.setModels(result.models); // advance to the models step + requestRender(); + }, + (error) => { + if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; + wizard.setKeyError(`配置失败:${error instanceof Error ? error.message : String(error)}`); + requestRender(); + }, + ); }; // Models submit from the wizard: persist the curated enabled set, refresh the @@ -2162,8 +2156,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // success (first-run closes the TUI so the host re-resolves the new default). // Setup never appends a transcript Note and never switches the active session. const submitWizardModels = (enabledModelIds: readonly string[]): void => { - const providerType = wizardProviderType; - if (!providerType || !wizard) return; + const target = wizardTarget; + if (!target || !wizard) return; if (!input.onboarding) { wizard.setModelError('Onboarding 不可用:当前运行环境未提供配置入口。'); requestRender(); @@ -2175,8 +2169,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); void input.onboarding .save({ - providerType, - connectionId: wizardConnectionId, + target, apiKey: wizardApiKey, baseUrl: wizardBaseUrl, enabledModelIds, @@ -2233,7 +2226,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // wizard can report unavailability in-frame at submit instead of throwing. providers = listApiKeyOnboardableProviders().map((provider) => ({ ...provider, - hasConnection: false, + target: { kind: 'create' as const, providerType: provider.providerType }, + label: `${provider.label} · 添加账号`, enabledModelIds: [], })); } @@ -2249,11 +2243,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { wizardOverlay?.hide(); wizard = new OnboardingWizard(tui, { providers, - onPickProvider: (providerType, existingConnectionId) => { - wizardProviderType = providerType; + onPickProvider: (provider) => { + wizardTarget = provider.target; wizardApiKey = ''; wizardBaseUrl = ''; - wizardConnectionId = existingConnectionId; wizardModels = []; wizardAttempt += 1; // a new pick supersedes any in-flight attempt requestRender(); diff --git a/packages/cli/src/runtime-host-onboarding.ts b/packages/cli/src/runtime-host-onboarding.ts index 167dba54fc..080beec893 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -17,7 +17,6 @@ * under the License. */ -import { deriveConnectionSlug } from '@maka/core/llm-connections'; import { isRetiredProvider } from '@maka/core/provider-registry'; import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; import { @@ -40,8 +39,7 @@ export function createRuntimeHostOnboardingSurface( verify: async (input) => { try { const result = await connection.request('connection.onboarding.verify', { - providerType: input.providerType, - connectionId: input.connectionId ?? null, + target: input.target, apiKey: trimmedOrNull(input.apiKey), baseUrl: trimmedOrNull(input.baseUrl), }); @@ -60,8 +58,7 @@ export function createRuntimeHostOnboardingSurface( save: async (input) => { try { const result = await connection.request('connection.onboarding.save', { - providerType: input.providerType, - connectionId: input.connectionId ?? null, + target: input.target, apiKey: trimmedOrNull(input.apiKey), baseUrl: trimmedOrNull(input.baseUrl), enabledModelIds: [...input.enabledModelIds], @@ -112,31 +109,26 @@ export function projectRuntimeHostModelChoices(catalog: ConnectionCatalogSnapsho } export function projectProviders(catalog: ConnectionCatalogSnapshot): OnboardingProviderEntry[] { - const bySlug = new Map(catalog.connections.map((connection) => [connection.slug, connection])); - return listApiKeyOnboardableProviders().map((provider) => { - // Prefer the canonical-slug connection; failing that, a provider's sole - // connection is unambiguously "the" one to edit — a Desktop-created relay - // under a custom slug must read as configured here, or saving would - // duplicate it at the canonical slug. With several non-canonical - // connections there is no honest single answer, so the wizard offers a - // fresh canonical-slug setup. - const canonical = bySlug.get(deriveConnectionSlug(provider.providerType)); - const ofType = catalog.connections.filter( - (connection) => connection.providerType === provider.providerType, - ); - const existing = - canonical?.providerType === provider.providerType - ? canonical - : ofType.length === 1 - ? ofType[0] - : undefined; - return { + const entries: OnboardingProviderEntry[] = []; + for (const provider of listApiKeyOnboardableProviders()) { + for (const connection of catalog.connections) { + if (connection.providerType !== provider.providerType) continue; + entries.push({ + ...provider, + target: { kind: 'existing', connectionId: connection.connectionId }, + label: `${connection.name} · ${connection.slug}`, + connectionSlug: connection.slug, + enabledModelIds: [...connection.enabledModelIds], + }); + } + entries.push({ ...provider, - hasConnection: existing !== undefined, - ...(existing ? { connectionId: existing.connectionId } : {}), - enabledModelIds: existing ? [...existing.enabledModelIds] : [], - }; - }); + target: { kind: 'create', providerType: provider.providerType }, + label: `${provider.label} · 添加账号`, + enabledModelIds: [], + }); + } + return entries; } function trimmedOrNull(value: string | undefined): string | null { @@ -161,8 +153,8 @@ function onboardingFailureText(input: { return 'The connection changed while onboarding — reopen /setup and try again'; case 'provider_unsupported': return 'This provider does not support API-key onboarding'; - case 'slug_conflict': - return 'The provider connection name is already used by another provider'; + case 'catalog_full': + return 'The connection catalog is full'; case 'model_unavailable': return 'The selected model is no longer available'; default: From de839fc90f4cef33ebca9fdad1008b7dc5bac0cb Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 21:39:17 +0800 Subject: [PATCH 4/5] test(cli): pin existing account onboarding identity Generated-by: OpenAI Codex --- .../cli/src/__tests__/pi-tui-runner.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 5377cd16f2..14113af58a 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -818,6 +818,81 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('threads the selected same-provider account identity through verify and save', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + const verifyCalls: OnboardingVerifyInput[] = []; + const saveCalls: OnboardingSaveInput[] = []; + const provider = listApiKeyOnboardableProviders().find( + (candidate) => candidate.providerType === 'openai', + ); + assert.ok(provider); + const providers: OnboardingProviderEntry[] = [ + { + ...provider, + label: 'OpenAI · openai', + target: { kind: 'existing', connectionId: 'connection-openai-1' }, + connectionSlug: 'openai', + enabledModelIds: [], + }, + { + ...provider, + label: 'OpenAI · openai-2', + target: { kind: 'existing', connectionId: 'connection-openai-2' }, + connectionSlug: 'openai-2', + enabledModelIds: [], + }, + ]; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'gpt-5.5', + connectionId: 'connection-openai-1', + connectionSlug: 'openai', + providerType: 'openai', + permissionMode: 'bypass', + terminal, + onboarding: fakeOnboardingSurface({ + providers, + verify: async (input) => { + verifyCalls.push(input); + return { kind: 'ok', models: [{ id: 'gpt-5.5' }] }; + }, + save: async (input) => { + saveCalls.push(input); + return { kind: 'ok', modelChoices: [] }; + }, + }), + }); + + await waitForTuiPaint(terminal); + terminal.input('/setup'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('openai-2')); + terminal.input('\x1b[B'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('API key')); + terminal.input('\r'); // reuse the selected account's stored key + await waitFor(() => verifyCalls.length === 1); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); + terminal.input(' '); + terminal.input('\r'); + await waitFor(() => saveCalls.length === 1); + + const expectedTarget = { kind: 'existing' as const, connectionId: 'connection-openai-2' }; + assert.deepEqual(verifyCalls[0]?.target, expectedTarget); + assert.deepEqual(saveCalls[0]?.target, expectedTarget); + + process.emit('SIGTERM'); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close after SIGTERM'); + }), + ]); + }); + test('an armed key prompt routes a slash command instead of swallowing it as the key', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); From 9dc56691e01e72c445720b2d6da8bb255ed70d61 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 23:28:19 +0800 Subject: [PATCH 5/5] fix(cli): preserve committed onboarding saves Generated-by: OpenAI Codex --- .../cli/src/__tests__/pi-tui-runner.test.ts | 182 +++++++++++++++--- .../__tests__/runtime-host-onboarding.test.ts | 45 ++++- packages/cli/src/pi-tui-contracts.ts | 13 +- packages/cli/src/pi-tui-pickers.ts | 5 +- packages/cli/src/pi-tui-runner.ts | 31 ++- packages/cli/src/runtime-host-onboarding.ts | 30 ++- 6 files changed, 267 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 14113af58a..f6db8e7598 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -207,7 +207,39 @@ function fakeOnboardingSurface(opts: FakeOnboardingOpts = {}): MakaOnboardingSur verify: opts.verify ?? (async () => ({ kind: 'ok', models: [{ id: 'gpt-5.5' }, { id: 'gpt-5.5-mini' }] })), - save: opts.save ?? (async () => ({ kind: 'ok', modelChoices: [] })), + save: opts.save ?? (async () => savedOnboardingResult()), + }; +} + +function savedOnboardingResult( + modelChoices: ModelChoice[] = [], + connectionId = 'saved-connection-id', +): OnboardingSaveResult { + return { + kind: 'ok', + connection: { + connectionId, + revision: 1, + slug: 'openai', + providerType: 'openai', + }, + refresh: { kind: 'ok', modelChoices }, + }; +} + +function savedOnboardingRefreshFailed(connectionId = 'saved-connection-id'): OnboardingSaveResult { + return { + kind: 'ok', + connection: { + connectionId, + revision: 1, + slug: 'openai', + providerType: 'openai', + }, + refresh: { + kind: 'failed', + warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + }, }; } @@ -861,7 +893,7 @@ describe('Maka Pi TUI runner', () => { }, save: async (input) => { saveCalls.push(input); - return { kind: 'ok', modelChoices: [] }; + return savedOnboardingResult([], 'connection-openai-2'); }, }), }); @@ -1023,7 +1055,7 @@ describe('Maka Pi TUI runner', () => { throw new Error('storage read failed'); }, verify: async () => ({ kind: 'ok', models: [] }), - save: async () => ({ kind: 'ok', modelChoices: [] }), + save: async () => savedOnboardingResult(), }, }); @@ -1141,10 +1173,10 @@ describe('Maka Pi TUI runner', () => { ]); }); - test('wizard ignores a save result from an abandoned attempt', async () => { + test('an abandoned create save keeps its committed identity for retry', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); - const saveCalls: Array<{ enabledModelIds: readonly string[] }> = []; + const saveCalls: OnboardingSaveInput[] = []; let resolveFirstSave!: (value: OnboardingSaveResult) => void; const run = runMakaPiTui({ title: 'Maka', @@ -1161,7 +1193,7 @@ describe('Maka Pi TUI runner', () => { ? new Promise((r) => { resolveFirstSave = r; }) - : Promise.resolve({ kind: 'ok', modelChoices: [] }); + : Promise.resolve(savedOnboardingResult([], 'committed-openai-id')); }, }), }); @@ -1206,8 +1238,10 @@ describe('Maka Pi TUI runner', () => { return false; } }); - // A's save now resolves ok. It must not show success or refresh choices. - resolveFirstSave({ kind: 'ok', modelChoices: [] }); + // A's durable create succeeds but its follow-up catalog refresh fails. The + // abandoned attempt must not show stale success, while its exact committed + // identity must still replace the create target for a later submit. + resolveFirstSave(savedOnboardingRefreshFailed('committed-openai-id')); // Sentinel render: typing into the key field forces a repaint that lands // after A's settled save continuation, so a wrongly-shown success frame // would be in this exact frame. @@ -1215,6 +1249,77 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('sk-z')); assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /已启用/); + terminal.input('\r'); // verify the now-existing Connection + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); + terminal.input('\r'); // preserve the prior selection and save again + await waitFor(() => saveCalls.length === 2); + assert.deepEqual(saveCalls[1]?.target, { + kind: 'existing', + connectionId: 'committed-openai-id', + }); + + process.emit('SIGTERM'); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close after SIGTERM'); + }), + ]); + }); + + test('a late save cannot rebind a reselected create row', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + const saveCalls: OnboardingSaveInput[] = []; + let resolveFirstSave!: (value: OnboardingSaveResult) => void; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'bypass', + terminal, + onboarding: fakeOnboardingSurface({ + save: (input) => { + saveCalls.push(input); + return saveCalls.length === 1 + ? new Promise((resolve) => { + resolveFirstSave = resolve; + }) + : Promise.resolve(savedOnboardingResult([], 'second-account-id')); + }, + }), + }); + + await waitForTuiPaint(terminal); + terminal.input('/setup'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Set Up Provider')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('API key')); + terminal.input('sk-a'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); + terminal.input(' '); + terminal.input('\r'); + await waitFor(() => saveCalls.length === 1); + + terminal.input('\x1b'); // models -> key + terminal.input('\x1b'); // key -> provider search + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('1/3')); + terminal.input('\r'); // reselect the same add-account row as a new intent + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('API key')); + resolveFirstSave(savedOnboardingRefreshFailed('first-account-id')); + terminal.input('sk-b'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); + terminal.input(' '); + terminal.input('\r'); + await waitFor(() => saveCalls.length === 2); + assert.deepEqual(saveCalls[1]?.target, saveCalls[0]?.target); + assert.equal(saveCalls[1]?.target.kind, 'create'); + process.emit('SIGTERM'); await Promise.race([ run, @@ -1244,7 +1349,7 @@ describe('Maka Pi TUI runner', () => { }, save: async (input) => { saveCalls.push(input); - return { kind: 'ok', modelChoices: [] }; + return savedOnboardingRefreshFailed('relay-connection-id'); }, }), }); @@ -1289,6 +1394,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => saveCalls.length === 1); assert.equal(saveCalls[0]?.baseUrl, 'https://relay.example.test/v1'); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('已启用')); + assert.match(plainTerminalOutput(terminal.screenOutput()), /账号已保存,但模型列表暂未刷新/); process.emit('SIGTERM'); await Promise.race([ @@ -1299,7 +1405,7 @@ describe('Maka Pi TUI runner', () => { ]); }); - test('save refreshes the running model choices even when the user backs out during saving', async () => { + test('a late abandoned save cannot replace the running model choices', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); let resolveFirstSave!: (value: OnboardingSaveResult) => void; @@ -1359,12 +1465,10 @@ describe('Maka Pi TUI runner', () => { // The wizard frame leaving the screen proves the overlay released input // focus, so the next line routes to the editor. await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('2/3')); - // The save completes after the user left. The running TUI's ready model - // choices are still authoritatively refreshed — abandoning the wizard only - // drops the in-frame success UI, not the background state sync. - resolveFirstSave({ - kind: 'ok', - modelChoices: [ + // The save completes after the user left. Its projection may be older than + // a newer attempt, so it must not replace the running model choices. + resolveFirstSave( + savedOnboardingResult([ { connectionSlug: 'openai', connectionName: 'OpenAI', @@ -1372,15 +1476,14 @@ describe('Maka Pi TUI runner', () => { model: 'gpt-5.5-new', isDefaultConnection: true, }, - ], - }); - // The refresh (`modelChoices = result.modelChoices`) lands in the save - // promise's first continuation; one macrotask turn runs strictly after - // every queued microtask, so the choices are applied by the time it fires. + ]), + ); + // One macrotask turn lands after the save continuation. await delay(0); terminal.input('/model'); terminal.input('\r'); - await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('gpt-5.5-new')); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Select Model')); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /gpt-5\.5-new/); assert.deepEqual(driver.models, []); process.emit('SIGTERM'); @@ -1458,6 +1561,41 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('first-run closes after a durable save even when catalog refresh fails', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: '', + connectionSlug: '', + permissionMode: 'bypass', + terminal, + firstRun: true, + onboarding: fakeOnboardingSurface({ + save: async () => savedOnboardingRefreshFailed('first-run-connection-id'), + }), + }); + + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Set Up Provider')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('API key')); + terminal.input('sk-test'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); + terminal.input(' '); + terminal.input('\r'); + + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('first-run TUI did not close after the committed save'); + }), + ]); + assert.equal(terminal.stopCalls, 1); + }); + test('freezes and preserves the editor draft while a boundary request owns input', async () => { const terminal = new FakeTerminal(); let releaseBoundaryRequest!: () => void; diff --git a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts index dbac01dc0f..105b80ffb4 100644 --- a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts +++ b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts @@ -20,7 +20,12 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; -import { projectProviders, projectRuntimeHostModelChoices } from '../runtime-host-onboarding.js'; +import type { RuntimeHostConnection } from '@maka/runtime-host/client'; +import { + createRuntimeHostOnboardingSurface, + projectProviders, + projectRuntimeHostModelChoices, +} from '../runtime-host-onboarding.js'; function catalog(connections: ConnectionCatalogSnapshot['connections']): ConnectionCatalogSnapshot { return { revision: 1, defaultTarget: null, connections }; @@ -37,6 +42,44 @@ const live = { models: [{ id: 'gpt-5-mini', displayName: 'GPT-5 Mini' }], } as const; +describe('createRuntimeHostOnboardingSurface', () => { + test('keeps the committed Connection when the follow-up catalog refresh fails', async () => { + const committed = { + connectionId: 'committed-openai-id', + revision: 3, + slug: 'openai-2', + providerType: 'openai', + } as const; + const connection = { + request: async (operation: string) => { + if (operation === 'connection.onboarding.save') { + return { kind: 'saved', connection: committed }; + } + if (operation === 'connection.catalog.query') { + throw new Error('transient catalog failure'); + } + throw new Error(`Unexpected operation ${operation}`); + }, + } as unknown as RuntimeHostConnection; + + const result = await createRuntimeHostOnboardingSurface(connection).save({ + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'sk-test', + enabledModelIds: ['gpt-5-mini'], + models: [{ id: 'gpt-5-mini' }], + }); + + assert.deepEqual(result, { + kind: 'ok', + connection: committed, + refresh: { + kind: 'failed', + warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + }, + }); + }); +}); + describe('projectRuntimeHostModelChoices', () => { test('a retained retired connection contributes no /model choices', () => { // Retirement keeps the connection enabled so its credential stays visible diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index 608a80439c..7bbf0de444 100644 --- a/packages/cli/src/pi-tui-contracts.ts +++ b/packages/cli/src/pi-tui-contracts.ts @@ -93,8 +93,19 @@ export interface OnboardingSaveInput { models: readonly ModelInfo[]; } +export interface OnboardingSavedConnection { + connectionId: string; + revision: number; + slug: string; + providerType: ProviderType; +} + export type OnboardingSaveResult = - | { kind: 'ok'; modelChoices: ModelChoice[] } + | { + kind: 'ok'; + connection: OnboardingSavedConnection; + refresh: { kind: 'ok'; modelChoices: ModelChoice[] } | { kind: 'failed'; warning: string }; + } | { kind: 'error'; text: string }; export interface MakaOnboardingSurface { diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 2ced0e9a23..4e4852ff55 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -953,6 +953,7 @@ export class OnboardingWizard implements Component { private modelHighlight = 0; private modelScroll = 0; private successCount = 0; + private successWarning: string | undefined; constructor( private readonly tui: TUI, @@ -1136,9 +1137,10 @@ export class OnboardingWizard implements Component { } /** Runner hook: save succeeded — show the enabled-model count in-frame. */ - setSuccess(enabledCount: number): void { + setSuccess(enabledCount: number, warning?: string): void { this.phase = 'success'; this.successCount = enabledCount; + this.successWarning = warning; this.status = { kind: 'prompt' }; } @@ -1472,6 +1474,7 @@ export class OnboardingWizard implements Component { return [ padLine(`Set Up Provider ${ansi.dim('· 完成')} ${ansi.accent(label)}`, width), padLine(ansi.green(`✓ 已启用 ${this.successCount} 个模型`), width), + ...(this.successWarning ? [padLine(ansi.yellow(this.successWarning), width)] : []), padLine('', width), padLine(ansi.dim('Enter 关闭'), width), padLine(ansi.accent('-'.repeat(width)), width), diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 1db5866e82..45f0842fd8 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -2183,18 +2183,30 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); return; } - // Authoritatively refresh the running TUI's ready model choices so the - // newly configured models are immediately available from /model — even - // if the user abandoned the wizard mid-save. Abandonment only drops the - // in-frame success UI, not the background state sync. The active - // session is not switched. - modelChoices = result.modelChoices; + // The durable save may finish after the user backed out of the models + // step. Preserve the Host-assigned identity while this wizard still + // points at the same target, so submitting it again edits that exact + // Connection instead of allocating a duplicate account. + if (wizard === targetWizard && wizardTarget === target) { + wizardTarget = { + kind: 'existing', + connectionId: result.connection.connectionId, + }; + } if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; + // Catalog projection is attempt-scoped: a late save from an abandoned + // attempt must not overwrite choices refreshed by a newer save. + if (result.refresh.kind === 'ok') { + modelChoices = result.refresh.modelChoices; + } if (input.firstRun) { beginClose(); return; } - wizard.setSuccess(enabledModelIds.length); + wizard.setSuccess( + enabledModelIds.length, + result.refresh.kind === 'failed' ? result.refresh.warning : undefined, + ); requestRender(); }, (error) => { @@ -2244,7 +2256,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { wizard = new OnboardingWizard(tui, { providers, onPickProvider: (provider) => { - wizardTarget = provider.target; + // Each picker selection is a new logical intent, even when the user + // reselects the same catalog row. A late save may converge only the + // exact target object captured by its own submit. + wizardTarget = { ...provider.target }; wizardApiKey = ''; wizardBaseUrl = ''; wizardModels = []; diff --git a/packages/cli/src/runtime-host-onboarding.ts b/packages/cli/src/runtime-host-onboarding.ts index 080beec893..0ec2cdd6f1 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -66,12 +66,30 @@ export function createRuntimeHostOnboardingSurface( if (result.kind !== 'saved') { return { kind: 'error', text: onboardingFailureText(result) }; } - return { - kind: 'ok', - modelChoices: projectRuntimeHostModelChoices( - await readRuntimeHostConnectionCatalog(connection), - ), - }; + try { + return { + kind: 'ok', + connection: result.connection, + refresh: { + kind: 'ok', + modelChoices: projectRuntimeHostModelChoices( + await readRuntimeHostConnectionCatalog(connection), + ), + }, + }; + } catch { + // Saving and refreshing are separate outcomes. The Host has already + // committed this exact Connection, so a transient catalog read must + // never turn a successful create into a retryable create failure. + return { + kind: 'ok', + connection: result.connection, + refresh: { + kind: 'failed', + warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + }, + }; + } } catch (error) { return { kind: 'error', text: errorText(error) }; }