blob: 3f8bdc7a38cc52fe83b6591f8ad918fc94c8639c [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 { activateLocalManagedRuntimeHost } from './runtime-host-local-managed-activation.js';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { NO_REAL_CONNECTION_CODE } from '@maka/core/connection-error-copy';
import type {
RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry,
RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot,
} from '@maka/runtime-host/client';
import type { ChatDefaultPermissionMode } from '@maka/core/settings';
import {
connectOrSpawnRuntimeHost,
forceTerminateObservedRegisteredRuntimeHost,
connectRuntimeHost,
connectRuntimeHostProfile,
createClientRuntimeHostProfileCatalog,
createRuntimeHostPeerClientFromEnvironment,
createRuntimeHostReconnectingConnection,
loadOrCreateRuntimeHostClientInstanceId,
LOCAL_RUNTIME_HOST_PROFILE,
readRuntimeHostConnectionCatalog,
RuntimeHostPermanentReconnectError,
RuntimeHostRemoteCompatibilityError,
runHostHandoff,
type OpenHostHandoffSurface,
type HostHandoffReplacement,
type HostHandoffObservation,
runtimeHostStartupError,
type RuntimeHostConnection,
type RuntimeHostProfile,
type ResolvedRuntimeHostProfile,
type RuntimeHostProfileCatalog,
type RuntimeHostPeerClient,
} from '@maka/runtime-host/client';
import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_PROTOCOL_VERSION,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
} from '@maka/runtime-host/protocol';
import {
readLocalHostDeploymentRecord,
resolveRuntimeHostManagedDeploymentAuthority,
} from '@maka/runtime-host/operator';
import { resolveMakaClientDataRoot } from '@maka/storage/workspace-root';
import {
isTemporaryNpxInstallation,
resolveRuntimeHostNpmGlobalInstallation,
} from './runtime-host-cli-installation.js';
import {
restartRuntimeHostNpmGlobalDeployment,
runtimeHostNpmGlobalSourceRetirementAvailable,
} from './runtime-host-local-handoff.js';
/**
* 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 interface RuntimeHostCliConnectionOnlyContext {
readonly connection: RuntimeHostConnection;
readonly profile: RuntimeHostProfile;
close(): Promise<void>;
}
export interface RuntimeHostCliConnectionOnlyContextWithIdentity
extends RuntimeHostCliConnectionOnlyContext {
readonly clientInstanceId: string;
readonly profileIncarnationId?: string;
}
export interface RuntimeHostCliConnectionContext extends RuntimeHostCliConnectionOnlyContext {
readonly catalog: ConnectionCatalogSnapshot;
}
export interface RuntimeHostCliConnectionContextWithIdentity
extends RuntimeHostCliConnectionContext,
RuntimeHostCliConnectionOnlyContextWithIdentity {}
export interface RuntimeHostCliConnectionInput {
readonly rootPath: string;
readonly profileId?: string;
readonly clientDataRoot?: string;
readonly interactiveSsh?: boolean;
readonly signal?: AbortSignal;
readonly handoffSurface?: OpenHostHandoffSurface;
}
export interface RuntimeHostCliTarget {
readonly connection: ConnectionCatalogEntry;
readonly model: string;
}
interface RuntimeHostCliContextDeps {
readonly connectOrSpawn: typeof connectOrSpawnRuntimeHost;
readonly connectActivatedHost: typeof connectRuntimeHost;
readonly activateLocalManagedHost: typeof activateLocalManagedRuntimeHost;
readonly connectProfile: typeof connectRuntimeHostProfile;
readonly readConnectionCatalog: typeof readRuntimeHostConnectionCatalog;
readonly loadClientInstanceId: typeof loadOrCreateRuntimeHostClientInstanceId;
readonly executionCandidateEntrypoint: URL;
readonly readDeploymentRecord: typeof readLocalHostDeploymentRecord;
readonly resolveManagedAuthority: typeof resolveRuntimeHostManagedDeploymentAuthority;
readonly createPeerClient: typeof createRuntimeHostPeerClientFromEnvironment;
readonly profileCatalog?: RuntimeHostProfileCatalog;
readonly resolveInstallation: typeof resolveRuntimeHostNpmGlobalInstallation;
readonly isTemporaryNpxInstallation: typeof isTemporaryNpxInstallation;
readonly terminateObservedHost: typeof forceTerminateObservedRegisteredRuntimeHost;
readonly restartDeployment: typeof restartRuntimeHostNpmGlobalDeployment;
readonly sourceRetirementAvailable: typeof runtimeHostNpmGlobalSourceRetirementAvailable;
}
export async function connectRuntimeHostCli(
input: RuntimeHostCliConnectionInput,
overrides: Partial<RuntimeHostCliContextDeps> = {},
): Promise<RuntimeHostCliConnectionContextWithIdentity> {
const context = await connectRuntimeHostCliConnection(input, overrides);
try {
const catalog = await runAbortably(
() =>
(overrides.readConnectionCatalog ?? readRuntimeHostConnectionCatalog)(context.connection),
input.signal,
);
return { ...context, catalog };
} catch (error) {
await context.close().catch(() => undefined);
throw error;
}
}
export async function connectRuntimeHostCliConnection(
input: RuntimeHostCliConnectionInput,
overrides: Partial<RuntimeHostCliContextDeps> = {},
): Promise<RuntimeHostCliConnectionOnlyContextWithIdentity> {
const deps: RuntimeHostCliContextDeps = {
connectOrSpawn: connectOrSpawnRuntimeHost,
activateLocalManagedHost: activateLocalManagedRuntimeHost,
connectActivatedHost: connectRuntimeHost,
connectProfile: connectRuntimeHostProfile,
readConnectionCatalog: readRuntimeHostConnectionCatalog,
loadClientInstanceId: loadOrCreateRuntimeHostClientInstanceId,
executionCandidateEntrypoint: new URL(
import.meta.resolve('@maka/runtime-host/execution-candidate-main'),
),
readDeploymentRecord: readLocalHostDeploymentRecord,
resolveManagedAuthority: resolveRuntimeHostManagedDeploymentAuthority,
createPeerClient: createRuntimeHostPeerClientFromEnvironment,
resolveInstallation: resolveRuntimeHostNpmGlobalInstallation,
isTemporaryNpxInstallation,
terminateObservedHost: forceTerminateObservedRegisteredRuntimeHost,
restartDeployment: restartRuntimeHostNpmGlobalDeployment,
sourceRetirementAvailable: runtimeHostNpmGlobalSourceRetirementAvailable,
...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;
// A temporary package is evidence for invocation lifetime, never deployment
// authority. This only guards candidates we create; using an existing Host
// leaves that Host's ownership and lifetime unchanged.
const invocationOwned = profile.kind === 'local' && (await deps.isTemporaryNpxInstallation());
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,
...(invocationOwned ? { closeOnLauncherExit: true } : {}),
} as const;
const connect = async (
signal?: AbortSignal,
sshInteraction: 'batch' | 'inherit' = 'batch',
): Promise<RuntimeHostConnection> => {
const observe = async (): Promise<HostHandoffObservation<RuntimeHostConnection>> => {
if (profile.kind !== 'local') {
try {
const connection = await deps.connectProfile({
profile,
...(resolvedProfile.credential ? { credential: resolvedProfile.credential } : {}),
clientInstanceId,
sshInteraction,
...(peerClient ? { peerClient } : {}),
...(signal ? { signal } : {}),
});
return { kind: 'ready', value: connection };
} catch (error) {
if (!(error instanceof RuntimeHostRemoteCompatibilityError)) throw error;
return {
kind: 'blocked',
blocker: {
identity: JSON.stringify([profile, error.hostEpoch, error.details]),
target: {
name: profile.name,
location: 'remote',
rootId: profile.rootId,
hostEpoch: error.hostEpoch,
},
reason: 'upgrade',
mayExitNaturally: false,
diagnostic: error.message,
},
};
}
}
let connected = await deps.connectOrSpawn({
...connectInput,
...(signal ? { signal } : {}),
});
if (connected.kind === 'failed' && connected.reason === 'managed_root_requires_operator') {
await deps.activateLocalManagedHost({
rootPath: input.rootPath,
...(signal ? { signal } : {}),
});
signal?.throwIfAborted();
// The installed operator owns launch. Compatibility is still projected
// by the same handoff journey as an already-running managed Host.
const activated = await deps.connectActivatedHost(connectInput);
if (activated.kind === 'unavailable' || activated.kind === 'draining') {
throw new Error(
`The installed Runtime Host was activated but the local CLI could not join it (${activated.kind === 'unavailable' ? activated.reason : activated.kind}). Use a compatible CLI or update the managed Host through its configured operator.`,
);
}
if (activated.kind === 'connected' && signal?.aborted) {
await activated.connection.close();
signal.throwIfAborted();
}
connected = activated;
}
if (connected.kind === 'incompatible' || connected.kind === 'upgrade_required') {
const managed = await deps.resolveManagedAuthority(connected.registration.rootId);
const record = managed
? undefined
: await deps.readDeploymentRecord(connected.registration.rootId);
let replacement: HostHandoffReplacement | undefined;
let installation;
let installationFailure: string | undefined;
// On-demand managed Hosts are also ephemeral processes. Their durable
// operator authority, not the process lifetime label, decides who may
// replace them (including when the Host was already running).
if (!managed && connected.registration.lifecycleMode === 'ephemeral') {
try {
installation = await deps.resolveInstallation();
} catch (error) {
installationFailure = error instanceof Error ? error.message : String(error);
}
const owner = record?.state.kind === 'handoff' ? record.state.from : record?.state.owner;
if (
installation &&
(!owner ||
(owner.kind === installation.owner.kind &&
owner.installationId === installation.owner.installationId))
) {
const expectedInstallation = installation;
const canInterrupt =
connected.processIdentity !== undefined ||
(record
? await deps
.sourceRetirementAvailable({
rootId: connected.registration.rootId,
owner: installation.owner,
source: record.state.selected,
})
.catch(() => false)
: false);
replacement = {
kind: record?.state.kind === 'handoff' ? 'repair' : 'replace',
canReplaceIdle: true,
canInterrupt,
execute: async (activeWorkPolicy, _progress, _consent, attemptSignal) => {
const result = await deps.restartDeployment({
rootPath: input.rootPath,
registration: connected.registration,
...(connected.processIdentity
? { processIdentity: connected.processIdentity }
: {}),
activeWorkPolicy,
expectedInstallation,
...(attemptSignal ? { signal: attemptSignal } : {}),
});
if (result.kind === 'completed') return { kind: 'completed' };
if (result.kind === 'active_work') return { kind: 'active_work' };
if (
result.kind === 'changed' ||
result.kind === 'rejected' ||
result.kind === 'operator_required'
)
return { kind: 'changed' };
return {
kind: 'recovery_required',
diagnostic: `Local service recovery is required at ${result.phase}`,
};
},
};
}
}
// The same observed-process recovery used by Desktop is available to
// persistent local invocations without a deployment owner. This is an
// explicit interruption, never an idle inference or an installation claim.
const processIdentity = connected.processIdentity;
if (
!replacement &&
!managed &&
!record &&
!invocationOwned &&
connected.registration.lifecycleMode === 'ephemeral' &&
processIdentity
) {
const registration = connected.registration;
replacement = {
kind: 'replace',
canReplaceIdle: false,
canInterrupt: true,
execute: async (policy, progress, consent, attemptSignal) => {
if (policy !== 'interrupt_active_work' || consent !== 'explicit') {
return { kind: 'active_work' };
}
attemptSignal?.throwIfAborted();
if (
(await deps.resolveManagedAuthority(registration.rootId)) ||
(await deps.readDeploymentRecord(registration.rootId))
)
return { kind: 'changed' };
attemptSignal?.throwIfAborted();
progress('retiring');
const stopped = await deps.terminateObservedHost(
{ rootPath: input.rootPath, registration },
{ processIdentity, isCurrent: () => !signal?.aborted && !attemptSignal?.aborted },
);
if (!stopped) return { kind: 'changed' };
progress('verifying');
return { kind: 'completed' };
},
};
}
return {
kind: 'blocked',
blocker: {
identity: JSON.stringify([
connected.registration,
processIdentity,
managed?.record,
record,
installation,
]),
target: {
name: profile.name,
location: 'local',
rootId: connected.registration.rootId,
hostEpoch: connected.registration.hostEpoch,
},
reason: record?.state.kind === 'handoff' ? 'repair' : 'upgrade',
...(!replacement
? {
recoveryBlocker:
managed || connected.registration.lifecycleMode === 'service'
? ('managed' as const)
: record
? ('owner' as const)
: invocationOwned
? ('installation' as const)
: ('identity' as const),
diagnostic: [
`Host compatibility: ${connected.registration.compatibilityEpoch}; client: ${RUNTIME_HOST_COMPATIBILITY_EPOCH}.`,
installationFailure,
]
.filter(Boolean)
.join('\n'),
}
: {}),
...(connected.handshake?.activity ? { activity: connected.handshake.activity } : {}),
mayExitNaturally:
!managed &&
connected.registration.lifecycleMode === 'ephemeral' &&
connected.handshake?.replacement === 'wait_for_idle_exit',
...(replacement ? { replacement } : {}),
},
};
}
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 { kind: 'ready', value: connected.connection };
};
return runHostHandoff({ observe, signal, openSurface: input.handoffSurface });
};
let initialConnection: RuntimeHostConnection | undefined;
let connection: Awaited<ReturnType<typeof createRuntimeHostReconnectingConnection>> | undefined;
try {
initialConnection = await acquireAbortably(
() =>
connect(
input.signal,
input.interactiveSsh && process.stdin.isTTY && process.stdout.isTTY ? 'inherit' : 'batch',
),
input.signal,
);
connection = await createRuntimeHostReconnectingConnection({
initialConnection,
connect: (signal) => connect(signal, 'batch'),
});
initialConnection = undefined;
const liveConnection = connection;
return {
connection: liveConnection,
profile,
clientInstanceId,
...(resolvedProfile.profileIncarnationId
? { profileIncarnationId: resolvedProfile.profileIncarnationId }
: {}),
close: async () => {
try {
await liveConnection.close();
} finally {
await peerClient?.close();
}
},
};
} catch (error) {
await (connection ?? initialConnection)?.close().catch(() => undefined);
await peerClient?.close().catch(() => undefined);
throw error;
}
}
function acquireAbortably<T extends { close(): Promise<void> }>(
operation: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
if (!signal) return operation();
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise<T>((resolve, reject) => {
let settled = false;
const settle = (callback: () => void) => {
if (settled) return false;
settled = true;
signal.removeEventListener('abort', onAbort);
callback();
return true;
};
const onAbort = () => settle(() => reject(signal.reason));
signal.addEventListener('abort', onAbort, { once: true });
let running: Promise<T>;
try {
running = operation();
} catch (error) {
settle(() => reject(error));
return;
}
void running.then(
(value) => {
if (!settle(() => resolve(value))) void value.close().catch(() => undefined);
},
(error: unknown) => settle(() => reject(error)),
);
});
}
function runAbortably<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return operation();
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise<T>((resolve, reject) => {
let settled = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
signal.removeEventListener('abort', onAbort);
callback();
};
const onAbort = () => settle(() => reject(signal.reason));
signal.addEventListener('abort', onAbort, { once: true });
let running: Promise<T>;
try {
running = operation();
} catch (error) {
settle(() => reject(error));
return;
}
void running.then(
(value) => settle(() => resolve(value)),
(error: unknown) => settle(() => reject(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);
}
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 };
}