Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ import {
} from '../runtime-host-desktop-manager.js';

test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, async () => {
const first = candidateHarness({ delayDisconnect: true });
const second = candidateHarness();
const first = candidateHarness({ delayDisconnect: true, hostEpoch: 'host-before' });
const second = candidateHarness({ hostEpoch: 'host-after' });
const queue = [ready(first.candidate), ready(second.candidate)];
let starts = 0;
const interactions: Array<string | undefined> = [];
Expand Down Expand Up @@ -71,6 +71,7 @@ test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, asy
});

first.disconnect();
const replacementReady = owner.waitUntilReady(owner.defaultProfileId(), 'host-before');
const botMessage = owner.handleBotIncomingMessage({ text: 'hello' } as BotIncomingMessage);
const stop = owner.stopSession({
hostId: 'test-host',
Expand All @@ -95,7 +96,7 @@ test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, asy
assert.equal(second.botMessages, 0);
assert.deepEqual(second.stoppedSessions, []);
releaseSecond();
await Promise.all([botMessage, stop]);
await Promise.all([botMessage, stop, replacementReady]);

assert.equal(first.botMessages, 0);
assert.equal(second.botMessages, 1);
Expand Down Expand Up @@ -815,6 +816,7 @@ function candidateHarness(
activeTasks?: boolean;
lifecycleMode?: 'ephemeral' | 'service' | 'remote';
hostId?: string;
hostEpoch?: string;
finalizeFailures?: Error[];
disconnectOnFinalizeFailure?: boolean;
onPrepare?: () => void;
Expand All @@ -837,6 +839,7 @@ function candidateHarness(
hostLifecycleMode: options.lifecycleMode ?? 'ephemeral',
client: {
hostId: options.hostId ?? 'test-host',
hostEpoch: options.hostEpoch ?? 'test-host-epoch',
get lifecycleState() {
return lifecycleState;
},
Expand Down
146 changes: 146 additions & 0 deletions apps/desktop/src/main/__tests__/runtime-host-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
DesktopRuntimeHostSshAccessInput,
DesktopRuntimeHostSshCleanupInput,
DesktopRuntimeHostSshManagementInput,
DesktopRuntimeHostSshUpdateInput,
} from '../runtime-host-ssh-terminal.js';

test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => {
Expand Down Expand Up @@ -64,6 +65,7 @@ test('identifies, rotates, and revokes managed credentials without exposing secr
];

createDesktopRuntimeHostManagement({
...unusedUpdateDependencies(),
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
removeHandler: (channel) => handlers.delete(channel),
Expand Down Expand Up @@ -185,6 +187,8 @@ test('manages only the service identity bound by Desktop onboarding', async () =
const uninstallOrder: string[] = [];
let operatorAccess = false;
let cleared = 0;
let statusGate: Promise<void> | undefined;
let releaseStatus: (() => void) | undefined;
const managedProfile = {
id: 'office',
name: 'Office',
Expand All @@ -203,6 +207,7 @@ test('manages only the service identity bound by Desktop onboarding', async () =
operatorPath: '/home/operator/.local/share/maka/operator',
};
const management = createDesktopRuntimeHostManagement({
...unusedUpdateDependencies(),
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
removeHandler: (channel) => handlers.delete(channel),
Expand All @@ -229,6 +234,7 @@ test('manages only the service identity bound by Desktop onboarding', async () =
},
runServiceManagement: async (input) => {
managementInputs.push(input);
if (input.action === 'status') await statusGate;
if (input.action === 'uninstall') {
uninstallOrder.push('uninstall-service');
}
Expand All @@ -243,6 +249,19 @@ test('manages only the service identity bound by Desktop onboarding', async () =
const run = handlers.get('runtime-host-management:run');
assert.ok(run);

statusGate = new Promise((resolve) => {
releaseStatus = resolve;
});
const firstStatus = run({}, 'office', 'status');
const secondStatus = run({}, 'office', 'status');
await Promise.resolve();
await Promise.resolve();
assert.equal(managementInputs.length, 1);
releaseStatus?.();
await Promise.all([firstStatus, secondStatus]);
statusGate = undefined;
managementInputs.length = 0;

await assert.rejects(
run({}, 'manual', 'uninstall') as Promise<unknown>,
/not bound to a managed service/u,
Expand Down Expand Up @@ -309,6 +328,121 @@ test('manages only the service identity bound by Desktop onboarding', async () =
assert.equal(handlers.size, 0);
});

test('publishes update progress and waits for the managed profile to reconnect', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const updates: DesktopRuntimeHostSshUpdateInput[] = [];
const progress: unknown[] = [];
const connectionCompletions: unknown[] = [];
let failConnection = false;
let bindingPresent = true;
let removeBindingAfterUpdate = false;
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',
};
createDesktopRuntimeHostManagement({
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
removeHandler: (channel) => handlers.delete(channel),
},
profiles: {
resolveManagedService: async () =>
bindingPresent ? { profile, service, state: 'active' as const } : undefined,
resolveManagedAccess: async () => undefined,
rotateManagedCredential: async () => assert.fail('credential rotation is not expected'),
markManagedServiceUninstalling: async (binding) => binding,
markManagedServiceCleanupPending: async (binding) => binding,
clearManagedServiceBinding: async () => undefined,
},
runServiceManagement: async () => assert.fail('ordinary management is not expected'),
runUpdate: async (input, onProgress) => {
updates.push(input);
onProgress('staging');
if (removeBindingAfterUpdate) bindingPresent = false;
return {
schemaVersion: 1,
kind: 'result',
action: 'update',
service: {
platform: 'linux',
arch: 'x64',
osRelease: '6.8.0',
state: 'running',
pid: 43,
lastExitCode: 0,
installedVersion: '1.3.0',
projectDirectoryRoots: [],
},
operatorCapabilities: ['access-management-v1'],
update: { kind: 'updated', previousVersion: '1.2.3', targetVersion: '1.3.0' },
};
},
resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.3.0' }),
currentHostEpoch: () => 'host-before-update',
awaitUpdatedConnection: async (...args) => {
connectionCompletions.push(args);
if (failConnection) throw new Error('authentication required');
},
sendProgress: (event) => progress.push(event),
runAccessManagement: async () => assert.fail('access management is not expected'),
cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'),
});

const update = handlers.get('runtime-host-management:update');
assert.ok(update);
const response = await update({}, profile.id, false);
assert.equal((response as { accessManagementAvailable: boolean }).accessManagementAvailable, true);
assert.deepEqual(updates, [{
destination: profile.transport.destination,
setupPackage: { kind: 'npm', specifier: 'maka-agent@1.3.0' },
expectedTarget: {
serviceId: service.id,
rootPath: service.rootPath,
rootId: profile.rootId,
},
}]);
assert.deepEqual(progress, [{ profileId: profile.id, phase: 'staging' }]);
assert.deepEqual(connectionCompletions, [
[profile.id, profile.rootId, 'host-before-update', true],
]);

removeBindingAfterUpdate = true;
const changedProfile = await update({}, profile.id, false);
assert.equal(
(changedProfile as { kind: string; error?: { message: string } }).error?.message,
'The Runtime Host update completed, but Desktop could not reconnect: ' +
'Runtime Host profile changed while its service was updating',
);

bindingPresent = true;
removeBindingAfterUpdate = false;
failConnection = true;
const reconnectFailure = await update({}, profile.id, false);
assert.deepEqual(reconnectFailure, {
schemaVersion: 1,
kind: 'error',
action: 'update',
error: {
code: 'desktop_reconnect_failed',
message:
'The Runtime Host update completed, but Desktop could not reconnect: authentication required',
},
});
});

test('resumes deployment cleanup without invoking the removed operator', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const profile = {
Expand All @@ -333,6 +467,7 @@ test('resumes deployment cleanup without invoking the removed operator', async (
let state: 'active' | 'uninstalling' | 'cleanup_pending' = 'active';
let clearAttempts = 0;
createDesktopRuntimeHostManagement({
...unusedUpdateDependencies(),
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
removeHandler: (channel) => handlers.delete(channel),
Expand Down Expand Up @@ -384,6 +519,7 @@ test('rechecks uninstall intent before retrying the remote service', async () =>
const handlers = new Map<string, (...args: unknown[]) => unknown>();
let marked = false;
createDesktopRuntimeHostManagement({
...unusedUpdateDependencies(),
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
removeHandler: (channel) => handlers.delete(channel),
Expand Down Expand Up @@ -464,6 +600,16 @@ function serviceResult(
: { ...result, action };
}

function unusedUpdateDependencies() {
return {
runUpdate: async (): Promise<never> => assert.fail('update is not expected'),
resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const),
currentHostEpoch: () => undefined,
awaitUpdatedConnection: async () => undefined,
sendProgress: () => undefined,
};
}

function accessCredential(
credentialId: string,
principalId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,43 @@ test("keeps Local enabled while a new remote Host connects", async () => {
);
});

test("reconnects an enabled remote Host with interactive SSH", async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
await catalog.create(MANAGED_PROFILE, "opaque-token");
const calls: string[] = [];
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup: {
preferences: {
schemaVersion: 2,
defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
enabledRemoteProfileIds: [MANAGED_PROFILE.id],
},
pairingIntents: [],
remotes: [{ profile: MANAGED_PROFILE, credential: "opaque-token" }],
unavailable: new Map(),
},
catalog,
states: () => [connectingLocal()],
enable: async (target, interaction) => {
calls.push(`enable:${target.profile.id}:${interaction}`);
},
disable: async (profileId) => {
calls.push(`disable:${profileId}`);
},
setDefault: () => undefined,
finalizePairing: async () => undefined,
});

await service.reconnect(MANAGED_PROFILE.id, MANAGED_PROFILE.rootId);

assert.deepEqual(calls, [
`disable:${MANAGED_PROFILE.id}`,
`enable:${MANAGED_PROFILE.id}:terminal`,
]);
});

test("does not enable the same State Root twice", async () => {
const root = await clientRoot();
const startup = await resolveDesktopRuntimeHostStartup(root);
Expand Down
64 changes: 64 additions & 0 deletions apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,70 @@ test('keeps a received management result when SSH teardown times out', async ()
await harness.terminal.close();
});

test('runs an exact update package and reports progress before an active-work result', async () => {
const harness = createHarness('pending');
const phases: string[] = [];
const update = harness.terminal.runUpdate(
{
destination: 'operator@example.com',
setupPackage: { kind: 'npm', specifier: 'maka-agent@1.3.0' },
expectedTarget: {
serviceId: 'b'.repeat(64),
rootPath: '/srv/maka',
rootId: 'a'.repeat(64),
},
},
(phase) => phases.push(phase),
);
await waitFor(() => harness.pty.hasDataListener());
const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? '';
assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u);
assert.match(remoteCommand, /runtime-host.*service.*update/u);
assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u);
harness.pty.emitData('Password: ');
harness.pty.emitData(
encodeRuntimeHostServiceManagementFrame({
schemaVersion: 1,
kind: 'progress',
action: 'update',
phase: 'retiring',
currentVersion: '1.2.3',
targetVersion: '1.3.0',
}),
);
harness.pty.emitData(
encodeRuntimeHostServiceManagementFrame({
schemaVersion: 1,
kind: 'result',
action: 'update',
service: {
platform: 'linux',
arch: 'x64',
osRelease: '6.8.0',
state: 'running',
pid: 42,
lastExitCode: 0,
installedVersion: '1.2.3',
projectDirectoryRoots: [],
},
update: {
kind: 'active_tasks',
currentVersion: '1.2.3',
targetVersion: '1.3.0',
},
}),
);
harness.pty.exit(1);

const result = await update;
assert.equal(result.kind, 'result');
assert.equal(result.kind === 'result' ? result.update.kind : undefined, 'active_tasks');
assert.deepEqual(phases, ['retiring']);
assert.deepEqual(harness.events.map(({ kind }) => kind), ['opened', 'data', 'connected']);
assert.doesNotMatch(JSON.stringify(harness.events), /MAKA_RUNTIME_HOST_SERVICE/u);
await harness.terminal.close();
});

test('keeps a prepared access credential out of the SSH terminal projection', async () => {
const harness = createHarness('pending');
const credential = 'maka_rh_secret-replacement';
Expand Down
Loading