diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 64d7fe30f4..4086f5f6de 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -624,7 +624,7 @@ "react": 1 }, "importSpecifiers": 22, - "nonTriviaTokens": 990 + "nonTriviaTokens": 977 }, "src/renderer/app-shell-project-actions.ts": { "importDeclarations": 9, @@ -945,7 +945,7 @@ "useShellRunUpdates": 1, "useShellSearch": 1, "useStableActions": 7, - "useState": 18, + "useState": 17, "useSystemUiLocale": 1, "useTaskEntryController": 1, "useTaskSubmissionReadiness": 1, @@ -1063,7 +1063,7 @@ "react": 1 }, "importSpecifiers": 187, - "nonTriviaTokens": 15912 + "nonTriviaTokens": 15908 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts index fc044de4d0..e6f0045d0c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-managed-services.test.ts @@ -26,6 +26,7 @@ import { createClientRuntimeHostProfileCatalog } from "@maka/runtime-host/client import { createDesktopRuntimeHostManagedServiceStore, findDesktopRuntimeHostManagedServiceBinding, + isDesktopRuntimeHostManagedSshServiceBinding, } from "../runtime-host-managed-services.js"; import { createDesktopRuntimeHostProfileService, @@ -154,6 +155,7 @@ test("keeps Desktop service bindings outside the shared profile catalog", async profile, ); assert.ok(binding); + assert.ok(isDesktopRuntimeHostManagedSshServiceBinding(binding)); assert.equal(await managedServices.markUninstallingIfCurrent(binding), true); assert.equal( findDesktopRuntimeHostManagedServiceBinding( @@ -186,3 +188,51 @@ test("keeps Desktop service bindings outside the shared profile catalog", async true, ); }); + +test("persists a WSL deployment through its environment control route", async () => { + const root = await mkdtemp(join(tmpdir(), "maka-managed-wsl-deployment-")); + roots.push(root); + const store = createDesktopRuntimeHostManagedServiceStore(root); + const environment = { + id: "ubuntu", + name: "Ubuntu", + kind: "environment" as const, + provider: { kind: "wsl" as const, distribution: "Ubuntu-24.04" }, + rootId: "a".repeat(64), + operatorPath: "/home/operator/.local/share/maka/operator", + }; + await store.save(environment, { + deployment: { + id: environment.rootId, + rootPath: "/home/operator/.config/Maka/workspaces/default", + deploymentId, + }, + }); + + assert.deepEqual( + findDesktopRuntimeHostManagedServiceBinding(await store.read(), environment), + { + profile: environment, + deployment: { + id: environment.rootId, + rootPath: "/home/operator/.config/Maka/workspaces/default", + deploymentId, + }, + state: "active", + }, + ); + await assert.rejects( + store.save( + { ...environment, id: "ubuntu-duplicate" }, + { + deployment: { + id: environment.rootId, + rootPath: "/home/operator/.config/Maka/workspaces/default", + deploymentId, + }, + }, + ), + /already bound/u, + ); + await assert.rejects(store.save(profile, deployedService), /already bound/u); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index 09de646e14..0424973723 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -36,6 +36,7 @@ import type { DesktopRuntimeHostSshUpdatePolicyInput, DesktopRuntimeHostSshUpdateReconciliationInput, } from '../runtime-host-ssh-terminal.js'; +import type { DesktopRuntimeHostWslManagementInput } from '../runtime-host-wsl-controller.js'; const DEPLOYMENT_ID = '11111111-1111-4111-8111-111111111111'; @@ -176,6 +177,100 @@ test('requires explicit interruption authority before a provider restarts active ); }); +test('routes WSL status and directory configuration through the persisted operator route', async () => { + const handlers = new Map unknown>(); + const calls: DesktopRuntimeHostWslManagementInput[] = []; + const profile = { + id: 'ubuntu', + name: 'Ubuntu', + kind: 'environment' as const, + provider: { kind: 'wsl' as const, distribution: 'Ubuntu-24.04' }, + rootId: 'a'.repeat(64), + operatorPath: '/home/operator/.local/share/maka/operator', + }; + const binding = { + profile, + deployment: { + id: 'a'.repeat(64), + rootPath: '/home/operator/.config/Maka/workspaces/default', + deploymentId: DEPLOYMENT_ID, + }, + state: 'active' as const, + }; + let reconnects = 0; + createDesktopRuntimeHostManagement({ + ...unusedUpdateDependencies(), + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), + removeHandler: (channel) => handlers.delete(channel), + }, + profiles: { + ...unusedDirectPeerProfileDependencies(), + resolveManagedService: async () => binding, + resolveManagedAccess: async () => undefined, + rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), + markManagedServiceUninstalling: async () => assert.fail('uninstall is not expected'), + markManagedServiceCleanupPending: async () => assert.fail('uninstall is not expected'), + clearManagedServiceBinding: async () => assert.fail('uninstall is not expected'), + }, + runServiceManagement: async () => assert.fail('WSL must not use SSH management'), + runWslManagement: async (input) => { + calls.push(input); + return serviceResult(input.action); + }, + runAccessManagement: async () => assert.fail('access management is not expected'), + cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'), + currentHostEpoch: () => 'before-configure', + awaitUpdatedConnection: async () => { + reconnects += 1; + }, + }); + + const run = handlers.get('runtime-host-management:run'); + const configure = handlers.get('runtime-host-management:configure-project-directories'); + assert.ok(run); + assert.ok(configure); + await run({}, profile.id, 'status'); + await configure( + {}, + profile.id, + [{ label: 'Work', path: '/srv/work' }], + `sha256:${'b'.repeat(64)}`, + false, + ); + + assert.deepEqual(calls.map(({ action, distribution, operatorPath, expectedTarget }) => ({ + action, + distribution, + operatorPath, + expectedTarget, + })), [ + { + action: 'status', + distribution: 'Ubuntu-24.04', + operatorPath: profile.operatorPath, + expectedTarget: { + serviceId: 'a'.repeat(64), + rootPath: '/home/operator/.config/Maka/workspaces/default', + rootId: 'a'.repeat(64), + deploymentId: DEPLOYMENT_ID, + }, + }, + { + action: 'configure', + distribution: 'Ubuntu-24.04', + operatorPath: profile.operatorPath, + expectedTarget: { + serviceId: 'a'.repeat(64), + rootPath: '/home/operator/.config/Maka/workspaces/default', + rootId: 'a'.repeat(64), + deploymentId: DEPLOYMENT_ID, + }, + }, + ]); + assert.equal(reconnects, 0); +}); + test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => { const handlers = new Map unknown>(); const profile = { @@ -538,6 +633,7 @@ test('publishes update progress and waits for the managed profile to reconnect', clearManagedServiceBinding: async () => undefined, }, runServiceManagement: async () => assert.fail('ordinary management is not expected'), + runWslManagement: async () => assert.fail('WSL management is not expected'), runPeerManagement: async () => assert.fail('direct peer management is not expected'), directPeerClientAvailable: false, runUpdate: async (input, onProgress) => { @@ -1312,6 +1408,8 @@ function serviceSummary(installedVersion: string) { function unusedUpdateDependencies() { return { + runWslManagement: async (): Promise => + assert.fail('WSL management is not expected'), runUpdate: async (): Promise => assert.fail('update is not expected'), runUpdatePolicy: async (): Promise => assert.fail('update policy is not expected'), runUpdateReconciliation: async (): Promise => 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 c40b096d21..8d2b3302c0 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts @@ -19,15 +19,19 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import type { EnvironmentRuntimeHostProfile } from '@maka/runtime-host/client'; import type { DesktopRuntimeHostProfileAddInput } from '../../preload/bridge-contract.js'; -import type { DesktopRuntimeHostManagedServiceTarget } from '../runtime-host-managed-services.js'; +import type { + DesktopRuntimeHostManagedSshServiceTarget, + DesktopRuntimeHostManagedWslServiceTarget, +} from '../runtime-host-managed-services.js'; import { createDesktopRuntimeHostOnboarding } from '../runtime-host-onboarding.js'; test('persists a verified on-demand SSH profile without endpoint or credential projection', async () => { let setupInput: unknown; let saved: | (DesktopRuntimeHostProfileAddInput & { - readonly managedService?: DesktopRuntimeHostManagedServiceTarget; + readonly managedService?: DesktopRuntimeHostManagedSshServiceTarget; }) | undefined; const harness = createHarness({ @@ -93,18 +97,28 @@ test('persists a verified on-demand SSH profile without endpoint or credential p }); test('onboards WSL as a credential-free environment profile', async () => { - let saved: DesktopRuntimeHostProfileAddInput | undefined; + let saved: + | { + readonly profile: EnvironmentRuntimeHostProfile; + readonly managedService: DesktopRuntimeHostManagedWslServiceTarget; + } + | undefined; const peerTargets: string[] = []; const harness = createHarness({ profiles: { - addAndEnable: async (input) => { + addManagedEnvironmentAndEnable: async (input) => { saved = input; - return { kind: 'connected', snapshot: { entries: [], defaultProfileId: 'local' } }; + return { + profileId: input.profile.id, + }; }, }, runWslSetup: async (_input, _onProgress, onComplete) => { onComplete(); return { + serviceId: 'a'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', + rootPath: '/home/operator/.config/Maka/workspaces/default', rootId: 'a'.repeat(64), operatorPath: '/home/operator/.local/share/maka/operator', }; @@ -121,7 +135,6 @@ test('onboards WSL as a credential-free environment profile', async () => { }); 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', @@ -130,6 +143,13 @@ test('onboards WSL as a credential-free environment profile', async () => { rootId: 'a'.repeat(64), operatorPath: '/home/operator/.local/share/maka/operator', }); + assert.deepEqual(saved?.managedService, { + deployment: { + id: 'a'.repeat(64), + rootPath: '/home/operator/.config/Maka/workspaces/default', + deploymentId: '00000000-0000-4000-8000-000000000001', + }, + }); assert.deepEqual(peerTargets, ['none']); await harness.onboarding.close(); }); @@ -296,7 +316,7 @@ function createHarness(overrides: HarnessOverrides = {}) { const onboarding = createDesktopRuntimeHostOnboarding({ clientInstanceId: 'stable-client', profiles: { - addAndEnable: async () => assert.fail('profile must not be saved'), + addManagedEnvironmentAndEnable: async () => assert.fail('profile must not be saved'), addAndEnableVerified: async () => assert.fail('profile must not be saved'), ...profiles, }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts index 7b63ce2568..48fffe5211 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts @@ -41,7 +41,11 @@ import { RuntimeHostPairingFinalizationInterruptedError, type RuntimeHostDesktopTargetState, } from "../runtime-host-desktop-manager.js"; -import { createDesktopRuntimeHostManagedServiceStore } from "../runtime-host-managed-services.js"; +import { + createDesktopRuntimeHostManagedServiceStore, + findDesktopRuntimeHostManagedServiceBinding, + isDesktopRuntimeHostManagedSshServiceBinding, +} from "../runtime-host-managed-services.js"; import { createDesktopRuntimeHostPairingIntent, writeDesktopRuntimeHostPairingIntents, @@ -272,6 +276,58 @@ test("keeps Local enabled while a new remote Host connects", async () => { ); }); +test("reuses the existing WSL profile when the same managed Host is added again", async () => { + const root = await clientRoot(); + const catalog = createClientRuntimeHostProfileCatalog(root); + const managedServices = createDesktopRuntimeHostManagedServiceStore(root); + const existing = { + id: "ubuntu", + name: "Ubuntu", + kind: "environment" as const, + provider: { kind: "wsl" as const, distribution: "Ubuntu-24.04" }, + rootId: ROOT_ID, + operatorPath: "/home/operator/.local/share/Maka/runtime-host-services/operator", + }; + await catalog.create(existing); + const enabled: string[] = []; + const service = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup: await resolveDesktopRuntimeHostStartup(root, { catalog }), + catalog, + managedServices, + states: () => [connectingLocal()], + enable: async (target) => { + enabled.push(target.profile.id); + }, + disable: async () => undefined, + setDefault: () => undefined, + finalizePairing: async () => undefined, + }); + const managedService = { + deployment: { + id: ROOT_ID, + rootPath: "/home/operator/.config/Maka/workspaces/default", + deploymentId: "11111111-1111-4111-8111-111111111111", + }, + }; + + const result = await service.addManagedEnvironmentAndEnable({ + profile: { ...existing, id: "replacement", name: "Replacement" }, + managedService, + }); + + assert.equal(result.profileId, existing.id); + assert.deepEqual((await catalog.read()).profiles, [existing]); + assert.deepEqual(enabled, [existing.id]); + assert.deepEqual( + findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + existing, + ), + { profile: existing, ...managedService, state: "active" }, + ); +}); + test("reconnects an enabled remote Host with interactive SSH", async () => { const root = await clientRoot(); const catalog = createClientRuntimeHostProfileCatalog(root); @@ -711,6 +767,7 @@ test("keeps a managed Direct route on the SSH profile credential authority", asy }; const managedBinding = await service.resolveManagedService(MANAGED_PROFILE.id); assert.ok(managedBinding); + assert.ok(isDesktopRuntimeHostManagedSshServiceBinding(managedBinding)); await assert.rejects( service.remove(MANAGED_PROFILE.id), /remove the Direct peer profile/u, @@ -786,6 +843,7 @@ test("recovers interrupted managed credential rotation after restart", async () assert.equal((await catalog.resolve(MANAGED_PROFILE.id)).credential, "new-token"); const managed = await service.resolveManagedService(MANAGED_PROFILE.id); assert.ok(managed); + assert.ok(isDesktopRuntimeHostManagedSshServiceBinding(managed)); await assert.rejects( service.markManagedServiceUninstalling(managed), /unfinished pairing/u, diff --git a/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts b/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts index 42fbc75a09..71023c8549 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-wsl-controller.test.ts @@ -25,10 +25,97 @@ import { PassThrough } from 'node:stream'; import test from 'node:test'; import type { RuntimeHostWslProcessFactory } from '@maka/runtime-host/client'; import { + encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, + RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, } from '@maka/runtime-host/operator'; -import { runDesktopRuntimeHostWslSetup } from '../runtime-host-wsl-controller.js'; +import { + runDesktopRuntimeHostWslManagement, + runDesktopRuntimeHostWslSetup, +} from '../runtime-host-wsl-controller.js'; + +test('WSL management invokes the stable operator directly with the exact deployment target', async () => { + let launch: + | { readonly executable: string; readonly args: readonly string[]; readonly environment: NodeJS.ProcessEnv } + | undefined; + const frame = encodeRuntimeHostServiceManagementFrame({ + schemaVersion: 1, + kind: 'result', + action: 'configure', + service: { + platform: 'linux', + arch: 'x64', + osRelease: '6.8.0', + state: 'running', + pid: 42, + lastExitCode: 0, + installedVersion: '0.2.0', + configurationFingerprint: `sha256:${'c'.repeat(64)}`, + projectDirectoryRoots: [{ label: '工作', path: '/srv/work' }], + }, + configuration: { kind: 'configured' }, + }); + const result = await runDesktopRuntimeHostWslManagement({ + distribution: 'Ubuntu', + operatorPath: '/home/operator/.local/share/maka/operator', + action: 'configure', + expectedTarget: { + serviceId: 'a'.repeat(64), + rootPath: '/home/operator/.config/Maka/workspaces/default', + rootId: 'a'.repeat(64), + deploymentId: '00000000-0000-4000-8000-000000000001', + }, + projectDirectoryRoots: [{ label: 'Work', path: '/srv/work' }], + expectedConfigFingerprint: `sha256:${'b'.repeat(64)}`, + }, { + wslExecutable: 'wsl.exe', + processFactory: (executable, args, environment) => { + launch = { executable, args: [...args], environment }; + const child = new EventEmitter() as ChildProcessWithoutNullStreams; + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + Object.assign(child, { stdin, stdout, stderr, kill: () => true }); + process.nextTick(() => { + const bytes = Buffer.from(frame); + const split = bytes.indexOf(Buffer.from('工作')) + 1; + stdout.write(bytes.subarray(0, split)); + stdout.end(bytes.subarray(split)); + stderr.end(); + child.emit('close', 0, null); + }); + return child; + }, + }); + + assert.equal(launch?.executable, 'wsl.exe'); + assert.deepEqual(launch?.args.slice(0, 5), [ + '--distribution', + 'Ubuntu', + '--exec', + '/home/operator/.local/share/maka/operator', + 'configure', + ]); + assert.ok(launch?.args.includes('--expected-deployment-id')); + assert.equal( + launch?.environment[RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV], + '1', + ); + assert.ok( + launch?.environment.WSLENV?.split(':').includes( + RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, + ), + ); + assert.equal(result.kind, 'result'); + assert.equal(result.action, 'configure'); + if (result.kind !== 'result' || result.action !== 'configure') { + assert.fail('Expected the WSL operator configure result'); + } + assert.deepEqual(result.service.projectDirectoryRoots, [ + { label: '工作', path: '/srv/work' }, + ]); +}); test('WSL setup forwards the development archive and its exact evidence', async () => { const launches: string[][] = []; diff --git a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts index 0988848d20..1e32f93f0c 100644 --- a/apps/desktop/src/main/__tests__/task-entry-controller.test.ts +++ b/apps/desktop/src/main/__tests__/task-entry-controller.test.ts @@ -108,7 +108,10 @@ function deferred() { let latestController: TaskEntryController | undefined; function ControllerProbe(props: { reportError(error: unknown): void }) { - latestController = useTaskEntryController({ reportError: props.reportError }); + latestController = useTaskEntryController({ + reportError: props.reportError, + manageProjects() {}, + }); return null; } diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 4f5f78ee3c..3962b57441 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -184,7 +184,10 @@ import { import { createDesktopRuntimeHostSshTerminal, } from "./runtime-host-ssh-terminal.js"; -import { runDesktopRuntimeHostWslSetup } from './runtime-host-wsl-controller.js'; +import { + runDesktopRuntimeHostWslManagement, + runDesktopRuntimeHostWslSetup, +} from './runtime-host-wsl-controller.js'; import { createRuntimeHostSetupPackageResolver, desktopRuntimeHostDevelopmentPeerTarget, @@ -569,6 +572,7 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ ipcMain, profiles: runtimeHostProfileService, runServiceManagement: runtimeHostSshTerminal.runServiceManagement, + runWslManagement: runDesktopRuntimeHostWslManagement, runPeerManagement: runtimeHostSshTerminal.runPeerManagement, directPeerClientAvailable: runtimeHostDirectPeerAvailable, runUpdate: runtimeHostSshTerminal.runUpdate, diff --git a/apps/desktop/src/main/runtime-host-managed-services.ts b/apps/desktop/src/main/runtime-host-managed-services.ts index 2c9202543d..665c52724b 100644 --- a/apps/desktop/src/main/runtime-host-managed-services.ts +++ b/apps/desktop/src/main/runtime-host-managed-services.ts @@ -21,9 +21,12 @@ import { randomUUID } from "node:crypto"; import { mkdir, open, readFile, rename, rm } from "node:fs/promises"; import { dirname, join } from "node:path"; import { - decodeRemoteRuntimeHostProfile, - sameRemoteRuntimeHostProfileTarget, + decodePersistedRuntimeHostProfile, + sameResolvedRuntimeHostProfileTarget, + type EnvironmentRuntimeHostProfile, + type PersistedRuntimeHostProfile, type RemoteRuntimeHostProfile, + type RuntimeHostRemoteTransport, } from "@maka/runtime-host/client"; import { requireHostRootId } from "@maka/runtime-host/protocol"; import { withFileUpdateLock } from "@maka/storage/file-update-lock"; @@ -40,23 +43,72 @@ export interface DesktopRuntimeHostDeploymentBinding { readonly deploymentId?: string; } -export interface DesktopRuntimeHostControlRoute { +type ManagedSshRuntimeHostProfile = RemoteRuntimeHostProfile & { + readonly transport: Extract; +}; + +interface DesktopRuntimeHostSshControlRoute { readonly kind: "ssh_operator"; readonly operatorPath: string; } -export interface DesktopRuntimeHostManagedServiceTarget { +interface DesktopRuntimeHostManagedServiceTargetBase { readonly deployment: DesktopRuntimeHostDeploymentBinding; - readonly control: DesktopRuntimeHostControlRoute; } -export interface DesktopRuntimeHostManagedServiceBinding { - readonly profile: RemoteRuntimeHostProfile; +export interface DesktopRuntimeHostManagedSshServiceTarget + extends DesktopRuntimeHostManagedServiceTargetBase { + readonly control: DesktopRuntimeHostSshControlRoute; +} + +export type DesktopRuntimeHostManagedWslServiceTarget = + DesktopRuntimeHostManagedServiceTargetBase; + +type DesktopRuntimeHostManagedServiceTarget = + | DesktopRuntimeHostManagedSshServiceTarget + | DesktopRuntimeHostManagedWslServiceTarget; + +interface DesktopRuntimeHostManagedServiceBindingBase { readonly deployment: DesktopRuntimeHostDeploymentBinding; - readonly control: DesktopRuntimeHostControlRoute; - readonly state: "active" | "uninstalling" | "cleanup_pending"; } +export type DesktopRuntimeHostManagedSshServiceBinding = + DesktopRuntimeHostManagedServiceBindingBase & { + readonly profile: ManagedSshRuntimeHostProfile; + readonly control: DesktopRuntimeHostSshControlRoute; + readonly state: "active" | "uninstalling" | "cleanup_pending"; + }; + +export type DesktopRuntimeHostManagedServiceBinding = + | DesktopRuntimeHostManagedSshServiceBinding + | (DesktopRuntimeHostManagedServiceBindingBase & { + readonly profile: EnvironmentRuntimeHostProfile; + readonly state: "active"; + }); + +export function isDesktopRuntimeHostManagedSshProfile( + profile: PersistedRuntimeHostProfile, +): profile is ManagedSshRuntimeHostProfile { + return profile.kind === "remote" && profile.transport.kind === "ssh"; +} + +export function isDesktopRuntimeHostManagedSshServiceBinding( + binding: DesktopRuntimeHostManagedServiceBinding, +): binding is DesktopRuntimeHostManagedSshServiceBinding { + return binding.profile.kind === "remote"; +} + +type DesktopRuntimeHostManagedServiceBindingInput = + | { + readonly profile: ManagedSshRuntimeHostProfile; + readonly deployment: DesktopRuntimeHostDeploymentBinding; + readonly control: DesktopRuntimeHostSshControlRoute; + } + | { + readonly profile: EnvironmentRuntimeHostProfile; + readonly deployment: DesktopRuntimeHostDeploymentBinding; + }; + export interface DesktopRuntimeHostManagedServiceDocument { readonly schemaVersion: typeof SCHEMA_VERSION; readonly bindings: readonly DesktopRuntimeHostManagedServiceBinding[]; @@ -65,23 +117,27 @@ export interface DesktopRuntimeHostManagedServiceDocument { export interface DesktopRuntimeHostManagedServiceStore { read(): Promise; save( - profile: RemoteRuntimeHostProfile, - target: DesktopRuntimeHostManagedServiceTarget, + profile: ManagedSshRuntimeHostProfile, + target: DesktopRuntimeHostManagedSshServiceTarget, + ): Promise; + save( + profile: EnvironmentRuntimeHostProfile, + target: DesktopRuntimeHostManagedWslServiceTarget, ): Promise; removeIfCurrent( binding: DesktopRuntimeHostManagedServiceBinding, ): Promise; removeForProfileIfCurrent( - profile: RemoteRuntimeHostProfile, + profile: PersistedRuntimeHostProfile, ): Promise; markUninstallingIfCurrent( - binding: DesktopRuntimeHostManagedServiceBinding, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise; markCleanupPendingIfCurrent( - binding: DesktopRuntimeHostManagedServiceBinding, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise; removeCleanupPendingIfCurrent( - binding: DesktopRuntimeHostManagedServiceBinding, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise; } @@ -96,12 +152,12 @@ export function createDesktopRuntimeHostManagedServiceStore( export function findDesktopRuntimeHostManagedServiceBinding( document: DesktopRuntimeHostManagedServiceDocument, - profile: RemoteRuntimeHostProfile, + profile: PersistedRuntimeHostProfile, ): DesktopRuntimeHostManagedServiceBinding | undefined { const binding = document.bindings.find( (candidate) => candidate.profile.id === profile.id, ); - return binding && sameRemoteRuntimeHostProfileTarget(binding.profile, profile) + return binding && sameManagedProfileTarget(binding.profile, profile) ? binding : undefined; } @@ -113,7 +169,7 @@ export function sameDesktopRuntimeHostManagedServiceBinding( return ( left.state === right.state && left.profile.id === right.profile.id && - sameRemoteRuntimeHostProfileTarget(left.profile, right.profile) && + sameManagedProfileTarget(left.profile, right.profile) && sameBindingTarget(left, right) ); } @@ -158,22 +214,39 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan } save( - value: RemoteRuntimeHostProfile, - managedTarget: DesktopRuntimeHostManagedServiceTarget, + profile: ManagedSshRuntimeHostProfile, + target: DesktopRuntimeHostManagedSshServiceTarget, + ): Promise; + save( + profile: EnvironmentRuntimeHostProfile, + target: DesktopRuntimeHostManagedWslServiceTarget, + ): Promise; + save( + profile: ManagedSshRuntimeHostProfile | EnvironmentRuntimeHostProfile, + target: DesktopRuntimeHostManagedServiceTarget, ): Promise { - const profile = decodeRemoteRuntimeHostProfile(value); - if (profile.transport.kind !== "ssh") { - return Promise.reject( - new Error("A managed Runtime Host service requires SSH"), - ); - } - const deployment = decodeDeployment(managedTarget.deployment); - const control = decodeControlRoute(managedTarget.control); + const binding = decodeBinding( + { profile, ...target }, + "Runtime Host managed service binding", + ); + const bindingProfile = binding.profile; return this.#exclusive(async () => { const current = await this.#readUnlocked(); const bindings = current.bindings.filter( - (binding) => binding.profile.id !== profile.id, + (binding) => binding.profile.id !== bindingProfile.id, ); + if ( + bindings.some( + (binding) => + binding.profile.rootId === bindingProfile.rootId && + (bindingProfile.kind === "environment" || + binding.profile.kind === "environment"), + ) + ) { + throw new Error( + "A managed Runtime Host deployment is already bound to another profile", + ); + } if (bindings.length >= BINDING_COUNT_MAX) { throw new Error( "Too many managed Runtime Host services are configured", @@ -184,9 +257,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan bindings: [ ...bindings, { - profile, - deployment, - control, + ...binding, state: "active", }, ], @@ -195,7 +266,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan } markUninstallingIfCurrent( - binding: DesktopRuntimeHostManagedServiceBinding, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise { return this.#setStateIfCurrent( binding, @@ -205,7 +276,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan } markCleanupPendingIfCurrent( - binding: DesktopRuntimeHostManagedServiceBinding, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise { return this.#setStateIfCurrent( binding, @@ -215,7 +286,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan } removeCleanupPendingIfCurrent( - binding: DesktopRuntimeHostManagedServiceBinding, + binding: DesktopRuntimeHostManagedSshServiceBinding, ): Promise { return this.#remove(binding, "cleanup_pending"); } @@ -224,14 +295,14 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan return this.#remove(binding); } - removeForProfileIfCurrent(value: RemoteRuntimeHostProfile): Promise { - return this.#remove(undefined, undefined, decodeRemoteRuntimeHostProfile(value)); + removeForProfileIfCurrent(value: PersistedRuntimeHostProfile): Promise { + return this.#remove(undefined, undefined, decodePersistedRuntimeHostProfile(value)); } #remove( expected?: DesktopRuntimeHostManagedServiceBinding, state?: DesktopRuntimeHostManagedServiceBinding["state"], - profileOverride?: RemoteRuntimeHostProfile, + profileOverride?: PersistedRuntimeHostProfile, ): Promise { const profile = expected?.profile ?? profileOverride!; return this.#exclusive(async () => { @@ -241,7 +312,7 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan ); if ( !binding || - !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) || + !sameManagedProfileTarget(binding.profile, profile) || (expected && !sameBindingTarget(binding, expected)) || (state && binding.state !== state) ) { @@ -258,9 +329,9 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan } #setStateIfCurrent( - expected: DesktopRuntimeHostManagedServiceBinding, - allowedStates: readonly DesktopRuntimeHostManagedServiceBinding["state"][], - state: DesktopRuntimeHostManagedServiceBinding["state"], + expected: DesktopRuntimeHostManagedSshServiceBinding, + allowedStates: readonly DesktopRuntimeHostManagedSshServiceBinding["state"][], + state: DesktopRuntimeHostManagedSshServiceBinding["state"], ): Promise { const profile = expected.profile; return this.#exclusive(async () => { @@ -270,7 +341,8 @@ class FileDesktopRuntimeHostManagedServiceStore implements DesktopRuntimeHostMan ); if ( !binding || - !sameRemoteRuntimeHostProfileTarget(binding.profile, profile) || + !isDesktopRuntimeHostManagedSshServiceBinding(binding) || + !sameManagedProfileTarget(binding.profile, profile) || !sameBindingTarget(binding, expected) || !allowedStates.includes(binding.state) ) { @@ -313,15 +385,16 @@ function decodeDocument( ); } const bindings = record.bindings.map((candidate) => { + const candidateProfile = decodePersistedRuntimeHostProfile( + (candidate as { readonly profile?: unknown } | null)?.profile, + ); const binding = requireExactRecord( candidate, "Runtime Host managed service binding", - ["control", "deployment", "profile", "state"], + candidateProfile.kind === "environment" + ? ["deployment", "profile", "state"] + : ["control", "deployment", "profile", "state"], ); - const profile = decodeRemoteRuntimeHostProfile(binding.profile); - if (profile.transport.kind !== "ssh") { - throw new Error("A managed Runtime Host service requires SSH"); - } if ( binding.state !== "active" && binding.state !== "uninstalling" && @@ -329,10 +402,24 @@ function decodeDocument( ) { throw new Error("Runtime Host managed service state is invalid"); } + const decoded = decodeBinding( + binding, + "Runtime Host managed service binding", + ); + if (!("control" in decoded)) { + if (binding.state !== "active") { + throw new Error("Managed WSL Runtime Host binding state is invalid"); + } + return Object.freeze({ + profile: decoded.profile, + deployment: decoded.deployment, + state: "active" as const, + }); + } return Object.freeze({ - profile, - deployment: decodeDeployment(binding.deployment), - control: decodeControlRoute(binding.control), + profile: decoded.profile, + deployment: decoded.deployment, + control: decoded.control, state: binding.state, }); }); @@ -344,6 +431,24 @@ function decodeDocument( "Runtime Host managed service bindings must have unique profile IDs", ); } + const bindingCountByRootId = new Map(); + for (const binding of bindings) { + bindingCountByRootId.set( + binding.profile.rootId, + (bindingCountByRootId.get(binding.profile.rootId) ?? 0) + 1, + ); + } + if ( + bindings.some( + (binding) => + binding.profile.kind === "environment" && + bindingCountByRootId.get(binding.profile.rootId)! > 1, + ) + ) { + throw new Error( + "Managed WSL Runtime Host State Roots cannot have another deployment binding", + ); + } return Object.freeze({ schemaVersion: SCHEMA_VERSION, bindings: Object.freeze(bindings), @@ -400,7 +505,7 @@ function decodeDeployment(value: unknown): DesktopRuntimeHostDeploymentBinding { }); } -function decodeControlRoute(value: unknown): DesktopRuntimeHostControlRoute { +function decodeSshControlRoute(value: unknown): DesktopRuntimeHostSshControlRoute { const record = requireExactRecord( value, "Managed Runtime Host control route", @@ -419,6 +524,26 @@ function decodeControlRoute(value: unknown): DesktopRuntimeHostControlRoute { return Object.freeze({ kind: "ssh_operator", operatorPath }); } +function decodeBinding( + value: { + readonly profile?: unknown; + readonly deployment?: unknown; + readonly control?: unknown; + }, + label: string, +): DesktopRuntimeHostManagedServiceBindingInput { + const profile = decodePersistedRuntimeHostProfile(value.profile); + const deployment = decodeDeployment(value.deployment); + if (profile.kind === "environment") { + return Object.freeze({ profile, deployment }); + } + if (!isDesktopRuntimeHostManagedSshProfile(profile)) { + throw new Error(`${label} has no supported control route`); + } + const control = decodeSshControlRoute(value.control); + return Object.freeze({ profile, deployment, control }); +} + function decodeLegacyService(value: unknown): { readonly id: string; readonly rootPath: string; @@ -495,15 +620,32 @@ function sameBindingTarget( left: DesktopRuntimeHostManagedServiceBinding, right: DesktopRuntimeHostManagedServiceBinding, ): boolean { + if ( + left.deployment.id !== right.deployment.id || + left.deployment.rootPath !== right.deployment.rootPath || + left.deployment.deploymentId !== right.deployment.deploymentId + ) { + return false; + } + if (!isDesktopRuntimeHostManagedSshServiceBinding(left)) { + return !isDesktopRuntimeHostManagedSshServiceBinding(right); + } return ( - left.deployment.id === right.deployment.id && - left.deployment.rootPath === right.deployment.rootPath && - left.deployment.deploymentId === right.deployment.deploymentId && - left.control.kind === right.control.kind && + isDesktopRuntimeHostManagedSshServiceBinding(right) && left.control.operatorPath === right.control.operatorPath ); } +function sameManagedProfileTarget( + left: PersistedRuntimeHostProfile, + right: PersistedRuntimeHostProfile, +): boolean { + return ( + left.id === right.id && + sameResolvedRuntimeHostProfileTarget({ profile: left }, { profile: right }) + ); +} + function emptyDocument(): DesktopRuntimeHostManagedServiceDocument { return Object.freeze({ schemaVersion: SCHEMA_VERSION, diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index 9381767178..da03a1ef0d 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -39,7 +39,12 @@ import type { DesktopRuntimeHostUpdateReconciliationResponse, } from '../preload/bridge-contract.js'; import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; -import { sameDesktopRuntimeHostManagedServiceBinding } from './runtime-host-managed-services.js'; +import { + isDesktopRuntimeHostManagedSshServiceBinding, + sameDesktopRuntimeHostManagedServiceBinding, + type DesktopRuntimeHostManagedServiceBinding, + type DesktopRuntimeHostManagedSshServiceBinding, +} from './runtime-host-managed-services.js'; import { requireProjectDirectoryRoots } from '../shared/runtime-host-project-directory-policy.js'; import type { DesktopRuntimeHostSshCleanupInput, @@ -61,6 +66,7 @@ import type { DesktopRuntimeHostManagementProvider, DesktopRuntimeHostManagementTerminalFrame, } from './runtime-host-management-provider.js'; +import type { DesktopRuntimeHostWslManagementInput } from './runtime-host-wsl-controller.js'; const MANAGEMENT_ACTIONS = new Set([ 'status', @@ -94,6 +100,9 @@ export function createDesktopRuntimeHostManagement(input: { readonly runServiceManagement: ( input: DesktopRuntimeHostSshManagementInput, ) => Promise>; + readonly runWslManagement: ( + input: DesktopRuntimeHostWslManagementInput, + ) => Promise>; readonly runAccessManagement: ( input: DesktopRuntimeHostSshAccessInput, ) => Promise; @@ -175,10 +184,7 @@ export function createDesktopRuntimeHostManagement(input: { allowInterruptActiveTasks = false, ): Promise => { const managed = await resolveManagedService(profileId); - const { profile, deployment, control } = managed; - if (profile.transport.kind !== 'ssh') { - throw new Error('This Runtime Host profile is not bound to a managed service'); - } + const { deployment } = managed; if (managed.state !== 'active' && managementAction !== 'uninstall') { throw new Error('Finish uninstalling this Runtime Host service before managing it'); } @@ -192,22 +198,37 @@ export function createDesktopRuntimeHostManagement(input: { 'Re-onboard this Runtime Host before changing it; its legacy binding has no deployment generation', ); } + const expectedTarget = { + serviceId: deployment.id, + rootPath: deployment.rootPath, + rootId: managed.profile.rootId, + ...(deployment.deploymentId ? { deploymentId: deployment.deploymentId } : {}), + }; + if (!isDesktopRuntimeHostManagedSshServiceBinding(managed)) { + if (managementAction !== 'status') { + throw new Error('This WSL Runtime Host management action is not available'); + } + const response = await input.runWslManagement({ + distribution: managed.profile.provider.distribution, + operatorPath: managed.profile.operatorPath, + action: managementAction, + expectedTarget, + }); + return projectManagementFrame(response, false); + } const managementInput: DesktopRuntimeHostSshManagementInput = { - destination: profile.transport.destination, - ...(profile.transport.sshPort === undefined ? {} : { sshPort: profile.transport.sshPort }), - operatorPath: control.operatorPath, + destination: managed.profile.transport.destination, + ...(managed.profile.transport.sshPort === undefined + ? {} + : { sshPort: managed.profile.transport.sshPort }), + operatorPath: managed.control.operatorPath, action: managementAction, - expectedTarget: { - serviceId: deployment.id, - rootPath: deployment.rootPath, - rootId: profile.rootId, - ...(deployment.deploymentId ? { deploymentId: deployment.deploymentId } : {}), - }, + expectedTarget, ...(managementAction === 'install' ? { rootPath: deployment.rootPath, - websocketPort: profile.transport.remotePort, - websocketPath: profile.transport.websocketPath, + websocketPort: managed.profile.transport.remotePort, + websocketPath: managed.profile.transport.websocketPath, } : {}), ...((managementAction === 'uninstall' || managementAction === 'restart') && @@ -229,7 +250,7 @@ export function createDesktopRuntimeHostManagement(input: { ); } - let pending = managed; + let pending: DesktopRuntimeHostManagedSshServiceBinding = managed; if (pending.state !== 'cleanup_pending') { pending = await input.profiles.markManagedServiceUninstalling(pending); const response = await input.runServiceManagement({ @@ -324,9 +345,6 @@ export function createDesktopRuntimeHostManagement(input: { if (managed.state !== 'active') { throw new Error('Finish uninstalling this Runtime Host service before managing access'); } - if (managed.profile.transport.kind !== 'ssh') { - throw new Error('This Runtime Host profile does not have an SSH management channel'); - } return { managed, canRotate: managed.enabled, @@ -343,12 +361,9 @@ export function createDesktopRuntimeHostManagement(input: { }; }; - const managedMutationTarget = async (profileIdValue: unknown) => { - const profileId = requireProfileId(profileIdValue); - input.profiles.assertPairingComplete(profileId); + const activeManagedTarget = async (profileId: string) => { const managed = await resolveManagedService(profileId); - const transport = managed.profile.transport; - if (managed.state !== 'active' || transport.kind !== 'ssh') { + if (managed.state !== 'active') { throw new Error('This Runtime Host profile is not available for managed service changes'); } if (!managed.deployment.deploymentId) { @@ -359,7 +374,6 @@ export function createDesktopRuntimeHostManagement(input: { return { profileId, managed, - transport, expectedTarget: { serviceId: managed.deployment.id, rootPath: managed.deployment.rootPath, @@ -369,6 +383,20 @@ export function createDesktopRuntimeHostManagement(input: { }; }; + const managedMutationTarget = async (profileIdValue: unknown) => { + const profileId = requireProfileId(profileIdValue); + input.profiles.assertPairingComplete(profileId); + const target = await activeManagedTarget(profileId); + if (!isDesktopRuntimeHostManagedSshServiceBinding(target.managed)) { + throw new Error('This Runtime Host profile is not available for managed service changes'); + } + return { + ...target, + managed: target.managed, + transport: target.managed.profile.transport, + }; + }; + const reconnectManagedTarget = ( profileId: string, managed: Awaited>, @@ -643,18 +671,31 @@ export function createDesktopRuntimeHostManagement(input: { ); reconnect = () => provider.awaitUpdatedConnection(previousHostEpoch, true); } else { - const { managed, transport, expectedTarget } = await managedMutationTarget(profileId); + const { managed, expectedTarget } = await activeManagedTarget(profileId); const previousHostEpoch = input.currentHostEpoch(profileId); - execute = () => input.runServiceManagement({ - destination: transport.destination, - ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, - action: 'configure', - expectedTarget, - projectDirectoryRoots: roots, - expectedConfigFingerprint: expectedConfigFingerprintValue, - ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), - }); + if (!isDesktopRuntimeHostManagedSshServiceBinding(managed)) { + execute = () => input.runWslManagement({ + distribution: managed.profile.provider.distribution, + operatorPath: managed.profile.operatorPath, + action: 'configure', + expectedTarget, + projectDirectoryRoots: roots, + expectedConfigFingerprint: expectedConfigFingerprintValue, + ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), + }); + } else { + const transport = managed.profile.transport; + execute = () => input.runServiceManagement({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + action: 'configure', + expectedTarget, + projectDirectoryRoots: roots, + expectedConfigFingerprint: expectedConfigFingerprintValue, + ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}), + }); + } reconnect = reconnectManagedTarget(profileId, managed, previousHostEpoch); } const response = requireManagementFrame(await execute(), 'configure'); diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts index 0311ac536a..68d59f5adb 100644 --- a/apps/desktop/src/main/runtime-host-onboarding.ts +++ b/apps/desktop/src/main/runtime-host-onboarding.ts @@ -45,7 +45,10 @@ type OnboardingState = DesktopRuntimeHostOnboardingSnapshot extends infer Snapsh export function createDesktopRuntimeHostOnboarding(input: { readonly ipcMain: Pick; readonly clientInstanceId: string; - readonly profiles: Pick; + readonly profiles: Pick< + DesktopRuntimeHostProfileService, + 'addManagedEnvironmentAndEnable' | 'addAndEnableVerified' + >; readonly runSetup: ( input: DesktopRuntimeHostSshSetupInput, onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void, @@ -63,7 +66,13 @@ export function createDesktopRuntimeHostOnboarding(input: { input: DesktopRuntimeHostWslSetupInput, onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void, onComplete: () => void, - ) => Promise<{ readonly rootId: string; readonly operatorPath: string }>; + ) => Promise<{ + readonly rootId: string; + readonly rootPath: string; + readonly serviceId: string; + readonly deploymentId: string; + readonly operatorPath: string; + }>; readonly listWslDistributions: () => Promise; readonly send: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void; readonly setupPackageMode: 'published' | 'development'; @@ -261,18 +270,25 @@ export function createDesktopRuntimeHostOnboarding(input: { ); 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, + const profile = { + id: profileId, + name: request.name?.trim() || request.distribution, + kind: 'environment' as const, + provider: { kind: 'wsl' as const, distribution: request.distribution }, + rootId: complete.rootId, + operatorPath: complete.operatorPath, + }; + const connected = await input.profiles.addManagedEnvironmentAndEnable({ + profile, + managedService: { + deployment: { + id: complete.serviceId, + rootPath: complete.rootPath, + deploymentId: complete.deploymentId, + }, }, }); - if (connected.kind === 'unavailable') throw new Error(connected.message); - return publish({ kind: 'complete', profileId }); + return publish({ kind: 'complete', profileId: connected.profileId }); }; const channels = [ diff --git a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts index 017f505119..abd20a4b5e 100644 --- a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts +++ b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts @@ -36,6 +36,7 @@ import type { DesktopRuntimeHostPeerMeshTarget, } from '../preload/bridge-contract.js'; import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js'; +import { isDesktopRuntimeHostManagedSshServiceBinding } from './runtime-host-managed-services.js'; import type { DesktopRuntimeHostSshPeerMeshManagementInput, createDesktopRuntimeHostSshTerminal, @@ -187,7 +188,7 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { if ( !managed || managed.state !== 'active' || - managed.profile.transport.kind !== 'ssh' || + !isDesktopRuntimeHostManagedSshServiceBinding(managed) || !managed.deployment.deploymentId ) { throw new Error('This Runtime Host does not have an active SSH management channel'); diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index 670283e40b..f40dbc6fc0 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -23,6 +23,8 @@ import { dirname, join } from "node:path"; import { createClientRuntimeHostCredentialStore, createClientRuntimeHostProfileCatalog, + decodeEnvironmentRuntimeHostProfile, + decodeRemoteRuntimeHostProfile, decodeRuntimeHostOwnerConnectionCode, LOCAL_RUNTIME_HOST_PROFILE, RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, @@ -31,6 +33,7 @@ import { RuntimeHostRemoteCompatibilityError, sameRemoteRuntimeHostProfileTarget, sameResolvedRuntimeHostProfileTarget, + type EnvironmentRuntimeHostProfile, type PersistedRuntimeHostProfile, type RemoteRuntimeHostProfile, type ResolvedRuntimeHostProfile, @@ -61,9 +64,13 @@ import { import { createDesktopRuntimeHostManagedServiceStore, findDesktopRuntimeHostManagedServiceBinding, + isDesktopRuntimeHostManagedSshProfile, + isDesktopRuntimeHostManagedSshServiceBinding, sameDesktopRuntimeHostManagedServiceBinding, - type DesktopRuntimeHostManagedServiceTarget, type DesktopRuntimeHostManagedServiceBinding, + type DesktopRuntimeHostManagedSshServiceBinding, + type DesktopRuntimeHostManagedSshServiceTarget, + type DesktopRuntimeHostManagedWslServiceTarget, type DesktopRuntimeHostManagedServiceStore, } from "./runtime-host-managed-services.js"; import type { DesktopCollaborationConnectionTarget } from './runtime-host-collaboration-invitation.js'; @@ -92,11 +99,15 @@ export interface DesktopRuntimeHostProfileService { addAndEnable( input: DesktopRuntimeHostProfileAddInput, ): Promise; + addManagedEnvironmentAndEnable(input: { + readonly profile: EnvironmentRuntimeHostProfile; + readonly managedService: DesktopRuntimeHostManagedWslServiceTarget; + }): Promise<{ readonly profileId: string }>; addAndEnableVerified( input: { readonly profile: RemoteRuntimeHostProfile; readonly credential: string; - readonly managedService?: DesktopRuntimeHostManagedServiceTarget; + readonly managedService?: DesktopRuntimeHostManagedSshServiceTarget; }, ): Promise<{ readonly profileId: string }>; importConnectionCode(code: string): Promise; @@ -123,13 +134,13 @@ export interface DesktopRuntimeHostProfileService { }, ): Promise; removeManagedDirectPeerProfile(profileId: string): Promise; - clearManagedServiceBinding(expected: DesktopRuntimeHostManagedServiceBinding): Promise; + clearManagedServiceBinding(expected: DesktopRuntimeHostManagedSshServiceBinding): Promise; markManagedServiceUninstalling( - expected: DesktopRuntimeHostManagedServiceBinding, - ): Promise; + expected: DesktopRuntimeHostManagedSshServiceBinding, + ): Promise; markManagedServiceCleanupPending( - expected: DesktopRuntimeHostManagedServiceBinding, - ): Promise; + expected: DesktopRuntimeHostManagedSshServiceBinding, + ): Promise; rotateManagedCredential( expected: DesktopRuntimeHostManagedAccess, credential: string, @@ -143,11 +154,13 @@ export interface DesktopRuntimeHostProfileService { remove(profileId: string): Promise; } -export interface DesktopRuntimeHostManagedAccess - extends DesktopRuntimeHostManagedServiceBinding { +export type DesktopRuntimeHostManagedAccess = Extract< + DesktopRuntimeHostManagedServiceBinding, + { readonly control: { readonly kind: 'ssh_operator' } } +> & { readonly credentialFingerprint: string; readonly enabled: boolean; -} +}; export async function resolveDesktopRuntimeHostStartup( clientDataRoot: string, @@ -378,7 +391,7 @@ export function createDesktopRuntimeHostProfileService(input: { : unavailable.get(profile.id); return { profile, - ...(profile.kind === "remote" && + ...(profile.kind !== "local" && findDesktopRuntimeHostManagedServiceBinding(managedDocument, profile) ? { managedService: true as const } : {}), @@ -700,7 +713,7 @@ export function createDesktopRuntimeHostProfileService(input: { value: { readonly profile: RemoteRuntimeHostProfile; readonly credential: string; - readonly managedService?: DesktopRuntimeHostManagedServiceTarget; + readonly managedService?: DesktopRuntimeHostManagedSshServiceTarget; }, ): Promise<{ readonly profileId: string }> => { requireSaveInput(value); @@ -729,6 +742,9 @@ export function createDesktopRuntimeHostProfileService(input: { await beginPairingIntent(intent); try { if (value.managedService) { + if (!isDesktopRuntimeHostManagedSshProfile(profile)) { + throw new Error('A managed Runtime Host service requires SSH'); + } await managedServices.save(profile, value.managedService); } if (previousTarget) { @@ -758,14 +774,11 @@ export function createDesktopRuntimeHostProfileService(input: { addAndEnable(value) { requireSaveInput(value); return mutateProfiles(async () => { - 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); + const requestedProfile = decodeRemoteRuntimeHostProfile(value.profile); + const document = await catalog.create(requestedProfile, value.credential); + const profile = document.profiles.find( + (candidate) => candidate.id === requestedProfile.id, + ); if (!profile) throw new Error("Runtime Host profile creation did not persist"); const target: ResolvedRuntimeHostProfile = { profile, @@ -783,6 +796,34 @@ export function createDesktopRuntimeHostProfileService(input: { : { kind: "connected", snapshot: await snapshot() }; }); }, + addManagedEnvironmentAndEnable(value) { + const requestedProfile = decodeEnvironmentRuntimeHostProfile(value.profile); + return mutateProfiles(async () => { + const currentDocument = await catalog.read(); + const existing = currentDocument.profiles.find( + (candidate): candidate is EnvironmentRuntimeHostProfile => + candidate.kind === "environment" && + sameResolvedRuntimeHostProfileTarget( + { profile: candidate }, + { profile: requestedProfile }, + ), + ); + const profile = existing ?? requestedProfile; + if (!existing) { + const document = await catalog.create(profile); + const persisted = document.profiles.find( + (candidate) => candidate.id === profile.id, + ); + if (!persisted || persisted.kind !== "environment") { + throw new Error("Runtime Host profile creation did not persist"); + } + } + await managedServices.save(profile, value.managedService); + const error = await enable(profile.id); + if (error) throw error; + return { profileId: profile.id }; + }); + }, addAndEnableVerified, async importConnectionCode(code) { let decoded; @@ -862,7 +903,6 @@ 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, @@ -908,13 +948,12 @@ export function createDesktopRuntimeHostProfileService(input: { await managedServices.read(), resolved.profile, ); - return binding - ? { - ...binding, - credentialFingerprint: runtimeHostAccessCredentialFingerprint(resolved.credential), - enabled: preferences.enabledRemoteProfileIds.includes(profileId), - } - : undefined; + if (!binding || !isDesktopRuntimeHostManagedSshServiceBinding(binding)) return undefined; + return { + ...binding, + credentialFingerprint: runtimeHostAccessCredentialFingerprint(resolved.credential), + enabled: preferences.enabledRemoteProfileIds.includes(profileId), + }; }); }, assertPairingComplete(profileId) { @@ -1025,8 +1064,10 @@ export function createDesktopRuntimeHostProfileService(input: { ); if ( !current || - current.kind !== 'remote' || - !sameRemoteRuntimeHostProfileTarget(current, expected.profile) + !sameResolvedRuntimeHostProfileTarget( + { profile: current }, + { profile: expected.profile }, + ) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); } @@ -1053,8 +1094,10 @@ export function createDesktopRuntimeHostProfileService(input: { ); if ( !current || - current.kind !== 'remote' || - !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || + !sameResolvedRuntimeHostProfileTarget( + { profile: current }, + { profile: expected.profile }, + ) || !(await managedServices.markCleanupPendingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); @@ -1070,8 +1113,10 @@ export function createDesktopRuntimeHostProfileService(input: { ); if ( !current || - current.kind !== 'remote' || - !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || + !sameResolvedRuntimeHostProfileTarget( + { profile: current }, + { profile: expected.profile }, + ) || !(await managedServices.removeCleanupPendingIfCurrent(expected)) ) { throw new Error('Runtime Host managed service binding changed during uninstall'); @@ -1177,7 +1222,10 @@ export function createDesktopRuntimeHostProfileService(input: { throw new Error('Enable this Runtime Host before reconnecting it'); } const target = await catalog.resolve(profileId); - if (target.profile.kind !== 'remote' || target.profile.rootId !== expectedRootId) { + if ( + target.profile.kind === 'local' || + target.profile.rootId !== expectedRootId + ) { throw new Error('Runtime Host profile changed before it could reconnect'); } await input.disable(profileId); @@ -1224,20 +1272,21 @@ export function createDesktopRuntimeHostProfileService(input: { ) { throw new Error('Disable and remove the Direct peer profile before removing its SSH profile'); } - const managedBinding = profile.kind === 'remote' - ? findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile) - : undefined; + const managedBinding = findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + profile, + ); if (managedBinding && managedBinding.state !== 'active') { throw new Error('Finish uninstalling this Runtime Host service before removing it'); } - await catalog.remove(profileId); if (managedBinding) { - await managedServices - .removeIfCurrent(managedBinding) - .catch((error) => - console.error("[runtime-host] removed Profile left stale service metadata:", error), + if (!(await managedServices.removeIfCurrent(managedBinding))) { + throw new Error( + "Runtime Host managed service binding changed before its profile could be removed", ); + } } + await catalog.remove(profileId); unavailable.delete(profileId); return snapshot(); }); @@ -1430,8 +1479,8 @@ function errorCode(error: unknown): string | undefined { } function requireSaveInput(value: unknown): asserts value is { - readonly profile: PersistedRuntimeHostProfile; - readonly credential?: string; + readonly profile: RemoteRuntimeHostProfile; + readonly credential: string; } { if (typeof value !== "object" || value === null || !("profile" in value)) { throw new Error("Runtime Host profile input is invalid"); @@ -1445,10 +1494,9 @@ function requireSaveInput(value: unknown): asserts value is { throw new Error("Runtime Host profile input is invalid"); } if ( - "credential" in value && - value.credential !== undefined && - (typeof value.credential !== "string" || - Buffer.byteLength(value.credential, "utf8") > RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES) + !("credential" in value) || + typeof value.credential !== "string" || + Buffer.byteLength(value.credential, "utf8") > RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES ) { throw new Error("Runtime Host credential input is invalid"); } diff --git a/apps/desktop/src/main/runtime-host-wsl-controller.ts b/apps/desktop/src/main/runtime-host-wsl-controller.ts index 80bc59a038..c8ff168b4e 100644 --- a/apps/desktop/src/main/runtime-host-wsl-controller.ts +++ b/apps/desktop/src/main/runtime-host-wsl-controller.ts @@ -21,15 +21,20 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import type { Readable } from 'node:stream'; import { normalizeRuntimeHostWslDistribution, + normalizeRuntimeHostWslOperatorPath, resolveSystemRuntimeHostWslExecutable, type RuntimeHostWslProcessFactory, } from '@maka/runtime-host/client'; import { decodeRuntimeHostSetupFrame, + decodeRuntimeHostServiceManagementFrame, + RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, + RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV, type RuntimeHostSetupFrame, type RuntimeHostSetupPhase, + type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js'; import type { DesktopRuntimeHostSetupPackage } from './runtime-host-setup-package.js'; @@ -39,6 +44,103 @@ const WSL_SETUP_OUTPUT_MAX_BYTES = 64 * 1024; const WSL_SETUP_STDERR_MAX_BYTES = 8 * 1024; type RuntimeHostSetupCompleteFrame = Extract; +type RuntimeHostManagementTerminalFrame = Exclude< + RuntimeHostServiceManagementFrame, + { readonly kind: 'progress' } +>; + +export interface DesktopRuntimeHostWslManagementInput { + readonly distribution: string; + readonly operatorPath: string; + readonly action: 'status' | 'configure'; + readonly expectedTarget: { + readonly serviceId: string; + readonly rootPath: string; + readonly rootId: string; + readonly deploymentId?: string; + }; + readonly projectDirectoryRoots?: readonly { + readonly label: string; + readonly path: string; + }[]; + readonly expectedConfigFingerprint?: string; + readonly allowInterruptActiveTasks?: boolean; + readonly signal?: AbortSignal; +} + +type RuntimeHostWslManagementProcessFactory = ( + executable: string, + args: readonly string[], + environment: NodeJS.ProcessEnv, +) => ChildProcessWithoutNullStreams; + +export async function runDesktopRuntimeHostWslManagement( + input: DesktopRuntimeHostWslManagementInput, + overrides: { + readonly processFactory?: RuntimeHostWslManagementProcessFactory; + readonly wslExecutable?: string; + } = {}, +): Promise { + input.signal?.throwIfAborted(); + const distribution = normalizeRuntimeHostWslDistribution(input.distribution); + const operatorPath = normalizeRuntimeHostWslOperatorPath(input.operatorPath); + const args = [ + '--distribution', + distribution, + '--exec', + operatorPath, + input.action, + '--framed', + ...(input.projectDirectoryRoots === undefined + ? [] + : input.projectDirectoryRoots.length === 0 + ? ['--no-project-roots'] + : input.projectDirectoryRoots.flatMap(({ label, path }) => [ + '--project-root-json', + JSON.stringify({ label, path }), + ])), + ...(input.expectedConfigFingerprint + ? ['--expected-config-fingerprint', input.expectedConfigFingerprint] + : []), + ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), + '--expected-service-id', + input.expectedTarget.serviceId, + '--expected-root-path', + input.expectedTarget.rootPath, + '--expected-root-id', + input.expectedTarget.rootId, + ...(input.expectedTarget.deploymentId + ? ['--expected-deployment-id', input.expectedTarget.deploymentId] + : []), + ]; + const environment = passEnvironmentToWsl( + process.env, + RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, + '1', + ); + const child = (overrides.processFactory ?? spawnWslManagement)( + overrides.wslExecutable ?? resolveSystemRuntimeHostWslExecutable(), + args, + environment, + ); + const terminal = await runWslFramedProcess({ + child, + signal: input.signal, + prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, + decode: decodeRuntimeHostServiceManagementFrame, + label: 'WSL Runtime Host management', + onFrame: (frame) => { + if (frame.kind === 'progress') { + throw new Error('WSL Runtime Host management returned unexpected progress'); + } + return frame; + }, + }); + if (terminal.action !== input.action) { + throw new Error('WSL Runtime Host returned an unrelated management result'); + } + return terminal; +} export interface DesktopRuntimeHostWslSetupInput { readonly distribution: string; @@ -72,61 +174,22 @@ export async function runDesktopRuntimeHostWslSetup( ); 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({ + return runWslFramedProcess({ + child, + signal: input.signal, 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; + if (frame.kind === 'progress') { + onProgress(frame); + return undefined; + } + if (frame.kind === 'error') throw new Error(frame.error.message); + return frame; }, + onResult: () => onComplete?.(), }); - 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( @@ -207,6 +270,103 @@ function spawnWsl(executable: string, args: readonly string[]) { }); } +function spawnWslManagement( + executable: string, + args: readonly string[], + environment: NodeJS.ProcessEnv, +) { + return spawn(executable, args, { + shell: false, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: environment, + }); +} + +function passEnvironmentToWsl( + source: NodeJS.ProcessEnv, + name: string, + value: string, +): NodeJS.ProcessEnv { + const existing = source.WSLENV?.split(':').filter(Boolean) ?? []; + const included = existing.some((entry) => entry.split('/')[0] === name); + return { + ...source, + [name]: value, + WSLENV: included ? existing.join(':') : [...existing, name].join(':'), + }; +} + +async function runWslFramedProcess(input: { + readonly child: ChildProcessWithoutNullStreams; + readonly signal?: AbortSignal; + readonly prefix: string; + readonly decode: (line: string) => Frame | undefined; + readonly label: string; + readonly onFrame: (frame: Frame) => Result | undefined; + readonly onResult?: (result: Result) => void; +}): Promise { + const abort = () => input.child.kill(); + input.signal?.addEventListener('abort', abort, { once: true }); + if (input.signal?.aborted) abort(); + input.child.stdin.end(); + let result: Result | undefined; + let failure: Error | undefined; + const filter = createRuntimeHostFramedOutputFilter({ + prefix: input.prefix, + pendingMaxBytes: WSL_SETUP_OUTPUT_MAX_BYTES, + decode: input.decode, + label: input.label, + onFrame: (frame) => { + try { + const terminal = input.onFrame(frame); + if (terminal === undefined) return; + if (result !== undefined) { + throw new Error(`${input.label} returned multiple results`); + } + result = terminal; + input.onResult?.(terminal); + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)); + } + }, + onError: (error) => { + failure = error; + }, + }); + let outputBytes = 0; + input.child.stdout.setEncoding('utf8'); + input.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(`${input.label} output exceeded its byte limit`); + input.child.kill(); + return; + } + filter.push(chunk.toString('utf8')); + }); + const stderr = collectBounded(input.child.stderr, WSL_SETUP_STDERR_MAX_BYTES); + const timeout = setTimeout(() => input.child.kill(), WSL_SETUP_TIMEOUT_MS); + const [exit, capturedStderr] = await Promise.all([ + childExit(input.child), + stderr, + ]).finally(() => { + clearTimeout(timeout); + input.signal?.removeEventListener('abort', abort); + }); + filter.finish(); + input.signal?.throwIfAborted(); + if (failure) throw failure; + if (result === undefined) { + const diagnostic = formatBoundedDiagnostic(capturedStderr); + throw new Error( + `${input.label} exited with code ${String(exit.code)} without a result${diagnostic ? `: ${diagnostic}` : ''}`, + ); + } + return result; +} + function childExit(child: ChildProcessWithoutNullStreams): Promise<{ readonly code: number | null; readonly signal: NodeJS.Signals | null; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index ce1ab70ce2..99b2c77a63 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -204,7 +204,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 { - PersistedRuntimeHostProfile, + RemoteRuntimeHostProfile, RuntimeHostProfile, RuntimeHostProfileAccess, } from '@maka/runtime-host/client'; @@ -391,8 +391,8 @@ export interface DesktopNewTaskCatalog { } export interface DesktopRuntimeHostProfileAddInput { - readonly profile: PersistedRuntimeHostProfile; - readonly credential?: string; + readonly profile: RemoteRuntimeHostProfile; + readonly credential: string; } export type DesktopRuntimeHostProfileAddResult = diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx index d1509582ec..d675b618e1 100644 --- a/apps/desktop/src/renderer/app-shell-overlays.tsx +++ b/apps/desktop/src/renderer/app-shell-overlays.tsx @@ -39,9 +39,7 @@ const SettingsModal = lazy(async () => { } ).makaE2eLatch; await e2eLatch?.wait('settings.chunk'); - return import('./settings/settings-modal').then((module) => ({ - default: module.SettingsModal, - })); + return import('./settings/settings-modal'); }); type SearchModalProps = Parameters[0]; @@ -80,7 +78,7 @@ export function AppShellOverlays(props: { * can disagree the moment anything else writes the setting. */ refreshChatDefaults(): void; - settingsRequestedSection: SettingsSection | undefined; + settingsRequest: { readonly section?: SettingsSection; readonly profileId?: string }; settingsProviderCatalogOpen: boolean; settingsConnectionDetailSlug: string | undefined; settingsCreateProviderType: ProviderType | undefined; @@ -113,7 +111,7 @@ export function AppShellOverlays(props: { searchModalOnNavigate, searchModalOpen, settingsOpen, - settingsRequestedSection, + settingsRequest, settingsProviderCatalogOpen, settingsConnectionDetailSlug, settingsCreateProviderType, @@ -161,12 +159,11 @@ export function AppShellOverlays(props: { // #1045: base commands freeze per open/close; session rows stay live on // visibleSessions/activeId. run() closures read latest options via ref. const commands = useAppShellCommands(paletteOpen, commandOptions); - const copyDiagnosticsCommand = commands.find((command) => command.id === 'diag:copy-diagnostics'); useHotkeys([ { keys: 'mod+shift+d', allowInInputs: true, - onPress: () => void copyDiagnosticsCommand?.run(), + onPress: () => void commands.find((command) => command.id === 'diag:copy-diagnostics')?.run(), }, ]); return ( @@ -183,7 +180,7 @@ export function AppShellOverlays(props: { uiLocaleUpdateGate={uiLocaleUpdateGate} onUserLabelChange={setUserLabel} onDefaultPermissionModeChange={() => refreshChatDefaults()} - requestedSection={settingsRequestedSection} + request={settingsRequest} openProviderCatalog={settingsProviderCatalogOpen} initialConnectionSlug={settingsConnectionDetailSlug} initialCreateProviderType={settingsCreateProviderType} diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 72c00b81be..5d6f5763b8 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -389,6 +389,23 @@ function AppShellContent({ epochs.set(sessionId, (epochs.get(sessionId) ?? 0) + 1); }, []); + const { + settingsOpen, + settingsRequest, + settingsProviderCatalogOpen, + settingsConnectionDetailSlug, + settingsCreateProviderType, + setSettingsOpen, + setSettingsProviderCatalogOpen, + setSettingsProfileId, + openSettings, + openSettingsSection, + openProjectSettings, + openProviderCatalog, + openConnectionDetail, + openProviderCreate, + } = useSettingsModal(); + const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot); const reportTaskEntryError = useCallback( ({ title, description, profileId }: TaskEntryError) => { @@ -398,6 +415,7 @@ function AppShellContent({ ); const taskEntry = useTaskEntryController({ reportError: reportTaskEntryError, + manageProjects: openProjectSettings, }); // Named on its own because the rail depends on it: `taskEntry.commands` is a // fresh object every render, so depending on the bag rather than the command @@ -610,22 +628,6 @@ function AppShellContent({ onboarding.snapshot, sessions.length > 0, ); - const { - settingsOpen, - settingsRequestedSection, - settingsProviderCatalogOpen, - settingsConnectionDetailSlug, - settingsCreateProviderType, - setSettingsOpen, - setSettingsProviderCatalogOpen, - openSettings, - openSettingsSection, - openProviderCatalog, - openConnectionDetail, - openProviderCreate, - } = useSettingsModal(); - const [settingsDiagnosticProfileId, setSettingsDiagnosticProfileId] = - useState(); const { themePref, setThemePref, @@ -2671,7 +2673,7 @@ function AppShellContent({ messages, newTaskProfileId: taskEntry.selectors.selectedProfileId, settingsOpen, - settingsProfileId: settingsDiagnosticProfileId, + settingsProfileId: settingsRequest.profileId, sessions, themePref, visibleSessions, @@ -3288,7 +3290,7 @@ function AppShellContent({ refreshChatDefaults={() => { void taskEntry.commands.refresh().catch(() => undefined); }} - settingsRequestedSection={settingsRequestedSection} + settingsRequest={settingsRequest} settingsProviderCatalogOpen={settingsProviderCatalogOpen} settingsConnectionDetailSlug={settingsConnectionDetailSlug} settingsCreateProviderType={settingsCreateProviderType} @@ -3320,7 +3322,7 @@ function AppShellContent({ openNewTaskSurface(); void taskEntry.commands.chooseProjectForProfile(profileId).catch(() => undefined); }} - onSelectedRuntimeHostProfileIdChange={setSettingsDiagnosticProfileId} + onSelectedRuntimeHostProfileIdChange={setSettingsProfileId} /> 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 23602b0a60..820797be1b 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 @@ -62,6 +62,7 @@ export interface TaskEntryError { export interface UseTaskEntryControllerInput { reportError(error: TaskEntryError): void; + manageProjects(profileId: string): void; } export interface TaskEntryControllerSelectors { @@ -137,6 +138,7 @@ export function useTaskEntryController( const copy = getShellCopy(locale).projectActions; const conversationCopy = getConversationCopy(locale).workspace; const reportError = input.reportError; + const manageProjects = input.manageProjects; const { catalog: service } = useTaskEntryServices(); const [catalog, setCatalog] = useState(EMPTY_CATALOG); const [selectedProfileId, setSelectedProfileId] = useState(); @@ -453,6 +455,7 @@ export function useTaskEntryController( ...(host.capabilities.selectNoProject ? { onSelectNoProject: () => selectNoProject(host) } : {}), + onManage: () => manageProjects(host.profile.id), }; }), ...(catalogNeedsRetry @@ -473,6 +476,7 @@ export function useTaskEntryController( copy.runtimeHostReadiness, currentProject?.name, error, + manageProjects, pending, refreshing, refresh, diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx index f8fce1c2e7..b1bc2fc730 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -84,6 +84,7 @@ export interface RuntimeHostManagementTarget { readonly id: string; readonly name: string; readonly subtitle?: string; + readonly scope: 'full' | 'project_directories'; readonly directPeerManagement: boolean; } @@ -153,7 +154,7 @@ export function RuntimeHostManagementDialog(props: { } catch (failure) { if (!disposed) setError(settingsActionErrorMessage(failure, locale)); } - if (shouldLoadUpdatePolicy) { + if (shouldLoadUpdatePolicy && target.scope === 'full') { try { const policy = await window.maka.runtimeHostManagement.getUpdatePolicy(target.id); if (!disposed) applyUpdatePolicy(policy); @@ -164,7 +165,7 @@ export function RuntimeHostManagementDialog(props: { } } } - if (shouldLoadUpdatePolicy && target.directPeerManagement) { + if (shouldLoadUpdatePolicy && target.scope === 'full' && target.directPeerManagement) { try { const peer = await window.maka.runtimeHostManagement.getDirectPeer(target.id); if (!disposed) applyDirectPeer(peer); @@ -231,7 +232,9 @@ export function RuntimeHostManagementDialog(props: { setResult(response); reconcileDirectoryPolicy(response.service); if (response.service.state === 'not_installed') setUpdatePolicy(undefined); - else if (action !== 'logs') await reloadUpdatePolicy(target.id); + else if (target.scope === 'full' && action !== 'logs') { + await reloadUpdatePolicy(target.id); + } } catch (failure) { const message = settingsActionErrorMessage(failure, locale); setUpdatePolicy(undefined); @@ -582,6 +585,7 @@ export function RuntimeHostManagementDialog(props: { directoryPolicyEdit !== undefined && JSON.stringify(normalizedDirectoryRoots) !== JSON.stringify(directoryPolicyEdit.baseline.roots); const updateOutcome = lastUpdateOutcome; + const fullManagement = target?.scope === 'full'; return ( ) : null} - {serviceInstalled && target?.directPeerManagement ? ( + {serviceInstalled && fullManagement && target?.directPeerManagement ? (
@@ -866,7 +870,7 @@ export function RuntimeHostManagementDialog(props: { ) : null}
) : null} - {serviceInstalled ? ( + {serviceInstalled && fullManagement ? (
@@ -1036,7 +1040,7 @@ export function RuntimeHostManagementDialog(props: { )}
- {result.action === 'logs' ? ( + {fullManagement && result.action === 'logs' ? (
                       {result.logs || copy.noLogs}
                     
@@ -1253,7 +1257,7 @@ export function RuntimeHostManagementDialog(props: { isDisabled={loading} onClick={props.onClose} /> - {target && !uninstalled ? ( + {target && fullManagement && !uninstalled ? ( void run('status')} /> - {serviceInstalled && supervised && serviceActive ? ( + {fullManagement && serviceInstalled && supervised && serviceActive ? (