blob: 246c9067d3e683090ee05ea6ce239e7f66cb45bc [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { randomUUID } from 'node:crypto';
import {
deriveConnectionSlug,
deriveInteractiveOAuthConnectionSlug,
offerableCatalogEntries,
providerFallbackModelIds,
PROVIDER_REGISTRY,
} from '@maka/core/llm-connections';
import type { RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot } from '@maka/runtime-host/client';
import {
createOAuthPresentationClientProvider,
readRuntimeHostConnectionCatalog,
RuntimeHostOperationError,
RuntimeHostRequestInterruptedError,
type RuntimeHostConnection,
} from '@maka/runtime-host/client';
import type {
OAuthLoginProjection,
OAuthLoginTarget,
OperationInput,
} from '@maka/runtime-host/protocol';
import { listApiKeyOnboardableProviders } from './onboarding-catalog.js';
import type {
ConnectionIdentity,
MakaOnboardingSurface,
ModelChoice,
OnboardingProviderEntry,
OnboardingOAuthInput,
OnboardingOAuthResult,
} from './pi-tui-contracts.js';
export interface RuntimeHostOnboardingOAuthConnection {
readonly connection: RuntimeHostConnection;
close(): Promise<void>;
}
export interface RuntimeHostOnboardingSurfaceOptions {
readonly connectOAuth?: (signal: AbortSignal) => Promise<RuntimeHostOnboardingOAuthConnection>;
readonly pollIntervalMs?: number;
readonly createAttemptId?: () => string;
readonly requestTimeoutMs?: number;
readonly cancellationTimeoutMs?: number;
readonly shutdownTimeoutMs?: number;
}
export interface RuntimeHostOnboardingSurface extends MakaOnboardingSurface {
close(): Promise<void>;
}
interface OAuthLoginAttempt {
readonly attemptId: string;
readonly target: OAuthLoginTarget;
rootId?: string;
startRequested: boolean;
task?: Promise<OnboardingOAuthResult>;
}
type OAuthLoginRequest = <
K extends 'oauth.login.start' | 'oauth.login.query' | 'oauth.login.cancel',
>(
operation: K,
input: OperationInput<K>,
deadline?: number,
) => Promise<OAuthLoginProjection>;
/** Adapt the TUI onboarding workflow to Host-owned verification and persistence. */
export function createRuntimeHostOnboardingSurface(
connection: RuntimeHostConnection,
options: RuntimeHostOnboardingSurfaceOptions = {},
): RuntimeHostOnboardingSurface {
const shutdown = new AbortController();
// An interrupted observation is not a new login intent. Keep its identity
// until a later observation establishes a terminal result on the same root.
const oauthAttempts = new Map<string, OAuthLoginAttempt>();
return {
listProviders: async () => {
const catalog = await readRuntimeHostConnectionCatalog(connection);
let codexOAuthEnabled = false;
try {
codexOAuthEnabled = (
await connection.request('oauth.enrollment.query', { provider: 'openai-codex' })
).enabled;
} catch {
// The API-key catalog remains useful when an older or temporarily
// unavailable Host cannot answer the optional OAuth enrollment query.
}
return projectProviders(catalog, codexOAuthEnabled);
},
loginOAuth: (input) => {
const target = asOAuthTarget(input.target);
if (!target) return Promise.resolve({ kind: 'failed', reason: 'unavailable' });
const key = JSON.stringify(target);
const pending = oauthAttempts.get(key);
if (input.signal.aborted || shutdown.signal.aborted) {
return Promise.resolve({ kind: pending?.startRequested ? 'unconfirmed' : 'cancelled' });
}
const attempt: OAuthLoginAttempt = pending ?? {
attemptId: (options.createAttemptId ?? randomUUID)(),
target,
startRequested: false,
};
// Re-entering the same intent observes its existing result.
if (attempt.task) return attempt.task;
oauthAttempts.set(key, attempt);
const task = runOAuthLogin(input, options, shutdown.signal, attempt);
attempt.task = task;
void task.then(
(result) => {
attempt.task = undefined;
if (result.kind !== 'unconfirmed') oauthAttempts.delete(key);
},
() => {
attempt.task = undefined;
},
);
return task;
},
verify: async (input) => {
try {
const result = await connection.request('connection.onboarding.verify', {
target: input.target,
apiKey: trimmedOrNull(input.apiKey),
baseUrl: trimmedOrNull(input.baseUrl),
});
if (result.kind === 'verified') return { kind: 'ok', models: [...result.models] };
return result;
} catch {
return { kind: 'unavailable' };
}
},
save: async (input) => {
try {
const result = await connection.request('connection.onboarding.save', {
target: input.target,
apiKey: trimmedOrNull(input.apiKey),
baseUrl: trimmedOrNull(input.baseUrl),
enabledModelIds: [...input.enabledModelIds],
});
if (result.kind !== 'saved') {
return result;
}
try {
const catalog = await readRuntimeHostConnectionCatalog(connection);
return {
kind: 'ok',
connection: result.connection,
refresh: {
kind: 'ok',
modelChoices: projectRuntimeHostModelChoices(catalog),
connectionIdentities: projectRuntimeHostConnectionIdentities(catalog),
},
};
} 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',
reason: 'catalog_unavailable',
},
};
}
} catch {
return { kind: 'unavailable' };
}
},
close: async () => {
shutdown.abort();
await Promise.allSettled([...oauthAttempts.values()].map(({ task }) => task));
oauthAttempts.clear();
},
};
}
async function runOAuthLogin(
input: OnboardingOAuthInput,
options: RuntimeHostOnboardingSurfaceOptions,
shutdownSignal: AbortSignal,
attempt: OAuthLoginAttempt,
): Promise<OnboardingOAuthResult> {
if (!options.connectOAuth) return { kind: 'failed', reason: 'unavailable' };
const signal = AbortSignal.any([input.signal, shutdownSignal]);
if (signal.aborted) return { kind: attempt.startRequested ? 'unconfirmed' : 'cancelled' };
const requestTimeoutMs = options.requestTimeoutMs ?? 5_000;
const shutdownTimeoutMs = options.shutdownTimeoutMs ?? 1_000;
const observation = new AbortController();
const pollingSignal = AbortSignal.any([signal, observation.signal]);
let connected: RuntimeHostOnboardingOAuthConnection | undefined;
let stopTimer: ReturnType<typeof setTimeout> | undefined;
let stopDeadline = Number.POSITIVE_INFINITY;
const stopAfter = (milliseconds: number): void => {
const deadline = Date.now() + milliseconds;
if (deadline >= stopDeadline) return;
stopDeadline = deadline;
clearTimeout(stopTimer);
stopTimer = setTimeout(() => observation.abort(), milliseconds);
};
const cancelObservation = () => stopAfter(options.cancellationTimeoutMs ?? requestTimeoutMs);
const closeObservation = () => stopAfter(shutdownTimeoutMs);
input.signal.addEventListener('abort', cancelObservation, { once: true });
shutdownSignal.addEventListener('abort', closeObservation, { once: true });
try {
const connecting = options.connectOAuth(AbortSignal.any([signal, observation.signal]));
void connecting.then(
(resource) => {
// A connector may finish after its observer timed out. It still needs
// an owner to release the late connection.
if (observation.signal.aborted) void resource.close().catch(() => undefined);
},
() => undefined,
);
connected = await waitForOAuthOperation(connecting, undefined, observation.signal);
const connection = connected.connection;
if (attempt.rootId !== undefined && attempt.rootId !== connection.rootId) {
return { kind: 'unconfirmed' };
}
attempt.rootId = connection.rootId;
const provider = createOAuthPresentationClientProvider({
openExternal: async (url, stateHint) => {
if (!signal.aborted && !observation.signal.aborted) {
input.onPresentation({ url, ...(stateHint === undefined ? {} : { stateHint }) });
}
},
});
const connectionKey = () => JSON.stringify([connection.hostEpoch, connection.connectionId]);
let presentationConnectionKey: string | undefined;
const request: OAuthLoginRequest = async (operation, input, deadline) => {
const remainingTimeout = (): number => {
if (observation.signal.aborted) throw new Error('OAuth observation ended');
const remaining = Math.min(deadline ?? Infinity, stopDeadline) - Date.now();
if (remaining <= 0) throw new Error('OAuth reconciliation timed out');
return Math.min(requestTimeoutMs, remaining);
};
if (operation === 'oauth.login.start') {
const current = connectionKey();
if (presentationConnectionKey !== current) {
// Capability ownership follows a physical connection. Queries need
// no presentation service; only a start must await its publication.
const timeoutMs = remainingTimeout();
try {
await waitForOAuthOperation(
connection.replaceClientCapabilities(provider, timeoutMs),
timeoutMs,
observation.signal,
);
} catch (error) {
if (
error instanceof RuntimeHostRequestInterruptedError &&
error.reason === 'connection_lost'
) {
throw interruptedOAuthStart();
}
throw error;
}
if (current !== connectionKey()) throw interruptedOAuthStart();
presentationConnectionKey = current;
}
remainingTimeout();
if (signal.aborted && !attempt.startRequested)
throw new Error('OAuth cancelled before start');
attempt.startRequested = true;
}
const timeoutMs = remainingTimeout();
return waitForOAuthOperation(
connection.request(operation, input, timeoutMs),
timeoutMs,
observation.signal,
);
};
const { attemptId, target } = attempt;
let projection = await startOAuthAttempt(
request,
attemptId,
target,
requestTimeoutMs,
attempt.startRequested,
);
let cancellationSent = false;
while (!isTerminalOAuthProjection(projection)) {
if (signal.aborted && !cancellationSent) {
cancellationSent = true;
const cancelledProjection = await cancelOAuthAttempt(request, attemptId, requestTimeoutMs);
if (!cancelledProjection) return { kind: 'cancelled' };
projection = cancelledProjection;
continue;
}
// Once cancellation has reached the Host, the aborted UI signal must no
// longer collapse this delay into a busy query loop while a commit wins.
await waitForOAuthPoll(
options.pollIntervalMs ?? 250,
cancellationSent ? observation.signal : pollingSignal,
);
if (signal.aborted && !cancellationSent) continue;
projection = await request('oauth.login.query', { attemptId });
}
if (projection.phase === 'authenticated') {
return { kind: 'authenticated', connection: projection.connection };
}
if (projection.phase === 'cancelled') return { kind: 'cancelled' };
return { kind: 'failed', reason: projection.failure ?? 'internal_failure' };
} catch (error) {
if (error instanceof RuntimeHostOperationError) {
if (
attempt.startRequested &&
(error.code === 'persistence_failed' ||
error.code === 'internal_failure' ||
(error.code === 'not_found' && error.operation === 'oauth.login.query'))
)
return { kind: 'unconfirmed' };
if (error.code === 'not_found') return { kind: 'failed', reason: 'connection_not_found' };
if (error.code === 'operation_conflict') {
return { kind: 'failed', reason: 'operation_conflict' };
}
if (error.code === 'slug_taken') return { kind: 'failed', reason: 'slug_taken' };
if (error.code === 'capability_unavailable') {
return { kind: 'failed', reason: 'capability_unavailable' };
}
if (error.code === 'persistence_failed' || error.code === 'internal_failure') {
return { kind: 'failed', reason: error.code };
}
}
if (attempt.startRequested) return { kind: 'unconfirmed' };
return signal.aborted ? { kind: 'cancelled' } : { kind: 'failed', reason: 'unavailable' };
} finally {
observation.abort();
clearTimeout(stopTimer);
input.signal.removeEventListener('abort', cancelObservation);
shutdownSignal.removeEventListener('abort', closeObservation);
if (connected) {
const resource = connected;
await waitForOAuthOperation(
Promise.resolve().then(() => resource.close()),
Math.max(1, Math.min(shutdownTimeoutMs, stopDeadline - Date.now())),
).catch(() => undefined);
}
}
}
async function startOAuthAttempt(
request: OAuthLoginRequest,
attemptId: string,
target: OAuthLoginTarget,
timeoutMs: number,
resume: boolean,
): Promise<OAuthLoginProjection> {
const deadline = Date.now() + timeoutMs;
if (resume) {
try {
return await request('oauth.login.query', { attemptId }, deadline);
} catch (error) {
if (!isOAuthAttemptNotFound(error)) throw error;
}
}
while (true) {
try {
return await request('oauth.login.start', { attemptId, target }, deadline);
} catch (error) {
if (!isOAuthRequestInterruption(error, 'oauth.login.start')) throw error;
try {
// A write acknowledged by the local transport may already be running
// on the Host. Query the stable attempt identity before retrying the
// idempotent start so a lost response cannot create a second login.
return await request('oauth.login.query', { attemptId }, deadline);
} catch (queryError) {
if (!isOAuthAttemptNotFound(queryError)) throw queryError;
// No live or durable state is visible yet. Starting again with the same
// attemptId is safe and synchronizes with an original handler still
// behind the Host start gate, even if local cancellation arrived while
// the outcome was unknown.
}
}
}
}
async function cancelOAuthAttempt(
request: OAuthLoginRequest,
attemptId: string,
timeoutMs: number,
): Promise<OAuthLoginProjection | null> {
const deadline = Date.now() + timeoutMs;
while (true) {
try {
return await request('oauth.login.cancel', { attemptId }, deadline);
} catch (error) {
if (isOAuthAttemptNotFound(error)) return null;
if (!isOAuthRequestInterruption(error, 'oauth.login.cancel')) throw error;
try {
const projection = await request('oauth.login.query', { attemptId }, deadline);
if (isTerminalOAuthProjection(projection)) return projection;
// A non-terminal query cannot prove that the interrupted cancellation
// reached the Host. Cancel again; the operation is attempt-idempotent.
} catch (queryError) {
if (isOAuthAttemptNotFound(queryError)) return null;
throw queryError;
}
}
}
}
function interruptedOAuthStart(): RuntimeHostRequestInterruptedError {
return new RuntimeHostRequestInterruptedError(
'oauth.login.start',
'command',
'not_dispatched',
'connection_lost',
);
}
/** A local observation deadline says nothing about whether the Host committed. */
function waitForOAuthOperation<T>(
task: Promise<T>,
timeoutMs: number | undefined,
signal?: AbortSignal,
): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;
let stop: () => void;
return new Promise<T>((resolve, reject) => {
stop = () => reject(new Error('OAuth result could not be confirmed'));
if (timeoutMs !== undefined) timeout = setTimeout(stop, timeoutMs);
signal?.addEventListener('abort', stop, { once: true });
void task.then(resolve, reject);
if (signal?.aborted) stop();
}).finally(() => {
clearTimeout(timeout);
signal?.removeEventListener('abort', stop);
});
}
function isOAuthRequestInterruption(
error: unknown,
operation: 'oauth.login.start' | 'oauth.login.cancel',
): error is RuntimeHostRequestInterruptedError {
return error instanceof RuntimeHostRequestInterruptedError && error.operation === operation;
}
function isOAuthAttemptNotFound(error: unknown): error is RuntimeHostOperationError {
return error instanceof RuntimeHostOperationError && error.code === 'not_found';
}
function asOAuthTarget(target: OnboardingOAuthInput['target']): OAuthLoginTarget | null {
if (target.kind === 'existing') return { kind: 'existing', connectionId: target.connectionId };
return target.providerType === 'openai-codex'
? {
kind: 'create',
providerType: target.providerType,
...(target.slug === undefined ? {} : { slug: target.slug }),
...(target.name === undefined ? {} : { name: target.name }),
}
: null;
}
function isTerminalOAuthProjection(projection: OAuthLoginProjection): boolean {
return (
projection.phase === 'authenticated' ||
projection.phase === 'cancelled' ||
projection.phase === 'failed'
);
}
function waitForOAuthPoll(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (milliseconds <= 0 || signal?.aborted) return Promise.resolve();
return new Promise((resolve) => {
const timeout = setTimeout(done, milliseconds);
signal?.addEventListener('abort', done, { once: true });
function done(): void {
clearTimeout(timeout);
signal?.removeEventListener('abort', done);
resolve();
}
});
}
export function projectRuntimeHostModelChoices(catalog: ConnectionCatalogSnapshot): ModelChoice[] {
const choices: ModelChoice[] = [];
for (const connection of catalog.connections) {
// Which models are offerable, and what is true about them, are both the
// Host's answers. A TUI older or newer than the Host must not re-derive
// either against its own registry and bundled metadata — that is how the
// same model came to be selectable here and refused elsewhere. A retained
// retired connection drops out through the same gate: its entries are not
// chat-capable, so none of them reach this list.
for (const entry of offerableCatalogEntries(connection)) {
choices.push({
connectionId: connection.connectionId,
connectionSlug: connection.slug,
connectionName: connection.name,
providerType: connection.providerType,
model: entry.id,
displayName: entry.displayName,
isDefaultConnection: catalog.defaultTarget?.connectionId === connection.connectionId,
contextWindow: entry.contextWindow,
thinkingLevels: entry.thinkingLevels,
});
}
}
return choices;
}
export function projectRuntimeHostConnectionIdentities(
catalog: ConnectionCatalogSnapshot,
): ConnectionIdentity[] {
return catalog.connections.map((connection) => ({
connectionId: connection.connectionId,
connectionSlug: connection.slug,
enabled: connection.enabled,
}));
}
export function projectProviders(
catalog: ConnectionCatalogSnapshot,
codexOAuthEnabled = false,
): OnboardingProviderEntry[] {
const entries: OnboardingProviderEntry[] = [];
const existingSlugs = catalog.connections.map((connection) => connection.slug);
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,
target: { kind: 'create', providerType: provider.providerType },
label: provider.label,
suggestedSlug: deriveConnectionSlug(provider.providerType, existingSlugs),
enabledModelIds: [],
});
if (provider.providerType === 'openai' && codexOAuthEnabled) {
const providerType = 'openai-codex' as const;
const definition = PROVIDER_REGISTRY[providerType];
for (const connection of catalog.connections) {
if (connection.providerType !== providerType) continue;
entries.push({
providerType,
label: `${connection.name} · ${connection.slug}`,
requiresBaseUrl: false,
setupMethod: 'oauth',
target: { kind: 'existing', connectionId: connection.connectionId },
connectionSlug: connection.slug,
enabledModelIds: [...connection.enabledModelIds],
});
}
entries.push({
providerType,
label: definition.label,
requiresBaseUrl: false,
setupMethod: 'oauth',
target: { kind: 'create', providerType },
suggestedSlug: deriveInteractiveOAuthConnectionSlug(providerType, existingSlugs),
enabledModelIds: [...providerFallbackModelIds(definition)],
});
}
}
return entries;
}
function trimmedOrNull(value: string | undefined): string | null {
const secret = value?.trim() ?? '';
return secret.length === 0 ? null : secret;
}