blob: 41c64c5cb96ba050f62706be69d5c63f00336231 [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 { join } from 'node:path';
import { NO_REAL_CONNECTION_CODE } from '@maka/core/connection-error-copy';
import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot } from '@maka/core/runtime-policy';
import type { ChatDefaultPermissionMode } from '@maka/core/settings';
import {
connectOrSpawnRuntimeHost,
connectRuntimeHostProfile,
createClientRuntimeHostProfileCatalog,
createRuntimeHostPeerClientFromEnvironment,
createRuntimeHostReconnectingConnection,
loadOrCreateRuntimeHostClientInstanceId,
LOCAL_RUNTIME_HOST_PROFILE,
readRuntimeHostConnectionCatalog,
RuntimeHostPermanentReconnectError,
runtimeHostStartupError,
type RuntimeHostConnection,
type RuntimeHostProfile,
type ResolvedRuntimeHostProfile,
type RuntimeHostProfileCatalog,
type RuntimeHostPeerClient,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
type HostRegistration,
type HostIncompatible,
} from '@maka/runtime-host/protocol';
import { readLocalHostDeploymentRecord } from '@maka/runtime-host/operator';
import { resolveMakaClientDataRoot } from '@maka/storage/workspace-root';
/**
* The mode a new Session starts in belongs to the Host: `session.create`
* falls back to `chatDefaults.permissionMode` in the Runtime Policy whenever a
* client omits the field, so that policy value is the single authority.
*
* The CLI reads it rather than assuming Auto, because its pickers and its
* status indicator name the mode a new Session will *actually* get. Assuming
* Auto against a Host configured for full access would understate the
* boundary, which is the one direction that must never happen.
*
* A failed query throws rather than resolving to `ask`. Understating the
* boundary is not the safe direction it looks like: creation omits the field
* either way, so a Host configured for Bypass would run the first prompt with
* full access while the CLI displayed Auto. If the Host's own policy cannot be
* read, the CLI has nothing true to show and should not start.
*/
export async function readHostChatDefaultPermissionMode(
connection: Pick<RuntimeHostConnection, 'request'>,
): Promise<ChatDefaultPermissionMode> {
return (await connection.request('runtime.policy.query', {})).policy.chatDefaults.permissionMode;
}
export class RuntimeHostCliConflictError extends RuntimeHostPermanentReconnectError {
readonly code = 'RUNTIME_HOST_RESTART_REQUIRED';
constructor(
readonly handshake: HostIncompatible,
readonly registration: HostRegistration,
) {
super(formatRuntimeHostCliConflict(handshake, registration));
this.name = 'RuntimeHostCliConflictError';
}
}
export interface RuntimeHostCliConnectionContext {
readonly connection: RuntimeHostConnection;
readonly catalog: ConnectionCatalogSnapshot;
readonly profile: RuntimeHostProfile;
close(): Promise<void>;
}
export interface RuntimeHostCliTarget {
readonly connection: ConnectionCatalogEntry;
readonly model: string;
}
interface RuntimeHostCliContextDeps {
readonly connectOrSpawn: typeof connectOrSpawnRuntimeHost;
readonly connectProfile: typeof connectRuntimeHostProfile;
readonly readConnectionCatalog: typeof readRuntimeHostConnectionCatalog;
readonly loadClientInstanceId: typeof loadOrCreateRuntimeHostClientInstanceId;
readonly executionCandidateEntrypoint: URL;
readonly readDeploymentRecord: typeof readLocalHostDeploymentRecord;
readonly createPeerClient: typeof createRuntimeHostPeerClientFromEnvironment;
readonly profileCatalog?: RuntimeHostProfileCatalog;
}
export async function connectRuntimeHostCli(
input: {
readonly rootPath: string;
readonly profileId?: string;
readonly clientDataRoot?: string;
readonly interactiveSsh?: boolean;
},
overrides: Partial<RuntimeHostCliContextDeps> = {},
): Promise<RuntimeHostCliConnectionContext> {
const deps: RuntimeHostCliContextDeps = {
connectOrSpawn: connectOrSpawnRuntimeHost,
connectProfile: connectRuntimeHostProfile,
readConnectionCatalog: readRuntimeHostConnectionCatalog,
loadClientInstanceId: loadOrCreateRuntimeHostClientInstanceId,
executionCandidateEntrypoint: new URL(
import.meta.resolve('@maka/runtime-host/execution-candidate-main'),
),
readDeploymentRecord: readLocalHostDeploymentRecord,
createPeerClient: createRuntimeHostPeerClientFromEnvironment,
...overrides,
};
const resolvedProfile = await resolveHostProfile(input, deps);
const profile = resolvedProfile.profile;
const clientInstanceId =
profile.kind === 'local'
? randomUUID()
: await deps.loadClientInstanceId(
join(input.clientDataRoot ?? resolveMakaClientDataRoot(), 'runtime-host-client.json'),
);
const peerClient: RuntimeHostPeerClient | undefined =
profile.kind === 'remote' && profile.transport.kind === 'libp2p-direct'
? deps.createPeerClient()
: undefined;
const connectInput = {
rootPath: input.rootPath,
protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION },
clientInstanceId,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
candidateEntrypoint: deps.executionCandidateEntrypoint,
} as const;
const connect = async (
signal?: AbortSignal,
sshInteraction: 'batch' | 'inherit' = 'batch',
): Promise<RuntimeHostConnection> => {
if (profile.kind !== 'local') {
return deps.connectProfile({
profile,
...(resolvedProfile.credential ? { credential: resolvedProfile.credential } : {}),
clientInstanceId,
sshInteraction,
...(peerClient ? { peerClient } : {}),
...(signal ? { signal } : {}),
});
}
const connected = await deps.connectOrSpawn({
...connectInput,
...(signal ? { signal } : {}),
});
if (connected.kind === 'incompatible') {
throw new RuntimeHostCliConflictError(connected.handshake, connected.registration);
}
if (connected.kind === 'upgrade_required') {
throw new RuntimeHostPermanentReconnectError(
'RUNTIME_HOST_RESTART_REQUIRED: An older Runtime Host build is still running. Restart it, or wait for its background work to finish.',
);
}
if (connected.kind === 'failed') {
throw runtimeHostStartupError(connected.reason, connected.diagnostic);
}
if (connected.registration.generation?.startsWith('npm-global-handoff:')) {
const record = await deps.readDeploymentRecord(connected.registration.rootId);
if (record?.state.kind !== 'owned' || record.state.owner.kind !== 'cli') {
await connected.connection.close().catch(() => undefined);
throw new RuntimeHostPermanentReconnectError(
'RUNTIME_HOST_RECOVERY_REQUIRED: The staged local Runtime Host is Ready, but its installation ownership was not durably committed.',
);
}
}
return connected.connection;
};
let connection: Awaited<ReturnType<typeof createRuntimeHostReconnectingConnection>> | undefined;
try {
const initialConnection = await connect(
undefined,
input.interactiveSsh && process.stdin.isTTY && process.stdout.isTTY ? 'inherit' : 'batch',
);
connection = await createRuntimeHostReconnectingConnection({
initialConnection,
connect: (signal) => connect(signal, 'batch'),
});
const liveConnection = connection;
return {
connection: liveConnection,
catalog: await deps.readConnectionCatalog(liveConnection),
profile,
close: async () => {
try {
await liveConnection.close();
} finally {
await peerClient?.close();
}
},
};
} catch (error) {
await connection?.close().catch(() => undefined);
await peerClient?.close().catch(() => undefined);
throw error;
}
}
async function resolveHostProfile(
input: { readonly profileId?: string; readonly clientDataRoot?: string },
deps: RuntimeHostCliContextDeps,
): Promise<ResolvedRuntimeHostProfile> {
if (input.profileId === undefined || input.profileId === LOCAL_RUNTIME_HOST_PROFILE.id) {
return { profile: LOCAL_RUNTIME_HOST_PROFILE };
}
const root = input.clientDataRoot ?? resolveMakaClientDataRoot();
const catalog = deps.profileCatalog ?? createClientRuntimeHostProfileCatalog(root);
return catalog.resolve(input.profileId);
}
function formatRuntimeHostCliConflict(
handshake: HostIncompatible,
registration: HostRegistration,
): string {
const lines = [
'RUNTIME_HOST_RESTART_REQUIRED: An older Runtime Host is still running and cannot accept this client.',
`Local Runtime Host: PID ${registration.pid}; lifecycle ${registration.lifecycleMode ?? 'unknown'}; compatibility epoch ${registration.compatibilityEpoch}.`,
];
if (registration.lifecycleMode === 'ephemeral') {
lines.push('The ephemeral Host is not currently idle and cannot be replaced by this Client.');
} else if (registration.lifecycleMode === 'service') {
lines.push(
'This service Host is managed by its operator and cannot be replaced by this Client.',
);
} else {
lines.push('This Host cannot be replaced by this Client.');
}
if (handshake.compatibilityEpoch < RUNTIME_HOST_COMPATIBILITY_EPOCH) {
lines.push(
registration.lifecycleMode === 'service'
? 'Use the service operator to inspect or upgrade the Host.'
: 'Use a previous compatible Maka build to inspect the Host and finish or clear any retained work. Stop the Host only after deciding that interruption is safe.',
);
} else {
lines.push(
`Host protocol ${handshake.protocolMin}-${handshake.protocolMax}; CLI protocol ${RUNTIME_HOST_PROTOCOL_VERSION}.`,
);
}
return lines.join('\n');
}
export function shouldRetryRuntimeHostConflict(answer: string): boolean {
const normalized = answer.trim().toLowerCase();
return normalized === 'w' || normalized === 'wait';
}
export type RuntimeHostCliConflictDecision = 'restart' | 'wait' | 'cancel';
export function resolveRuntimeHostCliConflictDecision(
answer: string,
canRestart: boolean,
): RuntimeHostCliConflictDecision {
const normalized = answer.trim().toLowerCase();
if (canRestart && (normalized === 'r' || normalized === 'restart')) return 'restart';
if (normalized === 'w' || normalized === 'wait') return 'wait';
return 'cancel';
}
export function resolveRuntimeHostCliTarget(
catalog: ConnectionCatalogSnapshot,
input: { readonly connectionSlug?: string; readonly model?: string } = {},
): RuntimeHostCliTarget {
const defaultTarget = catalog.defaultTarget;
const connection = input.connectionSlug
? catalog.connections.find((candidate) => candidate.slug === input.connectionSlug)
: catalog.connections.find(
(candidate) => candidate.connectionId === defaultTarget?.connectionId,
);
if (!connection || !connection.enabled) {
throw new Error(
input.connectionSlug
? `Runtime Host model connection is unavailable: ${input.connectionSlug}`
: `${NO_REAL_CONNECTION_CODE}:missing_default_connection: Runtime Host has no default model connection`,
);
}
const model =
input.model ??
(connection.connectionId === defaultTarget?.connectionId
? defaultTarget.modelId
: connection.enabledModelIds[0]);
if (!model || !connection.enabledModelIds.includes(model)) {
throw new Error(`Runtime Host model is unavailable for ${connection.slug}: ${model ?? ''}`);
}
return { connection, model };
}