diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index ea2d4b24b7..bfef451ae1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -143,7 +143,7 @@ function createDesktopRuntimeHostCandidate( connection, candidateDeps, observationRegistry, - 'ephemeral', + 'owned_ephemeral', 'local', ); } @@ -244,7 +244,7 @@ test('rejects a stale target generation when two profiles share one Host', async first.connection, deps(ipc), undefined, - 'ephemeral', + 'owned_ephemeral', 'local', ); await firstCandidate.close(); @@ -255,7 +255,7 @@ test('rejects a stale target generation when two profiles share one Host', async second.connection, deps(ipc), undefined, - 'ephemeral', + 'owned_ephemeral', 'local', ); diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 2131d67ffa..d2b9accf06 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -59,7 +59,7 @@ test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, asy const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { startCandidate: async (input) => { starts += 1; - interactions.push(input.remote?.sshInteraction); + interactions.push(input.profileTarget?.sshInteraction); if (starts === 2) { resolveSecondStart(); await secondReleased; @@ -142,9 +142,9 @@ test('quiesces reconnect and waits for the Host process before update install', }); test('quiesces Local reconnect while a managed service changes', async () => { - const current = candidateHarness({ lifecycleMode: 'service' }); + const current = candidateHarness({ ownership: 'supervised' }); const replacement = candidateHarness({ - lifecycleMode: 'service', + ownership: 'supervised', hostEpoch: 'service-after', }); let starts = 0; @@ -477,12 +477,12 @@ test('keeps active-task confirmation bound to the current Host', async () => { await owner.close(); }); -for (const lifecycleMode of ['service', 'remote'] as const) { - test(`does not retire a ${lifecycleMode} Host for a Desktop update`, async () => { - const current = candidateHarness({ lifecycleMode }); +for (const ownership of ['supervised', 'external'] as const) { + test(`leaves ${ownership} Host ownership intact during a Desktop update`, async () => { + const current = candidateHarness({ ownership }); const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { startCandidate: async () => ready(current.candidate), - waitForHostExit: async () => assert.fail(`${lifecycleMode} Host exit must not be awaited`), + waitForHostExit: async () => assert.fail(`${ownership} Host exit must not be awaited`), }); const retirement = await owner.retireOwnedLocalHost('refuse_active_work'); @@ -496,7 +496,7 @@ for (const lifecycleMode of ['service', 'remote'] as const) { test('keeps Local and remote Hosts active and routes work by owning Host', async () => { const local = candidateHarness({ hostId: 'host-a' }); - const remote = candidateHarness({ hostId: 'host-b', lifecycleMode: 'remote' }); + const remote = candidateHarness({ hostId: 'host-b', ownership: 'external' }); let starts = 0; const manager = await startRuntimeHostDesktopManager( {} as DesktopRuntimeHostCandidateStartInput, @@ -757,7 +757,7 @@ test('bounds an in-flight pairing finalization and preserves its unknown outcome test('coalesces concurrent enable requests for one remote profile', async () => { const local = candidateHarness({ hostId: 'host-a' }); - const remote = candidateHarness({ hostId: 'host-b', lifecycleMode: 'remote' }); + const remote = candidateHarness({ hostId: 'host-b', ownership: 'external' }); let starts = 0; let releaseRemote!: () => void; const remoteReady = new Promise((resolve) => { @@ -787,7 +787,7 @@ test('coalesces concurrent enable requests for one remote profile', async () => test('waits for an in-flight remote enable before closing', async () => { const local = candidateHarness({ hostId: 'host-a' }); - const remote = candidateHarness({ hostId: 'host-b', lifecycleMode: 'remote' }); + const remote = candidateHarness({ hostId: 'host-b', ownership: 'external' }); let starts = 0; let releaseRemote!: () => void; const remoteReady = new Promise((resolve) => { @@ -1159,7 +1159,7 @@ function candidateHarness( delayDisconnect?: boolean; disconnectOnPrepare?: boolean; activeTasks?: boolean | 'always'; - lifecycleMode?: 'ephemeral' | 'service' | 'remote'; + ownership?: 'owned_ephemeral' | 'supervised' | 'external'; hostId?: string; hostEpoch?: string; finalizeFailures?: Error[]; @@ -1182,7 +1182,7 @@ function candidateHarness( const retirementModes: string[] = []; const candidate = { closed, - hostLifecycleMode: options.lifecycleMode ?? 'ephemeral', + hostOwnership: options.ownership ?? 'owned_ephemeral', hostPid: 42, client: { hostId: options.hostId ?? 'test-host', @@ -1273,7 +1273,7 @@ function ready(candidate: DesktopRuntimeHostCandidate): DesktopRuntimeHostCandid function remoteTarget( id: string, target = 'default', -): NonNullable { +): NonNullable { return { profile: { id, diff --git a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts index d90583a9ce..2eae7b7d41 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts @@ -53,6 +53,7 @@ test('persists a verified on-demand SSH profile without endpoint or credential p }); const result = await harness.invoke('runtime-host-onboarding:start', { + kind: 'ssh', name: 'Lab', destination: 'operator@example.com', projectDirectoryRoots: [{ label: 'Work', path: '/srv/work' }], @@ -60,7 +61,8 @@ test('persists a verified on-demand SSH profile without endpoint or credential p assert.equal((result as { kind?: string }).kind, 'complete'); assert.equal(saved?.profile.name, 'Lab'); - assert.deepEqual(saved?.profile.transport, { + if (saved?.profile.kind !== 'remote') assert.fail('expected remote profile'); + assert.deepEqual(saved.profile.transport, { kind: 'ssh', destination: 'operator@example.com', activation: { @@ -90,10 +92,68 @@ test('persists a verified on-demand SSH profile without endpoint or credential p assert.equal(harness.handlers.size, 0); }); +test('onboards WSL as a credential-free environment profile', async () => { + let saved: DesktopRuntimeHostProfileAddInput | undefined; + const harness = createHarness({ + profiles: { + addAndEnable: async (input) => { + saved = input; + return { kind: 'connected', snapshot: { entries: [], defaultProfileId: 'local' } }; + }, + }, + runWslSetup: async (_input, _onProgress, onComplete) => { + onComplete(); + return { + rootId: 'a'.repeat(64), + operatorPath: '/home/operator/.local/share/maka/operator', + }; + }, + }); + + const result = await harness.invoke('runtime-host-onboarding:start', { + kind: 'wsl', + distribution: 'Ubuntu-24.04', + }); + + assert.equal((result as { kind?: string }).kind, 'complete'); + assert.equal(saved?.credential, undefined); + assert.deepEqual(saved?.profile, { + id: saved?.profile.id, + name: 'Ubuntu-24.04', + kind: 'environment', + provider: { kind: 'wsl', distribution: 'Ubuntu-24.04' }, + rootId: 'a'.repeat(64), + operatorPath: '/home/operator/.local/share/maka/operator', + }); + await harness.onboarding.close(); +}); + +test('projects WSL setup failures as recoverable onboarding state', async () => { + const harness = createHarness({ + runWslSetup: async () => { + throw new Error('WSL requires Linux Node.js'); + }, + }); + + assert.deepEqual( + await harness.invoke('runtime-host-onboarding:start', { + kind: 'wsl', + distribution: 'Ubuntu', + }), + { + kind: 'failed', + message: 'WSL requires Linux Node.js', + revision: 3, + }, + ); + await harness.onboarding.close(); +}); + test('projects invalid setup input as a recoverable failure', async () => { const harness = createHarness(); const result = await harness.invoke('runtime-host-onboarding:start', { + kind: 'ssh', destination: '', }); assert.deepEqual(result, { @@ -113,6 +173,7 @@ test('rejects relative remote Project roots before starting SSH setup', async () const harness = createHarness(); const result = await harness.invoke('runtime-host-onboarding:start', { + kind: 'ssh', destination: 'operator@example.com', projectDirectoryRoots: [{ label: 'Work', path: 'srv/work' }], }); @@ -166,6 +227,7 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn }); const setup = harness.invoke('runtime-host-onboarding:start', { + kind: 'ssh', destination: 'operator@example.com', }) as Promise; while (!completeReceived) await Promise.resolve(); @@ -203,6 +265,7 @@ test('resolves the setup package only when onboarding starts', async () => { assert.equal(resolutions, 0); assert.deepEqual( await harness.invoke('runtime-host-onboarding:start', { + kind: 'ssh', destination: 'operator@example.com', }), { @@ -227,11 +290,14 @@ function createHarness(overrides: HarnessOverrides = {}) { const onboarding = createDesktopRuntimeHostOnboarding({ clientInstanceId: 'stable-client', profiles: { + addAndEnable: async () => assert.fail('profile must not be saved'), addAndEnableVerified: async () => assert.fail('profile must not be saved'), ...profiles, }, resolveSetupPackage: () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }), runSetup: async () => assert.fail('SSH must not start'), + runWslSetup: async () => assert.fail('WSL must not start'), + listWslDistributions: async () => [], ...rest, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index 1be227ab37..9cbb6dfabe 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -145,6 +145,11 @@ describe('useSessionNavigationController', () => { profileName: 'Remote Mac', profileKind: 'remote', }), + session('environment', { + profileId: 'wsl-ubuntu', + profileName: 'Ubuntu', + profileKind: 'environment', + }), session('archived', { isArchived: true }), session('hidden'), ]; @@ -153,7 +158,7 @@ describe('useSessionNavigationController', () => { assert.deepEqual( controller().selectors.visibleSessions.map(({ id }) => id), - ['root', 'remote'], + ['root', 'remote', 'environment'], ); assert.equal(controller().selectors.activeRowId, 'root'); assert.equal(controller().selectors.activeParentSession?.id, 'root'); @@ -163,9 +168,10 @@ describe('useSessionNavigationController', () => { }); assert.deepEqual( controller().selectors.groups.map(({ id }) => id), - ['project:project', 'runtime-host:remote-profile'], + ['project:project', 'runtime-host:remote-profile', 'runtime-host:wsl-ubuntu'], ); assert.equal(controller().selectors.sessionMeta(sessions[2]!), 'Remote Mac'); + assert.equal(controller().selectors.sessionMeta(sessions[3]!), 'Ubuntu'); }); it('owns Session jumps and preserves turn-target clearing semantics', async () => { diff --git a/apps/desktop/src/main/__tests__/task-entry-model.test.ts b/apps/desktop/src/main/__tests__/task-entry-model.test.ts index f1fe25285e..9d8f0a082b 100644 --- a/apps/desktop/src/main/__tests__/task-entry-model.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-model.test.ts @@ -19,6 +19,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; +import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from '../../renderer/new-task-reload-intent.js'; import { resolveProjectSelection, @@ -44,19 +45,18 @@ function project( function readyHost(input: { id: string; - kind?: 'local' | 'remote'; + kind?: RuntimeHostProfileKind; selectedProjectId?: string | null; defaultProjectId?: string; selectNoProject?: boolean; projects?: ReturnType[]; }): Extract { - const profile = input.kind === 'remote' - ? { - id: input.id, - name: input.id, - kind: 'remote' as const, - } - : { id: 'local' as const, name: 'Local' as const, kind: 'local' as const }; + const kind = input.kind ?? 'local'; + const profile = { + id: kind === 'local' ? 'local' : input.id, + name: kind === 'local' ? 'Local' : input.id, + kind, + }; return { profile, hostId: `host-${input.id}`, diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 21f1ba6707..0bbce55dba 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -51,8 +51,10 @@ import { createRuntimeHostPeerClientFromEnvironment, LOCAL_RUNTIME_HOST_PROFILE, loadOrCreateRuntimeHostClientInstanceId, + listRuntimeHostWslDistributions, } from "@maka/runtime-host/client"; import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; +import { runtimeHostProfileUsesHostWorkspace } from "@maka/runtime-host/profile-kind"; import { createCredentialMcpOAuthStorage, McpClientManager } from "@maka/mcp"; import { createWorkBoardStore } from "@maka/storage/work-board-store"; import { createFileCredentialStore } from "@maka/storage/credential-store"; @@ -169,6 +171,7 @@ import { import { createDesktopRuntimeHostSshTerminal, } from "./runtime-host-ssh-terminal.js"; +import { runDesktopRuntimeHostWslSetup } from './runtime-host-wsl-controller.js'; import { createRuntimeHostSetupPackageResolver } from "./runtime-host-setup-package.js"; import { configureDesktopRuntimeHostPeerClient } from './runtime-host-peer-client.js'; import { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-operator.js'; @@ -436,14 +439,19 @@ const runtimeHostProfileService = createDesktopRuntimeHostProfileService({ credentialStore: runtimeHostCredentialStore, states: () => runtimeHostManager?.entries() ?? [], enable: async (target, sshInteraction) => { - if (target.profile.kind !== "remote" || !target.credential) { - throw new Error("A resolved remote Runtime Host profile is required"); + if (target.profile.kind === 'local') { + throw new Error('A resolved non-local Runtime Host profile is required'); + } + if (target.profile.kind === 'remote' && !target.credential) { + throw new Error('A remote Runtime Host profile requires an access credential'); } if (!runtimeHostManager) throw new Error("Runtime Host manager is unavailable"); await runtimeHostManager.enable({ profile: target.profile, - credential: target.credential, - ...(target.profile.transport.kind === "ssh" ? { sshInteraction } : {}), + ...(target.credential ? { credential: target.credential } : {}), + ...(target.profile.kind === 'remote' && target.profile.transport.kind === "ssh" + ? { sshInteraction } + : {}), }); }, disable: async (profileId) => { @@ -464,6 +472,8 @@ const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({ clientInstanceId: runtimeHostClientInstanceId, profiles: runtimeHostProfileService, runSetup: runtimeHostSshTerminal.runSetup, + runWslSetup: runDesktopRuntimeHostWslSetup, + listWslDistributions: listRuntimeHostWslDistributions, resolveSetupPackage: runtimeHostSetupPackage.resolve, send: (snapshot) => mainWindowController.send("runtime-host-onboarding:changed", snapshot), @@ -566,7 +576,7 @@ const selectedDesktopWorkspaceTarget = async ( if (typeof current.projectId === "string") { return { kind: "project", projectId: current.projectId }; } - if (target.kind === "remote") return undefined; + if (runtimeHostProfileUsesHostWorkspace(target.kind)) return undefined; return { kind: "host_path", path: current.path }; }; const currentDesktopWorkspaceTarget = async ( @@ -574,7 +584,7 @@ const currentDesktopWorkspaceTarget = async ( ): Promise => { const workspace = await selectedDesktopWorkspaceTarget(target); if (!workspace) { - throw new Error("Select a project from the remote Runtime Host first"); + throw new Error("Select a project from the Runtime Host first"); } return workspace; }; @@ -822,7 +832,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( input, { ...currentTarget.projectManagement, - ...(target.kind === "local" + ...(!runtimeHostProfileUsesHostWorkspace(target.kind) ? { defaultProjectId: async () => (await settingsStore.get()).projects.defaultProjectId, @@ -830,7 +840,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( : {}), }, currentTarget.projectCatalog, - { allowHostPath: target.kind === "local" }, + { allowHostPath: !runtimeHostProfileUsesHostWorkspace(target.kind) }, ); }, emitSessionsChanged, @@ -1027,6 +1037,7 @@ function registerHostClientIpc( scope: DesktopTargetScope, isTargetActive: () => boolean, ): () => Promise { + const usesHostWorkspace = runtimeHostProfileUsesHostWorkspace(target.kind); const sendToRenderer = (channel: string, ...args: unknown[]): void => { if (isTargetActive()) mainWindowController.send(channel, scope, ...args); }; @@ -1047,7 +1058,7 @@ function registerHostClientIpc( }); const targetProjectCatalog = createRuntimeHostProjectCatalog(() => ({ client, - includeHostPaths: target.kind === "local", + includeHostPaths: !usesHostWorkspace, })); const targetProjectManagement = createProjectManagementService({ catalog: targetProjectCatalog, @@ -1060,7 +1071,7 @@ function registerHostClientIpc( return result.canceled ? undefined : result.filePaths[0]; }, selection: targetProjectRoot, - capabilities: target.kind === "local" + capabilities: !usesHostWorkspace ? { chooseClientDirectory: true, chooseHostDirectory: false, @@ -1164,7 +1175,7 @@ function registerHostClientIpc( client, workspaceRoot, openPath: (path) => shell.openPath(path), - allowLocalPaths: target.kind === "local", + allowLocalPaths: !usesHostWorkspace, }); const settingsIpcDeps = { ipcMain: scopedIpc, @@ -1218,7 +1229,7 @@ function registerHostClientIpc( return { kind: "project", projectId }; } if (projectId === null) { - if (target.kind === "remote") return undefined; + if (usesHostWorkspace) return undefined; return { kind: "host_path", path: (await requireRuntimePolicyTarget(target).projectManagement.current()).path, @@ -1229,7 +1240,7 @@ function registerHostClientIpc( getDefaultPermissionMode: () => resolveDefaultPermissionMode(() => loadRuntimeHostSettings(settingsIpcDeps)), openPath: (path) => shell.openPath(path), - allowLocalPaths: target.kind === "local", + allowLocalPaths: !usesHostWorkspace, }); registerRuntimeHostSearchIpc({ ipcMain: scopedIpc, client }); registerRuntimeHostUsageIpc({ @@ -1240,7 +1251,7 @@ function registerHostClientIpc( registerRuntimeHostWorkspaceIpc({ ipcMain: scopedIpc, client, - allowLocalWorkspace: target.kind === "local", + allowLocalWorkspace: !usesHostWorkspace, }); const resolveProjectRootForContext = (sessionId: unknown): Promise => resolveProjectContextRoot(sessionId, { @@ -1261,7 +1272,7 @@ function registerHostClientIpc( buildInfo, e2eFixture, projectManagement: targetProjectManagement, - allowLocalProjectPaths: target.kind === "local", + allowLocalProjectPaths: !usesHostWorkspace, }, scopedIpc, ); @@ -1275,7 +1286,7 @@ function registerHostClientIpc( if (!path) throw new Error(`Project is unavailable: ${projectId}`); return path; }, - allowLocalWorkspace: target.kind === "local", + allowLocalWorkspace: !usesHostWorkspace, }); const onboardingService = createOnboardingService({ listConnections: async () => @@ -1315,7 +1326,7 @@ function registerHostClientIpc( const taskSubmissionReadinessService = createDesktopTaskSubmissionReadinessService({ workspaceRoot, runtimeState: () => ({ state: client.lifecycleState, checkedAt: Date.now() }), - ...(target.kind === "remote" + ...(usesHostWorkspace ? { inspectWorkspace: async () => "ready" as const } : {}), resolveModelTarget: (requestedSlug) => diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index bb06bb2bef..3ecce8214c 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -27,7 +27,7 @@ import type { BotRegistry } from '@maka/runtime/bots'; import { type RuntimeHostSshOperatorActivationInput, connectOrSpawnRuntimeHost, - connectRemoteRuntimeHostProfile, + connectRuntimeHostProfile, type RuntimeHostPeerClient, type RuntimeHostSshInteraction, type RuntimeHostSshTunnel, @@ -37,14 +37,17 @@ import { type RuntimeHostConnection, type RuntimeHostCandidateLaunchBarrier, type RuntimeHostSpawnedProcess, - type RemoteRuntimeHostProfile, + type PersistedRuntimeHostProfile, type CandidateExitDetails, } from "@maka/runtime-host/client"; import type { RuntimeHostActivationResult } from "@maka/runtime-host/operator"; +import { + runtimeHostProfileUsesHostWorkspace, + type RuntimeHostProfileKind, +} from "@maka/runtime-host/profile-kind"; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, RUNTIME_HOST_PROTOCOL_VERSION, - type HostRegistration, type WorkspaceTarget, } from "@maka/runtime-host/protocol"; import type { AttachmentApprovalRegistry } from "./attachment-approval.js"; @@ -151,17 +154,17 @@ export interface DesktopRuntimeHostCandidateDeps { ) => void | (() => void | Promise); } -export type DesktopRuntimeHostTargetPolicy = - | { readonly kind: "local"; readonly rootId: string } - | { - readonly kind: "remote"; - readonly rootId: string; - }; +export interface DesktopRuntimeHostTargetPolicy { + readonly kind: RuntimeHostProfileKind; + readonly rootId: string; +} export interface DesktopRuntimeHostCandidateControls { refreshClientCapabilities(): Promise; } +export type DesktopRuntimeHostOwnership = 'owned_ephemeral' | 'supervised' | 'external'; + export interface DesktopRuntimeHostCandidateStartInput extends Omit { readonly ipcMain: CandidateIpcMain; @@ -178,9 +181,9 @@ export interface DesktopRuntimeHostCandidateStartInput readonly onExit?: (details: CandidateExitDetails) => void; readonly candidateLaunchBarrier?: RuntimeHostCandidateLaunchBarrier; readonly peerClient?: RuntimeHostPeerClient; - readonly remote?: { - readonly profile: RemoteRuntimeHostProfile; - readonly credential: string; + readonly profileTarget?: { + readonly profile: PersistedRuntimeHostProfile; + readonly credential?: string; readonly sshInteraction?: RuntimeHostSshInteraction; }; } @@ -196,7 +199,7 @@ export interface DesktopRuntimeHostCandidate { readonly botIncoming: BotIncomingMainService; readonly client: DesktopRuntimeHostClient; readonly closed: Promise; - readonly hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + readonly hostOwnership: DesktopRuntimeHostOwnership; readonly hostPid?: number; stopSession(sessionId: string): Promise; close(): Promise; @@ -206,7 +209,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { readonly botIncoming: BotIncomingMainService; readonly client: DesktopRuntimeHostClient; readonly closed: Promise; - readonly hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + readonly hostOwnership: DesktopRuntimeHostOwnership; readonly hostPid: number | undefined; readonly #client: DesktopRuntimeHostClient; readonly #observer: RuntimeHostSessionObserver; @@ -232,7 +235,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { detachSessionObservations: () => void; closeSessionObservations: () => Promise; connectionClosed: Promise; - hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + hostOwnership: DesktopRuntimeHostOwnership; hostPid?: number; hasRegisteredCapabilities: () => boolean; stopSession: (sessionId: string) => Promise; @@ -250,7 +253,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { this.#hasRegisteredCapabilities = input.hasRegisteredCapabilities; this.#stopSession = input.stopSession; this.botIncoming = input.botIncoming; - this.hostLifecycleMode = input.hostLifecycleMode; + this.hostOwnership = input.hostOwnership; this.hostPid = input.hostPid; this.closed = input.connectionClosed.then(() => this.close()); } @@ -295,10 +298,10 @@ export async function startDesktopRuntimeHostCandidate( observationRegistry?: RuntimeHostSessionObservationRegistry, ): Promise { const ipcMain = requireTargetIpcMain(input.ipcMain); - if (input.remote) { - return startRemoteDesktopRuntimeHostCandidate( + if (input.profileTarget) { + return startProfileDesktopRuntimeHostCandidate( input, - input.remote, + input.profileTarget, observationRegistry, ipcMain, ); @@ -315,7 +318,9 @@ export async function startDesktopRuntimeHostCandidate( connection.connection, { ...input, ipcMain }, observationRegistry, - connection.registration.lifecycleMode, + connection.registration.lifecycleMode === 'ephemeral' + ? 'owned_ephemeral' + : 'supervised', "local", connection.registration.pid, ), @@ -372,15 +377,17 @@ function redactRuntimeHostStderr(stderr: string): string { return redacted.replace(/\S+/gu, (token) => redactSecrets(token)); } -async function startRemoteDesktopRuntimeHostCandidate( +async function startProfileDesktopRuntimeHostCandidate( input: DesktopRuntimeHostCandidateStartInput, - remote: NonNullable, + profileTarget: NonNullable, observationRegistry: RuntimeHostSessionObservationRegistry | undefined, ipcMain: RuntimeHostTargetIpcMain, ): Promise { - const connection = await connectRemoteRuntimeHostProfile({ - profile: remote.profile, - credential: remote.credential, + const connection = await connectRuntimeHostProfile({ + profile: profileTarget.profile, + ...(profileTarget.credential === undefined + ? {} + : { credential: profileTarget.credential }), clientInstanceId: input.clientInstanceId ?? randomUUID(), ...(input.signal === undefined ? {} : { signal: input.signal }), ...(input.connectTimeoutMs === undefined @@ -391,9 +398,9 @@ async function startRemoteDesktopRuntimeHostCandidate( : { handshakeTimeoutMs: input.handshakeTimeoutMs }), readyTimeoutMs: input.electionDeadlineMs ?? 45_000, ...(input.peerClient === undefined ? {} : { peerClient: input.peerClient }), - ...(remote.sshInteraction === undefined + ...(profileTarget.sshInteraction === undefined ? {} - : { sshInteraction: remote.sshInteraction }), + : { sshInteraction: profileTarget.sshInteraction }), }, { ...(input.openSshTunnel ? { openSshTunnel: input.openSshTunnel } : {}), @@ -408,8 +415,8 @@ async function startRemoteDesktopRuntimeHostCandidate( connection, { ...input, ipcMain }, observationRegistry, - "remote", - "remote", + 'external', + profileTarget.profile.kind, ), }; } catch (error) { @@ -422,7 +429,7 @@ export async function createDesktopRuntimeHostCandidate( connection: RuntimeHostConnection, deps: DesktopRuntimeHostCandidateDeps, observationRegistry: RuntimeHostSessionObservationRegistry | undefined, - hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote", + hostOwnership: DesktopRuntimeHostOwnership, targetKind: DesktopRuntimeHostTargetPolicy["kind"], hostPid?: number, ): Promise { @@ -610,11 +617,12 @@ export async function createDesktopRuntimeHostCandidate( }; const createNativeProvider = (): DesktopNativeCapabilityProvider => { let provider: DesktopNativeCapabilityProvider; + const usesHostWorkspace = runtimeHostProfileUsesHostWorkspace(target.kind); provider = createDesktopNativeCapabilityProvider( deps.nativeCapabilities, { - hostPathAccess: target.kind === "local" ? "cwd" : "none", - ...(target.kind === "remote" ? { clientCwd: deps.workspaceRoot } : {}), + hostPathAccess: usesHostWorkspace ? "none" : "cwd", + ...(usesHostWorkspace ? { clientCwd: deps.workspaceRoot } : {}), releaseResourcesOnClose: false, targetScope: scope, nativeSessionId: (sessionId) => @@ -750,7 +758,7 @@ export async function createDesktopRuntimeHostCandidate( ? sessionObservations.close() : Promise.resolve(), connectionClosed: connection.closed, - hostLifecycleMode, + hostOwnership, ...(hostPid === undefined ? {} : { hostPid }), hasRegisteredCapabilities: () => capabilitiesRegistered, stopSession, diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index de733b6044..c03c6f6af9 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -63,7 +63,7 @@ export interface RuntimeHostDesktopManager { ): void; unobserveSession(observerId: string): Promise; enable( - remote: DesktopRuntimeHostCandidateStartInput['remote'], + profileTarget: DesktopRuntimeHostCandidateStartInput['profileTarget'], ): Promise; disable(profileId: string): Promise; waitUntilReady( @@ -205,7 +205,7 @@ export async function startRuntimeHostDesktopManager( onDefaultProfileChanged?: (profileId: string) => void; } = {}, ): Promise { - if (input.remote) throw new Error('Desktop Runtime Host manager must start with Local'); + if (input.profileTarget) throw new Error('Desktop Runtime Host manager must start with Local'); const manager = new RuntimeHostDesktopManagerImpl( input, options.startCandidate ?? startDesktopRuntimeHostCandidate, @@ -430,37 +430,37 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { } async enable( - remote: DesktopRuntimeHostCandidateStartInput['remote'], + profileTarget: DesktopRuntimeHostCandidateStartInput['profileTarget'], ): Promise { - if (!remote) throw new Error('A remote Runtime Host profile is required'); - return this.#mutateTarget(remote.profile.id, () => this.#enable(remote)); + if (!profileTarget) throw new Error('A non-local Runtime Host profile is required'); + return this.#mutateTarget(profileTarget.profile.id, () => this.#enable(profileTarget)); } async #enable( - remote: NonNullable, + profileTarget: NonNullable, ): Promise { if (this.#closed) throw new Error('Desktop Runtime Host manager is closed'); - const profileId = remote.profile.id; + const profileId = profileTarget.profile.id; if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) { throw new Error('Local Runtime Host is already enabled'); } for (const target of this.#targets.values()) { if (target.target.profile.id === profileId) continue; - const rootId = target.target.profile.kind === 'remote' + const rootId = target.target.profile.kind !== 'local' ? target.target.profile.rootId : target.hostId; - if (rootId === remote.profile.rootId) { - throw new Error(`Runtime Host ${remote.profile.rootId} is already enabled`); + if (rootId === profileTarget.profile.rootId) { + throw new Error(`Runtime Host ${profileTarget.profile.rootId} is already enabled`); } } const existing = this.#targets.get(profileId); if ( existing?.valid && - sameResolvedRuntimeHostProfileTarget(existing.target, remote) + sameResolvedRuntimeHostProfileTarget(existing.target, profileTarget) ) return; if (existing) await this.#removeTarget(existing); - const target = this.#createTarget(withRuntimeHostTarget(this.#baseInput, remote)); + const target = this.#createTarget(withRuntimeHostTarget(this.#baseInput, profileTarget)); this.#targets.set(profileId, target); this.#publishState(target, { epoch: target.epoch, @@ -529,7 +529,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ); const quiescence = await lifecycle.quiesce(); try { - if (quiescence.current.hostLifecycleMode !== 'service') { + if (quiescence.current.hostOwnership !== 'supervised') { throw new Error('The Local Runtime Host is not managed by a background service'); } return await change(); @@ -590,10 +590,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { quiescence.resume(); }; try { - if ( - quiescence.current.hostLifecycleMode === 'service' || - quiescence.current.hostLifecycleMode === 'remote' - ) { + if (quiescence.current.hostOwnership !== 'owned_ephemeral') { resume(); return { kind: 'not_owned' }; } @@ -689,7 +686,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { this.connect( target, signal, - starting ? target.input.remote?.sshInteraction : 'batch', + starting ? target.input.profileTarget?.sshInteraction : 'batch', ), onReconnectError: (error) => { console.warn('[runtime-host] reconnect attempt failed:', error); @@ -728,10 +725,10 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { { ...target.input, onExit: (details) => this.#reportCandidateExit(inheritedExit, details), - ...(target.input.remote + ...(target.input.profileTarget ? { - remote: { - ...target.input.remote, + profileTarget: { + ...target.input.profileTarget, ...(sshInteraction === undefined ? {} : { sshInteraction }), }, } @@ -838,10 +835,12 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { observations = new RuntimeHostSessionObservationRegistry((error) => input.onError?.(error)), ): DesktopRuntimeHostTargetGeneration { this.#observationRegistries.add(observations); - const target = input.remote + const target = input.profileTarget ? { - profile: input.remote.profile, - credential: input.remote.credential, + profile: input.profileTarget.profile, + ...(input.profileTarget.credential === undefined + ? {} + : { credential: input.profileTarget.credential }), } : { profile: LOCAL_RUNTIME_HOST_PROFILE }; const epoch = randomUUID(); @@ -1002,10 +1001,10 @@ function pairingFinalizeTimedOut(error: unknown): boolean { function withRuntimeHostTarget( input: DesktopRuntimeHostCandidateStartInput, - remote: DesktopRuntimeHostCandidateStartInput['remote'], + profileTarget: DesktopRuntimeHostCandidateStartInput['profileTarget'], ): DesktopRuntimeHostCandidateStartInput { - const { remote: _previousRemote, ...base } = input; - return remote ? { ...base, remote } : base; + const { profileTarget: _previousProfileTarget, ...base } = input; + return profileTarget ? { ...base, profileTarget } : base; } function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise { diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts index a3518fbe85..1c9674b8ec 100644 --- a/apps/desktop/src/main/runtime-host-onboarding.ts +++ b/apps/desktop/src/main/runtime-host-onboarding.ts @@ -32,6 +32,7 @@ import type { DesktopRuntimeHostSetupPackage, DesktopRuntimeHostSshSetupInput, } from './runtime-host-ssh-terminal.js'; +import type { DesktopRuntimeHostWslSetupInput } from './runtime-host-wsl-controller.js'; import { requireProjectDirectoryRoots } from '../shared/runtime-host-project-directory-policy.js'; type OnboardingState = DesktopRuntimeHostOnboardingSnapshot extends infer Snapshot @@ -43,7 +44,7 @@ type OnboardingState = DesktopRuntimeHostOnboardingSnapshot extends infer Snapsh export function createDesktopRuntimeHostOnboarding(input: { readonly ipcMain: Pick; readonly clientInstanceId: string; - readonly profiles: Pick; + readonly profiles: Pick; readonly runSetup: ( input: DesktopRuntimeHostSshSetupInput, onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void, @@ -57,6 +58,12 @@ export function createDesktopRuntimeHostOnboarding(input: { readonly endpoint: string; readonly credential: string; }>; + readonly runWslSetup: ( + input: DesktopRuntimeHostWslSetupInput, + onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void, + onComplete: () => void, + ) => Promise<{ readonly rootId: string; readonly operatorPath: string }>; + readonly listWslDistributions: () => Promise; readonly send: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void; readonly resolveSetupPackage: ( signal?: AbortSignal, @@ -107,6 +114,7 @@ export function createDesktopRuntimeHostOnboarding(input: { ): Promise => { try { const setupPackage = await input.resolveSetupPackage(signal); + if (request.kind === 'wsl') return await runWsl(request, setupPackage, signal); const lifecycle = setupPackage.kind === 'npm' ? 'on_demand' : 'supervised'; signal.throwIfAborted(); publish({ kind: 'running', phase: 'connecting_ssh' }); @@ -195,11 +203,56 @@ export function createDesktopRuntimeHostOnboarding(input: { } }; + const runWsl = async ( + request: Extract, + setupPackage: DesktopRuntimeHostSetupPackage, + signal: AbortSignal, + ): Promise => { + publish({ kind: 'running', phase: 'connecting_wsl' }); + let commitStarted = false; + const beginCommit = () => { + if (commitStarted) return; + commitStarted = true; + if (active) active.cancellable = false; + publish({ kind: 'running', phase: 'connecting_host' }); + }; + const complete = await input.runWslSetup( + { + distribution: request.distribution, + setupPackage, + principalId: `desktop:${input.clientInstanceId}`, + ...(request.projectDirectoryRoots + ? { projectDirectoryRoots: request.projectDirectoryRoots } + : {}), + signal, + }, + (progress) => { + if (!commitStarted) publish({ kind: 'running', phase: progress.phase }); + }, + beginCommit, + ); + beginCommit(); + const profileId = `environment-${randomUUID()}`; + const connected = await input.profiles.addAndEnable({ + profile: { + id: profileId, + name: request.name?.trim() || request.distribution, + kind: 'environment', + provider: { kind: 'wsl', distribution: request.distribution }, + rootId: complete.rootId, + operatorPath: complete.operatorPath, + }, + }); + if (connected.kind === 'unavailable') throw new Error(connected.message); + return publish({ kind: 'complete', profileId }); + }; + const channels = [ 'runtime-host-onboarding:getSnapshot', 'runtime-host-onboarding:start', 'runtime-host-onboarding:cancel', 'runtime-host-onboarding:reset', + 'runtime-host-onboarding:listWslDistributions', ] as const; input.ipcMain.handle(channels[0], () => snapshot); input.ipcMain.handle(channels[1], (_event, value: unknown) => start(value)); @@ -216,6 +269,7 @@ export function createDesktopRuntimeHostOnboarding(input: { await active?.task; publish({ kind: 'idle' }); }); + input.ipcMain.handle(channels[4], () => input.listWslDistributions()); return { close: async () => { @@ -232,25 +286,42 @@ function requireOnboardingInput(value: unknown): DesktopRuntimeHostOnboardingInp if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Remote Runtime Host setup input is invalid'); } - const input = value as Partial; + const input = value as Record; + if (input.name !== undefined && + (typeof input.name !== 'string' || input.name.trim().length > 128)) { + throw new Error('Remote Runtime Host setup input is invalid'); + } + const roots = input.projectDirectoryRoots === undefined + ? undefined + : requireProjectDirectoryRoots(input.projectDirectoryRoots); + if (input.kind === 'wsl') { + if ( + typeof input.distribution !== 'string' || + input.distribution.trim() !== input.distribution || + input.distribution.length === 0 || + input.distribution.length > 128 + ) throw new Error('WSL Runtime Host setup input is invalid'); + return { + kind: 'wsl', + distribution: input.distribution, + ...(typeof input.name === 'string' && input.name.trim() ? { name: input.name.trim() } : {}), + ...(roots ? { projectDirectoryRoots: roots } : {}), + }; + } if ( + input.kind !== 'ssh' || typeof input.destination !== 'string' || input.destination.trim() !== input.destination || input.destination.length === 0 || input.destination.length > 512 || - (input.name !== undefined && - (typeof input.name !== 'string' || input.name.trim().length > 128)) || (input.sshPort !== undefined && - (!Number.isInteger(input.sshPort) || input.sshPort < 1 || input.sshPort > 65_535)) - ) { - throw new Error('Remote Runtime Host setup input is invalid'); - } + (!Number.isInteger(input.sshPort) || Number(input.sshPort) < 1 || Number(input.sshPort) > 65_535)) + ) throw new Error('Remote Runtime Host setup input is invalid'); return { + kind: 'ssh', destination: input.destination, - ...(input.name?.trim() ? { name: input.name.trim() } : {}), - ...(input.sshPort === undefined ? {} : { sshPort: input.sshPort }), - ...(input.projectDirectoryRoots === undefined - ? {} - : { projectDirectoryRoots: requireProjectDirectoryRoots(input.projectDirectoryRoots) }), + ...(typeof input.name === 'string' && input.name.trim() ? { name: input.name.trim() } : {}), + ...(input.sshPort === undefined ? {} : { sshPort: Number(input.sshPort) }), + ...(roots ? { projectDirectoryRoots: roots } : {}), }; } diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index e40ee34917..1747d1343f 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -31,6 +31,7 @@ import { RuntimeHostRemoteCompatibilityError, sameRemoteRuntimeHostProfileTarget, sameResolvedRuntimeHostProfileTarget, + type PersistedRuntimeHostProfile, type RemoteRuntimeHostProfile, type ResolvedRuntimeHostProfile, type RuntimeHostProfileCatalog, @@ -91,7 +92,8 @@ export interface DesktopRuntimeHostProfileService { input: DesktopRuntimeHostProfileAddInput, ): Promise; addAndEnableVerified( - input: DesktopRuntimeHostProfileAddInput & { + input: { + readonly profile: RemoteRuntimeHostProfile; readonly credential: string; readonly managedService?: DesktopRuntimeHostManagedServiceTarget; }, @@ -405,7 +407,7 @@ export function createDesktopRuntimeHostProfileService(input: { profile.rootId === directProfile.rootId && managedDirectPeerProfileId(profile.id) === directProfile.id, ); - if (!sourceProfile) return target; + if (!sourceProfile || sourceProfile.kind !== 'remote') return target; const managed = findDesktopRuntimeHostManagedServiceBinding( await managedServices.read(), sourceProfile, @@ -419,7 +421,7 @@ export function createDesktopRuntimeHostProfileService(input: { throw new Error('The managed SSH recovery profile credential is not available'); } if (source.credential === target.credential) return target; - const rebound = await catalog.rebindIfCurrent(target, target.profile, source.credential); + const rebound = await catalog.rebindIfCurrent(target, directProfile, source.credential); if (!rebound.rebound) { throw new Error('The Direct peer profile changed before its credential could be refreshed'); } @@ -657,7 +659,8 @@ export function createDesktopRuntimeHostProfileService(input: { }); const addAndEnableVerified = ( - value: DesktopRuntimeHostProfileAddInput & { + value: { + readonly profile: RemoteRuntimeHostProfile; readonly credential: string; readonly managedService?: DesktopRuntimeHostManagedServiceTarget; }, @@ -666,6 +669,7 @@ export function createDesktopRuntimeHostProfileService(input: { return mutateProfiles(async () => { const currentDocument = await catalog.read(); const existing = currentDocument.profiles.find((profile) => + profile.kind === 'remote' && profile.rootId === value.profile.rootId && sameRemoteRuntimeHostProfileTarget(profile, value.profile), ); @@ -711,13 +715,19 @@ export function createDesktopRuntimeHostProfileService(input: { addAndEnable(value) { requireSaveInput(value); return mutateProfiles(async () => { - if (value.credential === undefined) { + if (value.profile.kind === 'remote' && value.credential === undefined) { throw new Error("A Runtime Host access credential is required"); } + if (value.profile.kind === 'environment' && value.credential !== undefined) { + throw new Error('A WSL environment does not accept an access credential'); + } const document = await catalog.create(value.profile, value.credential); const profile = document.profiles.find((candidate) => candidate.id === value.profile.id); if (!profile) throw new Error("Runtime Host profile creation did not persist"); - const target = { profile, credential: value.credential } as const; + const target: ResolvedRuntimeHostProfile = { + profile, + ...(value.credential ? { credential: value.credential } : {}), + }; let error: Error | undefined; try { error = await enable(profile.id); @@ -809,6 +819,7 @@ export function createDesktopRuntimeHostProfileService(input: { (candidate) => candidate.id === profileId, ); if (!profile) return undefined; + if (profile.kind !== 'remote') return undefined; const binding = findDesktopRuntimeHostManagedServiceBinding( await managedServices.read(), profile, @@ -944,6 +955,7 @@ export function createDesktopRuntimeHostProfileService(input: { ); if ( !current || + current.kind !== 'remote' || !sameRemoteRuntimeHostProfileTarget(current, expected.profile) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); @@ -971,6 +983,7 @@ export function createDesktopRuntimeHostProfileService(input: { ); if ( !current || + current.kind !== 'remote' || !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || !(await managedServices.markCleanupPendingIfCurrent(expected)) ) { @@ -987,6 +1000,7 @@ export function createDesktopRuntimeHostProfileService(input: { ); if ( !current || + current.kind !== 'remote' || !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || !(await managedServices.removeCleanupPendingIfCurrent(expected)) ) { @@ -1004,7 +1018,8 @@ export function createDesktopRuntimeHostProfileService(input: { tasks.push(interactiveStartup); } for (const target of input.startup.remotes) { - if (target.profile.kind !== "remote" || !target.credential) continue; + if (target.profile.kind === 'local' || + (target.profile.kind === 'remote' && !target.credential)) continue; if (pairingIntents.has(target.profile.id)) continue; const sshInteraction = startupSshInteraction(target); const activation = () => activateTarget(target, sshInteraction); @@ -1116,10 +1131,9 @@ export function createDesktopRuntimeHostProfileService(input: { ) { throw new Error('Disable and remove the Direct peer profile before removing its SSH profile'); } - const managedBinding = findDesktopRuntimeHostManagedServiceBinding( - await managedServices.read(), - profile, - ); + const managedBinding = profile.kind === 'remote' + ? findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile) + : undefined; if (managedBinding && managedBinding.state !== 'active') { throw new Error('Finish uninstalling this Runtime Host service before removing it'); } @@ -1171,18 +1185,17 @@ function assertRootIsNotEnabled( document: Awaited>, states: readonly RuntimeHostDesktopTargetState[], ): void { - if (target.profile.kind !== "remote") return; + if (target.profile.kind === 'local') return; const rootId = target.profile.rootId; const duplicateProfile = document.profiles.find( (profile) => - profile.kind === "remote" && profile.id !== target.profile.id && preferences.enabledRemoteProfileIds.includes(profile.id) && profile.rootId === rootId, ); const duplicateState = states.find((state) => { if (state.target.profile.id === target.profile.id) return false; - const stateRootId = state.target.profile.kind === "remote" + const stateRootId = state.target.profile.kind !== 'local' ? state.target.profile.rootId : state.readiness === "ready" ? state.candidate.client.hostId @@ -1286,7 +1299,7 @@ function connectionCodeImportFailure( } function requireSaveInput(value: unknown): asserts value is { - readonly profile: RemoteRuntimeHostProfile; + readonly profile: PersistedRuntimeHostProfile; readonly credential?: string; } { if (typeof value !== "object" || value === null || !("profile" in value)) { diff --git a/apps/desktop/src/main/runtime-host-wsl-controller.ts b/apps/desktop/src/main/runtime-host-wsl-controller.ts new file mode 100644 index 0000000000..63bd2e0740 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-wsl-controller.ts @@ -0,0 +1,279 @@ +/* + * 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 { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { realpath, stat } from 'node:fs/promises'; +import type { Readable } from 'node:stream'; +import { + normalizeRuntimeHostWslDistribution, + resolveSystemRuntimeHostWslExecutable, + type RuntimeHostWslProcessFactory, +} from '@maka/runtime-host/client'; +import { + decodeRuntimeHostSetupFrame, + RUNTIME_HOST_SETUP_FRAME_PREFIX, + RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, + type RuntimeHostSetupFrame, + type RuntimeHostSetupPhase, +} from '@maka/runtime-host/operator'; +import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; +import { + isExactRuntimeHostSetupPackageSpecifier, + type DesktopRuntimeHostSetupPackage, +} from './runtime-host-ssh-terminal.js'; + +const WSL_SETUP_TIMEOUT_MS = 10 * 60_000; +const WSL_SETUP_OUTPUT_MAX_BYTES = 64 * 1024; +const WSL_SETUP_STDERR_MAX_BYTES = 8 * 1024; + +type RuntimeHostSetupCompleteFrame = Extract; + +export interface DesktopRuntimeHostWslSetupInput { + readonly distribution: string; + readonly setupPackage: DesktopRuntimeHostSetupPackage; + readonly principalId: string; + readonly projectDirectoryRoots?: readonly { + readonly label: string; + readonly path: string; + }[]; + readonly signal?: AbortSignal; +} + +export async function runDesktopRuntimeHostWslSetup( + input: DesktopRuntimeHostWslSetupInput, + onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void, + onComplete?: () => void, + overrides: { + readonly processFactory?: RuntimeHostWslProcessFactory; + readonly wslExecutable?: string; + } = {}, +): Promise { + input.signal?.throwIfAborted(); + const distribution = normalizeRuntimeHostWslDistribution(input.distribution); + const processFactory = overrides.processFactory ?? spawnWsl; + const executable = overrides.wslExecutable ?? resolveSystemRuntimeHostWslExecutable(); + const setupPackage = await resolveWslPackageSpecifier( + input.setupPackage, + distribution, + executable, + processFactory, + ); + const command = runtimeHostWslSetupCommand(setupPackage, input); + const child = processFactory(executable, ['--distribution', distribution, '--exec', '/bin/sh', '-lc', command]); + const abort = () => child.kill(); + input.signal?.addEventListener('abort', abort, { once: true }); + if (input.signal?.aborted) abort(); + child.stdin.end(); + let complete: RuntimeHostSetupCompleteFrame | undefined; + let failure: Error | undefined; + const filter = createRuntimeHostFramedOutputFilter({ + prefix: RUNTIME_HOST_SETUP_FRAME_PREFIX, + pendingMaxBytes: WSL_SETUP_OUTPUT_MAX_BYTES, + decode: decodeRuntimeHostSetupFrame, + label: 'WSL Maka setup', + onFrame: (frame) => { + if (frame.kind === 'progress') onProgress(frame); + else if (frame.kind === 'complete') { + if (complete) failure = new Error('WSL Maka setup returned multiple results'); + else { + complete = frame; + onComplete?.(); + } + } else failure = new Error(frame.error.message); + }, + onError: (error) => { + failure = error; + }, + }); + let outputBytes = 0; + child.stdout.on('data', (value: Buffer | string) => { + const chunk = typeof value === 'string' ? Buffer.from(value) : value; + outputBytes += chunk.byteLength; + if (outputBytes > WSL_SETUP_OUTPUT_MAX_BYTES) { + failure = new Error('WSL Maka setup output exceeded its byte limit'); + child.kill(); + return; + } + filter.push(chunk.toString('utf8')); + }); + const stderr = collectBounded(child.stderr, WSL_SETUP_STDERR_MAX_BYTES); + const timeout = setTimeout(() => child.kill(), WSL_SETUP_TIMEOUT_MS); + const [exit, capturedStderr] = await Promise.all([ + childExit(child), + stderr, + ]).finally(() => { + clearTimeout(timeout); + input.signal?.removeEventListener('abort', abort); + }); + filter.finish(); + input.signal?.throwIfAborted(); + if (failure) throw failure; + if (!complete) { + const diagnostic = formatBoundedDiagnostic(capturedStderr); + throw new Error( + `WSL Maka setup exited with code ${String(exit.code)} without a result${diagnostic ? `: ${diagnostic}` : ''}`, + ); + } + return complete; +} + +async function resolveWslPackageSpecifier( + setupPackage: DesktopRuntimeHostSetupPackage, + distribution: string, + executable: string, + processFactory: RuntimeHostWslProcessFactory, +): Promise<{ readonly specifier: string; readonly integrity?: string }> { + if (setupPackage.kind === 'npm') { + if (!isExactRuntimeHostSetupPackageSpecifier(setupPackage.specifier)) { + throw new Error('Runtime Host setup package is invalid'); + } + return { specifier: setupPackage.specifier }; + } + const archive = await realpath(setupPackage.path); + if (!(await stat(archive)).isFile() || !archive.endsWith('.tgz')) { + throw new Error('Runtime Host development package must be a .tgz file'); + } + const child = processFactory(executable, ['--distribution', distribution, '--exec', 'wslpath', '-a', '-u', archive]); + child.stdin.end(); + const stdout = collectBounded(child.stdout, 4 * 1024); + const stderr = collectBounded(child.stderr, WSL_SETUP_STDERR_MAX_BYTES); + const [exit, capturedStdout, capturedStderr] = await Promise.all([ + childExit(child), + stdout, + stderr, + ]); + const path = requireBoundedOutput(capturedStdout).toString('utf8').trim(); + if (exit.code !== 0 || !path.startsWith('/')) { + const diagnostic = formatBoundedDiagnostic(capturedStderr); + throw new Error(`WSL could not resolve the setup package path${diagnostic ? `: ${diagnostic}` : ''}`); + } + return { specifier: path, integrity: await sha512Integrity(archive) }; +} + +function runtimeHostWslSetupCommand( + setupPackage: { readonly specifier: string; readonly integrity?: string }, + input: Pick, +): string { + if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.principalId)) { + throw new Error('Runtime Host setup principal is invalid'); + } + const args = [ + 'maka', + 'runtime-host', + 'setup', + '--principal', + input.principalId, + '--preset', + 'desktop-client', + '--lifecycle', + 'on-demand', + ...(input.projectDirectoryRoots === undefined + ? [] + : input.projectDirectoryRoots.length === 0 + ? ['--no-project-roots'] + : input.projectDirectoryRoots.flatMap(({ label, path }) => [ + '--project-root-json', + JSON.stringify({ label, path }), + ])), + '--json', + ]; + const invocation = ['npx', '--yes', '--package', setupPackage.specifier, ...args] + .map(quotePosix) + .join(' '); + const environment = setupPackage.integrity + ? `${RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV}=${quotePosix(setupPackage.integrity)} ` + : ''; + const command = `maka_prefix=$(mktemp -d) || exit 1; trap 'rm -rf -- "$maka_prefix"' EXIT; cd "$maka_prefix" || exit 1; ${environment}${invocation}`; + const loginCommand = `exec /bin/sh -c ${quotePosix(command)}`; + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(loginCommand)}`; +} + +function sha512Integrity(path: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash('sha512'); + const stream = createReadStream(path); + stream.on('data', (chunk) => hash.update(chunk)); + stream.once('error', reject); + stream.once('end', () => resolve(`sha512-${hash.digest('base64')}`)); + }); +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function spawnWsl(executable: string, args: readonly string[]) { + return spawn(executable, args, { + shell: false, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +function childExit(child: ChildProcessWithoutNullStreams): Promise<{ + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +}> { + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); +} + +interface BoundedOutput { + readonly bytes: Buffer; + readonly complete: boolean; +} + +async function collectBounded(stream: Readable, maxBytes: number): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + let complete = true; + try { + for await (const value of stream) { + const chunk = typeof value === 'string' ? Buffer.from(value) : value; + const remaining = Math.max(0, maxBytes - bytes); + if (remaining > 0) { + chunks.push(chunk.subarray(0, remaining)); + bytes += Math.min(chunk.byteLength, remaining); + } + if (chunk.byteLength > remaining) complete = false; + } + } catch { + complete = false; + } + return { bytes: Buffer.concat(chunks), complete }; +} + +function requireBoundedOutput(output: BoundedOutput): Buffer { + if (!output.complete) { + throw new Error('WSL process output exceeded its byte limit or could not be read'); + } + return output.bytes; +} + +function formatBoundedDiagnostic(output: BoundedOutput): string { + const message = output.bytes.toString('utf8').trim(); + return [ + ...(message ? [message] : []), + ...(!output.complete ? [''] : []), + ].join('\n'); +} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index c92e2d20ea..c7dee03160 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -51,6 +51,7 @@ import type { ShellRunUpdate, } from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; +import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; import type { PermissionMode } from '@maka/core/permission'; import type { CollaborationMode } from '@maka/core/collaboration'; import type { OrchestrationMode } from '@maka/core/orchestration'; @@ -187,7 +188,7 @@ import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpd import type { ConfigCategory } from '@maka/storage/config-transfer'; import type { OnboardingMilestone, OnboardingMilestoneId, OnboardingState } from '@maka/core/onboarding'; import type { - RemoteRuntimeHostProfile, + PersistedRuntimeHostProfile, RuntimeHostProfile, } from '@maka/runtime-host/client'; export interface OnboardingSnapshot { @@ -357,7 +358,7 @@ export interface DesktopNewTaskCatalog { } export interface DesktopRuntimeHostProfileAddInput { - readonly profile: RemoteRuntimeHostProfile; + readonly profile: PersistedRuntimeHostProfile; readonly credential?: string; } @@ -376,7 +377,7 @@ export interface DesktopRuntimeHostProfileChangedEvent { readonly epoch: string; readonly profileId: string; readonly profileName: string; - readonly profileKind: 'local' | 'remote'; + readonly profileKind: RuntimeHostProfileKind; readonly readiness: 'connecting' | 'ready' | 'reconnecting' | 'unavailable'; readonly hostId?: string; readonly isDefault: boolean; @@ -434,16 +435,25 @@ export type DesktopRuntimeHostSshTerminalSnapshot = readonly signal: string | null; }; -export interface DesktopRuntimeHostOnboardingInput { - readonly name?: string; - readonly destination: string; - readonly sshPort?: number; - readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; -} +export type DesktopRuntimeHostOnboardingInput = + | { + readonly kind: 'ssh'; + readonly name?: string; + readonly destination: string; + readonly sshPort?: number; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + } + | { + readonly kind: 'wsl'; + readonly name?: string; + readonly distribution: string; + readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[]; + }; export type DesktopRuntimeHostOnboardingPhase = | 'preparing_cli' | 'connecting_ssh' + | 'connecting_wsl' | RuntimeHostSetupPhase | 'connecting_host'; @@ -673,6 +683,7 @@ export interface MakaBridge { }; runtimeHostOnboarding: { + listWslDistributions(): Promise; getSnapshot(): Promise; start(input: DesktopRuntimeHostOnboardingInput): Promise; cancel(): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f1a8ce589b..eec694043d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -18,6 +18,10 @@ */ import { contextBridge, ipcRenderer } from 'electron'; +import { + isRuntimeHostProfileKind, + type RuntimeHostProfileKind, +} from '@maka/runtime-host/profile-kind'; import { encodeIngestItems } from './attachment-ingest-payload.js'; import { collectThreadSearchResponses } from './multi-host-thread-search.js'; import { releaseSessionObservation } from './session-observation-release.js'; @@ -247,7 +251,11 @@ const runtimeHostScopes = new Map(); const runtimeHostProfiles = new Map(); const runtimeHostMetadata = new Map< string, - { readonly profileId: string; readonly profileName: string; readonly profileKind: 'local' | 'remote' } + { + readonly profileId: string; + readonly profileName: string; + readonly profileKind: RuntimeHostProfileKind; + } >(); const newTaskChangeListeners = new Set<() => void>(); let previousMainProcessInterruptionRead: Promise | undefined; @@ -307,7 +315,7 @@ function recordRuntimeHostIdentity(value: unknown): { if ( typeof metadata.profileId !== 'string' || typeof metadata.profileName !== 'string' || - (metadata.profileKind !== 'local' && metadata.profileKind !== 'remote') || + !isRuntimeHostProfileKind(metadata.profileKind) || (metadata.readiness !== 'ready' && metadata.readiness !== 'reconnecting') ) { throw new Error('Desktop Runtime Host identity is invalid'); @@ -1259,6 +1267,9 @@ const makaBridge = { }, }, runtimeHostOnboarding: { + listWslDistributions(): Promise { + return ipcRenderer.invoke('runtime-host-onboarding:listWslDistributions'); + }, getSnapshot(): Promise { return ipcRenderer.invoke('runtime-host-onboarding:getSnapshot'); }, diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts index 64107e1990..30dfa3de8f 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts @@ -30,6 +30,7 @@ import { } from 'react'; import type { ProjectRecord } from '@maka/core/project'; import type { SessionSummary } from '@maka/core/session'; +import { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile-kind'; import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav'; import { useUiLocale, type SessionHistoryGroup, type SessionViewMode } from '@maka/ui'; import { safeLocalStorageSet } from '../../../browser-storage.js'; @@ -217,7 +218,9 @@ export function useSessionNavigationController( const worktreeSessionIds = useMemo( () => deriveWorktreeSessionIds( - rail.sessions.filter((session) => session.profileKind !== 'remote'), + rail.sessions.filter( + (session) => !runtimeHostProfileUsesHostWorkspace(session.profileKind), + ), input.projects, ), [input.projects, rail.sessions], @@ -240,7 +243,7 @@ export function useSessionNavigationController( const sessionMeta = useCallback( (session: SessionSummary): string | undefined => { const projected = sessionById.get(session.id); - return projected?.profileKind === 'remote' + return projected && runtimeHostProfileUsesHostWorkspace(projected.profileKind) ? projected.profileName : undefined; }, diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts index 84cf027ea7..193dfafe81 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-navigation-groups.ts @@ -19,36 +19,37 @@ import type { ProjectRecord } from '@maka/core/project'; import type { UiLocale } from '@maka/core/ui-locale'; +import { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile-kind'; import type { SessionHistoryGroup } from '@maka/ui'; import type { SessionNavigationSession } from '../ports.js'; import { deriveProjectGroups } from './session-project-grouping.js'; -/** Groups local Sessions by Project and remote Sessions by Runtime Host. */ +/** Groups native-local Sessions by Project and other Sessions by Runtime Host. */ export function deriveSessionNavigationGroups( sessions: readonly SessionNavigationSession[], projects: readonly ProjectRecord[], locale: UiLocale, ): SessionHistoryGroup[] { const local: SessionNavigationSession[] = []; - const remote = new Map< + const hosts = new Map< string, { label: string; sessions: SessionNavigationSession[] } >(); for (const session of sessions) { - if (session.profileKind !== 'remote') { + if (!runtimeHostProfileUsesHostWorkspace(session.profileKind)) { local.push(session); continue; } - const group = remote.get(session.profileId) ?? { + const group = hosts.get(session.profileId) ?? { label: session.profileName, sessions: [], }; group.sessions.push(session); - remote.set(session.profileId, group); + hosts.set(session.profileId, group); } return [ ...deriveProjectGroups(local, projects, locale), - ...[...remote].map(([id, group]) => ({ + ...[...hosts].map(([id, group]) => ({ id: `runtime-host:${id}`, label: group.label, sessions: group.sessions, diff --git a/apps/desktop/src/renderer/features/session-navigation/ports.ts b/apps/desktop/src/renderer/features/session-navigation/ports.ts index 60c45db690..19cdcfaf47 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ports.ts +++ b/apps/desktop/src/renderer/features/session-navigation/ports.ts @@ -18,13 +18,14 @@ */ import type { SessionSummary } from '@maka/core/session'; +import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; export type SessionNavigationRemoveDisposition = 'removed' | 'restored'; export interface SessionNavigationSession extends SessionSummary { readonly profileId: string; readonly profileName: string; - readonly profileKind: 'local' | 'remote'; + readonly profileKind: RuntimeHostProfileKind; } /** The minimum catalog mutation capability needed by Session Navigation. */ diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts index d59a9577cc..23602b0a60 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -25,6 +25,10 @@ import { useState, } from 'react'; import { findProjectByIdentity, type ProjectRecord } from '@maka/core/project'; +import { + runtimeHostProfileUsesHostWorkspace, + type RuntimeHostProfileKind, +} from '@maka/runtime-host/profile-kind'; import { getConversationCopy, type WorkspacePickerModel, @@ -68,7 +72,7 @@ export interface TaskEntryControllerSelectors { readonly profileId: string; readonly hostId: string; readonly name: string; - readonly kind: 'local' | 'remote'; + readonly kind: RuntimeHostProfileKind; readonly chatDefaults: ReadyTaskEntryHost['chatDefaults']; }; readonly selectedProfileId?: string; @@ -412,7 +416,8 @@ export function useTaskEntryController( return { label: selectedProject ?? selectedCatalogHost?.profile.name ?? (error ? copy.catalogUnavailable : undefined), - ...(selectedCatalogHost?.profile.kind === 'remote' + ...(selectedCatalogHost && + runtimeHostProfileUsesHostWorkspace(selectedCatalogHost.profile.kind) ? { hostBadge: selectedCatalogHost.profile.name } : {}), branch: selectedProjectId === null ? null : selectedBranch, diff --git a/apps/desktop/src/renderer/features/task-entry/ports.ts b/apps/desktop/src/renderer/features/task-entry/ports.ts index 16a52e6010..f386f96912 100644 --- a/apps/desktop/src/renderer/features/task-entry/ports.ts +++ b/apps/desktop/src/renderer/features/task-entry/ports.ts @@ -19,6 +19,7 @@ import type { ProjectRecord } from '@maka/core/project'; import type { ChatDefaultsSettings } from '@maka/core/settings'; +import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; export type TaskEntryUnsubscribe = () => void; @@ -40,7 +41,7 @@ export interface TaskEntryProjectCapabilities { export interface TaskEntryHostProfile { readonly id: string; readonly name: string; - readonly kind: 'local' | 'remote'; + readonly kind: RuntimeHostProfileKind; } export type TaskEntryHost = diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index ee4e6de8d7..048f63fbca 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -69,6 +69,10 @@ export type SettingsProjectsCopy = { setupTitle: string; setupDescription: string; setupName: string; + setupTarget: string; + sshComputer: string; + wslEnvironment: string; + wslDistribution: string; setupSshPort: string; setupDirectoryRootsDescription: string; setupConnect: string; @@ -282,11 +286,11 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { zh: { runtimeHost: { title: 'Runtime Host', - description: 'Local 与启用的远程 Host 会同时保持连接;任务仍由其所属 Host 处理。', + description: 'Local 与其他已启用的 Host 会同时保持连接;任务仍由其所属 Host 处理。', selected: '默认 Host', selectedHelp: '新任务和未指定 Host 的设置使用默认 Host', - remoteTitle: '远程 Host', - remoteDescription: '通过 SSH 自动设置一台电脑,或手动连接已有 Runtime Host。', + remoteTitle: '其他 Host', + remoteDescription: '在 SSH 电脑或本地 WSL 环境中设置 Runtime Host,也可手动连接已有 Host。', addComputer: '添加电脑', useConnectionCode: '使用连接码', configureManually: '手动配置', @@ -326,9 +330,13 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { interruptAndEnable: '中断任务并开启', interruptAndUninstall: '中断任务并移除', remoteAccessFailed: '远程访问操作失败', - setupTitle: '添加远程电脑', - setupDescription: '通过 SSH 安装并连接 Runtime Host', + setupTitle: '添加 Runtime Host', + setupDescription: '在 SSH 电脑或 WSL 环境中安装并连接 Runtime Host', setupName: '显示名称(可选)', + setupTarget: '运行位置', + sshComputer: 'SSH 计算机', + wslEnvironment: 'WSL 环境', + wslDistribution: 'WSL 发行版', setupSshPort: 'SSH 端口(可选)', setupDirectoryRootsDescription: '留空时使用远端 Home。添加目录后,只有这些目录可用于浏览并添加项目。', setupConnect: '连接', @@ -340,6 +348,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { setupPhase: { preparing_cli: '正在准备本地 CLI…', connecting_ssh: '正在连接 SSH…', + connecting_wsl: '正在连接 WSL 环境…', checking_environment: '正在检查远程环境…', installing_package: '正在安装 Maka…', installing_service: '正在启动 Runtime Host…', @@ -562,12 +571,12 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { en: { runtimeHost: { title: 'Runtime Host', - description: 'Local and enabled remote Hosts stay connected together. Each task remains owned by its Host.', + description: 'Local and other enabled Hosts stay connected together. Each task remains owned by its Host.', selected: 'Default Host', selectedHelp: 'New tasks and unscoped settings use the default Host', - remoteTitle: 'Remote Hosts', + remoteTitle: 'Other Hosts', remoteDescription: - 'Set up a computer over SSH, or connect an existing Runtime Host manually.', + 'Set up a Runtime Host on an SSH computer or local WSL environment, or connect an existing Host manually.', addComputer: 'Add computer', useConnectionCode: 'Use connection code', configureManually: 'Configure manually', @@ -607,9 +616,13 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { interruptAndEnable: 'Interrupt and enable', interruptAndUninstall: 'Interrupt and remove', remoteAccessFailed: 'Remote access failed', - setupTitle: 'Add remote computer', - setupDescription: 'Install and connect Runtime Host over SSH', + setupTitle: 'Add Runtime Host', + setupDescription: 'Install and connect Runtime Host on an SSH computer or WSL environment', setupName: 'Display name (optional)', + setupTarget: 'Run on', + sshComputer: 'SSH computer', + wslEnvironment: 'WSL environment', + wslDistribution: 'WSL distribution', setupSshPort: 'SSH port (optional)', setupDirectoryRootsDescription: 'Leave empty to use the remote Home directory. When directories are added, only those locations can be browsed to add projects.', setupConnect: 'Connect', @@ -621,6 +634,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { setupPhase: { preparing_cli: 'Preparing the local CLI…', connecting_ssh: 'Connecting over SSH…', + connecting_wsl: 'Connecting to the WSL environment…', checking_environment: 'Checking the remote environment…', installing_package: 'Installing Maka…', installing_service: 'Starting Runtime Host…', diff --git a/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx index c806c30b0a..90caa524b9 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx @@ -21,7 +21,8 @@ import { useEffect, useRef, useState } from 'react'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Text } from '@astryxdesign/core/Text'; -import { Banner, Button, FormLayout, Spinner, TextInput, useUiLocale } from '@maka/ui'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core'; +import { Banner, Button, FormLayout, Selector, Spinner, TextInput, useUiLocale } from '@maka/ui'; import type { DesktopRuntimeHostOnboardingSnapshot } from '../../preload/bridge-contract.js'; import { canonicalProjectDirectoryRoots, @@ -46,7 +47,10 @@ export function RuntimeHostOnboardingDialog(props: { revision: 0, }); const [name, setName] = useState(''); + const [targetKind, setTargetKind] = useState<'ssh' | 'wsl'>('ssh'); const [destination, setDestination] = useState(''); + const [distribution, setDistribution] = useState(''); + const [distributions, setDistributions] = useState([]); const [sshPort, setSshPort] = useState(''); const [projectDirectoryRoots, setProjectDirectoryRoots] = useState< readonly ProjectDirectoryRootDraft[] @@ -69,21 +73,42 @@ export function RuntimeHostOnboardingDialog(props: { }; }, [props.isOpen]); + useEffect(() => { + if (!props.isOpen || !navigator.userAgent.includes('Windows')) return; + let disposed = false; + void window.maka.runtimeHostOnboarding.listWslDistributions().then((available) => { + if (disposed) return; + setDistributions(available); + setDistribution((current) => current || available[0] || ''); + }, () => undefined); + return () => { disposed = true; }; + }, [props.isOpen]); + const running = snapshot.kind === 'running'; const canStart = - destination.trim().length > 0 && - validOptionalPort(sshPort) && + (targetKind === 'wsl' + ? distribution.trim().length > 0 + : destination.trim().length > 0 && validOptionalPort(sshPort)) && projectDirectoryRootsValid(projectDirectoryRoots); async function start(): Promise { - const next = await window.maka.runtimeHostOnboarding.start({ - destination: destination.trim(), - ...(name.trim() ? { name: name.trim() } : {}), - ...(sshPort.trim() ? { sshPort: Number(sshPort) } : {}), - ...(projectDirectoryRoots.length === 0 - ? {} - : { projectDirectoryRoots: canonicalProjectDirectoryRoots(projectDirectoryRoots) }), - }); + const roots = projectDirectoryRoots.length === 0 + ? {} + : { projectDirectoryRoots: canonicalProjectDirectoryRoots(projectDirectoryRoots) }; + const next = await window.maka.runtimeHostOnboarding.start(targetKind === 'wsl' + ? { + kind: 'wsl', + distribution: distribution.trim(), + ...(name.trim() ? { name: name.trim() } : {}), + ...roots, + } + : { + kind: 'ssh', + destination: destination.trim(), + ...(name.trim() ? { name: name.trim() } : {}), + ...(sshPort.trim() ? { sshPort: Number(sshPort) } : {}), + ...roots, + }); if (next.revision > revision.current) { revision.current = next.revision; setSnapshot(next); @@ -147,20 +172,50 @@ export function RuntimeHostOnboardingDialog(props: { isDisabled={running} onChange={setName} /> - - + {navigator.userAgent.includes('Windows') ? ( + setTargetKind(value as 'ssh' | 'wsl')} + > + + + + ) : null} + {targetKind === 'wsl' ? ( + distributions.length > 0 ? ( + ({ value, label: value }))} + onChange={setDistribution} + /> + ) : ( + + ) + ) : ( + <> + + + + )}
{copy.directoryRoots} diff --git a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx index 1915aad5c7..903f10c63b 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx @@ -376,7 +376,7 @@ export function RuntimeHostProfilesSection(props: { } } - const remoteEntries = snapshot?.entries.filter((entry) => entry.profile.kind === "remote") ?? []; + const connectedEntries = snapshot?.entries.filter((entry) => entry.profile.kind !== 'local') ?? []; const profileOptions = (snapshot?.entries ?? []) .filter((entry) => entry.enabled) .map((entry) => ({ @@ -618,23 +618,25 @@ export function RuntimeHostProfilesSection(props: { /> ) : null} - {remoteEntries.length === 0 && !showAdd ? ( + {connectedEntries.length === 0 && !showAdd ? ( ) : ( - {remoteEntries.map((entry) => { + {connectedEntries.map((entry) => { const profile = entry.profile; - if (profile.kind !== "remote") return null; + if (profile.kind === 'local') return null; return (