diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index b9af2e82a9..27f00430de 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -32,7 +32,12 @@ on: paths: - 'apps/desktop/electron-builder.config.mjs' - 'apps/desktop/package.json' + - 'native/runtime-host-peer/**' + - 'packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv' + - 'packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt' - 'scripts/package-windows-x64.mjs' + - 'scripts/generate-runtime-host-peer-dependencies.mjs' + - 'scripts/generate-runtime-host-peer-notices.mjs' - 'scripts/verify-windows-x64.mjs' - 'scripts/verify-windows-sandbox-e2e.mjs' - 'scripts/verify-windows-installer-lifecycle.mjs' @@ -119,6 +124,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Update stable Rust for Desktop native artifacts + run: rustup update stable --no-self-update + - name: Package the Windows installer and ZIP run: npm run package:windows-x64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32d10e1806..bf7e5eb180 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -137,6 +137,9 @@ jobs: - name: Audit shipped desktop closure run: node scripts/audit-shipped-dependencies.mjs + - name: Update stable Rust for Desktop native artifacts + run: rustup update stable --no-self-update + - name: Write App Store Connect API key if: matrix.platform == 'macos' env: diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 097effb352..3235bedac6 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -89,6 +89,14 @@ export default { from: 'resources/workers/filesystem-worker.js', to: 'workers/filesystem-worker.js', }, + { + from: '../../native/runtime-host-peer/target/release/maka_runtime_host_peer.node', + to: 'runtime-host-peer/maka_runtime_host_peer.node', + }, + { + from: '../../packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt', + to: 'licenses/runtime-host-peer/THIRD_PARTY_NOTICES.txt', + }, ...(process.platform === 'win32' ? [ { 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 2c30c6da1d..1554bd0f78 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -20,6 +20,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { + RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, runtimeHostAccessCredentialFingerprint, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; @@ -73,6 +74,7 @@ test('identifies, rotates, and revokes managed credentials without exposing secr removeHandler: (channel) => handlers.delete(channel), }, profiles: { + ...unusedDirectPeerProfileDependencies(), resolveManagedService: async () => ({ profile, service, state: 'active' as const }), resolveManagedAccess: async () => ({ profile, @@ -215,6 +217,7 @@ test('manages only the service identity bound by Desktop onboarding', async () = removeHandler: (channel) => handlers.delete(channel), }, profiles: { + ...unusedDirectPeerProfileDependencies(), resolveManagedService: async (profileId) => profileId === managedProfile.id ? { profile: managedProfile, service: managedService, state: 'active' as const } @@ -361,6 +364,7 @@ test('publishes update progress and waits for the managed profile to reconnect', removeHandler: (channel) => handlers.delete(channel), }, profiles: { + ...unusedDirectPeerProfileDependencies(), resolveManagedService: async () => bindingPresent ? { profile, service, state: 'active' as const } : undefined, resolveManagedAccess: async () => undefined, @@ -370,6 +374,8 @@ test('publishes update progress and waits for the managed profile to reconnect', clearManagedServiceBinding: async () => undefined, }, runServiceManagement: async () => assert.fail('ordinary management is not expected'), + runPeerManagement: async () => assert.fail('direct peer management is not expected'), + directPeerClientAvailable: false, runUpdate: async (input, onProgress) => { updates.push(input); onProgress('staging'); @@ -481,6 +487,7 @@ test('configures Project roots with CAS and reconnects only after a committed cu removeHandler: (channel) => handlers.delete(channel), }, profiles: { + ...unusedDirectPeerProfileDependencies(), resolveManagedService: async () => ({ profile, service, state: 'active' as const }), resolveManagedAccess: async () => undefined, rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), @@ -588,6 +595,7 @@ test('manages one Host update policy and reconciles it through the bound operato removeHandler: (channel) => handlers.delete(channel), }, profiles: { + ...unusedDirectPeerProfileDependencies(), resolveManagedService: async () => ({ profile, service, state: 'active' as const }), resolveManagedAccess: async () => undefined, rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), @@ -733,6 +741,7 @@ test('resumes deployment cleanup without invoking the removed operator', async ( removeHandler: (channel) => handlers.delete(channel), }, profiles: { + ...unusedDirectPeerProfileDependencies(), resolveManagedService: async () => ({ profile, service, state }), resolveManagedAccess: async () => undefined, markManagedServiceUninstalling: async (binding) => { @@ -785,6 +794,7 @@ test('rechecks uninstall intent before retrying the remote service', async () => removeHandler: (channel) => handlers.delete(channel), }, profiles: { + ...unusedDirectPeerProfileDependencies(), resolveManagedService: async () => ({ profile: { id: 'office', @@ -834,6 +844,235 @@ test('rechecks uninstall intent before retrying the remote service', async () => assert.equal(marked, true); }); +test('keeps the SSH profile while adding and removing its managed Direct peer', async () => { + const handlers = new Map unknown>(); + const profile = { + id: 'office', + name: 'Office', + kind: 'remote' as const, + rootId: 'a'.repeat(64), + transport: { + kind: 'ssh' as const, + destination: 'operator@example.com', + remotePort: 7443, + websocketPath: '/runtime-host', + }, + }; + const service = { + id: 'b'.repeat(64), + rootPath: '/srv/maka', + operatorPath: '/home/operator/.local/share/maka/operator', + }; + let peerProfileExists = false; + const actions: string[] = []; + + createDesktopRuntimeHostManagement({ + ...unusedUpdateDependencies(), + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), + removeHandler: (channel) => handlers.delete(channel), + }, + profiles: { + ...unusedDirectPeerProfileDependencies(), + resolveManagedService: async () => ({ profile, service, state: 'active' as const }), + resolveManagedAccess: async () => undefined, + rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), + markManagedServiceUninstalling: async (binding) => binding, + markManagedServiceCleanupPending: async (binding) => binding, + clearManagedServiceBinding: async () => undefined, + resolveManagedDirectPeerProfile: async () => ({ + exists: peerProfileExists, + enabled: false, + }), + upsertManagedDirectPeerProfile: async (_profileId, descriptor) => { + assert.deepEqual(descriptor.routeHints, ['/ip4/192.0.2.8/udp/44001/quic-v1']); + peerProfileExists = true; + }, + removeManagedDirectPeerProfile: async () => { + peerProfileExists = false; + }, + }, + directPeerClientAvailable: true, + runServiceManagement: async (input) => { + assert.equal(input.action, 'status'); + assert.equal( + input.capabilityRequest, + RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, + ); + return { + ...serviceResult('status'), + operatorCapabilities: [RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY], + }; + }, + runAccessManagement: async () => assert.fail('access management is not expected'), + runPeerManagement: async (input) => { + actions.push(input.action); + return { + kind: 'result', + action: input.action, + status: input.action === 'disable' + ? { + state: 'not_configured', + serviceState: 'running', + routeHints: [], + coordinationRelays: [], + } + : { + state: 'enabled', + serviceState: 'running', + peerId: '12D3KooWpeer', + rootId: profile.rootId, + routeHints: ['/ip4/192.0.2.8/udp/44001/quic-v1'], + coordinationRelays: [], + }, + }; + }, + cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'), + }); + + const configure = handlers.get('runtime-host-management:configure-direct-peer'); + assert.ok(configure); + const enabled = await configure({}, profile.id, true, []); + assert.equal((enabled as { profilePresent: boolean }).profilePresent, true); + const disabled = await configure({}, profile.id, false, []); + assert.equal((disabled as { profilePresent: boolean }).profilePresent, false); + assert.deepEqual(actions, ['enable', 'disable']); +}); + +test('disables a newly enabled listener when its Desktop profile cannot be committed', async () => { + for (const failure of ['descriptor', 'persistence'] as const) { + const handlers = new Map unknown>(); + const actions: string[] = []; + createDesktopRuntimeHostManagement({ + ...unusedUpdateDependencies(), + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), + removeHandler: (channel) => handlers.delete(channel), + }, + profiles: { + ...unusedDirectPeerProfileDependencies(), + resolveManagedService: async () => managedSshBinding(), + resolveManagedAccess: async () => undefined, + rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), + markManagedServiceUninstalling: async (binding) => binding, + markManagedServiceCleanupPending: async (binding) => binding, + clearManagedServiceBinding: async () => undefined, + resolveManagedDirectPeerProfile: async () => ({ exists: false, enabled: false }), + upsertManagedDirectPeerProfile: async () => { + if (failure === 'persistence') throw new Error('profile store failed'); + }, + removeManagedDirectPeerProfile: async () => undefined, + }, + directPeerClientAvailable: true, + runServiceManagement: async () => ({ + ...serviceResult('status'), + operatorCapabilities: [RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY], + }), + runAccessManagement: async () => assert.fail('access management is not expected'), + runPeerManagement: async (input) => { + actions.push(input.action); + return { + kind: 'result', + action: input.action, + status: input.action === 'disable' + ? { + state: 'disabled', + serviceState: 'running', + peerId: '12D3KooWpeer', + rootId: 'a'.repeat(64), + routeHints: [], + coordinationRelays: [], + } + : { + state: 'enabled', + serviceState: 'running', + peerId: '12D3KooWpeer', + rootId: 'a'.repeat(64), + routeHints: failure === 'descriptor' ? [] : ['/ip4/192.0.2.8/udp/44001/quic-v1'], + coordinationRelays: [], + }, + }; + }, + cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'), + }); + + const configure = handlers.get('runtime-host-management:configure-direct-peer'); + assert.ok(configure); + await assert.rejects( + configure({}, 'office', true, []) as Promise, + failure === 'descriptor' ? /usable direct-peer descriptor/u : /profile store failed/u, + ); + assert.deepEqual(actions, ['enable', 'disable']); + } +}); + +test('does not invoke peer management when the remote operator lacks its capability', async () => { + const handlers = new Map unknown>(); + createDesktopRuntimeHostManagement({ + ...unusedUpdateDependencies(), + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), + removeHandler: (channel) => handlers.delete(channel), + }, + profiles: { + ...unusedDirectPeerProfileDependencies(), + resolveManagedService: async () => managedSshBinding(), + resolveManagedAccess: async () => undefined, + rotateManagedCredential: async () => assert.fail('credential rotation is not expected'), + markManagedServiceUninstalling: async (binding) => binding, + markManagedServiceCleanupPending: async (binding) => binding, + clearManagedServiceBinding: async () => undefined, + resolveManagedDirectPeerProfile: async () => ({ exists: false, enabled: false }), + }, + directPeerClientAvailable: true, + runServiceManagement: async () => serviceResult('status'), + runAccessManagement: async () => assert.fail('access management is not expected'), + runPeerManagement: async () => assert.fail('peer management is not expected'), + cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'), + }); + + const get = handlers.get('runtime-host-management:get-direct-peer'); + const configure = handlers.get('runtime-host-management:configure-direct-peer'); + assert.ok(get); + assert.ok(configure); + assert.deepEqual(await get({}, 'office'), { + state: 'unsupported', + routeHints: [], + coordinationRelays: [], + profilePresent: false, + profileEnabled: false, + clientAvailable: true, + managementAvailable: false, + }); + await assert.rejects( + configure({}, 'office', true, []) as Promise, + /Update this Runtime Host/u, + ); +}); + +function managedSshBinding() { + return { + profile: { + id: 'office', + name: 'Office', + kind: 'remote' as const, + rootId: 'a'.repeat(64), + transport: { + kind: 'ssh' as const, + destination: 'operator@example.com', + remotePort: 7443, + websocketPath: '/runtime-host', + }, + }, + service: { + id: 'b'.repeat(64), + rootPath: '/srv/maka', + operatorPath: '/home/operator/.local/share/maka/operator', + }, + state: 'active' as const, + }; +} + function serviceResult( action: DesktopRuntimeHostSshManagementInput['action'], operatorAccess = false, @@ -884,6 +1123,9 @@ function unusedUpdateDependencies() { runUpdatePolicy: async (): Promise => assert.fail('update policy is not expected'), runUpdateReconciliation: async (): Promise => assert.fail('update reconciliation is not expected'), + runPeerManagement: async (): Promise => + assert.fail('direct peer management is not expected'), + directPeerClientAvailable: false, resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const), currentHostEpoch: () => undefined, awaitUpdatedConnection: async () => undefined, @@ -891,6 +1133,17 @@ function unusedUpdateDependencies() { }; } +function unusedDirectPeerProfileDependencies() { + return { + resolveManagedDirectPeerProfile: async (): Promise => + assert.fail('direct peer profile inspection is not expected'), + upsertManagedDirectPeerProfile: async (): Promise => + assert.fail('direct peer profile creation is not expected'), + removeManagedDirectPeerProfile: async (): Promise => + assert.fail('direct peer profile removal is not expected'), + }; +} + function accessCredential( credentialId: string, principalId: string, 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 3ccb04e3fa..5fe96453bd 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 @@ -523,6 +523,98 @@ test("finishes a persisted pairing after Desktop restarts before finalization", ); }); +test("keeps a managed Direct route on the SSH profile credential authority", async () => { + const root = await clientRoot(); + const catalog = createClientRuntimeHostProfileCatalog(root); + const managedServices = createDesktopRuntimeHostManagedServiceStore(root); + await catalog.create(MANAGED_PROFILE, "owner-token"); + await managedServices.save(MANAGED_PROFILE, MANAGED_SERVICE); + const startup = await resolveDesktopRuntimeHostStartup(root, { catalog }); + const activated: ResolvedRuntimeHostProfile[] = []; + const service = createDesktopRuntimeHostProfileService({ + clientDataRoot: root, + startup, + catalog, + managedServices, + states: () => [connectingLocal()], + enable: async (target) => { + activated.push(target); + }, + disable: async () => undefined, + setDefault: () => undefined, + finalizePairing: async () => undefined, + }); + + await service.upsertManagedDirectPeerProfile(MANAGED_PROFILE.id, { + peerId: "12D3KooWpeer", + routeHints: ["/ip4/192.0.2.8/udp/44001/quic-v1"], + coordinationRelays: [], + }); + + const directId = (await catalog.read()).profiles.find( + (profile) => profile.kind === 'remote' && profile.transport.kind === 'libp2p-direct', + )?.id; + assert.ok(directId); + const direct = await catalog.resolve(directId); + assert.equal(direct.credential, "owner-token"); + assert.equal(direct.profile.kind, "remote"); + if (direct.profile.kind !== "remote") assert.fail("expected a remote Direct profile"); + assert.deepEqual(direct.profile.transport, { + kind: "libp2p-direct", + peerId: "12D3KooWpeer", + routeHints: ["/ip4/192.0.2.8/udp/44001/quic-v1"], + coordinationRelays: [], + }); + assert.equal((await catalog.resolve(MANAGED_PROFILE.id)).credential, "owner-token"); + + const beforeRejectedRemoval = { + document: await catalog.read(), + source: await catalog.resolve(MANAGED_PROFILE.id), + direct: await catalog.resolve(directId), + managed: await managedServices.read(), + snapshot: await service.getSnapshot(), + }; + const managedBinding = await service.resolveManagedService(MANAGED_PROFILE.id); + assert.ok(managedBinding); + await assert.rejects( + service.remove(MANAGED_PROFILE.id), + /remove the Direct peer profile/u, + ); + await assert.rejects( + service.markManagedServiceUninstalling(managedBinding), + /remove the Direct peer profile/u, + ); + assert.deepEqual( + { + document: await catalog.read(), + source: await catalog.resolve(MANAGED_PROFILE.id), + direct: await catalog.resolve(directId), + managed: await managedServices.read(), + snapshot: await service.getSnapshot(), + }, + beforeRejectedRemoval, + ); + + await service.setEnabled(MANAGED_PROFILE.id, true); + const access = await service.resolveManagedAccess(MANAGED_PROFILE.id); + assert.ok(access); + await service.rotateManagedCredential(access, 'replacement-token'); + await service.setEnabled(MANAGED_PROFILE.id, false); + await service.setEnabled(directId, true); + + assert.equal((await catalog.resolve(directId)).credential, 'replacement-token'); + assert.equal(activated.at(-1)?.profile.id, directId); + assert.equal(activated.at(-1)?.credential, 'replacement-token'); + + await service.setEnabled(directId, false); + await service.removeManagedDirectPeerProfile(MANAGED_PROFILE.id); + + assert.deepEqual((await catalog.read()).profiles, [MANAGED_PROFILE]); + await service.remove(MANAGED_PROFILE.id); + assert.deepEqual((await catalog.read()).profiles, []); + assert.deepEqual((await managedServices.read()).bindings, []); +}); + test("recovers interrupted managed credential rotation after restart", async () => { const root = await clientRoot(); const catalog = createClientRuntimeHostProfileCatalog(root); diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index 3482479835..ea66dedbe9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -31,6 +31,7 @@ import { encodeRuntimeHostServiceManagementFrame, encodeRuntimeHostSetupFrame, runtimeHostAccessCredentialFingerprint, + RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, RUNTIME_HOST_SETUP_FRAME_PREFIX, } from '@maka/runtime-host/operator'; import { createDesktopRuntimeHostSshTerminal } from '../runtime-host-ssh-terminal.js'; @@ -256,6 +257,7 @@ test('reads a framed service result without projecting it into the SSH terminal' destination: 'operator@example.com', operatorPath: '/home/operator/.local/share/maka/operator', action: 'status', + capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, expectedTarget: { serviceId: 'b'.repeat(64), rootPath: '/home/operator/.config/Maka/workspaces/default', @@ -266,7 +268,7 @@ test('reads a framed service result without projecting it into the SSH terminal' const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? ''; assert.match(remoteCommand, /\.local\/share\/maka\/operator/u); assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u); - assert.match(remoteCommand, /access-management-v1/u); + assert.match(remoteCommand, /peer-management-v1/u); assert.doesNotMatch(remoteCommand, /npx|maka-agent@/u); harness.pty.emitData('Password: '); harness.pty.emitData( diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index b9a3771e17..0c05fa693a 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -169,6 +169,7 @@ import { createDesktopRuntimeHostSshTerminal, } from "./runtime-host-ssh-terminal.js"; import { createRuntimeHostSetupPackageResolver } from "./runtime-host-setup-package.js"; +import { configureDesktopRuntimeHostPeerClient } from './runtime-host-peer-client.js'; import { createDesktopRuntimeHostOnboarding } from "./runtime-host-onboarding.js"; import { createDesktopRuntimeHostManagement } from "./runtime-host-management.js"; import { registerRuntimeHostOAuthIpc } from "./runtime-host-oauth-ipc-main.js"; @@ -214,6 +215,12 @@ await resolveShellEnv(); const MANAGED_UPDATE_RECONNECT_TIMEOUT_MS = 10_000; const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); const userDataDir = app.getPath("userData"); +const runtimeHostDirectPeerAvailable = await configureDesktopRuntimeHostPeerClient({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + clientDataRoot: userDataDir, +}); const runtimeHostClientInstanceId = await loadOrCreateRuntimeHostClientInstanceId( join(userDataDir, "runtime-host-client.json"), ); @@ -452,6 +459,8 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ ipcMain, profiles: runtimeHostProfileService, runServiceManagement: runtimeHostSshTerminal.runServiceManagement, + runPeerManagement: runtimeHostSshTerminal.runPeerManagement, + directPeerClientAvailable: runtimeHostDirectPeerAvailable, runUpdate: runtimeHostSshTerminal.runUpdate, runUpdatePolicy: runtimeHostSshTerminal.runUpdatePolicy, runUpdateReconciliation: runtimeHostSshTerminal.runUpdateReconciliation, diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index dbf777e711..33bc920d7f 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -20,14 +20,18 @@ import type { IpcMain } from 'electron'; import { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, + RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, isProductReleaseVersion, runtimeHostAccessCredentialFingerprint, type RuntimeHostManagedUpdatePolicy, type RuntimeHostAccessManagementFrame, + type RuntimeHostPeerManagementFrame, + type RuntimeHostPeerStatus, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; import type { DesktopRuntimeHostAccessSnapshot, + DesktopRuntimeHostDirectPeerSnapshot, DesktopRuntimeHostManagementAction, DesktopRuntimeHostManagementResponse, DesktopRuntimeHostManagementProgress, @@ -41,6 +45,7 @@ import type { DesktopRuntimeHostSshCleanupInput, DesktopRuntimeHostSshAccessInput, DesktopRuntimeHostSshManagementInput, + DesktopRuntimeHostSshPeerManagementInput, DesktopRuntimeHostSshUpdateInput, DesktopRuntimeHostSshUpdatePolicyInput, DesktopRuntimeHostSshUpdateReconciliationInput, @@ -74,6 +79,9 @@ export function createDesktopRuntimeHostManagement(input: { | 'markManagedServiceUninstalling' | 'markManagedServiceCleanupPending' | 'clearManagedServiceBinding' + | 'resolveManagedDirectPeerProfile' + | 'upsertManagedDirectPeerProfile' + | 'removeManagedDirectPeerProfile' >; readonly runServiceManagement: ( input: DesktopRuntimeHostSshManagementInput, @@ -81,6 +89,10 @@ export function createDesktopRuntimeHostManagement(input: { readonly runAccessManagement: ( input: DesktopRuntimeHostSshAccessInput, ) => Promise; + readonly runPeerManagement: ( + input: DesktopRuntimeHostSshPeerManagementInput, + ) => Promise; + readonly directPeerClientAvailable: boolean; readonly runUpdate: ( input: DesktopRuntimeHostSshUpdateInput, onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void, @@ -260,6 +272,163 @@ export function createDesktopRuntimeHostManagement(input: { }; }; + const peerSnapshot = async ( + profileId: string, + status: RuntimeHostPeerStatus, + ): Promise => { + const profile = await input.profiles.resolveManagedDirectPeerProfile(profileId); + return { + state: status.state, + ...(status.peerId ? { peerId: status.peerId } : {}), + routeHints: status.routeHints, + coordinationRelays: status.coordinationRelays, + profilePresent: profile.exists, + profileEnabled: profile.enabled, + clientAvailable: input.directPeerClientAvailable, + managementAvailable: true, + }; + }; + + const peerManagementTarget = async (profileIdValue: unknown) => { + const target = await managedMutationTarget(profileIdValue); + const capability = await input.runServiceManagement({ + destination: target.transport.destination, + ...(target.transport.sshPort === undefined + ? {} + : { sshPort: target.transport.sshPort }), + operatorPath: target.managed.service.operatorPath, + action: 'status', + expectedTarget: target.expectedTarget, + capabilityRequest: RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, + }); + if (capability.kind === 'error') throw new Error(capability.error.message); + if (capability.action !== 'status') { + throw new Error('Runtime Host returned an unrelated capability result'); + } + return { + ...target, + available: capability.operatorCapabilities?.includes( + RUNTIME_HOST_OPERATOR_PEER_MANAGEMENT_CAPABILITY, + ) === true, + }; + }; + + const unavailablePeerSnapshot = async ( + profileId: string, + ): Promise => { + const profile = await input.profiles.resolveManagedDirectPeerProfile(profileId); + return { + state: 'unsupported', + routeHints: [], + coordinationRelays: [], + profilePresent: profile.exists, + profileEnabled: profile.enabled, + clientAvailable: input.directPeerClientAvailable, + managementAvailable: false, + }; + }; + + const getDirectPeer = async ( + profileIdValue: unknown, + ): Promise => { + const { profileId, managed, transport, expectedTarget, available } = + await peerManagementTarget(profileIdValue); + if (!available) return unavailablePeerSnapshot(profileId); + const response = await input.runPeerManagement({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.service.operatorPath, + action: 'status', + expectedTarget, + }); + if (response.kind !== 'result') { + throw new Error( + response.kind === 'error' + ? response.error.message + : 'Runtime Host returned an unrelated direct-peer result', + ); + } + return peerSnapshot(profileId, response.status); + }; + + const configureDirectPeer = async ( + profileIdValue: unknown, + enabledValue: unknown, + coordinationRelaysValue: unknown, + ): Promise => { + if (typeof enabledValue !== 'boolean') { + throw new Error('Runtime Host direct-peer state is invalid'); + } + const coordinationRelays = requireCoordinationRelays(coordinationRelaysValue); + const { profileId, managed, transport, expectedTarget, available } = + await peerManagementTarget(profileIdValue); + if (!available) { + throw new Error('Update this Runtime Host before managing Direct peer access'); + } + const peerProfile = await input.profiles.resolveManagedDirectPeerProfile(profileId); + if (peerProfile.enabled) { + throw new Error('Disable the Direct peer profile before changing its listener'); + } + const response = await input.runPeerManagement({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.service.operatorPath, + action: enabledValue ? 'enable' : 'disable', + ...(enabledValue ? { coordinationRelays } : {}), + expectedTarget, + }); + if (response.kind !== 'result') { + throw new Error( + response.kind === 'error' + ? response.error.message + : 'Runtime Host returned an unrelated direct-peer result', + ); + } + const status = response.status; + if (enabledValue) { + try { + if ( + status.state !== 'enabled' || + !status.peerId || + status.routeHints.length === 0 && status.coordinationRelays.length === 0 + ) { + throw new Error('Runtime Host did not return a usable direct-peer descriptor'); + } + await input.profiles.upsertManagedDirectPeerProfile(profileId, { + peerId: status.peerId, + routeHints: status.routeHints, + coordinationRelays: status.coordinationRelays, + }); + } catch (failure) { + try { + const rollback = await input.runPeerManagement({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.service.operatorPath, + action: 'disable', + expectedTarget, + }); + if (rollback.kind !== 'result' || rollback.status.state === 'enabled') { + throw new Error( + rollback.kind === 'error' + ? rollback.error.message + : 'Runtime Host did not confirm that Direct peer access was disabled', + ); + } + } catch (rollbackFailure) { + throw new AggregateError( + [asError(failure), asError(rollbackFailure)], + 'Direct peer setup failed and its listener may still be enabled', + ); + } + throw failure; + } + } else { + await input.profiles.removeManagedDirectPeerProfile(profileId); + } + return peerSnapshot(profileId, status); + }; + const update = async ( profileIdValue: unknown, allowInterruptActiveTasksValue: unknown, @@ -574,6 +743,8 @@ export function createDesktopRuntimeHostManagement(input: { getUpdatePolicy: 'runtime-host-management:get-update-policy', setUpdatePolicy: 'runtime-host-management:set-update-policy', reconcileUpdate: 'runtime-host-management:reconcile-update', + getDirectPeer: 'runtime-host-management:get-direct-peer', + configureDirectPeer: 'runtime-host-management:configure-direct-peer', } as const; input.ipcMain.handle(channels.run, (_event, profileId: unknown, action: unknown) => run(profileId, action)); @@ -613,6 +784,13 @@ export function createDesktopRuntimeHostManagement(input: { updatePolicy(profileId, policy)); input.ipcMain.handle(channels.reconcileUpdate, (_event, profileId: unknown) => reconcileUpdate(profileId)); + input.ipcMain.handle(channels.getDirectPeer, (_event, profileId: unknown) => + getDirectPeer(profileId)); + input.ipcMain.handle( + channels.configureDirectPeer, + (_event, profileId: unknown, enabled: unknown, coordinationRelays: unknown) => + configureDirectPeer(profileId, enabled, coordinationRelays), + ); return { close() { @@ -621,6 +799,28 @@ export function createDesktopRuntimeHostManagement(input: { }; } +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +function requireCoordinationRelays(value: unknown): readonly string[] { + if ( + !Array.isArray(value) || + value.length > 16 || + value.some( + (relay) => + typeof relay !== 'string' || + relay.length === 0 || + Buffer.byteLength(relay, 'utf8') > 2 * 1024 || + /[\s\u0000-\u001f\u007f]/u.test(relay), + ) || + new Set(value).size !== value.length + ) { + throw new Error('Runtime Host coordination relay list is invalid'); + } + return value; +} + function projectUpdatePolicy( frame: Extract { + const environment = input.environment ?? process.env; + const explicitNativePath = environment.MAKA_RUNTIME_HOST_PEER_NATIVE_PATH?.trim(); + const explicitKeyPath = environment.MAKA_RUNTIME_HOST_PEER_KEY_PATH?.trim(); + if (explicitNativePath || explicitKeyPath) return Boolean(explicitNativePath && explicitKeyPath); + const nativePath = input.isPackaged + ? join(input.resourcesPath, 'runtime-host-peer', NATIVE_FILE) + : join( + input.appPath, + '..', + '..', + 'native', + 'runtime-host-peer', + 'target', + 'release', + NATIVE_FILE, + ); + try { + await access(nativePath); + } catch { + return false; + } + environment.MAKA_RUNTIME_HOST_PEER_NATIVE_PATH = nativePath; + environment.MAKA_RUNTIME_HOST_PEER_KEY_PATH = join( + input.clientDataRoot, + 'runtime-host-client.peer.key', + ); + return true; +} diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts index 92c9aff152..5688823e0c 100644 --- a/apps/desktop/src/main/runtime-host-profile-service.ts +++ b/apps/desktop/src/main/runtime-host-profile-service.ts @@ -17,7 +17,7 @@ * under the License. */ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { open, readFile, rename, rm } from "node:fs/promises"; import { dirname, join } from "node:path"; import { @@ -99,6 +99,19 @@ export interface DesktopRuntimeHostProfileService { resolveManagedAccess( profileId: string, ): Promise; + resolveManagedDirectPeerProfile(profileId: string): Promise<{ + readonly exists: boolean; + readonly enabled: boolean; + }>; + upsertManagedDirectPeerProfile( + profileId: string, + peer: { + readonly peerId: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; + }, + ): Promise; + removeManagedDirectPeerProfile(profileId: string): Promise; clearManagedServiceBinding(expected: DesktopRuntimeHostManagedServiceBinding): Promise; markManagedServiceUninstalling( expected: DesktopRuntimeHostManagedServiceBinding, @@ -370,25 +383,65 @@ export function createDesktopRuntimeHostProfileService(input: { preferences = next; }; + const resolveActivationTarget = async ( + target: ResolvedRuntimeHostProfile, + ): Promise => { + if ( + target.profile.kind !== 'remote' || + target.profile.transport.kind !== 'libp2p-direct' + ) { + return target; + } + const directProfile = target.profile; + const document = await catalog.read(); + const sourceProfile = document.profiles.find( + (profile) => + profile.kind === 'remote' && + profile.transport.kind === 'ssh' && + profile.rootId === directProfile.rootId && + managedDirectPeerProfileId(profile.id) === directProfile.id, + ); + if (!sourceProfile) return target; + const managed = findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + sourceProfile, + ); + if (!managed) return target; + if (managed.state !== 'active') { + throw new Error('The managed SSH recovery profile is not available'); + } + const source = await catalog.resolve(sourceProfile.id); + if (source.profile.kind !== 'remote' || !source.credential) { + 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); + if (!rebound.rebound) { + throw new Error('The Direct peer profile changed before its credential could be refreshed'); + } + return catalog.resolve(target.profile.id); + }; + const activateTarget = async ( target: ResolvedRuntimeHostProfile, sshInteraction: "terminal" | "batch", ): Promise => { try { - await input.enable(target, sshInteraction); - const current = await catalog.resolve(target.profile.id).catch(() => undefined); - if (!current || !sameResolvedRuntimeHostProfileTarget(current, target)) { - await input.disable(target.profile.id); + const activationTarget = await resolveActivationTarget(target); + await input.enable(activationTarget, sshInteraction); + const current = await catalog.resolve(activationTarget.profile.id).catch(() => undefined); + if (!current || !sameResolvedRuntimeHostProfileTarget(current, activationTarget)) { + await input.disable(activationTarget.profile.id); throw new Error("Runtime Host profile changed while it was connecting"); } const remainsEnabled = - target.profile.id === preferences.defaultProfileId || - preferences.enabledRemoteProfileIds.includes(target.profile.id); + activationTarget.profile.id === preferences.defaultProfileId || + preferences.enabledRemoteProfileIds.includes(activationTarget.profile.id); if (!remainsEnabled) { - await input.disable(target.profile.id); + await input.disable(activationTarget.profile.id); return; } - unavailable.delete(target.profile.id); + unavailable.delete(activationTarget.profile.id); } catch (error) { const failure = asError(error); unavailable.set(target.profile.id, failure); @@ -752,15 +805,115 @@ export function createDesktopRuntimeHostProfileService(input: { : undefined; }); }, + resolveManagedDirectPeerProfile(profileId) { + return mutate(async () => { + const peerProfileId = managedDirectPeerProfileId(profileId); + return { + exists: (await catalog.read()).profiles.some((profile) => profile.id === peerProfileId), + enabled: preferences.enabledRemoteProfileIds.includes(peerProfileId), + }; + }); + }, + upsertManagedDirectPeerProfile(profileId, peer) { + return mutateProfiles(async () => { + assertPairingComplete(profileId); + const source = await catalog.resolve(profileId); + if ( + source.profile.kind !== 'remote' || + source.profile.transport.kind !== 'ssh' || + !source.credential + ) { + throw new Error('Direct peer can only be added through a managed SSH Runtime Host'); + } + const managed = findDesktopRuntimeHostManagedServiceBinding( + await managedServices.read(), + source.profile, + ); + if (!managed || managed.state !== 'active') { + throw new Error('This Runtime Host profile is not bound to an active managed service'); + } + if (peer.routeHints.length === 0 && peer.coordinationRelays.length === 0) { + throw new Error('Runtime Host returned an invalid direct-peer descriptor'); + } + const peerProfileId = managedDirectPeerProfileId(profileId); + if (preferences.enabledRemoteProfileIds.includes(peerProfileId)) { + throw new Error('Disable the Direct peer profile before changing its listener'); + } + const profile: RemoteRuntimeHostProfile = { + id: peerProfileId, + name: directPeerProfileName(source.profile.name), + kind: 'remote', + rootId: source.profile.rootId, + transport: { + kind: 'libp2p-direct', + peerId: peer.peerId, + routeHints: peer.routeHints, + coordinationRelays: peer.coordinationRelays, + }, + }; + const existing = (await catalog.read()).profiles.find( + (candidate) => candidate.id === peerProfileId, + ); + if (!existing) { + await catalog.create(profile, source.credential); + return; + } + const previous = await catalog.resolve(peerProfileId); + if (previous.profile.kind !== 'remote' || previous.profile.rootId !== source.profile.rootId) { + throw new Error('The Direct peer profile identity is already in use'); + } + const rebound = await catalog.rebindIfCurrent(previous, profile, source.credential); + if (!rebound.rebound) { + throw new Error('The Direct peer profile changed before it could be updated'); + } + }); + }, + removeManagedDirectPeerProfile(profileId) { + return mutateProfiles(async () => { + assertPairingComplete(profileId); + const peerProfileId = managedDirectPeerProfileId(profileId); + if (preferences.enabledRemoteProfileIds.includes(peerProfileId)) { + throw new Error('Disable the Direct peer profile before changing its listener'); + } + const current = await catalog.resolve(peerProfileId).catch(() => undefined); + if (!current) return; + const source = await catalog.resolve(profileId); + if ( + source.profile.kind !== 'remote' || + source.profile.transport.kind !== 'ssh' || + current.profile.kind !== 'remote' || + current.profile.transport.kind !== 'libp2p-direct' || + current.profile.rootId !== source.profile.rootId + ) { + throw new Error('The Direct peer profile identity is already in use'); + } + const removed = await catalog.removeIfCurrent(current); + if (!removed.removed) { + throw new Error('The Direct peer profile changed before it could be removed'); + } + }); + }, markManagedServiceUninstalling(expected) { return mutateProfiles(async () => { assertPairingComplete(expected.profile.id); - const current = (await catalog.read()).profiles.find( + const document = await catalog.read(); + const current = document.profiles.find( (profile) => profile.id === expected.profile.id, ); if ( !current || - !sameRemoteRuntimeHostProfileTarget(current, expected.profile) || + !sameRemoteRuntimeHostProfileTarget(current, expected.profile) + ) { + throw new Error('Runtime Host managed service binding changed during uninstall'); + } + if ( + document.profiles.some( + (profile) => profile.id === managedDirectPeerProfileId(expected.profile.id), + ) + ) { + throw new Error('Disable and remove the Direct peer profile before uninstalling this service'); + } + if ( !(await managedServices.markUninstallingIfCurrent( expected.profile, expected.service, @@ -916,10 +1069,20 @@ export function createDesktopRuntimeHostProfileService(input: { if (preferences.defaultProfileId === profileId) { throw new Error("Choose another default Runtime Host before removing this one"); } - const profile = (await catalog.read()).profiles.find( + const document = await catalog.read(); + const profile = document.profiles.find( (candidate) => candidate.id === profileId, ); if (!profile) throw new Error("Runtime Host profile was not found"); + if ( + profile.kind === 'remote' && + profile.transport.kind === 'ssh' && + document.profiles.some( + (candidate) => candidate.id === managedDirectPeerProfileId(profileId), + ) + ) { + throw new Error('Disable and remove the Direct peer profile before removing its SSH profile'); + } const managedBinding = findDesktopRuntimeHostManagedServiceBinding( await managedServices.read(), profile, @@ -942,6 +1105,18 @@ export function createDesktopRuntimeHostProfileService(input: { }; } +function managedDirectPeerProfileId(sourceProfileId: string): string { + const digest = createHash('sha256').update(sourceProfileId).digest('hex').slice(0, 32); + return `direct-${digest}`; +} + +function directPeerProfileName(sourceName: string): string { + const suffix = ' · Direct'; + let name = sourceName; + while (Buffer.byteLength(name + suffix, 'utf8') > 128) name = name.slice(0, -1); + return `${name}${suffix}`; +} + async function rollbackCreatedProfile( catalog: RuntimeHostProfileCatalog, target: ResolvedRuntimeHostProfile, diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index beaca6e0c3..6e7f05db87 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -35,6 +35,7 @@ import { } from '@maka/runtime-host/client'; import { decodeRuntimeHostAccessManagementFrame, + decodeRuntimeHostPeerManagementFrame, decodeRuntimeHostServiceManagementFrame, decodeRuntimeHostSetupFrame, RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX, @@ -42,10 +43,14 @@ import { RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV, RUNTIME_HOST_OPERATOR_UPDATE_SCHEDULER_CAPABILITY, + RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX, RUNTIME_HOST_SETUP_FRAME_PREFIX, type RuntimeHostAccessManagementFrame, type RuntimeHostManagedUpdatePolicy, + type RuntimeHostPeerManagementAction, + type RuntimeHostPeerManagementFrame, + type RuntimeHostOperatorCapability, type RuntimeHostServiceManagementAction, type RuntimeHostServiceManagementFrame, type RuntimeHostServiceUpdatePhase, @@ -78,6 +83,7 @@ const TERMINAL_OUTPUT_MAX = 64 * 1024; const SETUP_FRAME_PENDING_MAX = 20 * 1024; const MANAGEMENT_FRAME_PENDING_MAX = 128 * 1024; const ACCESS_MANAGEMENT_FRAME_PENDING_MAX = 768 * 1024; +const PEER_MANAGEMENT_FRAME_PENDING_MAX = 128 * 1024; const SETUP_TIMEOUT_MS = 10 * 60_000; const MANAGEMENT_TIMEOUT_MS = 2 * 60_000; const PROCESS_STOP_GRACE_MS = 2_000; @@ -111,6 +117,7 @@ export interface DesktopRuntimeHostSshManagementInput { readonly expectedConfigFingerprint?: string; readonly allowInterruptActiveTasks?: boolean; readonly retainManagedDeployment?: boolean; + readonly capabilityRequest?: RuntimeHostOperatorCapability; readonly signal?: AbortSignal; } @@ -140,6 +147,16 @@ export interface DesktopRuntimeHostSshUpdateReconciliationInput { readonly signal?: AbortSignal; } +export interface DesktopRuntimeHostSshPeerManagementInput { + readonly destination: string; + readonly sshPort?: number; + readonly operatorPath: string; + readonly action: Extract; + readonly coordinationRelays?: readonly string[]; + readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; + readonly signal?: AbortSignal; +} + export interface DesktopRuntimeHostSshCleanupInput { readonly destination: string; readonly sshPort?: number; @@ -227,6 +244,9 @@ export function createDesktopRuntimeHostSshTerminal(input: { runAccessManagement( input: DesktopRuntimeHostSshAccessInput, ): Promise; + runPeerManagement( + input: DesktopRuntimeHostSshPeerManagementInput, + ): Promise; cleanupManagedDeployment(input: DesktopRuntimeHostSshCleanupInput): Promise; close(): Promise; } { @@ -725,6 +745,17 @@ export function createDesktopRuntimeHostSshTerminal(input: { frameAction: (frame) => frame.action, label: 'Remote Runtime Host access management', }), + runPeerManagement: (peerInput) => + runFramedManagement({ + ...peerInput, + remoteCommand: runtimeHostPeerManagementRemoteCommand(peerInput), + prefix: RUNTIME_HOST_PEER_MANAGEMENT_FRAME_PREFIX, + pendingMaxBytes: PEER_MANAGEMENT_FRAME_PENDING_MAX, + decode: decodeRuntimeHostPeerManagementFrame, + action: peerInput.action, + frameAction: (frame) => frame.action, + label: 'Remote Runtime Host direct-peer management', + }), cleanupManagedDeployment: async (cleanupInput) => { if (closed) throw new Error('Runtime Host SSH terminal is closed'); cleanupInput.signal?.throwIfAborted(); @@ -1097,7 +1128,7 @@ function runtimeHostServiceManagementRemoteCommand( const invocation = `${RUNTIME_HOST_OPERATOR_PROJECT_DIRECTORY_CONFIGURATION_REQUEST_ENV}=1 ` + `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` + - `${quotePosix(RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)} exec ${command}`; + `${quotePosix(input.capabilityRequest ?? RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)} exec ${command}`; return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`; } @@ -1186,6 +1217,24 @@ function runtimeHostAccessManagementRemoteCommand( return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; } +function runtimeHostPeerManagementRemoteCommand( + input: DesktopRuntimeHostSshPeerManagementInput, +): string { + const command = [ + input.operatorPath, + 'peer', + input.action, + '--framed', + ...(input.action === 'enable' && input.coordinationRelays + ? input.coordinationRelays.length === 0 + ? ['--clear-coordination-relays'] + : input.coordinationRelays.flatMap((relay) => ['--coordination-relay', relay]) + : []), + ...managedServiceTargetArgs(input.expectedTarget), + ].map(quotePosix).join(' '); + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`; +} + function runtimeHostManagedDeploymentCleanupRemoteCommand( input: DesktopRuntimeHostSshCleanupInput, ): string { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 1eb3a0e72a..c8bf2aa372 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -471,6 +471,17 @@ export interface DesktopRuntimeHostManagementProgress { | import('@maka/runtime-host/operator').RuntimeHostServiceUpdatePhase; } +export interface DesktopRuntimeHostDirectPeerSnapshot { + readonly state: 'unsupported' | 'not_configured' | 'disabled' | 'enabled'; + readonly peerId?: string; + readonly routeHints: readonly string[]; + readonly coordinationRelays: readonly string[]; + readonly profilePresent: boolean; + readonly profileEnabled: boolean; + readonly clientAvailable: boolean; + readonly managementAvailable: boolean; +} + type RuntimeHostUpdatePolicyResult = Extract< RuntimeHostServiceManagementFrame, { kind: 'result'; action: 'update_policy' } @@ -650,6 +661,12 @@ export interface MakaBridge { policy: import('@maka/runtime-host/operator').RuntimeHostManagedUpdatePolicy, ): Promise; reconcileUpdate(profileId: string): Promise; + getDirectPeer(profileId: string): Promise; + configureDirectPeer( + profileId: string, + enabled: boolean, + coordinationRelays: readonly string[], + ): Promise; listCredentials(profileId: string): Promise; rotateCredential(profileId: string): Promise; revokeCredential( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 45a6fc19ca..22a583b09c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1301,6 +1301,21 @@ const makaBridge = { reconcileUpdate(profileId: string) { return ipcRenderer.invoke('runtime-host-management:reconcile-update', profileId); }, + getDirectPeer(profileId: string) { + return ipcRenderer.invoke('runtime-host-management:get-direct-peer', profileId); + }, + configureDirectPeer( + profileId: string, + enabled: boolean, + coordinationRelays: readonly string[], + ) { + return ipcRenderer.invoke( + 'runtime-host-management:configure-direct-peer', + profileId, + enabled, + coordinationRelays, + ); + }, listCredentials(profileId: string): Promise { return ipcRenderer.invoke('runtime-host-management:list-credentials', profileId); }, diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index b8667e7c5c..64b22fc9c5 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -75,12 +75,28 @@ export type SettingsProjectsCopy = { credentialHelp: string; saveAndEnable: string; defaultBadge: string; + experimentalBadge: string; defaultDisableHelp: string; unavailable: string; manage: string; managementTitle(name: string): string; serviceStatus: string; serviceState: Record; + directPeer: string; + directPeerDescription: string; + directPeerState: Record<'unsupported' | 'not_configured' | 'disabled' | 'enabled' | 'unavailable', string>; + directPeerUnavailable: string; + directPeerUpgradeRequired: string; + directPeerClientUnavailable: string; + directPeerDisableProfileFirst: string; + directPeerId: string; + directPeerRoutes: string; + directPeerCoordinationRelays: string; + directPeerCoordinationRelaysPlaceholder: string; + directPeerEnable: string; + directPeerDisable: string; + directPeerAddProfile: string; + directPeerActionFailed: string; installedVersion: string; operatingSystem: string; processId: string; @@ -291,6 +307,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { credentialHelp: '在远程机器使用 desktop-client preset 签发', saveAndEnable: '保存并启用', defaultBadge: '默认', + experimentalBadge: '实验性', defaultDisableHelp: '先选择另一个默认 Host,才能停用此 Host', unavailable: '无法连接', manage: '管理', @@ -303,6 +320,27 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { running: '运行中', failed: '启动失败', }, + directPeer: 'Direct peer(实验性)', + directPeerDescription: '创建独立的实验性 Direct profile。受限 NAT 或被阻止的 UDP 可能使其不可达,且不会自动回退;保留 SSH profile 用于手动恢复。', + directPeerState: { + unsupported: '需要更新', + not_configured: '未配置', + disabled: '已停用', + enabled: '已启用', + unavailable: '不可用', + }, + directPeerUnavailable: '无法读取 Direct peer 状态', + directPeerUpgradeRequired: '请先更新远程 Runtime Host,再管理 Direct peer。', + directPeerClientUnavailable: '当前 Desktop 构建不包含 Direct peer 支持。', + directPeerDisableProfileFirst: '请先在 Runtime Host 列表中停用 Direct peer。', + directPeerId: 'Peer ID', + directPeerRoutes: '可用路径', + directPeerCoordinationRelays: '连接协调节点(可选)', + directPeerCoordinationRelaysPlaceholder: '多个地址用逗号分隔', + directPeerEnable: '启用并添加', + directPeerDisable: '停用', + directPeerAddProfile: '添加到 Desktop', + directPeerActionFailed: 'Direct peer 操作失败', installedVersion: '版本', operatingSystem: '系统', processId: '进程 ID', @@ -513,6 +551,7 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { credentialHelp: 'Issue it on the remote machine with the desktop-client preset', saveAndEnable: 'Save and enable', defaultBadge: 'Default', + experimentalBadge: 'Experimental', defaultDisableHelp: 'Choose another default Host before disabling this Host', unavailable: 'Unavailable', manage: 'Manage', @@ -525,6 +564,27 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { running: 'Running', failed: 'Failed', }, + directPeer: 'Direct peer (experimental)', + directPeerDescription: 'Create an independent experimental Direct profile. Restrictive NAT or blocked UDP may make it unreachable, and it does not fall back automatically; keep the SSH profile for manual recovery.', + directPeerState: { + unsupported: 'Update required', + not_configured: 'Not configured', + disabled: 'Disabled', + enabled: 'Enabled', + unavailable: 'Unavailable', + }, + directPeerUnavailable: 'Direct peer status is unavailable', + directPeerUpgradeRequired: 'Update the remote Runtime Host before managing Direct peer.', + directPeerClientUnavailable: 'This Desktop build does not include Direct peer support.', + directPeerDisableProfileFirst: 'Disable the Direct peer in the Runtime Host list first.', + directPeerId: 'Peer ID', + directPeerRoutes: 'Routes', + directPeerCoordinationRelays: 'Connection coordination peers (optional)', + directPeerCoordinationRelaysPlaceholder: 'Separate multiple addresses with commas', + directPeerEnable: 'Enable and add', + directPeerDisable: 'Disable', + directPeerAddProfile: 'Add to Desktop', + directPeerActionFailed: 'Direct peer action failed', installedVersion: 'Version', operatingSystem: 'System', processId: 'Process ID', 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 3632c4876d..95585c395e 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -36,6 +36,7 @@ import { uiLocaleToIntlLocale, type UiLocale } from '@maka/core/ui-locale'; import type { RemoteRuntimeHostProfile } from '@maka/runtime-host/client'; import type { DesktopRuntimeHostManagementAction, + DesktopRuntimeHostDirectPeerSnapshot, DesktopRuntimeHostManagementResult, DesktopRuntimeHostManagementProgress, DesktopRuntimeHostAccessCredential, @@ -97,6 +98,9 @@ export function RuntimeHostManagementDialog(props: { const [lastUpdateOutcome, setLastUpdateOutcome] = useState(); const [directoryPolicyEdit, setDirectoryPolicyEdit] = useState(); + const [directPeer, setDirectPeer] = useState(); + const [directPeerError, setDirectPeerError] = useState(); + const [coordinationRelays, setCoordinationRelays] = useState(''); const nextDirectoryRootId = useRef(1); const logsRef = useRef(null); @@ -117,6 +121,9 @@ export function RuntimeHostManagementDialog(props: { setUpdatePolicyError(undefined); setLastUpdateOutcome(undefined); setDirectoryPolicyEdit(undefined); + setDirectPeer(undefined); + setDirectPeerError(undefined); + setCoordinationRelays(''); setLoading(true); void (async () => { let shouldLoadUpdatePolicy = false; @@ -144,6 +151,14 @@ export function RuntimeHostManagementDialog(props: { } } } + if (shouldLoadUpdatePolicy) { + try { + const peer = await window.maka.runtimeHostManagement.getDirectPeer(profile.id); + if (!disposed) applyDirectPeer(peer); + } catch (failure) { + if (!disposed) setDirectPeerError(settingsActionErrorMessage(failure, locale)); + } + } if (!disposed) setLoading(false); })(); return () => { @@ -195,6 +210,55 @@ export function RuntimeHostManagementDialog(props: { } } + function applyDirectPeer(snapshot: DesktopRuntimeHostDirectPeerSnapshot): void { + setDirectPeer(snapshot); + setCoordinationRelays(snapshot.coordinationRelays.join(', ')); + setDirectPeerError(undefined); + } + + async function reloadDirectPeer(): Promise { + if (!profile) return; + setLoading(true); + setDirectPeerError(undefined); + try { + applyDirectPeer(await window.maka.runtimeHostManagement.getDirectPeer(profile.id)); + } catch (failure) { + setDirectPeerError(settingsActionErrorMessage(failure, locale)); + } finally { + setLoading(false); + } + } + + async function configureDirectPeer(enabled: boolean): Promise { + if (!profile) return; + setLoading(true); + setDirectPeerError(undefined); + try { + const relays = coordinationRelays + .split(',') + .map((relay) => relay.trim()) + .filter(Boolean); + applyDirectPeer( + await window.maka.runtimeHostManagement.configureDirectPeer( + profile.id, + enabled, + relays, + ), + ); + } catch (failure) { + const message = settingsActionErrorMessage(failure, locale); + try { + applyDirectPeer(await window.maka.runtimeHostManagement.getDirectPeer(profile.id)); + } catch { + // Preserve the last authoritative snapshot when recovery cannot be read. + } + setDirectPeerError(message); + toast.error(copy.directPeerActionFailed, message); + } finally { + setLoading(false); + } + } + async function loadAccess(): Promise { if (!profile) return; setLoading(true); @@ -607,6 +671,119 @@ export function RuntimeHostManagementDialog(props: { ) : null} + {serviceInstalled ? ( +
+
+
+ {copy.directPeer} + + {copy.directPeerDescription} + +
+ +
+ {directPeerError ? ( + + ) : null} + {directPeer && !directPeer.managementAvailable ? ( + + ) : null} + {!directPeer ? ( +
+
+ ) : null} + {directPeer && !directPeer.clientAvailable ? ( + + ) : null} + {directPeer?.profileEnabled ? ( + + ) : null} + {directPeer?.managementAvailable ? ( + <> + {directPeer.peerId ? ( +
+ + +
+ ) : null} + +
+
+ + ) : null} +
+ ) : null} {serviceInstalled ? (
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 f66e7a32ea..8d1b305786 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx @@ -390,49 +390,56 @@ export function RuntimeHostProfilesSection(props: {