You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/23 04:28:47 UTC
(maka) branch main updated: feat(desktop): manage remote runtime host credentials (#3419)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 2e5fe9c55 feat(desktop): manage remote runtime host credentials (#3419)
2e5fe9c55 is described below
commit 2e5fe9c55f6a3128da8348603f7089a458d4c836
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Aug 23 12:28:41 2026 +0800
feat(desktop): manage remote runtime host credentials (#3419)
* feat(desktop): manage remote runtime host credentials
Add Host-local credential metadata, safe Desktop credential rotation, and explicit revocation through the existing SSH management plane. Credential mutation remains owned by Runtime Host access authority, while secrets stay out of renderer and terminal projections.
Generated-by: Codex
* fix(runtime-host): harden managed access controls
Advertise credential management through the persisted operator contract so Desktop never exposes actions an older deployment cannot execute. Keep metadata inspection read-only and align framed access operations around one action-discriminated protocol.
Generated-by: Codex
* fix(runtime-host): preserve managed access authority
Derive Desktop credential replacements from the active Host credential and fence access management while pairing recovery is unresolved. Negotiate operator capabilities per invocation so upgraded operators remain compatible with older Desktop clients.
Generated-by: Codex
* fix(runtime-host): make credential rotation atomic
Derive replacement credentials from the active Host-owned authority record and guard rotation and revocation with atomic credential state checks. Reject disabled-profile rotation before remote mutation so the UI cannot leave an unusable pending credential.
Generated-by: Codex
* fix(desktop): bound credential maintenance transitions
Require explicit confirmation before credential rotation reconnects a managed Host, and bound pairing finalization through the dispatched request. Simplify guarded revocation around stable credential identity while preserving recoverable unknown outcomes.
Generated-by: Codex
* fix(runtime-host): make credential revocation converge
Treat an already-absent target as a satisfied revocation while retaining an atomic active-credential fence. Keep Desktop identity validation at the SSH adapter boundary so the Host protocol stays client-agnostic.
Generated-by: Codex
* fix(desktop): clarify credential maintenance behavior
Warn before revocation disconnects clients using an active credential, and exercise rotation recovery through the real profile service path. Reuse the shared UTF-8 truncation boundary for framed access errors.
Generated-by: Codex
* fix(desktop): preserve credential rotation authority
Bind a prepared replacement to the exact managed profile, service, and current credential resolved before SSH begins. Pairing recovery now preserves unreadable journals and reconnects a valid pre-rebind target in the same startup, while revoke confirmation consistently warns about interruption.
Generated-by: Codex
* fix(runtime-host): preserve remote credential compatibility
Keep existing credential operation codecs at epoch 36 and move rotation-only guards into separately named additive operations. This preserves ordinary mixed-version Remote Host connections while keeping credential rotation atomic and Host-authoritative.
Also prevent unfinished pairing recovery from overriding profile lifecycle choices and disable access mutations while the managed service is offline.
* fix(desktop): preserve pairing recovery during uninstall
Persist the managed-service uninstall intent before invoking the remote operator, and retry the idempotent uninstall before cleanup. This keeps credential pairing recovery authoritative and prevents an interrupted uninstall from discarding the binding needed for repair.
Generated-by: Codex
* chore: add ASF headers to credential sources
Apply the repository source-header policy to credential management files introduced by this branch after rebasing onto the new audit gate.
Generated-by: Codex
* fix(desktop): recover interrupted host management
Persist a cleanup-only phase after remote service uninstall so retries never depend on an operator that cleanup may have removed. Bind management dialog state to its Host and use urgent busy updates so stale async results cannot cross profiles.
Generated-by: Codex
* fix(runtime-host): serialize managed deployment cleanup
Route deferred cleanup through the remote service authority so a stale Desktop retry cannot remove a reinstalled Host. Setup and cleanup now share the lifecycle lock, and cleanup refuses to delete while service state remains.
Restore visible management progress and align the compatible protocol declaration with the rebased epoch.
Generated-by: Codex
---
.../__tests__/runtime-host-desktop-manager.test.ts | 39 ++-
.../runtime-host-managed-services.test.ts | 9 +-
.../main/__tests__/runtime-host-management.test.ts | 227 ++++++++++++++-
.../__tests__/runtime-host-profile-service.test.ts | 210 +++++++++++++-
.../__tests__/runtime-host-ssh-terminal.test.ts | 59 ++++
apps/desktop/src/main/runtime-host-boot.ts | 1 +
apps/desktop/src/main/runtime-host-client.ts | 6 +-
.../src/main/runtime-host-desktop-manager.ts | 20 +-
.../src/main/runtime-host-managed-services.ts | 106 +++++--
apps/desktop/src/main/runtime-host-management.ts | 223 ++++++++++++++-
.../src/main/runtime-host-profile-service.ts | 180 +++++++++++-
apps/desktop/src/main/runtime-host-ssh-terminal.ts | 224 ++++++++++-----
apps/desktop/src/preload/bridge-contract.d.ts | 28 +-
apps/desktop/src/preload/preload.ts | 17 ++
.../src/renderer/locales/settings-projects-copy.ts | 63 ++++
.../settings/runtime-host-management-dialog.tsx | 252 ++++++++++++++--
.../settings/runtime-host-profiles-section.tsx | 1 +
.../src/renderer/styles/settings/runtime-host.css | 52 ++++
docs/astryx-surface-file-inventory.md | 2 +-
docs/windows-test-inventory.md | 2 +-
.../runtime-host-operator-command.test.ts | 48 ++++
.../__tests__/runtime-host-service-manager.test.ts | 152 +++++++---
.../cli/src/__tests__/runtime-host-setup.test.ts | 52 +++-
packages/cli/src/cli-core.ts | 48 +++-
packages/cli/src/runtime-host-access-command.ts | 227 ++++++++++++++-
packages/cli/src/runtime-host-cli.ts | 132 ++++++++-
.../cli/src/runtime-host-managed-deployment.ts | 18 +-
.../src/runtime-host-service-management-command.ts | 26 ++
packages/cli/src/runtime-host-service-manager.ts | 76 ++++-
packages/cli/src/runtime-host-setup-command.ts | 8 +-
.../credential-rotation-operations.json | 5 +
.../__tests__/access-credential-metadata.test.ts | 135 +++++++++
.../src/__tests__/authenticated-websocket.test.ts | 76 +++++
.../runtime-host/src/__tests__/protocol.test.ts | 41 +++
.../src/__tests__/websocket-listener.test.ts | 2 +
.../index.ts => access-credential-identity.ts} | 26 +-
.../src/operator/access-management-frame.ts | 126 ++++++++
packages/runtime-host/src/operator/index.ts | 14 +
.../src/operator/service-management-frame.ts | 6 +
.../runtime-host/src/protocol/access-authority.ts | 65 +++++
.../runtime-host/src/server/access-authority.ts | 317 ++++++++++++++-------
.../src/server/access-credential-metadata.ts | 83 ++++++
packages/runtime-host/src/server/host-kernel.ts | 10 +
packages/runtime-host/src/server/index.ts | 4 +
.../src/server/operation-dispatcher.ts | 14 +
scripts/protocol-epoch-check.mjs | 62 +++-
scripts/protocol-epoch-check.test.mjs | 59 ++++
47 files changed, 3195 insertions(+), 358 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
index 24975c2f4..eec6a5f71 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
@@ -463,6 +463,40 @@ test('defers pairing finalization when reconnect does not complete in time', asy
await manager.close();
});
+test('bounds an in-flight pairing finalization and preserves its unknown outcome', async () => {
+ const local = candidateHarness({ hostId: 'host-a' });
+ const remote = candidateHarness({
+ hostId: 'a'.repeat(64),
+ finalizeFailures: [
+ new RuntimeHostRequestInterruptedError(
+ 'access.credential.finalize',
+ 'command',
+ 'dispatched',
+ 'timeout',
+ ),
+ ],
+ });
+ let starts = 0;
+ const manager = await startRuntimeHostDesktopManager(
+ {} as DesktopRuntimeHostCandidateStartInput,
+ {
+ startCandidate: async () => ready(starts++ === 0 ? local.candidate : remote.candidate),
+ pairingFinalizationTimeoutMs: 25,
+ },
+ );
+ await manager.enable(remoteTarget('office'));
+
+ await assert.rejects(
+ () => manager.finalizePairing('office'),
+ RuntimeHostPairingFinalizationInterruptedError,
+ );
+
+ assert.equal(remote.finalizeCalls, 1);
+ assert.equal(remote.finalizeTimeouts.length, 1);
+ assert.ok(remote.finalizeTimeouts[0]! > 0 && remote.finalizeTimeouts[0]! <= 25);
+ await manager.close();
+});
+
test('coalesces concurrent enable requests for one remote profile', async () => {
const local = candidateHarness({ hostId: 'host-a' });
const remote = candidateHarness({ hostId: 'host-b', lifecycleMode: 'remote' });
@@ -796,6 +830,7 @@ function candidateHarness(
let lifecycleState: 'ready' | 'unavailable' = 'ready';
let prepareUpgradeCalls = 0;
let finalizeCalls = 0;
+ const finalizeTimeouts: number[] = [];
const prepareUpgradeAuthorities: boolean[] = [];
const candidate = {
closed,
@@ -821,8 +856,9 @@ function candidateHarness(
}
return { kind: 'prepared' as const, pid: 42 };
},
- async finalizeAccessCredential() {
+ async finalizeAccessCredential(timeoutMs?: number) {
finalizeCalls += 1;
+ if (timeoutMs !== undefined) finalizeTimeouts.push(timeoutMs);
const failure = options.finalizeFailures?.shift();
if (failure) {
if (options.disconnectOnFinalizeFailure) {
@@ -873,6 +909,7 @@ function candidateHarness(
get finalizeCalls() {
return finalizeCalls;
},
+ finalizeTimeouts,
};
}
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 7ceebc389..321ca55bc 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
@@ -88,5 +88,12 @@ test("keeps Desktop service bindings outside the shared profile catalog", async
findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile)?.state,
"uninstalling",
);
- assert.equal(await managedServices.removeIfCurrent(profile, service), true);
+ assert.equal(await managedServices.removeCleanupPendingIfCurrent(profile, service), false);
+ assert.equal(await managedServices.markCleanupPendingIfCurrent(profile, service), true);
+ assert.equal(
+ findDesktopRuntimeHostManagedServiceBinding(await managedServices.read(), profile)?.state,
+ "cleanup_pending",
+ );
+ assert.equal(await managedServices.markUninstallingIfCurrent(profile, service), false);
+ assert.equal(await managedServices.removeCleanupPendingIfCurrent(profile, service), true);
});
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 b4f4eb333..386f69047 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
@@ -19,17 +19,168 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
+import { runtimeHostAccessCredentialFingerprint } from '@maka/runtime-host/operator';
import { createDesktopRuntimeHostManagement } from '../runtime-host-management.js';
import type {
+ DesktopRuntimeHostSshAccessInput,
DesktopRuntimeHostSshCleanupInput,
DesktopRuntimeHostSshManagementInput,
} from '../runtime-host-ssh-terminal.js';
+test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => {
+ const handlers = new Map<string, (...args: unknown[]) => 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',
+ };
+ const principalId = 'desktop:original-installation';
+ const replacement = 'maka_rh_replacement-secret';
+ let profileEnabled = true;
+ let prepareCalls = 0;
+ const currentFingerprint = runtimeHostAccessCredentialFingerprint('maka_rh_current-secret');
+ const credentials = [
+ accessCredential('current', principalId, currentFingerprint),
+ accessCredential(
+ 'obsolete',
+ principalId,
+ runtimeHostAccessCredentialFingerprint('maka_rh_obsolete-secret'),
+ ),
+ ];
+
+ createDesktopRuntimeHostManagement({
+ ipcMain: {
+ handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
+ removeHandler: (channel) => handlers.delete(channel),
+ },
+ profiles: {
+ resolveManagedService: async () => ({ profile, service, state: 'active' as const }),
+ resolveManagedAccess: async () => ({
+ profile,
+ service,
+ state: 'active' as const,
+ credentialFingerprint: currentFingerprint,
+ enabled: profileEnabled,
+ }),
+ rotateManagedCredential: async (expected, credential) => {
+ assert.equal(expected.profile, profile);
+ assert.equal(expected.service, service);
+ assert.equal(expected.credentialFingerprint, currentFingerprint);
+ assert.equal(credential, replacement);
+ },
+ markManagedServiceUninstalling: async (binding) => binding,
+ markManagedServiceCleanupPending: async (binding) => binding,
+ clearManagedServiceBinding: async () => undefined,
+ },
+ runServiceManagement: async () => assert.fail('service management is not expected'),
+ runAccessManagement: async (input: DesktopRuntimeHostSshAccessInput) => {
+ if (input.action === 'list') {
+ return { schemaVersion: 1, kind: 'result', action: 'list', credentials };
+ }
+ if (input.action === 'prepare') {
+ prepareCalls += 1;
+ assert.equal(input.currentCredentialFingerprint, currentFingerprint);
+ const pending = {
+ ...accessCredential(
+ 'replacement',
+ principalId,
+ runtimeHostAccessCredentialFingerprint(replacement),
+ ),
+ status: 'pending' as const,
+ expiresAt: '2026-08-21T01:15:00.000Z',
+ };
+ return {
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'prepare',
+ credential: replacement,
+ credentials: [credentials[0]!, pending],
+ };
+ }
+ assert.equal(input.currentCredentialFingerprint, currentFingerprint);
+ if (input.credentialId === 'current') {
+ return {
+ schemaVersion: 1,
+ kind: 'error',
+ action: 'revoke',
+ error: {
+ code: 'credential_protected',
+ message: 'Rotate this Desktop credential instead of revoking it',
+ },
+ };
+ }
+ return {
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'revoke',
+ credentialId: input.credentialId!,
+ revoked: true,
+ credentials: [credentials[0]!],
+ };
+ },
+ cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'),
+ });
+
+ const list = handlers.get('runtime-host-management:list-credentials');
+ const rotate = handlers.get('runtime-host-management:rotate-credential');
+ const revoke = handlers.get('runtime-host-management:revoke-credential');
+ assert.ok(list && rotate && revoke);
+ const initial = await list({}, profile.id);
+ assert.equal((initial as { canRotate: boolean }).canRotate, true);
+ assert.deepEqual(
+ (initial as { credentials: { credentialId: string; isCurrentDesktop: boolean }[] }).credentials
+ .map(({ credentialId, isCurrentDesktop }) => ({ credentialId, isCurrentDesktop })),
+ [
+ { credentialId: 'current', isCurrentDesktop: true },
+ { credentialId: 'obsolete', isCurrentDesktop: false },
+ ],
+ );
+ await assert.rejects(
+ revoke({}, profile.id, 'current') as Promise<unknown>,
+ /Rotate this Desktop credential/u,
+ );
+ const revoked = await revoke({}, profile.id, 'obsolete');
+ assert.equal(JSON.stringify(revoked).includes('obsolete-secret'), false);
+ const rotated = await rotate({}, profile.id);
+ assert.equal(JSON.stringify(rotated).includes(replacement), false);
+ assert.deepEqual(
+ (rotated as { credentials: { credentialId: string; isCurrentDesktop: boolean }[] }).credentials,
+ [{
+ credentialId: 'replacement',
+ principalKind: 'remote_owner',
+ principalId,
+ status: 'active',
+ createdAt: '2026-08-21T01:00:00.000Z',
+ isCurrentDesktop: true,
+ }],
+ );
+ profileEnabled = false;
+ assert.equal((await list({}, profile.id) as { canRotate: boolean }).canRotate, false);
+ await assert.rejects(
+ rotate({}, profile.id) as Promise<unknown>,
+ /Enable this Runtime Host before rotating/u,
+ );
+ assert.equal(prepareCalls, 1);
+});
+
test('manages only the service identity bound by Desktop onboarding', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const managementInputs: DesktopRuntimeHostSshManagementInput[] = [];
const cleanupInputs: DesktopRuntimeHostSshCleanupInput[] = [];
const uninstallOrder: string[] = [];
+ let operatorAccess = false;
let cleared = 0;
const managedProfile = {
id: 'office',
@@ -58,22 +209,29 @@ test('manages only the service identity bound by Desktop onboarding', async () =
profileId === managedProfile.id
? { profile: managedProfile, service: managedService, state: 'active' as const }
: undefined,
+ resolveManagedAccess: async () => undefined,
markManagedServiceUninstalling: async (binding) => {
uninstallOrder.push('mark-uninstalling');
return { ...binding, state: 'uninstalling' as const };
},
+ markManagedServiceCleanupPending: async (binding) => {
+ uninstallOrder.push('mark-cleanup-pending');
+ return { ...binding, state: 'cleanup_pending' as const };
+ },
clearManagedServiceBinding: async () => {
cleared += 1;
uninstallOrder.push('clear-binding');
},
+ rotateManagedCredential: async () => assert.fail('credential rotation is not expected'),
},
runServiceManagement: async (input) => {
managementInputs.push(input);
if (input.action === 'uninstall') {
uninstallOrder.push('uninstall-service');
}
- return serviceResult(input.action);
+ return serviceResult(input.action, operatorAccess);
},
+ runAccessManagement: async () => assert.fail('access management is not expected'),
cleanupManagedDeployment: async (input) => {
cleanupInputs.push(input);
uninstallOrder.push('cleanup-deployment');
@@ -86,7 +244,17 @@ test('manages only the service identity bound by Desktop onboarding', async () =
run({}, 'manual', 'uninstall') as Promise<unknown>,
/not bound to a managed service/u,
);
- await run({}, 'office', 'status');
+ const legacyStatus = await run({}, 'office', 'status');
+ assert.equal(
+ (legacyStatus as { accessManagementAvailable: boolean }).accessManagementAvailable,
+ false,
+ );
+ operatorAccess = true;
+ const currentStatus = await run({}, 'office', 'status');
+ assert.equal(
+ (currentStatus as { accessManagementAvailable: boolean }).accessManagementAvailable,
+ true,
+ );
const managementInput = managementInputs.at(-1);
assert.deepEqual(managementInput && {
destination: managementInput.destination,
@@ -119,20 +287,26 @@ test('manages only the service identity bound by Desktop onboarding', async () =
await run({}, 'office', 'uninstall');
assert.equal(cleared, 1);
assert.deepEqual(uninstallOrder, [
- 'uninstall-service',
'mark-uninstalling',
+ 'uninstall-service',
+ 'mark-cleanup-pending',
'cleanup-deployment',
'clear-binding',
]);
assert.deepEqual(cleanupInputs, [{
destination: managedProfile.transport.destination,
operatorPath: managedService.operatorPath,
+ expectedTarget: {
+ serviceId: managedService.id,
+ rootPath: managedService.rootPath,
+ rootId: managedProfile.rootId,
+ },
}]);
management.close();
assert.equal(handlers.size, 0);
});
-test('resumes deployment cleanup without repeating the committed service uninstall', async () => {
+test('resumes deployment cleanup without invoking the removed operator', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const profile = {
id: 'office',
@@ -153,7 +327,7 @@ test('resumes deployment cleanup without repeating the committed service uninsta
};
const calls: DesktopRuntimeHostSshManagementInput[] = [];
let cleanups = 0;
- let state: 'active' | 'uninstalling' = 'active';
+ let state: 'active' | 'uninstalling' | 'cleanup_pending' = 'active';
let clearAttempts = 0;
createDesktopRuntimeHostManagement({
ipcMain: {
@@ -162,19 +336,26 @@ test('resumes deployment cleanup without repeating the committed service uninsta
},
profiles: {
resolveManagedService: async () => ({ profile, service, state }),
+ resolveManagedAccess: async () => undefined,
markManagedServiceUninstalling: async (binding) => {
state = 'uninstalling';
return { ...binding, state };
},
+ markManagedServiceCleanupPending: async (binding) => {
+ state = 'cleanup_pending';
+ return { ...binding, state };
+ },
clearManagedServiceBinding: async () => {
clearAttempts += 1;
if (clearAttempts === 1) throw new Error('local metadata is unavailable');
},
+ rotateManagedCredential: async () => assert.fail('credential rotation is not expected'),
},
runServiceManagement: async (input) => {
calls.push(input);
return serviceResult(input.action);
},
+ runAccessManagement: async () => assert.fail('access management is not expected'),
cleanupManagedDeployment: async () => {
cleanups += 1;
},
@@ -196,7 +377,7 @@ test('resumes deployment cleanup without repeating the committed service uninsta
assert.equal(cleanups, 2);
});
-test('does not commit uninstall until the remote service confirms it is removed', async () => {
+test('rechecks uninstall intent before retrying the remote service', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
let marked = false;
createDesktopRuntimeHostManagement({
@@ -223,13 +404,16 @@ test('does not commit uninstall until the remote service confirms it is removed'
rootPath: '/srv/maka',
operatorPath: '/home/operator/.local/share/maka/operator',
},
- state: 'active' as const,
+ state: 'uninstalling' as const,
}),
+ resolveManagedAccess: async () => undefined,
markManagedServiceUninstalling: async (binding) => {
marked = true;
return { ...binding, state: 'uninstalling' as const };
},
+ markManagedServiceCleanupPending: async () => assert.fail('uninstall was not confirmed'),
clearManagedServiceBinding: async () => assert.fail('uninstall was not committed'),
+ rotateManagedCredential: async () => assert.fail('credential rotation is not expected'),
},
runServiceManagement: async () => {
const result = serviceResult('uninstall');
@@ -238,6 +422,7 @@ test('does not commit uninstall until the remote service confirms it is removed'
service: { ...result.service, state: 'running' as const, pid: 42 },
};
},
+ runAccessManagement: async () => assert.fail('access management is not expected'),
cleanupManagedDeployment: async () => assert.fail('cleanup must not start'),
});
@@ -247,14 +432,20 @@ test('does not commit uninstall until the remote service confirms it is removed'
run({}, 'office', 'uninstall') as Promise<unknown>,
/did not confirm/u,
);
- assert.equal(marked, false);
+ assert.equal(marked, true);
});
-function serviceResult(action: DesktopRuntimeHostSshManagementInput['action']) {
+function serviceResult(
+ action: DesktopRuntimeHostSshManagementInput['action'],
+ operatorAccess = false,
+) {
return {
schemaVersion: 1 as const,
kind: 'result' as const,
action,
+ ...(operatorAccess
+ ? { operatorCapabilities: ['access-management-v1' as const] }
+ : {}),
service: {
platform: 'linux',
arch: 'x64',
@@ -267,3 +458,21 @@ function serviceResult(action: DesktopRuntimeHostSshManagementInput['action']) {
},
};
}
+
+function accessCredential(
+ credentialId: string,
+ principalId: string,
+ credentialFingerprint: string,
+) {
+ return {
+ credentialId,
+ credentialFingerprint,
+ principalKind: 'remote_owner' as const,
+ principalId,
+ status: 'active' as const,
+ operationGrants: ['host.status', 'turn.start'],
+ canPublishClientCapabilities: true,
+ canUseHostPaths: false,
+ createdAt: '2026-08-21T01:00:00.000Z',
+ };
+}
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 6cc338ecc..abb39cece 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
@@ -39,6 +39,11 @@ import {
RuntimeHostPairingFinalizationInterruptedError,
type RuntimeHostDesktopTargetState,
} from "../runtime-host-desktop-manager.js";
+import { createDesktopRuntimeHostManagedServiceStore } from "../runtime-host-managed-services.js";
+import {
+ createDesktopRuntimeHostPairingIntent,
+ writeDesktopRuntimeHostPairingIntents,
+} from "../runtime-host-pairing-journal.js";
import {
createDesktopRuntimeHostProfileService,
resolveDesktopRuntimeHostStartup,
@@ -481,10 +486,19 @@ test("finishes a persisted pairing after Desktop restarts before finalization",
);
});
-test("preserves a staged pairing when finalization is interrupted", async () => {
+test("recovers interrupted managed credential rotation after restart", async () => {
const root = await clientRoot();
const catalog = createClientRuntimeHostProfileCatalog(root);
await catalog.create(MANAGED_PROFILE, "old-token");
+ await createDesktopRuntimeHostManagedServiceStore(root).save(MANAGED_PROFILE, MANAGED_SERVICE);
+ await writeFile(
+ join(root, "runtime-host-profile-selection.json"),
+ `${JSON.stringify({
+ schemaVersion: 2,
+ defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
+ enabledRemoteProfileIds: [MANAGED_PROFILE.id],
+ })}\n`,
+ );
const startup = await resolveDesktopRuntimeHostStartup(root, { catalog });
const service = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
@@ -499,19 +513,28 @@ test("preserves a staged pairing when finalization is interrupted", async () =>
},
});
+ const managedAccess = await service.resolveManagedAccess(MANAGED_PROFILE.id);
+ assert.ok(managedAccess);
await assert.rejects(
- () =>
- service.addAndEnableVerified({
- profile: MANAGED_PROFILE,
- credential: "new-token",
- managedService: MANAGED_SERVICE,
- }),
+ () => service.rotateManagedCredential(managedAccess, "new-token"),
RuntimeHostPairingFinalizationInterruptedError,
);
assert.equal((await catalog.resolve(MANAGED_PROFILE.id)).credential, "new-token");
+ const managed = await service.resolveManagedService(MANAGED_PROFILE.id);
+ assert.ok(managed);
+ await assert.rejects(
+ service.markManagedServiceUninstalling(managed),
+ /unfinished pairing/u,
+ );
const recoveredStartup = await resolveDesktopRuntimeHostStartup(root, { catalog });
assert.equal(recoveredStartup.pairingIntents.length, 1);
+ const resolve = catalog.resolve.bind(catalog);
+ let catalogReadable = false;
+ catalog.resolve = async (profileId) => {
+ if (!catalogReadable) throw new Error("profile catalog is temporarily unavailable");
+ return resolve(profileId);
+ };
const recovered = createDesktopRuntimeHostProfileService({
clientDataRoot: root,
startup: recoveredStartup,
@@ -522,8 +545,20 @@ test("preserves a staged pairing when finalization is interrupted", async () =>
setDefault: () => undefined,
finalizePairing: async () => undefined,
});
+ await assert.rejects(
+ recovered.resolveManagedAccess(MANAGED_PROFILE.id),
+ /unfinished pairing/u,
+ );
+ await recovered.startEnabledProfiles();
+ assert.equal(
+ (await resolveDesktopRuntimeHostStartup(root)).pairingIntents.length,
+ 1,
+ );
+
+ catalogReadable = true;
await recovered.startEnabledProfiles();
+ assert.ok(await recovered.resolveManagedAccess(MANAGED_PROFILE.id));
assert.equal(
(await recovered.getSnapshot()).entries.find(
(entry) => entry.profile.id === MANAGED_PROFILE.id,
@@ -536,6 +571,167 @@ test("preserves a staged pairing when finalization is interrupted", async () =>
);
});
+test("does not rotate a managed credential after its profile target changes", async () => {
+ const root = await clientRoot();
+ const catalog = createClientRuntimeHostProfileCatalog(root);
+ const managedServices = createDesktopRuntimeHostManagedServiceStore(root);
+ await catalog.create(MANAGED_PROFILE, "old-token");
+ await managedServices.save(MANAGED_PROFILE, MANAGED_SERVICE);
+ await writeFile(
+ join(root, "runtime-host-profile-selection.json"),
+ `${JSON.stringify({
+ schemaVersion: 2,
+ defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
+ enabledRemoteProfileIds: [MANAGED_PROFILE.id],
+ })}\n`,
+ );
+ const startup = await resolveDesktopRuntimeHostStartup(root, { catalog });
+ let finalized = false;
+ const service = createDesktopRuntimeHostProfileService({
+ clientDataRoot: root,
+ startup,
+ catalog,
+ managedServices,
+ states: () => [connectingLocal()],
+ enable: async () => undefined,
+ disable: async () => undefined,
+ setDefault: () => undefined,
+ finalizePairing: async () => {
+ finalized = true;
+ },
+ });
+ const managedAccess = await service.resolveManagedAccess(MANAGED_PROFILE.id);
+ assert.ok(managedAccess);
+
+ const replacementProfile = {
+ ...MANAGED_PROFILE,
+ rootId: "d".repeat(64),
+ transport: {
+ ...MANAGED_PROFILE.transport,
+ destination: "other@example.com",
+ },
+ };
+ const replacementService = {
+ ...MANAGED_SERVICE,
+ id: "e".repeat(64),
+ rootPath: "/srv/other-maka",
+ };
+ await catalog.remove(MANAGED_PROFILE.id);
+ await catalog.create(replacementProfile, "other-token");
+ await managedServices.save(replacementProfile, replacementService);
+
+ await assert.rejects(
+ service.rotateManagedCredential(managedAccess, "prepared-for-original-host"),
+ /profile changed/u,
+ );
+ assert.equal((await catalog.resolve(MANAGED_PROFILE.id)).credential, "other-token");
+ assert.equal(finalized, false);
+ assert.deepEqual(
+ (await resolveDesktopRuntimeHostStartup(root, { catalog })).pairingIntents,
+ [],
+ );
+});
+
+test("reactivates the previous credential after a pre-rebind rotation crash", async () => {
+ const root = await clientRoot();
+ const credentialStore = createClientRuntimeHostCredentialStore(root);
+ const catalog = createClientRuntimeHostProfileCatalog(root, credentialStore);
+ await catalog.create(MANAGED_PROFILE, "old-token");
+ await createDesktopRuntimeHostManagedServiceStore(root).save(
+ MANAGED_PROFILE,
+ MANAGED_SERVICE,
+ );
+ await writeFile(
+ join(root, "runtime-host-profile-selection.json"),
+ `${JSON.stringify({
+ schemaVersion: 2,
+ defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
+ enabledRemoteProfileIds: [MANAGED_PROFILE.id],
+ })}\n`,
+ );
+ await writeDesktopRuntimeHostPairingIntents(credentialStore, [
+ createDesktopRuntimeHostPairingIntent({
+ target: { profile: MANAGED_PROFILE, credential: "new-token" },
+ previous: { profile: MANAGED_PROFILE, credential: "old-token" },
+ wasEnabled: true,
+ }),
+ ]);
+ const startup = await resolveDesktopRuntimeHostStartup(root, {
+ catalog,
+ credentialStore,
+ });
+ const enabled: ResolvedRuntimeHostProfile[] = [];
+ const service = createDesktopRuntimeHostProfileService({
+ clientDataRoot: root,
+ startup,
+ catalog,
+ credentialStore,
+ states: () => [connectingLocal()],
+ enable: async (target) => {
+ enabled.push(target);
+ },
+ disable: async () => undefined,
+ setDefault: () => undefined,
+ finalizePairing: async () => assert.fail("an unapplied credential cannot be finalized"),
+ });
+
+ await service.startEnabledProfiles();
+
+ assert.deepEqual(enabled.map((target) => target.credential), ["old-token"]);
+ assert.deepEqual(
+ (await resolveDesktopRuntimeHostStartup(root, { catalog })).pairingIntents,
+ [],
+ );
+});
+
+test("does not let unfinished pairing recovery override a later disable", async () => {
+ const root = await clientRoot();
+ const credentials = createClientRuntimeHostCredentialStore(root);
+ const credentialStore = {
+ getSecret: credentials.getSecret.bind(credentials),
+ setSecret: credentials.setSecret.bind(credentials),
+ deleteSecret: async () => {
+ throw new Error("credential cleanup failed");
+ },
+ };
+ const catalog = createClientRuntimeHostProfileCatalog(root, credentialStore);
+ await catalog.create(MANAGED_PROFILE, "old-token");
+ await createDesktopRuntimeHostManagedServiceStore(root).save(
+ MANAGED_PROFILE,
+ MANAGED_SERVICE,
+ );
+ await writeFile(
+ join(root, "runtime-host-profile-selection.json"),
+ `${JSON.stringify({
+ schemaVersion: 2,
+ defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
+ enabledRemoteProfileIds: [MANAGED_PROFILE.id],
+ })}\n`,
+ );
+ const startup = await resolveDesktopRuntimeHostStartup(root, { catalog, credentialStore });
+ const service = createDesktopRuntimeHostProfileService({
+ clientDataRoot: root,
+ startup,
+ catalog,
+ credentialStore,
+ states: () => [connectingLocal()],
+ enable: async () => undefined,
+ disable: async () => undefined,
+ setDefault: () => undefined,
+ finalizePairing: async () => undefined,
+ });
+ const access = await service.resolveManagedAccess(MANAGED_PROFILE.id);
+ assert.ok(access);
+
+ await service.rotateManagedCredential(access, "new-token");
+
+ assert.equal((await service.getSnapshot()).pairingRecoveryPending, true);
+ await assert.rejects(
+ () => service.setEnabled(MANAGED_PROFILE.id, false),
+ /unfinished pairing/u,
+ );
+});
+
test("keeps managed service recovery when a failed pairing profile cannot be removed", 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 2e3b00393..1b3eaec5b 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
@@ -27,8 +27,10 @@ import {
type RuntimeHostSshProcessFactory,
} from '@maka/runtime-host/client';
import {
+ encodeRuntimeHostAccessManagementFrame,
encodeRuntimeHostServiceManagementFrame,
encodeRuntimeHostSetupFrame,
+ runtimeHostAccessCredentialFingerprint,
RUNTIME_HOST_SETUP_FRAME_PREFIX,
} from '@maka/runtime-host/operator';
import { createDesktopRuntimeHostSshTerminal } from '../runtime-host-ssh-terminal.js';
@@ -236,6 +238,8 @@ test('reads a framed service result without projecting it into the SSH terminal'
await waitFor(() => harness.pty.hasDataListener());
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.doesNotMatch(remoteCommand, /npx|maka-agent@/u);
harness.pty.emitData('Password: ');
harness.pty.emitData(
@@ -267,6 +271,53 @@ test('reads a framed service result without projecting it into the SSH terminal'
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';
+ const management = harness.terminal.runAccessManagement({
+ destination: 'operator@example.com',
+ operatorPath: '/home/operator/.local/share/maka/operator',
+ rootPath: '/srv/maka',
+ expectedRootId: 'a'.repeat(64),
+ action: 'prepare',
+ currentCredentialFingerprint: 'b'.repeat(32),
+ });
+ await waitFor(() => harness.pty.hasDataListener());
+ harness.pty.emitData('Password: ');
+ harness.pty.emitData(
+ encodeRuntimeHostAccessManagementFrame({
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'prepare',
+ credential,
+ credentials: [{
+ credentialId: 'credential-2',
+ credentialFingerprint: runtimeHostAccessCredentialFingerprint(credential),
+ principalKind: 'remote_owner',
+ principalId: 'desktop:stable-client',
+ status: 'pending',
+ operationGrants: ['host.status', 'access.credential.finalize'],
+ canPublishClientCapabilities: true,
+ canUseHostPaths: false,
+ createdAt: '2026-08-21T01:00:00.000Z',
+ expiresAt: '2026-08-21T01:15:00.000Z',
+ }],
+ }),
+ );
+ harness.pty.exit(0);
+
+ const result = await management;
+ assert.equal(result.kind, 'result');
+ assert.equal(result.kind === 'result' && result.action === 'prepare' ? result.credential : undefined, credential);
+ assert.doesNotMatch(JSON.stringify(harness.events), /secret-replacement|MAKA_RUNTIME/u);
+ const command = harness.launchArgs.at(-1)?.at(-1) ?? '';
+ assert.match(command, /access.*prepare/u);
+ assert.match(command, /--current-fingerprint/u);
+ assert.match(command, new RegExp('b{32}', 'u'));
+ assert.doesNotMatch(command, /secret-replacement/u);
+ await harness.terminal.close();
+});
+
test('rejects a framed service result for a different action', async () => {
const harness = createHarness('pending');
const management = harness.terminal.runServiceManagement({
@@ -308,6 +359,11 @@ test('requires an absent operator deployment root to be absent or empty', async
const cleanup = harness.terminal.cleanupManagedDeployment({
destination: 'operator@example.com',
operatorPath: '/home/operator/.local/share/maka/operator',
+ expectedTarget: {
+ serviceId: 'b'.repeat(64),
+ rootPath: '/srv/maka',
+ rootId: 'a'.repeat(64),
+ },
});
await waitFor(() => harness.pty.hasDataListener());
const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? '';
@@ -315,6 +371,9 @@ test('requires an absent operator deployment root to be absent or empty', async
assert.match(remoteCommand, /rmdir --/u);
assert.match(remoteCommand, /home\/operator\/\.local\/share\/maka/u);
assert.match(remoteCommand, /__cleanup-managed-deployment/u);
+ assert.match(remoteCommand, /--expected-service-id/u);
+ assert.match(remoteCommand, /--expected-root-path/u);
+ assert.match(remoteCommand, /--expected-root-id/u);
harness.pty.exit(0);
await cleanup;
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 8ae077969..d732a25bc 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -439,6 +439,7 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({
ipcMain,
profiles: runtimeHostProfileService,
runServiceManagement: runtimeHostSshTerminal.runServiceManagement,
+ runAccessManagement: runtimeHostSshTerminal.runAccessManagement,
cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment,
});
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index b2dc6289d..e115a6f71 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -255,8 +255,10 @@ export class DesktopRuntimeHostClient {
return this.#connectionClosed || this.#closeTask ? 'unavailable' : 'ready';
}
- finalizeAccessCredential(): Promise<OperationOutput<'access.credential.finalize'>> {
- return this.request('access.credential.finalize', {});
+ finalizeAccessCredential(
+ timeoutMs?: number,
+ ): Promise<OperationOutput<'access.credential.finalize'>> {
+ return this.request('access.credential.finalize', {}, timeoutMs);
}
subscribeConfigurationChanges(listener: (revision: number) => void): () => void {
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index dfad952d9..4099e413b 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -115,8 +115,8 @@ export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconn
}
export class RuntimeHostPairingFinalizationInterruptedError extends Error {
- constructor() {
- super('Runtime Host pairing finalization was deferred until the next startup');
+ constructor(options?: ErrorOptions) {
+ super('Runtime Host pairing finalization was deferred until the next startup', options);
this.name = 'RuntimeHostPairingFinalizationInterruptedError';
}
}
@@ -275,6 +275,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
throw new Error('Only remote Runtime Host profiles can finalize pairing');
}
const lifecycle = this.#requireLifecycle(target);
+ const deadline = Date.now() + this.pairingFinalizationTimeoutMs;
const timeout = new AbortController();
const timer = setTimeout(
() => timeout.abort(new RuntimeHostPairingFinalizationInterruptedError()),
@@ -292,9 +293,14 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
throw new Error('Runtime Host target changed before pairing was finalized');
}
try {
- await candidate.client.finalizeAccessCredential();
+ const remainingMs = deadline - Date.now();
+ if (remainingMs <= 0) throw new RuntimeHostPairingFinalizationInterruptedError();
+ await candidate.client.finalizeAccessCredential(remainingMs);
return;
} catch (error) {
+ if (pairingFinalizeTimedOut(error)) {
+ throw new RuntimeHostPairingFinalizationInterruptedError({ cause: error });
+ }
const retry = pairingFinalizeRetry(error);
if (!retry) throw error;
candidate = await this.#waitForReadyCandidate(lifecycle, candidate, signal);
@@ -821,6 +827,14 @@ function pairingFinalizeRetry(error: unknown): boolean {
return false;
}
+function pairingFinalizeTimedOut(error: unknown): boolean {
+ return (
+ error instanceof RuntimeHostRequestInterruptedError &&
+ error.operation === 'access.credential.finalize' &&
+ error.reason === 'timeout'
+ );
+}
+
function withRuntimeHostTarget(
input: DesktopRuntimeHostCandidateStartInput,
remote: DesktopRuntimeHostCandidateStartInput['remote'],
diff --git a/apps/desktop/src/main/runtime-host-managed-services.ts b/apps/desktop/src/main/runtime-host-managed-services.ts
index d852ede7f..33b24de6f 100644
--- a/apps/desktop/src/main/runtime-host-managed-services.ts
+++ b/apps/desktop/src/main/runtime-host-managed-services.ts
@@ -43,7 +43,7 @@ export interface DesktopRuntimeHostManagedService {
export interface DesktopRuntimeHostManagedServiceBinding {
readonly profile: RemoteRuntimeHostProfile;
readonly service: DesktopRuntimeHostManagedService;
- readonly state: "active" | "uninstalling";
+ readonly state: "active" | "uninstalling" | "cleanup_pending";
}
export interface DesktopRuntimeHostManagedServiceDocument {
@@ -66,6 +66,14 @@ export interface DesktopRuntimeHostManagedServiceStore {
profile: RemoteRuntimeHostProfile,
service: DesktopRuntimeHostManagedService,
): Promise<boolean>;
+ markCleanupPendingIfCurrent(
+ profile: RemoteRuntimeHostProfile,
+ service: DesktopRuntimeHostManagedService,
+ ): Promise<boolean>;
+ removeCleanupPendingIfCurrent(
+ profile: RemoteRuntimeHostProfile,
+ service: DesktopRuntimeHostManagedService,
+ ): Promise<boolean>;
}
export function createDesktopRuntimeHostManagedServiceStore(
@@ -86,6 +94,18 @@ export function findDesktopRuntimeHostManagedServiceBinding(
: undefined;
}
+export function sameDesktopRuntimeHostManagedServiceBinding(
+ left: DesktopRuntimeHostManagedServiceBinding,
+ right: DesktopRuntimeHostManagedServiceBinding,
+): boolean {
+ return (
+ left.state === right.state &&
+ left.profile.id === right.profile.id &&
+ sameRemoteRuntimeHostProfileTarget(left.profile, right.profile) &&
+ sameService(left.service, right.service)
+ );
+}
+
class FileDesktopRuntimeHostManagedServiceStore
implements DesktopRuntimeHostManagedServiceStore
{
@@ -136,9 +156,56 @@ class FileDesktopRuntimeHostManagedServiceStore
markUninstallingIfCurrent(
value: RemoteRuntimeHostProfile,
managedService: DesktopRuntimeHostManagedService,
+ ): Promise<boolean> {
+ return this.#setStateIfCurrent(
+ value,
+ managedService,
+ ["active", "uninstalling"],
+ "uninstalling",
+ );
+ }
+
+ markCleanupPendingIfCurrent(
+ value: RemoteRuntimeHostProfile,
+ managedService: DesktopRuntimeHostManagedService,
+ ): Promise<boolean> {
+ return this.#setStateIfCurrent(
+ value,
+ managedService,
+ ["uninstalling", "cleanup_pending"],
+ "cleanup_pending",
+ );
+ }
+
+ removeCleanupPendingIfCurrent(
+ value: RemoteRuntimeHostProfile,
+ managedService: DesktopRuntimeHostManagedService,
+ ): Promise<boolean> {
+ return this.#remove(
+ decodeRemoteRuntimeHostProfile(value),
+ decodeService(managedService),
+ "cleanup_pending",
+ );
+ }
+
+ removeIfCurrent(
+ value: RemoteRuntimeHostProfile,
+ managedService: DesktopRuntimeHostManagedService,
): Promise<boolean> {
const profile = decodeRemoteRuntimeHostProfile(value);
const service = decodeService(managedService);
+ return this.#remove(profile, service);
+ }
+
+ removeForProfileIfCurrent(value: RemoteRuntimeHostProfile): Promise<boolean> {
+ return this.#remove(decodeRemoteRuntimeHostProfile(value));
+ }
+
+ #remove(
+ profile: RemoteRuntimeHostProfile,
+ service?: DesktopRuntimeHostManagedService,
+ state?: DesktopRuntimeHostManagedServiceBinding["state"],
+ ): Promise<boolean> {
return this.#exclusive(async () => {
const current = await this.read();
const binding = current.bindings.find(
@@ -147,38 +214,29 @@ class FileDesktopRuntimeHostManagedServiceStore
if (
!binding ||
!sameRemoteRuntimeHostProfileTarget(binding.profile, profile) ||
- !sameService(binding.service, service)
+ (service && !sameService(binding.service, service)) ||
+ (state && binding.state !== state)
) {
return false;
}
- if (binding.state === "uninstalling") return true;
await writeDocument(this.#path, {
schemaVersion: SCHEMA_VERSION,
- bindings: current.bindings.map((candidate) =>
- candidate === binding ? { ...candidate, state: "uninstalling" as const } : candidate,
+ bindings: current.bindings.filter(
+ (candidate) => candidate.profile.id !== profile.id,
),
});
return true;
});
}
- removeIfCurrent(
+ #setStateIfCurrent(
value: RemoteRuntimeHostProfile,
managedService: DesktopRuntimeHostManagedService,
+ allowedStates: readonly DesktopRuntimeHostManagedServiceBinding["state"][],
+ state: DesktopRuntimeHostManagedServiceBinding["state"],
): Promise<boolean> {
const profile = decodeRemoteRuntimeHostProfile(value);
const service = decodeService(managedService);
- return this.#remove(profile, service);
- }
-
- removeForProfileIfCurrent(value: RemoteRuntimeHostProfile): Promise<boolean> {
- return this.#remove(decodeRemoteRuntimeHostProfile(value));
- }
-
- #remove(
- profile: RemoteRuntimeHostProfile,
- service?: DesktopRuntimeHostManagedService,
- ): Promise<boolean> {
return this.#exclusive(async () => {
const current = await this.read();
const binding = current.bindings.find(
@@ -187,14 +245,16 @@ class FileDesktopRuntimeHostManagedServiceStore
if (
!binding ||
!sameRemoteRuntimeHostProfileTarget(binding.profile, profile) ||
- (service && !sameService(binding.service, service))
+ !sameService(binding.service, service) ||
+ !allowedStates.includes(binding.state)
) {
return false;
}
+ if (binding.state === state) return true;
await writeDocument(this.#path, {
schemaVersion: SCHEMA_VERSION,
- bindings: current.bindings.filter(
- (candidate) => candidate.profile.id !== profile.id,
+ bindings: current.bindings.map((candidate) =>
+ candidate === binding ? { ...candidate, state } : candidate,
),
});
return true;
@@ -228,7 +288,11 @@ function decodeDocument(value: unknown): DesktopRuntimeHostManagedServiceDocumen
if (profile.transport.kind !== "ssh") {
throw new Error("A managed Runtime Host service requires SSH");
}
- if (binding.state !== "active" && binding.state !== "uninstalling") {
+ if (
+ binding.state !== "active" &&
+ binding.state !== "uninstalling" &&
+ binding.state !== "cleanup_pending"
+ ) {
throw new Error("Runtime Host managed service state is invalid");
}
return Object.freeze({
diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
index a65ab39e9..75a700d0a 100644
--- a/apps/desktop/src/main/runtime-host-management.ts
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -18,14 +18,21 @@
*/
import type { IpcMain } from 'electron';
-import type { RuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator';
+import {
+ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+ runtimeHostAccessCredentialFingerprint,
+ type RuntimeHostAccessManagementFrame,
+ type RuntimeHostServiceManagementFrame,
+} from '@maka/runtime-host/operator';
import type {
+ DesktopRuntimeHostAccessSnapshot,
DesktopRuntimeHostManagementAction,
DesktopRuntimeHostManagementResponse,
} from '../preload/bridge-contract.js';
import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js';
import type {
DesktopRuntimeHostSshCleanupInput,
+ DesktopRuntimeHostSshAccessInput,
DesktopRuntimeHostSshManagementInput,
} from './runtime-host-ssh-terminal.js';
@@ -38,26 +45,40 @@ const MANAGEMENT_ACTIONS = new Set<DesktopRuntimeHostManagementAction>([
'uninstall',
]);
+type RuntimeHostAccessCredentialMetadata = Extract<
+ RuntimeHostAccessManagementFrame,
+ { kind: 'result'; action: 'list' }
+>['credentials'][number];
+
export function createDesktopRuntimeHostManagement(input: {
readonly ipcMain: Pick<IpcMain, 'handle' | 'removeHandler'>;
readonly profiles: Pick<
DesktopRuntimeHostProfileService,
| 'resolveManagedService'
+ | 'resolveManagedAccess'
+ | 'rotateManagedCredential'
| 'markManagedServiceUninstalling'
+ | 'markManagedServiceCleanupPending'
| 'clearManagedServiceBinding'
>;
readonly runServiceManagement: (
input: DesktopRuntimeHostSshManagementInput,
) => Promise<RuntimeHostServiceManagementFrame>;
+ readonly runAccessManagement: (
+ input: DesktopRuntimeHostSshAccessInput,
+ ) => Promise<RuntimeHostAccessManagementFrame>;
readonly cleanupManagedDeployment: (
input: DesktopRuntimeHostSshCleanupInput,
) => Promise<void>;
}): { close(): void } {
- const resolveManagedService = async (value: unknown) => {
+ const requireProfileId = (value: unknown): string => {
if (typeof value !== 'string' || value.length === 0 || value.length > 128) {
throw new Error('Runtime Host profile ID is invalid');
}
- const managed = await input.profiles.resolveManagedService(value);
+ return value;
+ };
+ const resolveManagedService = async (value: unknown) => {
+ const managed = await input.profiles.resolveManagedService(requireProfileId(value));
if (!managed) throw new Error('This Runtime Host profile is not bound to a managed service');
return managed;
};
@@ -75,7 +96,7 @@ export function createDesktopRuntimeHostManagement(input: {
if (profile.transport.kind !== 'ssh') {
throw new Error('This Runtime Host profile is not bound to a managed service');
}
- if (managed.state === 'uninstalling' && managementAction !== 'uninstall') {
+ if (managed.state !== 'active' && managementAction !== 'uninstall') {
throw new Error('Finish uninstalling this Runtime Host service before managing it');
}
const managementInput: DesktopRuntimeHostSshManagementInput = {
@@ -97,18 +118,28 @@ export function createDesktopRuntimeHostManagement(input: {
: {}),
};
if (managementAction !== 'uninstall') {
- return input.runServiceManagement(managementInput);
+ const response = await input.runServiceManagement(managementInput);
+ return response.kind === 'result'
+ ? {
+ ...response,
+ accessManagementAvailable:
+ response.operatorCapabilities?.includes(
+ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+ ) ?? false,
+ }
+ : response;
}
let pending = managed;
- if (managed.state === 'active') {
+ if (pending.state !== 'cleanup_pending') {
+ pending = await input.profiles.markManagedServiceUninstalling(pending);
const response = await input.runServiceManagement({
...managementInput,
retainManagedDeployment: true,
});
if (response.kind === 'error') return response;
assertUninstalled(response);
- pending = await input.profiles.markManagedServiceUninstalling(managed);
+ pending = await input.profiles.markManagedServiceCleanupPending(pending);
}
await input.cleanupManagedDeployment({
destination: managementInput.destination,
@@ -116,22 +147,194 @@ export function createDesktopRuntimeHostManagement(input: {
? {}
: { sshPort: managementInput.sshPort }),
operatorPath: managementInput.operatorPath,
+ expectedTarget: managementInput.expectedTarget,
});
await input.profiles.clearManagedServiceBinding(pending);
return { kind: 'uninstalled', retainedStateRoot: service.rootPath };
};
- const channel = 'runtime-host-management:run';
- input.ipcMain.handle(channel, (_event, profileId: unknown, action: unknown) =>
+ const resolveAccess = async (value: unknown) => {
+ const profileId = requireProfileId(value);
+ const managed = await input.profiles.resolveManagedAccess(profileId);
+ if (!managed) {
+ throw new Error('This Runtime Host profile does not have managed credential access');
+ }
+ 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,
+ currentCredentialFingerprint: managed.credentialFingerprint,
+ target: {
+ destination: managed.profile.transport.destination,
+ ...(managed.profile.transport.sshPort === undefined
+ ? {}
+ : { sshPort: managed.profile.transport.sshPort }),
+ operatorPath: managed.service.operatorPath,
+ rootPath: managed.service.rootPath,
+ expectedRootId: managed.profile.rootId,
+ },
+ };
+ };
+
+ const accessSnapshot = (
+ credentials: Extract<
+ RuntimeHostAccessManagementFrame,
+ { kind: 'result'; action: 'list' }
+ >['credentials'],
+ currentFingerprint: string,
+ canRotate: boolean,
+ ): DesktopRuntimeHostAccessSnapshot => ({
+ canRotate,
+ credentials: credentials.map((credential) => ({
+ credentialId: credential.credentialId,
+ principalKind: credential.principalKind,
+ principalId: credential.principalId,
+ status: credential.status,
+ createdAt: credential.createdAt,
+ ...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}),
+ isCurrentDesktop: credential.credentialFingerprint === currentFingerprint,
+ })),
+ });
+
+ const listCredentials = async (
+ profileId: unknown,
+ ): Promise<DesktopRuntimeHostAccessSnapshot> => {
+ const access = await resolveAccess(profileId);
+ const response = await input.runAccessManagement({
+ ...access.target,
+ action: 'list',
+ });
+ if (response.kind === 'error') throw new Error(response.error.message);
+ if (response.action !== 'list') {
+ throw new Error('Remote Runtime Host did not return its access credentials');
+ }
+ return accessSnapshot(
+ response.credentials,
+ access.currentCredentialFingerprint,
+ access.canRotate,
+ );
+ };
+
+ const rotateCredential = async (
+ profileId: unknown,
+ ): Promise<DesktopRuntimeHostAccessSnapshot> => {
+ const access = await resolveAccess(profileId);
+ if (!access.canRotate) {
+ throw new Error('Enable this Runtime Host before rotating its access credential');
+ }
+ const response = await input.runAccessManagement({
+ ...access.target,
+ action: 'prepare',
+ currentCredentialFingerprint: access.currentCredentialFingerprint,
+ });
+ if (response.kind === 'error') throw new Error(response.error.message);
+ if (response.action !== 'prepare') {
+ throw new Error('Remote Runtime Host did not prepare a replacement credential');
+ }
+ const replacementFingerprint = runtimeHostAccessCredentialFingerprint(response.credential);
+ const current = response.credentials.find(
+ (credential) =>
+ credential.credentialFingerprint === access.currentCredentialFingerprint,
+ );
+ const replacement = response.credentials.find(
+ (credential) => credential.credentialFingerprint === replacementFingerprint,
+ );
+ if (
+ !current ||
+ current.status !== 'active' ||
+ current.principalKind !== 'remote_owner' ||
+ !current.canPublishClientCapabilities ||
+ current.canUseHostPaths ||
+ !replacement ||
+ replacement.status !== 'pending' ||
+ !sameCredentialAuthority(current, replacement)
+ ) {
+ throw new Error('Remote Runtime Host returned an invalid Desktop credential replacement');
+ }
+ await input.profiles.rotateManagedCredential(access.managed, response.credential);
+ const finalized = response.credentials.flatMap((credential) => {
+ if (credential.credentialId === replacement.credentialId) {
+ const { expiresAt: _expiresAt, ...active } = credential;
+ return [{ ...active, status: 'active' as const }];
+ }
+ return credential.status === 'active' &&
+ credential.principalKind === replacement.principalKind &&
+ credential.principalId === replacement.principalId
+ ? []
+ : [credential];
+ });
+ return accessSnapshot(finalized, replacementFingerprint, true);
+ };
+
+ const revokeCredential = async (
+ profileId: unknown,
+ credentialId: unknown,
+ ): Promise<DesktopRuntimeHostAccessSnapshot> => {
+ if (typeof credentialId !== 'string' || credentialId.length === 0 || credentialId.length > 128) {
+ throw new Error('Runtime Host access credential ID is invalid');
+ }
+ const access = await resolveAccess(profileId);
+ const response = await input.runAccessManagement({
+ ...access.target,
+ action: 'revoke',
+ credentialId,
+ currentCredentialFingerprint: access.currentCredentialFingerprint,
+ });
+ if (response.kind === 'error') throw new Error(response.error.message);
+ if (response.action !== 'revoke') {
+ throw new Error('Remote Runtime Host did not confirm credential revocation');
+ }
+ return accessSnapshot(
+ response.credentials,
+ access.currentCredentialFingerprint,
+ access.canRotate,
+ );
+ };
+
+ const channels = [
+ 'runtime-host-management:run',
+ 'runtime-host-management:list-credentials',
+ 'runtime-host-management:rotate-credential',
+ 'runtime-host-management:revoke-credential',
+ ] as const;
+ input.ipcMain.handle(channels[0], (_event, profileId: unknown, action: unknown) =>
run(profileId, action));
+ input.ipcMain.handle(channels[1], (_event, profileId: unknown) =>
+ listCredentials(profileId));
+ input.ipcMain.handle(channels[2], (_event, profileId: unknown) =>
+ rotateCredential(profileId));
+ input.ipcMain.handle(
+ channels[3],
+ (_event, profileId: unknown, credentialId: unknown) =>
+ revokeCredential(profileId, credentialId),
+ );
return {
close() {
- input.ipcMain.removeHandler(channel);
+ for (const channel of channels) input.ipcMain.removeHandler(channel);
},
};
}
+function sameCredentialAuthority(
+ current: RuntimeHostAccessCredentialMetadata,
+ replacement: RuntimeHostAccessCredentialMetadata,
+): boolean {
+ return (
+ current.principalKind === replacement.principalKind &&
+ current.principalId === replacement.principalId &&
+ current.canPublishClientCapabilities === replacement.canPublishClientCapabilities &&
+ current.canUseHostPaths === replacement.canUseHostPaths &&
+ current.operationGrants.length === replacement.operationGrants.length &&
+ current.operationGrants.every((grant) => replacement.operationGrants.includes(grant))
+ );
+}
+
function assertUninstalled(
frame: Extract<RuntimeHostServiceManagementFrame, { kind: 'result' }>,
): void {
diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts
index 4e609884a..38b2fcee2 100644
--- a/apps/desktop/src/main/runtime-host-profile-service.ts
+++ b/apps/desktop/src/main/runtime-host-profile-service.ts
@@ -33,6 +33,7 @@ import {
type ResolvedRuntimeHostProfile,
type RuntimeHostProfileCatalog,
} from "@maka/runtime-host/client";
+import { runtimeHostAccessCredentialFingerprint } from "@maka/runtime-host/operator";
import type { CredentialStore } from "@maka/storage/credential-store";
import { withFileUpdateLock } from "@maka/storage/file-update-lock";
import type {
@@ -56,6 +57,7 @@ import {
import {
createDesktopRuntimeHostManagedServiceStore,
findDesktopRuntimeHostManagedServiceBinding,
+ sameDesktopRuntimeHostManagedServiceBinding,
type DesktopRuntimeHostManagedService,
type DesktopRuntimeHostManagedServiceBinding,
type DesktopRuntimeHostManagedServiceStore,
@@ -94,10 +96,20 @@ export interface DesktopRuntimeHostProfileService {
resolveManagedService(
profileId: string,
): Promise<DesktopRuntimeHostManagedServiceBinding | undefined>;
+ resolveManagedAccess(
+ profileId: string,
+ ): Promise<DesktopRuntimeHostManagedAccess | undefined>;
clearManagedServiceBinding(expected: DesktopRuntimeHostManagedServiceBinding): Promise<void>;
markManagedServiceUninstalling(
expected: DesktopRuntimeHostManagedServiceBinding,
): Promise<DesktopRuntimeHostManagedServiceBinding>;
+ markManagedServiceCleanupPending(
+ expected: DesktopRuntimeHostManagedServiceBinding,
+ ): Promise<DesktopRuntimeHostManagedServiceBinding>;
+ rotateManagedCredential(
+ expected: DesktopRuntimeHostManagedAccess,
+ credential: string,
+ ): Promise<void>;
startEnabledProfiles(): Promise<void>;
resolvePairingRecovery(): Promise<DesktopRuntimeHostProfileSnapshot>;
setEnabled(profileId: string, enabled: boolean): Promise<DesktopRuntimeHostProfileSnapshot>;
@@ -105,6 +117,12 @@ export interface DesktopRuntimeHostProfileService {
remove(profileId: string): Promise<DesktopRuntimeHostProfileSnapshot>;
}
+export interface DesktopRuntimeHostManagedAccess
+ extends DesktopRuntimeHostManagedServiceBinding {
+ readonly credentialFingerprint: string;
+ readonly enabled: boolean;
+}
+
export async function resolveDesktopRuntimeHostStartup(
clientDataRoot: string,
overrides: {
@@ -286,6 +304,12 @@ export function createDesktopRuntimeHostProfileService(input: {
await persistPairingIntents(new Map(pairingIntents).set(profileId, intent));
};
+ const assertPairingComplete = (profileId: string): void => {
+ if (pairingIntents.has(profileId)) {
+ throw new Error('Resolve this Runtime Host\'s unfinished pairing before changing it');
+ }
+ };
+
const snapshot = async (): Promise<DesktopRuntimeHostProfileSnapshot> => {
const document = await catalog.read();
const managedDocument = await managedServices.read();
@@ -371,6 +395,24 @@ export function createDesktopRuntimeHostProfileService(input: {
}
};
+ const startupSshInteraction = (
+ target: ResolvedRuntimeHostProfile,
+ ): "terminal" | "batch" =>
+ target.profile.kind === "remote" &&
+ target.profile.transport.kind === "ssh" &&
+ target.profile.id === preferences.defaultProfileId
+ ? "terminal"
+ : "batch";
+
+ const resolveExistingPairingTarget = async (
+ profileId: string,
+ ): Promise<ResolvedRuntimeHostProfile | undefined> => {
+ const profile = (await catalog.read()).profiles.find(
+ (candidate) => candidate.id === profileId,
+ );
+ return profile ? catalog.resolve(profileId) : undefined;
+ };
+
const finishPairingIntent = async (
intent: DesktopRuntimeHostPairingIntent,
): Promise<void> => {
@@ -388,7 +430,15 @@ export function createDesktopRuntimeHostProfileService(input: {
intent: DesktopRuntimeHostPairingIntent,
failure: unknown,
): Promise<void> => {
- const current = await catalog.resolve(intent.target.profile.id).catch(() => undefined);
+ let current: ResolvedRuntimeHostProfile | undefined;
+ try {
+ current = await resolveExistingPairingTarget(intent.target.profile.id);
+ } catch (resolveFailure) {
+ throw new AggregateError(
+ [failure, resolveFailure],
+ "Runtime Host pairing failed and its current profile could not be read",
+ );
+ }
if (!current || !pairingIntentMatchesTarget(intent.target, current)) {
if (!intent.previous) {
await managedServices.removeForProfileIfCurrent(intent.target.profile);
@@ -474,12 +524,30 @@ export function createDesktopRuntimeHostProfileService(input: {
const recoverPairingIntent = async (
intent: DesktopRuntimeHostPairingIntent,
): Promise<Error | undefined> => {
- const current = await catalog.resolve(intent.target.profile.id).catch(() => undefined);
+ let current: ResolvedRuntimeHostProfile | undefined;
+ try {
+ current = await resolveExistingPairingTarget(intent.target.profile.id);
+ } catch (error) {
+ const failure = asError(error);
+ unavailable.set(intent.target.profile.id, failure);
+ return failure;
+ }
if (!current || !pairingIntentMatchesTarget(intent.target, current)) {
if (!intent.previous) {
await managedServices.removeForProfileIfCurrent(intent.target.profile);
}
await clearPairingIntentBestEffort(intent.target.profile.id);
+ if (
+ current &&
+ (preferences.defaultProfileId === current.profile.id ||
+ preferences.enabledRemoteProfileIds.includes(current.profile.id))
+ ) {
+ try {
+ await activateTarget(current, startupSshInteraction(current));
+ } catch (error) {
+ return asError(error);
+ }
+ }
return undefined;
}
try {
@@ -598,6 +666,55 @@ export function createDesktopRuntimeHostProfileService(input: {
}
});
},
+ rotateManagedCredential(expected, credential) {
+ return mutateProfiles(async () => {
+ const profileId = expected.profile.id;
+ if (!preferences.enabledRemoteProfileIds.includes(profileId)) {
+ throw new Error('Enable this Runtime Host before rotating its access credential');
+ }
+ const previous = await catalog.resolve(profileId);
+ if (previous.profile.kind !== 'remote') {
+ throw new Error('Only a remote Runtime Host credential can be rotated');
+ }
+ const managed = findDesktopRuntimeHostManagedServiceBinding(
+ await managedServices.read(),
+ previous.profile,
+ );
+ if (
+ !managed ||
+ !sameDesktopRuntimeHostManagedServiceBinding(managed, expected) ||
+ !previous.credential ||
+ runtimeHostAccessCredentialFingerprint(previous.credential) !==
+ expected.credentialFingerprint
+ ) {
+ throw new Error(
+ 'Runtime Host profile changed before its access credential could be rotated',
+ );
+ }
+ const target = { profile: previous.profile, credential } as const;
+ const intent = createDesktopRuntimeHostPairingIntent({
+ target,
+ previous,
+ wasEnabled: true,
+ });
+ await beginPairingIntent(intent);
+ try {
+ const rebound = await catalog.rebindIfCurrent(
+ previous,
+ previous.profile,
+ credential,
+ );
+ if (!rebound.rebound) {
+ throw new Error('Runtime Host profile changed before its credential could be rotated');
+ }
+ await finishPairingIntent(intent);
+ } catch (failure) {
+ if (failure instanceof RuntimeHostPairingFinalizationInterruptedError) throw failure;
+ await rollbackPairingIntent(intent, failure);
+ throw failure;
+ }
+ });
+ },
resolveManagedService(profileId) {
return mutate(async () => {
const profile = (await catalog.read()).profiles.find(
@@ -611,8 +728,32 @@ export function createDesktopRuntimeHostProfileService(input: {
return binding;
});
},
+ resolveManagedAccess(profileId) {
+ return mutate(async () => {
+ if (pairingReadFailure || pairingIntents.has(profileId)) {
+ throw new Error(
+ "Resolve this Runtime Host's unfinished pairing before managing its access",
+ pairingReadFailure ? { cause: pairingReadFailure } : undefined,
+ );
+ }
+ const resolved = await catalog.resolve(profileId).catch(() => undefined);
+ if (!resolved?.credential || resolved.profile.kind !== "remote") return undefined;
+ const binding = findDesktopRuntimeHostManagedServiceBinding(
+ await managedServices.read(),
+ resolved.profile,
+ );
+ return binding
+ ? {
+ ...binding,
+ credentialFingerprint: runtimeHostAccessCredentialFingerprint(resolved.credential),
+ enabled: preferences.enabledRemoteProfileIds.includes(profileId),
+ }
+ : undefined;
+ });
+ },
markManagedServiceUninstalling(expected) {
return mutateProfiles(async () => {
+ assertPairingComplete(expected.profile.id);
const current = (await catalog.read()).profiles.find(
(profile) => profile.id === expected.profile.id,
);
@@ -629,15 +770,38 @@ export function createDesktopRuntimeHostProfileService(input: {
return { ...expected, state: 'uninstalling' };
});
},
+ markManagedServiceCleanupPending(expected) {
+ return mutateProfiles(async () => {
+ assertPairingComplete(expected.profile.id);
+ const current = (await catalog.read()).profiles.find(
+ (profile) => profile.id === expected.profile.id,
+ );
+ if (
+ !current ||
+ !sameRemoteRuntimeHostProfileTarget(current, expected.profile) ||
+ !(await managedServices.markCleanupPendingIfCurrent(
+ expected.profile,
+ expected.service,
+ ))
+ ) {
+ throw new Error('Runtime Host managed service binding changed during uninstall');
+ }
+ return { ...expected, state: 'cleanup_pending' };
+ });
+ },
clearManagedServiceBinding(expected) {
return mutateProfiles(async () => {
+ assertPairingComplete(expected.profile.id);
const current = (await catalog.read()).profiles.find(
(profile) => profile.id === expected.profile.id,
);
if (
!current ||
!sameRemoteRuntimeHostProfileTarget(current, expected.profile) ||
- !(await managedServices.removeIfCurrent(expected.profile, expected.service))
+ !(await managedServices.removeCleanupPendingIfCurrent(
+ expected.profile,
+ expected.service,
+ ))
) {
throw new Error('Runtime Host managed service binding changed during uninstall');
}
@@ -655,11 +819,7 @@ export function createDesktopRuntimeHostProfileService(input: {
for (const target of input.startup.remotes) {
if (target.profile.kind !== "remote" || !target.credential) continue;
if (pairingIntents.has(target.profile.id)) continue;
- const sshInteraction =
- target.profile.transport.kind === "ssh" &&
- target.profile.id === preferences.defaultProfileId
- ? "terminal"
- : "batch";
+ const sshInteraction = startupSshInteraction(target);
const activation = () => activateTarget(target, sshInteraction);
tasks.push(
(sshInteraction === "terminal" ? interactiveStartup.then(activation) : activation()).catch((error) =>
@@ -704,6 +864,7 @@ export function createDesktopRuntimeHostProfileService(input: {
await enable(profileId);
return snapshot();
}
+ assertPairingComplete(profileId);
if (preferences.defaultProfileId === profileId) {
throw new Error("Choose another default Runtime Host before disabling this one");
}
@@ -733,6 +894,7 @@ export function createDesktopRuntimeHostProfileService(input: {
if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) {
throw new Error("Local Runtime Host cannot be removed");
}
+ assertPairingComplete(profileId);
if (preferences.enabledRemoteProfileIds.includes(profileId)) {
throw new Error("Disable a Runtime Host before removing it");
}
@@ -747,7 +909,7 @@ export function createDesktopRuntimeHostProfileService(input: {
await managedServices.read(),
profile,
);
- if (managedBinding?.state === 'uninstalling') {
+ if (managedBinding && managedBinding.state !== 'active') {
throw new Error('Finish uninstalling this Runtime Host service before removing it');
}
await catalog.remove(profileId);
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index 072c378e1..373c3cd96 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -33,10 +33,15 @@ import {
type RuntimeHostSshTunnelInput,
} from '@maka/runtime-host/client';
import {
+ decodeRuntimeHostAccessManagementFrame,
decodeRuntimeHostServiceManagementFrame,
decodeRuntimeHostSetupFrame,
+ RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX,
+ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+ RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
RUNTIME_HOST_SETUP_FRAME_PREFIX,
+ type RuntimeHostAccessManagementFrame,
type RuntimeHostServiceManagementAction,
type RuntimeHostServiceManagementFrame,
type RuntimeHostSetupFrame,
@@ -61,6 +66,7 @@ const TERMINAL_REVEAL_DELAY_MS = 500;
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 SETUP_TIMEOUT_MS = 10 * 60_000;
const MANAGEMENT_TIMEOUT_MS = 2 * 60_000;
const PROCESS_STOP_GRACE_MS = 2_000;
@@ -94,9 +100,30 @@ export interface DesktopRuntimeHostSshCleanupInput {
readonly destination: string;
readonly sshPort?: number;
readonly operatorPath: string;
+ readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget'];
readonly signal?: AbortSignal;
}
+interface DesktopRuntimeHostSshAccessTarget {
+ readonly destination: string;
+ readonly sshPort?: number;
+ readonly operatorPath: string;
+ readonly rootPath: string;
+ readonly expectedRootId: string;
+ readonly signal?: AbortSignal;
+}
+
+export type DesktopRuntimeHostSshAccessInput = DesktopRuntimeHostSshAccessTarget &
+ (
+ | { readonly action: 'list' }
+ | { readonly action: 'prepare'; readonly currentCredentialFingerprint: string }
+ | {
+ readonly action: 'revoke';
+ readonly credentialId: string;
+ readonly currentCredentialFingerprint: string;
+ }
+ );
+
export type DesktopRuntimeHostSetupPackage =
| { readonly kind: 'npm'; readonly specifier: string }
| { readonly kind: 'development_archive'; readonly path: string };
@@ -124,6 +151,9 @@ export function createDesktopRuntimeHostSshTerminal(input: {
runServiceManagement(
input: DesktopRuntimeHostSshManagementInput,
): Promise<RuntimeHostServiceManagementFrame>;
+ runAccessManagement(
+ input: DesktopRuntimeHostSshAccessInput,
+ ): Promise<RuntimeHostAccessManagementFrame>;
cleanupManagedDeployment(input: DesktopRuntimeHostSshCleanupInput): Promise<void>;
close(): Promise<void>;
} {
@@ -323,6 +353,75 @@ export function createDesktopRuntimeHostSshTerminal(input: {
await terminateActiveTerminal(terminal, input.processStopGraceMs);
});
+ const runFramedManagement = async <Frame>(options: {
+ readonly destination: string;
+ readonly sshPort?: number;
+ readonly signal?: AbortSignal;
+ readonly remoteCommand: string;
+ readonly prefix: string;
+ readonly pendingMaxBytes: number;
+ readonly decode: (line: string) => Frame | undefined;
+ readonly action: string;
+ readonly frameAction: (frame: Frame) => string;
+ readonly label: string;
+ }): Promise<Frame> => {
+ if (closed) throw new Error('Runtime Host SSH terminal is closed');
+ options.signal?.throwIfAborted();
+ const destination = normalizeRuntimeHostSshDestination(options.destination);
+ const sshPort = options.sshPort === undefined ? undefined : requireSetupPort(options.sshPort);
+ let frame: Frame | undefined;
+ let failure: Error | undefined;
+ let activeTerminal: ActiveTerminal | undefined;
+ const filter = createFramedOutputFilter({
+ prefix: options.prefix,
+ pendingMaxBytes: options.pendingMaxBytes,
+ decode: options.decode,
+ label: options.label,
+ onFrame: (next) => {
+ const action = options.frameAction(next);
+ if (action !== options.action) {
+ failure = new Error(`${options.label} returned ${action} for ${options.action}`);
+ return;
+ }
+ if (frame) {
+ failure = new Error(`${options.label} returned multiple results`);
+ return;
+ }
+ frame = next;
+ if (activeTerminal) completePresentation(activeTerminal);
+ },
+ onError: (error) => {
+ failure = error;
+ },
+ });
+ const { process, terminal } = startTerminalProcess(
+ 'ssh',
+ sshRemoteCommandArgs(destination, sshPort, options.remoteCommand),
+ filter.push,
+ true,
+ );
+ activeTerminal = terminal;
+ if (frame) completePresentation(terminal);
+ const result = await waitForTerminalProcess(process, {
+ signal: options.signal,
+ timeoutMs: MANAGEMENT_TIMEOUT_MS,
+ timeoutMessage: `${options.label} timed out`,
+ stopGraceMs: input.processStopGraceMs,
+ onAbort: () => dismissPresentation(terminal),
+ });
+ filter.finish();
+ if (failure) throw failure;
+ if (!frame) {
+ throw new Error(
+ result.code === 0
+ ? `${options.label} ended without a result`
+ : `${options.label} exited with code ${String(result.code)}`,
+ );
+ }
+ completePresentation(terminal);
+ return frame;
+ };
+
return {
openSshTunnel: async (tunnelInput) => {
if (closed) throw new Error('Runtime Host SSH terminal is closed');
@@ -408,72 +507,28 @@ export function createDesktopRuntimeHostSshTerminal(input: {
cancellation.close();
}
},
- runServiceManagement: async (managementInput) => {
- if (closed) throw new Error('Runtime Host SSH terminal is closed');
- managementInput.signal?.throwIfAborted();
- const destination = normalizeRuntimeHostSshDestination(managementInput.destination);
- const sshPort = managementInput.sshPort === undefined
- ? undefined
- : requireSetupPort(managementInput.sshPort);
- let frame: RuntimeHostServiceManagementFrame | undefined;
- let frameFailure: Error | undefined;
- let managementTerminal: ActiveTerminal | undefined;
- const filter = createFramedOutputFilter({
+ runServiceManagement: (managementInput) =>
+ runFramedManagement({
+ ...managementInput,
+ remoteCommand: runtimeHostServiceManagementRemoteCommand(managementInput),
prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
pendingMaxBytes: MANAGEMENT_FRAME_PENDING_MAX,
decode: decodeRuntimeHostServiceManagementFrame,
+ action: managementInput.action,
+ frameAction: (frame) => frame.action,
label: 'Remote Runtime Host service management',
- onFrame: (next) => {
- if (next.action !== managementInput.action) {
- frameFailure = new Error(
- `Remote Runtime Host service management returned ${next.action} for ${managementInput.action}`,
- );
- return;
- }
- if (frame) {
- frameFailure = new Error(
- 'Remote Runtime Host service management returned multiple results',
- );
- return;
- }
- frame = next;
- if (managementTerminal) completePresentation(managementTerminal);
- },
- onError: (error) => {
- frameFailure = error;
- },
- });
- const { process, terminal } = startTerminalProcess(
- 'ssh',
- sshRemoteCommandArgs(
- destination,
- sshPort,
- runtimeHostServiceManagementRemoteCommand(managementInput),
- ),
- filter.push,
- true,
- );
- managementTerminal = terminal;
- if (frame) completePresentation(terminal);
- const result = await waitForTerminalProcess(process, {
- signal: managementInput.signal,
- timeoutMs: MANAGEMENT_TIMEOUT_MS,
- timeoutMessage: 'Remote Runtime Host service management timed out',
- stopGraceMs: input.processStopGraceMs,
- onAbort: () => dismissPresentation(terminal),
- });
- filter.finish();
- if (frameFailure) throw frameFailure;
- if (!frame) {
- throw new Error(
- result.code === 0
- ? 'Remote Runtime Host service management ended without a result'
- : `Remote Runtime Host service management exited with code ${String(result.code)}`,
- );
- }
- completePresentation(terminal);
- return frame;
- },
+ }),
+ runAccessManagement: (accessInput) =>
+ runFramedManagement({
+ ...accessInput,
+ remoteCommand: runtimeHostAccessManagementRemoteCommand(accessInput),
+ prefix: RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX,
+ pendingMaxBytes: ACCESS_MANAGEMENT_FRAME_PENDING_MAX,
+ decode: decodeRuntimeHostAccessManagementFrame,
+ action: accessInput.action,
+ frameAction: (frame) => frame.action,
+ label: 'Remote Runtime Host access management',
+ }),
cleanupManagedDeployment: async (cleanupInput) => {
if (closed) throw new Error('Runtime Host SSH terminal is closed');
cleanupInput.signal?.throwIfAborted();
@@ -486,7 +541,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
sshRemoteCommandArgs(
destination,
sshPort,
- runtimeHostManagedDeploymentCleanupRemoteCommand(cleanupInput.operatorPath),
+ runtimeHostManagedDeploymentCleanupRemoteCommand(cleanupInput),
),
undefined,
true,
@@ -792,17 +847,52 @@ function runtimeHostServiceManagementRemoteCommand(
...(input.retainManagedDeployment ? ['--retain-managed-deployment'] : []),
...managedServiceTargetArgs(input.expectedTarget),
].map(quotePosix).join(' ');
+ const invocation =
+ `${RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV}=` +
+ `${quotePosix(RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)} exec ${command}`;
+ return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`;
+}
+
+function runtimeHostAccessManagementRemoteCommand(
+ input: DesktopRuntimeHostSshAccessInput,
+): string {
+ const actionArgs = input.action === 'prepare'
+ ? ['--current-fingerprint', input.currentCredentialFingerprint]
+ : input.action === 'revoke'
+ ? [
+ '--credential', input.credentialId,
+ '--current-fingerprint', input.currentCredentialFingerprint,
+ ]
+ : [];
+ const command = [
+ input.operatorPath,
+ 'access',
+ input.action,
+ '--framed',
+ '--root',
+ input.rootPath,
+ '--expected-root',
+ input.expectedRootId,
+ ...actionArgs,
+ ].map(quotePosix).join(' ');
return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`;
}
-function runtimeHostManagedDeploymentCleanupRemoteCommand(operatorPath: string): string {
- const operator = quotePosix(operatorPath);
- const deploymentRoot = quotePosix(pathPosix.dirname(operatorPath));
+function runtimeHostManagedDeploymentCleanupRemoteCommand(
+ input: DesktopRuntimeHostSshCleanupInput,
+): string {
+ const operator = quotePosix(input.operatorPath);
+ const deploymentRoot = quotePosix(pathPosix.dirname(input.operatorPath));
+ const cleanup = [
+ input.operatorPath,
+ '__cleanup-managed-deployment',
+ ...managedServiceTargetArgs(input.expectedTarget),
+ ].map(quotePosix).join(' ');
const invocation =
`if [ ! -e ${operator} ]; then ` +
`if [ ! -e ${deploymentRoot} ]; then exit 0; fi; ` +
`exec rmdir -- ${deploymentRoot}; fi; ` +
- `exec ${operator} __cleanup-managed-deployment`;
+ `exec ${cleanup}`;
return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`;
}
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 25f03434b..9efe344c7 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -394,15 +394,33 @@ export type DesktopRuntimeHostManagementAction =
export type DesktopRuntimeHostManagementResult = Extract<
RuntimeHostServiceManagementFrame,
{ kind: 'result' }
->;
+> & {
+ readonly accessManagementAvailable: boolean;
+};
export type DesktopRuntimeHostManagementResponse =
- | RuntimeHostServiceManagementFrame
+ | DesktopRuntimeHostManagementResult
+ | Extract<RuntimeHostServiceManagementFrame, { kind: 'error' }>
| {
readonly kind: 'uninstalled';
readonly retainedStateRoot: string;
};
+export interface DesktopRuntimeHostAccessCredential {
+ readonly credentialId: string;
+ readonly principalKind: 'remote_owner' | 'capability_provider';
+ readonly principalId: string;
+ readonly status: 'active' | 'pending';
+ readonly createdAt: string;
+ readonly expiresAt?: string;
+ readonly isCurrentDesktop: boolean;
+}
+
+export interface DesktopRuntimeHostAccessSnapshot {
+ readonly canRotate: boolean;
+ readonly credentials: readonly DesktopRuntimeHostAccessCredential[];
+}
+
export interface DesktopProjectCapabilities {
readonly chooseClientDirectory: boolean;
readonly chooseHostDirectory: boolean;
@@ -515,6 +533,12 @@ export interface MakaBridge {
profileId: string,
action: DesktopRuntimeHostManagementAction,
): Promise<DesktopRuntimeHostManagementResponse>;
+ listCredentials(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot>;
+ rotateCredential(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot>;
+ revokeCredential(
+ profileId: string,
+ credentialId: string,
+ ): Promise<DesktopRuntimeHostAccessSnapshot>;
};
newTasks: {
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index b5c53dc7a..baba2a4f2 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -45,6 +45,7 @@ import type {
DesktopRuntimeHostOnboardingSnapshot,
DesktopRuntimeHostManagementAction,
DesktopRuntimeHostManagementResponse,
+ DesktopRuntimeHostAccessSnapshot,
DesktopNewTaskCatalog,
DesktopNewTaskHost,
DesktopNewTaskHostRef,
@@ -1210,6 +1211,22 @@ const makaBridge = {
): Promise<DesktopRuntimeHostManagementResponse> {
return ipcRenderer.invoke('runtime-host-management:run', profileId, action);
},
+ listCredentials(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot> {
+ return ipcRenderer.invoke('runtime-host-management:list-credentials', profileId);
+ },
+ rotateCredential(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot> {
+ return ipcRenderer.invoke('runtime-host-management:rotate-credential', profileId);
+ },
+ revokeCredential(
+ profileId: string,
+ credentialId: string,
+ ): Promise<DesktopRuntimeHostAccessSnapshot> {
+ return ipcRenderer.invoke(
+ 'runtime-host-management:revoke-credential',
+ profileId,
+ credentialId,
+ );
+ },
},
newTasks: {
getCatalog(): Promise<DesktopNewTaskCatalog> {
diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
index 01f74096b..68af99b20 100644
--- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
@@ -99,6 +99,27 @@ export type SettingsProjectsCopy = {
uninstallConfirm: string;
uninstallRetained(path: string): string;
managementActionFailed: string;
+ manageAccess: string;
+ accessTitle: string;
+ noAccessCredentials: string;
+ currentDesktop: string;
+ accessKind: {
+ owner: string;
+ capabilityProvider: string;
+ };
+ accessPending: string;
+ accessCreated(date: string): string;
+ rotateCredential: string;
+ rotateCredentialConfirmTitle: string;
+ rotateCredentialConfirmBody: string;
+ rotateCredentialConfirm: string;
+ enableBeforeRotate: string;
+ startBeforeChangingAccess: string;
+ revokeCredential: string;
+ revokeCredentialConfirm(name: string): string;
+ revokeCredentialConfirmBody: string;
+ accessActionFailed: string;
+ back: string;
remove: string;
empty: string;
loadFailed: string;
@@ -242,6 +263,27 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
uninstallConfirm: '卸载服务',
uninstallRetained: (path: string) => `服务已卸载,数据保留在 ${path}`,
managementActionFailed: '无法管理 Runtime Host 服务',
+ manageAccess: '管理访问权限',
+ accessTitle: '访问权限',
+ noAccessCredentials: '没有访问凭据',
+ currentDesktop: '当前 Desktop',
+ accessKind: {
+ owner: '客户端访问',
+ capabilityProvider: 'Capability Provider',
+ },
+ accessPending: '等待确认',
+ accessCreated: (date: string) => `创建于 ${date}`,
+ rotateCredential: '轮换凭据',
+ rotateCredentialConfirmTitle: '轮换当前 Desktop 的凭据?',
+ rotateCredentialConfirmBody: '轮换会重新连接这个 Runtime Host,并可能中断正在进行的工作。请先完成或暂停活跃任务。',
+ rotateCredentialConfirm: '继续轮换',
+ enableBeforeRotate: '请先启用这个 Runtime Host,再轮换当前 Desktop 的凭据。',
+ startBeforeChangingAccess: '请先启动 Runtime Host 服务,再修改访问权限。',
+ revokeCredential: '撤销',
+ revokeCredentialConfirm: (name: string) => `撤销 ${name} 的访问权限?`,
+ revokeCredentialConfirmBody: '使用此凭据的客户端会立即断开连接,并可能中断正在进行的工作。',
+ accessActionFailed: '无法管理访问权限',
+ back: '返回',
remove: '移除',
empty: '还没有远程 Host',
loadFailed: '无法读取 Runtime Host profiles',
@@ -383,6 +425,27 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
uninstallConfirm: 'Uninstall service',
uninstallRetained: (path: string) => `Service uninstalled. Data was retained at ${path}`,
managementActionFailed: 'Unable to manage the Runtime Host service',
+ manageAccess: 'Manage access',
+ accessTitle: 'Access',
+ noAccessCredentials: 'No active access credentials',
+ currentDesktop: 'This Desktop',
+ accessKind: {
+ owner: 'Client access',
+ capabilityProvider: 'Capability provider',
+ },
+ accessPending: 'Pending confirmation',
+ accessCreated: (date: string) => `Created ${date}`,
+ rotateCredential: 'Rotate credential',
+ rotateCredentialConfirmTitle: 'Rotate this Desktop credential?',
+ rotateCredentialConfirmBody: 'Rotation reconnects this Runtime Host and may interrupt active work. Finish or pause active tasks before continuing.',
+ rotateCredentialConfirm: 'Continue rotation',
+ enableBeforeRotate: 'Enable this Runtime Host before rotating this Desktop credential.',
+ startBeforeChangingAccess: 'Start the Runtime Host service before changing access.',
+ revokeCredential: 'Revoke',
+ revokeCredentialConfirm: (name: string) => `Revoke access for ${name}?`,
+ revokeCredentialConfirmBody: 'Clients using this credential disconnect immediately, which may interrupt active work.',
+ accessActionFailed: 'Unable to manage access',
+ back: 'Back',
remove: 'Remove',
empty: 'No remote Hosts yet',
loadFailed: 'Could not load Runtime Host profiles',
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 8b34fb95d..ee78cc87b 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
@@ -21,15 +21,26 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { Text } from '@astryxdesign/core/Text';
-import { Banner, Button, Spinner, useToast, useUiLocale } from '@maka/ui';
+import { Badge, Banner, Button, Spinner, useToast, useUiLocale } from '@maka/ui';
+import { uiLocaleToIntlLocale, type UiLocale } from '@maka/core/ui-locale';
import type { RemoteRuntimeHostProfile } from '@maka/runtime-host/client';
import type {
DesktopRuntimeHostManagementAction,
DesktopRuntimeHostManagementResult,
+ DesktopRuntimeHostAccessCredential,
+ DesktopRuntimeHostAccessSnapshot,
} from '../../preload/bridge-contract.js';
import { getSettingsProjectsCopy } from '../locales/settings-projects-copy.js';
import { settingsActionErrorMessage } from './settings-error-copy.js';
+type RuntimeHostManagementConfirmation =
+ | { readonly kind: 'uninstall' }
+ | { readonly kind: 'rotate' }
+ | {
+ readonly kind: 'revoke';
+ readonly credential: DesktopRuntimeHostAccessCredential;
+ };
+
export function RuntimeHostManagementDialog(props: {
readonly profile: RemoteRuntimeHostProfile | undefined;
readonly onClose: () => void;
@@ -41,7 +52,8 @@ export function RuntimeHostManagementDialog(props: {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const [uninstalledRoot, setUninstalledRoot] = useState<string>();
- const [confirmingUninstall, setConfirmingUninstall] = useState(false);
+ const [access, setAccess] = useState<DesktopRuntimeHostAccessSnapshot>();
+ const [confirmation, setConfirmation] = useState<RuntimeHostManagementConfirmation>();
const logsRef = useRef<HTMLPreElement>(null);
const profile = props.profile;
@@ -51,7 +63,8 @@ export function RuntimeHostManagementDialog(props: {
setResult(undefined);
setError(undefined);
setUninstalledRoot(undefined);
- setConfirmingUninstall(false);
+ setAccess(undefined);
+ setConfirmation(undefined);
setLoading(true);
void window.maka.runtimeHostManagement.run(profile.id, 'status').then(
(response) => {
@@ -103,6 +116,60 @@ export function RuntimeHostManagementDialog(props: {
}
}
+ async function loadAccess(): Promise<void> {
+ if (!profile) return;
+ setLoading(true);
+ setError(undefined);
+ try {
+ setAccess(await window.maka.runtimeHostManagement.listCredentials(profile.id));
+ } catch (failure) {
+ const message = settingsActionErrorMessage(failure, locale);
+ setError(message);
+ toast.error(copy.accessActionFailed, message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function rotateCredential(): Promise<void> {
+ if (!profile) return;
+ setLoading(true);
+ setError(undefined);
+ try {
+ setAccess(await window.maka.runtimeHostManagement.rotateCredential(profile.id));
+ } catch (failure) {
+ const message = settingsActionErrorMessage(failure, locale);
+ setError(message);
+ toast.error(copy.accessActionFailed, message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function revokeCredential(): Promise<void> {
+ const revokeTarget = confirmation?.kind === 'revoke'
+ ? confirmation.credential
+ : undefined;
+ if (!profile || !revokeTarget) return;
+ setLoading(true);
+ setError(undefined);
+ try {
+ setAccess(
+ await window.maka.runtimeHostManagement.revokeCredential(
+ profile.id,
+ revokeTarget.credentialId,
+ ),
+ );
+ setConfirmation(undefined);
+ } catch (failure) {
+ const message = settingsActionErrorMessage(failure, locale);
+ setError(message);
+ toast.error(copy.accessActionFailed, message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
const service = result?.service;
const uninstalled = uninstalledRoot !== undefined;
const serviceInstalled = service !== undefined && service.state !== 'not_installed';
@@ -130,26 +197,33 @@ export function RuntimeHostManagementDialog(props: {
content={(
<LayoutContent padding={4}>
<div className="settingsRuntimeHostManagement">
- {loading && !result ? (
+ {loading ? (
<div className="settingsRuntimeHostSetupProgress" role="status">
<Spinner size="sm" />
</div>
) : null}
{error ? <Banner status="error" title={error} /> : null}
- {confirmingUninstall ? (
+ {confirmation?.kind === 'uninstall' ? (
<Banner
status="warning"
title={copy.uninstallConfirmTitle}
description={copy.uninstallConfirmBody}
/>
) : null}
+ {confirmation?.kind === 'rotate' ? (
+ <Banner
+ status="warning"
+ title={copy.rotateCredentialConfirmTitle}
+ description={copy.rotateCredentialConfirmBody}
+ />
+ ) : null}
{uninstalledRoot ? (
<Banner
status="success"
title={copy.uninstallRetained(uninstalledRoot)}
/>
) : null}
- {service ? (
+ {!access && service ? (
<>
<dl className="settingsRuntimeHostManagementFacts">
<Fact label={copy.serviceStatus} value={copy.serviceState[service.state]} />
@@ -189,29 +263,157 @@ export function RuntimeHostManagementDialog(props: {
) : null}
</>
) : null}
+ {access ? (
+ <div className="settingsRuntimeHostAccess">
+ <Text type="body" weight="semibold">{copy.accessTitle}</Text>
+ {!access.canRotate ? (
+ <Text type="supporting" color="secondary">
+ {copy.enableBeforeRotate}
+ </Text>
+ ) : null}
+ {!serviceActive ? (
+ <Text type="supporting" color="secondary">
+ {copy.startBeforeChangingAccess}
+ </Text>
+ ) : null}
+ {confirmation?.kind === 'revoke' ? (
+ <Banner
+ status="warning"
+ title={copy.revokeCredentialConfirm(
+ confirmation.credential.principalId,
+ )}
+ description={copy.revokeCredentialConfirmBody}
+ />
+ ) : null}
+ {access.credentials.length === 0 ? (
+ <Text type="supporting" color="secondary">
+ {copy.noAccessCredentials}
+ </Text>
+ ) : (
+ <ul className="settingsRuntimeHostAccessList">
+ {access.credentials.map((credential) => (
+ <li key={credential.credentialId}>
+ <div className="settingsRuntimeHostAccessIdentity">
+ <div>
+ <strong>{credential.principalId}</strong>
+ <span>
+ {credential.principalKind === 'capability_provider'
+ ? copy.accessKind.capabilityProvider
+ : copy.accessKind.owner}
+ </span>
+ </div>
+ <div className="settingsRuntimeHostAccessBadges">
+ {credential.isCurrentDesktop ? (
+ <Badge variant="neutral" label={copy.currentDesktop} />
+ ) : null}
+ {credential.status === 'pending' ? (
+ <Badge variant="warning" label={copy.accessPending} />
+ ) : null}
+ </div>
+ </div>
+ <div className="settingsRuntimeHostAccessMeta">
+ <span>{copy.accessCreated(formatCredentialDate(credential.createdAt, locale))}</span>
+ {credential.isCurrentDesktop ? (
+ <Button
+ variant="secondary"
+ size="sm"
+ label={copy.rotateCredential}
+ isDisabled={
+ loading ||
+ confirmation !== undefined ||
+ credential.status === 'pending' ||
+ !access.canRotate ||
+ !serviceActive
+ }
+ onClick={() => setConfirmation({ kind: 'rotate' })}
+ />
+ ) : (
+ <Button
+ variant="secondary"
+ size="sm"
+ label={copy.revokeCredential}
+ isDisabled={
+ loading || confirmation !== undefined || !serviceActive
+ }
+ onClick={() => setConfirmation({ kind: 'revoke', credential })}
+ />
+ )}
+ </div>
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ ) : null}
</div>
</LayoutContent>
)}
footer={(
<LayoutFooter hasDivider>
<div className="settingsRuntimeHostManagementActions">
- {confirmingUninstall ? (
+ {confirmation?.kind === 'revoke' ? (
<>
<Button
variant="secondary"
label={copy.cancel}
isDisabled={loading}
- onClick={() => setConfirmingUninstall(false)}
+ onClick={() => setConfirmation(undefined)}
+ />
+ <Button
+ variant="destructive"
+ label={copy.revokeCredential}
+ isDisabled={loading}
+ onClick={() => void revokeCredential()}
+ />
+ </>
+ ) : confirmation?.kind === 'uninstall' ? (
+ <>
+ <Button
+ variant="secondary"
+ label={copy.cancel}
+ isDisabled={loading}
+ onClick={() => setConfirmation(undefined)}
/>
<Button
variant="destructive"
label={copy.uninstallConfirm}
isDisabled={loading}
- clickAction={async () => {
- await run('uninstall');
- setConfirmingUninstall(false);
+ onClick={() => void run('uninstall').then(() => setConfirmation(undefined))}
+ />
+ </>
+ ) : confirmation?.kind === 'rotate' ? (
+ <>
+ <Button
+ variant="secondary"
+ label={copy.cancel}
+ isDisabled={loading}
+ onClick={() => setConfirmation(undefined)}
+ />
+ <Button
+ variant="primary"
+ label={copy.rotateCredentialConfirm}
+ isDisabled={loading}
+ onClick={() => void rotateCredential().then(() => setConfirmation(undefined))}
+ />
+ </>
+ ) : access ? (
+ <>
+ <Button
+ variant="secondary"
+ label={copy.back}
+ isDisabled={loading}
+ onClick={() => {
+ setAccess(undefined);
+ setConfirmation(undefined);
+ setError(undefined);
}}
/>
+ <Button
+ variant="primary"
+ label={copy.refresh}
+ isDisabled={loading}
+ onClick={() => void loadAccess()}
+ />
</>
) : (
<>
@@ -226,7 +428,15 @@ export function RuntimeHostManagementDialog(props: {
variant="secondary"
label={copy.repairService}
isDisabled={loading}
- clickAction={() => run('install')}
+ onClick={() => void run('install')}
+ />
+ ) : null}
+ {profile && serviceInstalled && result?.accessManagementAvailable && !uninstalled ? (
+ <Button
+ variant="secondary"
+ label={copy.manageAccess}
+ isDisabled={loading}
+ onClick={() => void loadAccess()}
/>
) : null}
{result && profile && !uninstalled ? (
@@ -235,14 +445,14 @@ export function RuntimeHostManagementDialog(props: {
variant="secondary"
label={copy.refresh}
isDisabled={loading}
- clickAction={() => run('status')}
+ onClick={() => void run('status')}
/>
{serviceInstalled ? (
<Button
variant="secondary"
label={copy.showLogs}
isDisabled={loading}
- clickAction={() => run('logs')}
+ onClick={() => void run('logs')}
/>
) : null}
{serviceInstalled && serviceActive ? (
@@ -250,14 +460,14 @@ export function RuntimeHostManagementDialog(props: {
variant="primary"
label={copy.restartService}
isDisabled={loading}
- clickAction={() => run('restart')}
+ onClick={() => void run('restart')}
/>
) : serviceInstalled ? (
<Button
variant="primary"
label={copy.startService}
isDisabled={loading}
- clickAction={() => run('start')}
+ onClick={() => void run('start')}
/>
) : null}
</>
@@ -267,7 +477,7 @@ export function RuntimeHostManagementDialog(props: {
variant="secondary"
label={copy.uninstallService}
isDisabled={loading}
- onClick={() => setConfirmingUninstall(true)}
+ onClick={() => setConfirmation({ kind: 'uninstall' })}
/>
) : null}
</>
@@ -292,3 +502,11 @@ function Fact(props: {
</div>
);
}
+
+function formatCredentialDate(value: string, locale: UiLocale): string {
+ const timestamp = Date.parse(value);
+ if (!Number.isFinite(timestamp)) return value;
+ return new Intl.DateTimeFormat(uiLocaleToIntlLocale(locale), {
+ dateStyle: 'medium',
+ }).format(timestamp);
+}
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 2aac09f10..e166f03df 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
@@ -446,6 +446,7 @@ export function RuntimeHostProfilesSection(props: {
onRemoteHostAdded={props.onRemoteHostAdded}
/>
<RuntimeHostManagementDialog
+ key={managedProfile ? `profile:${managedProfile.id}` : 'no-profile'}
profile={managedProfile}
onClose={() => {
setManagedProfile(undefined);
diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css
index 98a405c83..d5d648e67 100644
--- a/apps/desktop/src/renderer/styles/settings/runtime-host.css
+++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css
@@ -146,6 +146,58 @@
font: var(--maka-text-code);
}
+.settingsRuntimeHostAccess {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.settingsRuntimeHostAccessList {
+ display: grid;
+ gap: var(--space-2);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.settingsRuntimeHostAccessList li {
+ display: grid;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ border: var(--border-width-hairline) solid var(--border-soft);
+ border-radius: var(--radius-container);
+ background: var(--background-secondary);
+}
+
+.settingsRuntimeHostAccessIdentity,
+.settingsRuntimeHostAccessMeta,
+.settingsRuntimeHostAccessBadges {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.settingsRuntimeHostAccessIdentity,
+.settingsRuntimeHostAccessMeta {
+ justify-content: space-between;
+}
+
+.settingsRuntimeHostAccessIdentity > div:first-child {
+ display: grid;
+ min-width: 0;
+}
+
+.settingsRuntimeHostAccessIdentity strong {
+ overflow-wrap: anywhere;
+ color: var(--foreground-primary);
+ font: var(--maka-text-body);
+}
+
+.settingsRuntimeHostAccessIdentity span,
+.settingsRuntimeHostAccessMeta > span {
+ color: var(--foreground-secondary);
+ font: var(--maka-text-supporting);
+}
+
.settingsRuntimeHostManagementLogs {
max-height: 240px;
overflow: auto;
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 9b46ef873..f174bb33c 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -92,7 +92,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/settings/provider-oauth-section.tsx` | settings-module | Banner, Button, HStack, Text, VStack | aligned — uses Astryx (Banner, Button, HStack, Text, VStack) | aligned |
| `apps/desktop/src/renderer/settings/providers-panel.tsx` | settings-module | Badge, Banner, Button, EmptyState, HStack, Heading, List, ListItem, Text, VStack | aligned — uses Astryx (Badge, Banner, Button, EmptyState, HStack, Heading, List, ListItem) | aligned |
| `apps/desktop/src/renderer/settings/request-customization-editor.tsx` | settings-module | Button, HStack, IconButton, Text, VStack | aligned — uses Astryx (Button, HStack, IconButton, Text, VStack) | aligned |
-| `apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx` | settings-module | Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text) | aligned |
+| `apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx` | settings-module | Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner) | aligned |
| `apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx` | settings-module | Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, Layout, LayoutContent, Spinner, Text) | aligned |
| `apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx` | settings-module | Badge, Banner, Button, HStack, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, Switch | aligned — uses Astryx (Badge, Banner, Button, HStack, List, ListItem, SegmentedControl, SegmentedControlItem) | aligned |
| `apps/desktop/src/renderer/settings/runtime-host-settings-target.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned |
diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md
index fec0d52fd..61635cf99 100644
--- a/docs/windows-test-inventory.md
+++ b/docs/windows-test-inventory.md
@@ -30,7 +30,7 @@ Total Windows-excluded declarations: **63**
| platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` keeps the inherited PATH and does not log shell stderr when capture fails | `process.platform === 'win32'` |
| platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` kills login-shell descendants when capture times out | `process.platform === 'win32'` |
| platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` bounds shell output instead of buffering until the global timeout | `process.platform === 'win32'` |
-| portable-candidate | `packages/cli/src/__tests__/runtime-host-setup.test.ts` managed operator binds its Client Data Root and survives package cleanup interruption | `process.platform === 'win32'` |
+| portable-candidate | `packages/cli/src/__tests__/runtime-host-setup.test.ts` managed operator binds its Client Data Root and routes deployment cleanup | `process.platform === 'win32'` |
| portable-candidate | `packages/eval/src/__tests__/install-preflight.test.ts` rejects an unusable trials root before invoking external prerequisites | `process.platform === 'win32' \|\| process.geteuid?.() === 0` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts` leaves canonical onboarding state unchanged when the durable intent cannot be published | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts` recovers a durable onboarding intent instead of rolling back a partial publication | `process.platform === 'win32'` |
diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
index eadb10a8c..477757f53 100644
--- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
@@ -125,6 +125,8 @@ describe('Runtime Host operator commands', () => {
assert.equal(resolved.operationGrants.includes('access.credential.prepare'), false);
assert.equal(resolved.operationGrants.includes('access.credential.replace'), false);
assert.equal(resolved.operationGrants.includes('access.credential.revoke'), false);
+ assert.equal(resolved.operationGrants.includes('access.credential.rotation.prepare'), false);
+ assert.equal(resolved.operationGrants.includes('access.credential.rotation.revoke'), false);
assert.equal(resolved.operationGrants.includes('host.upgrade.prepare'), false);
assert.equal(resolved.operationGrants.includes('turn.start'), true);
assert.equal(resolved.operationGrants.includes('project.catalog.query'), true);
@@ -146,6 +148,50 @@ describe('Runtime Host operator commands', () => {
]).kind,
'error',
);
+ assert.deepEqual(
+ parseRuntimeHostCommand([
+ 'access',
+ 'prepare',
+ '--current-fingerprint',
+ 'a'.repeat(32),
+ '--root',
+ '/srv/maka',
+ '--expected-root',
+ 'a'.repeat(64),
+ '--framed',
+ ]),
+ {
+ kind: 'runtime-host-access-prepare',
+ rootPath: '/srv/maka',
+ expectedRootId: 'a'.repeat(64),
+ currentCredentialFingerprint: 'a'.repeat(32),
+ },
+ );
+ assert.equal(
+ parseRuntimeHostCommand(['access', 'prepare', '--current-fingerprint', 'a'.repeat(32)]).kind,
+ 'error',
+ );
+ assert.deepEqual(parseRuntimeHostCommand(['access', 'list', '--framed']), {
+ kind: 'runtime-host-access-list',
+ framed: true,
+ });
+ assert.deepEqual(
+ parseRuntimeHostCommand([
+ 'access',
+ 'revoke',
+ '--credential',
+ 'credential-1',
+ '--current-fingerprint',
+ 'a'.repeat(32),
+ '--framed',
+ ]),
+ {
+ kind: 'runtime-host-access-revoke',
+ credentialId: 'credential-1',
+ currentCredentialFingerprint: 'a'.repeat(32),
+ framed: true,
+ },
+ );
assert.deepEqual(
Object.keys(HOST_OPERATION_SPECS)
.filter(
@@ -160,6 +206,8 @@ describe('Runtime Host operator commands', () => {
'access.credential.prepare',
'access.credential.replace',
'access.credential.revoke',
+ 'access.credential.rotation.prepare',
+ 'access.credential.rotation.revoke',
'host.upgrade.prepare',
'hosted.execution.cancel',
'hosted.execution.start',
diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
index c08eb81c7..1aeb02760 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -32,7 +32,11 @@ import {
import { tmpdir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { describe, it } from 'node:test';
-import { decodeRuntimeHostServiceManagementFrame } from '@maka/runtime-host/operator';
+import {
+ decodeRuntimeHostServiceManagementFrame,
+ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+ RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
+} from '@maka/runtime-host/operator';
import { resolveStorageRoot } from '@maka/storage/root-authority';
import { parseRuntimeHostCommand } from '../runtime-host-cli.js';
import {
@@ -41,11 +45,13 @@ import {
} from '../runtime-host-managed-deployment.js';
import { runManagedRuntimeHostServiceCli } from '../runtime-host-service-management-command.js';
import {
+ cleanupRuntimeHostManagedDeployment,
manageRuntimeHostService,
resolveRuntimeHostManagedServiceConfigPath,
resolveRuntimeHostManagedServiceId,
RuntimeHostServiceManagerError,
type RuntimeHostManagedServiceConfig,
+ type RuntimeHostManagedServiceResult,
type RuntimeHostServiceBackend,
} from '../runtime-host-service-manager.js';
import {
@@ -122,6 +128,26 @@ describe('managed Runtime Host service', () => {
retainManagedDeployment: true,
},
);
+ assert.deepEqual(
+ parseRuntimeHostCommand([
+ 'service',
+ 'cleanup-deployment',
+ '--expected-service-id',
+ 'b'.repeat(64),
+ '--expected-root-path',
+ '/srv/maka',
+ '--expected-root-id',
+ 'a'.repeat(64),
+ ]),
+ {
+ kind: 'runtime-host-managed-deployment-cleanup',
+ expectedTarget: {
+ serviceId: 'b'.repeat(64),
+ rootPath: '/srv/maka',
+ rootId: 'a'.repeat(64),
+ },
+ },
+ );
assert.deepEqual(
parseRuntimeHostCommand([
'service',
@@ -302,6 +328,36 @@ describe('managed Runtime Host service', () => {
assert.equal(retained.service.installed, false);
await access(deploymentRoot);
+ await manageRuntimeHostService({ ...common, action: 'install' }, backend(), managerDeps);
+ const expectedTarget = {
+ serviceId,
+ rootPath: root.canonicalPath,
+ rootId: root.rootId,
+ } as const;
+ await assert.rejects(
+ cleanupRuntimeHostManagedDeployment(
+ { clientDataRoot, cliPath: canonicalCliPath, expectedTarget },
+ backend(),
+ ),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError && error.code === 'uninstall_incomplete',
+ );
+ await access(deploymentRoot);
+ await manageRuntimeHostService(
+ {
+ ...common,
+ action: 'uninstall',
+ retainManagedDeployment: true,
+ expectedTarget,
+ },
+ backend(),
+ );
+ await cleanupRuntimeHostManagedDeployment(
+ { clientDataRoot, cliPath: canonicalCliPath, expectedTarget },
+ backend(),
+ );
+ await assert.rejects(access(deploymentRoot));
+
const movedRootPath = `${rootPath}-moved`;
await rename(rootPath, movedRootPath);
@@ -319,7 +375,6 @@ describe('managed Runtime Host service', () => {
backend(),
);
assert.equal(repeatedRetain.service.installed, false);
- await access(deploymentRoot);
const uninstalled = await manageRuntimeHostService(
{
@@ -508,52 +563,73 @@ describe('managed Runtime Host service', () => {
});
});
- it('projects a framed service summary without launch configuration', async () => {
- let output = '';
- const exitCode = await runManagedRuntimeHostServiceCli(
- {
- action: 'status',
- json: false,
- framed: true,
- clientDataRoot: '/config/Maka',
- defaultRootPath: '/config/Maka/workspaces/default',
- nodePath: '/usr/bin/node',
- cliPath: '/opt/maka/cli.js',
+ it('projects requested operator capabilities without launch configuration', async (t) => {
+ const previousCapabilityRequest = process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV];
+ delete process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV];
+ t.after(() => {
+ if (previousCapabilityRequest === undefined) {
+ delete process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV];
+ } else {
+ process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] = previousCapabilityRequest;
+ }
+ });
+ const options = {
+ action: 'status' as const,
+ json: false,
+ framed: true,
+ clientDataRoot: '/config/Maka',
+ defaultRootPath: '/config/Maka/workspaces/default',
+ nodePath: '/usr/bin/node',
+ cliPath: '/opt/maka/cli.js',
+ };
+ const manage = async (): Promise<RuntimeHostManagedServiceResult> => ({
+ schemaVersion: 1 as const,
+ action: 'status' as const,
+ service: {
+ manager: 'systemd_user' as const,
+ installed: true,
+ enabled: true,
+ active: true,
+ state: 'running' as const,
+ pid: 42,
+ lastExitCode: 0,
+ installedVersion: '1.2.3',
+ config: {
+ schemaVersion: 1 as const,
+ rootPath: '/srv/maka',
+ projectDirectoryRoots: [{ label: 'Home', path: '/home/ada' }],
+ websocket: { host: '127.0.0.1', port: 7443, path: '/runtime-host' },
+ launch: { nodePath: '/secret/node', cliPath: '/secret/cli.js' },
+ },
},
- {
- manage: async () => ({
- schemaVersion: 1,
- action: 'status',
- service: {
- manager: 'systemd_user',
- installed: true,
- enabled: true,
- active: true,
- state: 'running',
- pid: 42,
- lastExitCode: 0,
- installedVersion: '1.2.3',
- config: {
- schemaVersion: 1,
- rootPath: '/srv/maka',
- projectDirectoryRoots: [{ label: 'Home', path: '/home/ada' }],
- websocket: { host: '127.0.0.1', port: 7443, path: '/runtime-host' },
- launch: { nodePath: '/secret/node', cliPath: '/secret/cli.js' },
- },
- },
- }),
+ });
+ const run = async () => {
+ let output = '';
+ const exitCode = await runManagedRuntimeHostServiceCli(options, {
+ manage,
createBackend: createUnusedBackend,
writeOutput: (value) => {
output += value;
},
- },
+ });
+ assert.equal(exitCode, 0);
+ return output;
+ };
+
+ const legacyFrame = decodeRuntimeHostServiceManagementFrame(await run());
+ assert.equal(legacyFrame?.kind, 'result');
+ assert.equal(
+ legacyFrame?.kind === 'result' ? legacyFrame.operatorCapabilities : undefined,
+ undefined,
);
- assert.equal(exitCode, 0);
- const frame = decodeRuntimeHostServiceManagementFrame(output);
+ process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] =
+ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY;
+ const frame = decodeRuntimeHostServiceManagementFrame(await run());
assert.equal(frame?.kind, 'result');
if (frame?.kind !== 'result') assert.fail('Expected a service result frame');
assert.equal(frame.service.installedVersion, '1.2.3');
+ assert.deepEqual(frame.operatorCapabilities, ['access-management-v1']);
assert.equal(frame.service.stateRoot, '/srv/maka');
assert.doesNotMatch(JSON.stringify(frame), /secret/u);
});
diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts
index 3a27de7ac..95666f40d 100644
--- a/packages/cli/src/__tests__/runtime-host-setup.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts
@@ -335,7 +335,7 @@ test('managed setup removes a newly copied package when service installation fai
);
});
-test('managed operator binds its Client Data Root and survives package cleanup interruption', {
+test('managed operator binds its Client Data Root and routes deployment cleanup', {
skip: process.platform === 'win32',
}, async (t) => {
const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-operator-'));
@@ -343,9 +343,10 @@ test('managed operator binds its Client Data Root and survives package cleanup i
const version = '0.2.0';
const sourcePackageRoot = await createReleasePackage(base, version);
const clientDataRoot = join(base, 'config', 'Maka');
+ const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot);
const deployment = await prepareRuntimeHostManagedPackageDeployment(
{
- serviceId: resolveRuntimeHostManagedServiceId(clientDataRoot),
+ serviceId,
clientDataRoot,
sourcePackageRoot,
version,
@@ -378,9 +379,50 @@ test('managed operator binds its Client Data Root and survives package cleanup i
clientDataRoot,
]);
- await rm(join(deployment.root, 'versions'), { recursive: true });
- await execFile(deployment.operatorPath, ['__cleanup-managed-deployment']);
- await assert.rejects(access(deployment.root));
+ await execFile(
+ deployment.operatorPath,
+ ['access', 'list', '--root', '/runtime-root', '--framed'],
+ {
+ env: { ...process.env, MAKA_TEST_OUTPUT: invocationPath },
+ },
+ );
+ assert.deepEqual(JSON.parse(await readFile(invocationPath, 'utf8')), [
+ 'runtime-host',
+ 'access',
+ 'list',
+ '--root',
+ '/runtime-root',
+ '--framed',
+ ]);
+
+ await execFile(
+ deployment.operatorPath,
+ [
+ '__cleanup-managed-deployment',
+ '--expected-service-id',
+ serviceId,
+ '--expected-root-path',
+ '/srv/maka',
+ '--expected-root-id',
+ 'a'.repeat(64),
+ ],
+ {
+ env: { ...process.env, MAKA_TEST_OUTPUT: invocationPath },
+ },
+ );
+ assert.deepEqual(JSON.parse(await readFile(invocationPath, 'utf8')), [
+ 'runtime-host',
+ 'service',
+ 'cleanup-deployment',
+ '--expected-service-id',
+ serviceId,
+ '--expected-root-path',
+ '/srv/maka',
+ '--expected-root-id',
+ 'a'.repeat(64),
+ '--client-data-root',
+ clientDataRoot,
+ ]);
});
async function createReleasePackage(base: string, version: string): Promise<string> {
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index ea6f27c5c..398751a17 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -123,6 +123,7 @@ function helpText(cliCommand: string): string {
` ${cliCommand} runtime-host service status|start|stop|restart|logs|uninstall [--json]`,
` ${cliCommand} runtime-host access issue --principal <id> --grant <operation>`,
` ${cliCommand} runtime-host access issue --principal <id> --preset <desktop-client|terminal-client>`,
+ ` ${cliCommand} runtime-host access list`,
` ${cliCommand} runtime-host access issue --kind capability-provider --principal <id>`,
` ${cliCommand} runtime-host access revoke --credential <id>`,
` ${cliCommand} runtime-host project list [--root <path>]`,
@@ -272,10 +273,24 @@ export async function runMakaCli(
...(command.retainManagedDeployment ? { retainManagedDeployment: true } : {}),
});
}
+ case 'runtime-host-managed-deployment-cleanup': {
+ const { runManagedRuntimeHostDeploymentCleanupCli } = await import(
+ './runtime-host-service-management-command.js'
+ );
+ const serviceDataRoots = command.clientDataRoot
+ ? deriveMakaDataRoots(command.clientDataRoot)
+ : dataRoots;
+ return runManagedRuntimeHostDeploymentCleanupCli({
+ clientDataRoot: serviceDataRoots.clientDataRoot,
+ cliPath: process.argv[1] ?? '',
+ expectedTarget: command.expectedTarget,
+ });
+ }
case 'runtime-host-access-issue': {
const { runRuntimeHostAccessIssueCli } = await import('./runtime-host-access-command.js');
return runRuntimeHostAccessIssueCli({
rootPath: command.rootPath ?? dataRoots.workspaceRoot,
+ ...(command.expectedRootId ? { expectedRootId: command.expectedRootId } : {}),
principalKind: command.principalKind,
principalId: command.principalId,
operationGrants: command.operationGrants,
@@ -284,13 +299,38 @@ export async function runMakaCli(
...(command.preset ? { preset: command.preset } : {}),
});
}
- case 'runtime-host-access-revoke': {
- const { runRuntimeHostAccessRevokeCli } = await import('./runtime-host-access-command.js');
- return runRuntimeHostAccessRevokeCli({
+ case 'runtime-host-access-prepare': {
+ const { runRuntimeHostAccessPrepareCli } = await import('./runtime-host-access-command.js');
+ return runRuntimeHostAccessPrepareCli({
rootPath: command.rootPath ?? dataRoots.workspaceRoot,
- credentialId: command.credentialId,
+ ...(command.expectedRootId ? { expectedRootId: command.expectedRootId } : {}),
+ currentCredentialFingerprint: command.currentCredentialFingerprint,
});
}
+ case 'runtime-host-access-list': {
+ const { runRuntimeHostAccessListCli } = await import('./runtime-host-access-command.js');
+ return runRuntimeHostAccessListCli(
+ {
+ rootPath: command.rootPath ?? dataRoots.workspaceRoot,
+ ...(command.expectedRootId ? { expectedRootId: command.expectedRootId } : {}),
+ },
+ command.framed,
+ );
+ }
+ case 'runtime-host-access-revoke': {
+ const { runRuntimeHostAccessRevokeCli } = await import('./runtime-host-access-command.js');
+ return runRuntimeHostAccessRevokeCli(
+ {
+ rootPath: command.rootPath ?? dataRoots.workspaceRoot,
+ ...(command.expectedRootId ? { expectedRootId: command.expectedRootId } : {}),
+ credentialId: command.credentialId,
+ ...(command.currentCredentialFingerprint
+ ? { currentCredentialFingerprint: command.currentCredentialFingerprint }
+ : {}),
+ },
+ command.framed,
+ );
+ }
case 'runtime-host-project-list':
case 'runtime-host-project-add': {
const { runRuntimeHostProjectCli } = await import('./runtime-host-project-command.js');
diff --git a/packages/cli/src/runtime-host-access-command.ts b/packages/cli/src/runtime-host-access-command.ts
index 98a9b0ddc..e3b35c4a0 100644
--- a/packages/cli/src/runtime-host-access-command.ts
+++ b/packages/cli/src/runtime-host-access-command.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { truncateUtf8 } from '@maka/core/diagnostic-log';
import {
connectExistingRuntimeHost,
consumeAccessCredentialDelivery,
@@ -25,9 +26,19 @@ import {
isOperationKey,
REMOTE_OWNER_OPERATION_GRANTS,
RUNTIME_HOST_PROTOCOL_VERSION,
+ type AccessCredentialRotationRevokeInput,
type AccessCredentialPrincipalKind,
type OperationKey,
} from '@maka/runtime-host/protocol';
+import {
+ readRuntimeHostAccessCredentialMetadata,
+ type RuntimeHostAccessCredentialMetadata,
+} from '@maka/runtime-host/server';
+import {
+ encodeRuntimeHostAccessManagementFrame,
+ RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES,
+ type RuntimeHostAccessManagementAction,
+} from '@maka/runtime-host/operator';
const PROTOCOL = {
min: RUNTIME_HOST_PROTOCOL_VERSION,
@@ -36,6 +47,7 @@ const PROTOCOL = {
export interface RuntimeHostAccessIssueOptions {
readonly rootPath: string;
+ readonly expectedRootId?: string;
readonly principalKind: AccessCredentialPrincipalKind;
readonly principalId: string;
readonly operationGrants: readonly string[];
@@ -58,9 +70,18 @@ const CLIENT_CAPABILITY_PUBLICATION_OPERATIONS = new Set<OperationKey>([
'client.capability.unregister',
]);
-export interface RuntimeHostAccessRevokeOptions {
+export interface RuntimeHostAccessListOptions {
readonly rootPath: string;
+ readonly expectedRootId?: string;
+}
+
+export interface RuntimeHostAccessRevokeOptions extends RuntimeHostAccessListOptions {
readonly credentialId: string;
+ readonly currentCredentialFingerprint?: string;
+}
+
+export interface RuntimeHostAccessPrepareOptions extends RuntimeHostAccessListOptions {
+ readonly currentCredentialFingerprint: string;
}
export interface IssuedRuntimeHostAccessCredential {
@@ -83,6 +104,65 @@ export async function runRuntimeHostAccessIssueCli(
return 0;
}
+export async function runRuntimeHostAccessListCli(
+ options: RuntimeHostAccessListOptions,
+ framed = false,
+): Promise<number> {
+ try {
+ const result = await listRuntimeHostAccessCredentials(options);
+ process.stdout.write(
+ framed
+ ? encodeRuntimeHostAccessManagementFrame({
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'list',
+ credentials: mutableCredentialMetadata(result.credentials),
+ })
+ : `${JSON.stringify(result, null, 2)}\n`,
+ );
+ return 0;
+ } catch (error) {
+ if (!framed) throw error;
+ writeAccessManagementError('list', error);
+ return 1;
+ }
+}
+
+export async function runRuntimeHostAccessPrepareCli(
+ options: RuntimeHostAccessPrepareOptions,
+): Promise<number> {
+ try {
+ const before = await listRuntimeHostAccessCredentials(options);
+ const current = requireCurrentDesktopCredential(
+ before.credentials,
+ options.currentCredentialFingerprint,
+ );
+ const prepared = await prepareRuntimeHostAccessCredentialReplacement(
+ options,
+ current.credentialId,
+ );
+ const listed = await listRuntimeHostAccessCredentials(options);
+ if (
+ !listed.credentials.some((credential) => credential.credentialId === prepared.credentialId)
+ ) {
+ throw new Error('Prepared Runtime Host credential metadata is unavailable');
+ }
+ process.stdout.write(
+ encodeRuntimeHostAccessManagementFrame({
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'prepare',
+ credential: prepared.credential,
+ credentials: mutableCredentialMetadata(listed.credentials),
+ }),
+ );
+ return 0;
+ } catch (error) {
+ writeAccessManagementError('prepare', error);
+ return 1;
+ }
+}
+
export function issueRuntimeHostAccessCredential(
options: RuntimeHostAccessIssueOptions,
): Promise<IssuedRuntimeHostAccessCredential> {
@@ -95,7 +175,7 @@ export function prepareRuntimeHostAccessCredential(
return mutateRuntimeHostAccessCredential(options, 'access.credential.prepare');
}
-export async function replaceRuntimeHostAccessCredential(
+export function replaceRuntimeHostAccessCredential(
options: RuntimeHostAccessIssueOptions,
): Promise<ReplacedRuntimeHostAccessCredential> {
return mutateRuntimeHostAccessCredential(options, 'access.credential.replace');
@@ -103,21 +183,41 @@ export async function replaceRuntimeHostAccessCredential(
export type ReplacedRuntimeHostAccessCredential = IssuedRuntimeHostAccessCredential;
+async function prepareRuntimeHostAccessCredentialReplacement(
+ options: RuntimeHostAccessListOptions,
+ replacementOfCredentialId: string,
+): Promise<IssuedRuntimeHostAccessCredential> {
+ const connection = await connectLocalOwner(options.rootPath, options.expectedRootId);
+ try {
+ const result = await connection.request('access.credential.rotation.prepare', {
+ replacementOfCredentialId,
+ });
+ const credential = await consumeAccessCredentialDelivery(
+ options.rootPath,
+ result.deliveryId,
+ result.credentialId,
+ );
+ const { deliveryId: _deliveryId, ...metadata } = result;
+ return { rootId: connection.rootId, credential, ...metadata };
+ } finally {
+ await connection.close();
+ }
+}
+
async function mutateRuntimeHostAccessCredential(
options: RuntimeHostAccessIssueOptions,
operation: 'access.credential.issue' | 'access.credential.prepare' | 'access.credential.replace',
): Promise<IssuedRuntimeHostAccessCredential> {
const resolved = resolveRuntimeHostAccessIssue(options);
- const connection = await connectLocalOwner(options.rootPath);
+ const connection = await connectLocalOwner(options.rootPath, options.expectedRootId);
try {
- const credentialInput = {
+ const result = await connection.request(operation, {
principalKind: resolved.principalKind,
principalId: options.principalId,
operationGrants: resolved.operationGrants,
canPublishClientCapabilities: resolved.canPublishClientCapabilities,
canUseHostPaths: resolved.canUseHostPaths,
- };
- const result = await connection.request(operation, credentialInput);
+ });
const credential = await consumeAccessCredentialDelivery(
options.rootPath,
result.deliveryId,
@@ -130,6 +230,12 @@ async function mutateRuntimeHostAccessCredential(
}
}
+export async function listRuntimeHostAccessCredentials(
+ options: RuntimeHostAccessListOptions,
+): Promise<{ readonly credentials: readonly RuntimeHostAccessCredentialMetadata[] }> {
+ return readRuntimeHostAccessCredentialMetadata(options.rootPath, options.expectedRootId);
+}
+
export function resolveRuntimeHostAccessIssue(
options: RuntimeHostAccessIssueOptions,
): ResolvedRuntimeHostAccessIssue {
@@ -156,24 +262,90 @@ export function resolveRuntimeHostAccessIssue(
export async function runRuntimeHostAccessRevokeCli(
options: RuntimeHostAccessRevokeOptions,
+ framed = false,
): Promise<number> {
- const result = await revokeRuntimeHostAccessCredential(options);
- process.stdout.write(`${JSON.stringify(result)}\n`);
- return result.revoked ? 0 : 1;
+ try {
+ const before = await listRuntimeHostAccessCredentials(options);
+ const target = before.credentials.find(
+ (credential) => credential.credentialId === options.credentialId,
+ );
+ const current = options.currentCredentialFingerprint
+ ? requireCurrentDesktopCredential(before.credentials, options.currentCredentialFingerprint)
+ : undefined;
+ if (target?.credentialFingerprint === options.currentCredentialFingerprint) {
+ throw new Error('Rotate this Desktop credential instead of revoking it');
+ }
+ const result = await revokeRuntimeHostAccessCredential(
+ options,
+ current
+ ? {
+ credentialId: options.credentialId,
+ requiredActiveCredentialId: current.credentialId,
+ }
+ : undefined,
+ );
+ const listed = await listRuntimeHostAccessCredentials(options);
+ process.stdout.write(
+ framed
+ ? encodeRuntimeHostAccessManagementFrame({
+ schemaVersion: 1,
+ kind: 'result',
+ action: 'revoke',
+ ...result,
+ credentials: mutableCredentialMetadata(listed.credentials),
+ })
+ : `${JSON.stringify(result)}\n`,
+ );
+ return result.revoked ? 0 : 1;
+ } catch (error) {
+ if (!framed) throw error;
+ writeAccessManagementError('revoke', error);
+ return 1;
+ }
+}
+
+function requireCurrentDesktopCredential(
+ credentials: readonly RuntimeHostAccessCredentialMetadata[],
+ fingerprint: string,
+): RuntimeHostAccessCredentialMetadata {
+ const current = credentials.find(
+ (credential) => credential.credentialFingerprint === fingerprint,
+ );
+ if (
+ !current ||
+ current.status !== 'active' ||
+ current.principalKind !== 'remote_owner' ||
+ !current.canPublishClientCapabilities ||
+ current.canUseHostPaths
+ ) {
+ throw new Error('The current Desktop credential is not active on this Runtime Host');
+ }
+ return current;
+}
+
+function mutableCredentialMetadata(credentials: readonly RuntimeHostAccessCredentialMetadata[]) {
+ return credentials.map((credential) => ({
+ ...credential,
+ operationGrants: [...credential.operationGrants],
+ }));
}
-export async function revokeRuntimeHostAccessCredential(options: RuntimeHostAccessRevokeOptions) {
- const connection = await connectLocalOwner(options.rootPath);
+export async function revokeRuntimeHostAccessCredential(
+ options: RuntimeHostAccessRevokeOptions,
+ guardedInput?: AccessCredentialRotationRevokeInput,
+) {
+ const connection = await connectLocalOwner(options.rootPath, options.expectedRootId);
try {
- return await connection.request('access.credential.revoke', {
- credentialId: options.credentialId,
- });
+ return await connection.request(
+ guardedInput ? 'access.credential.rotation.revoke' : 'access.credential.revoke',
+ guardedInput ?? { credentialId: options.credentialId },
+ );
} finally {
await connection.close();
}
}
-async function connectLocalOwner(rootPath: string) {
+async function connectLocalOwner(rootPath: string, expectedRootId?: string) {
const result = await connectExistingRuntimeHost({
rootPath,
protocol: PROTOCOL,
@@ -181,9 +353,34 @@ async function connectLocalOwner(rootPath: string) {
if (result.kind !== 'connected') {
throw new Error(`Runtime Host service is not available (${result.kind})`);
}
+ if (expectedRootId && result.connection.rootId !== expectedRootId) {
+ await result.connection.close();
+ throw new Error('Runtime Host service is bound to a different State Root');
+ }
return result.connection;
}
+function writeAccessManagementError(
+ action: RuntimeHostAccessManagementAction,
+ error: unknown,
+): void {
+ process.stdout.write(
+ encodeRuntimeHostAccessManagementFrame({
+ schemaVersion: 1,
+ kind: 'error',
+ action,
+ error: {
+ code: 'access_management_failed',
+ message:
+ truncateUtf8(
+ error instanceof Error ? error.message : String(error),
+ RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES,
+ ) || 'Runtime Host access management failed',
+ },
+ }),
+ );
+}
+
function requireOperationGrants(values: readonly string[]): readonly OperationKey[] {
const grants = values.flatMap((value) => value.split(',')).filter((value) => value.length > 0);
if (grants.length === 0) throw new Error('At least one --grant is required');
diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts
index 5acdfdda1..99df25511 100644
--- a/packages/cli/src/runtime-host-cli.ts
+++ b/packages/cli/src/runtime-host-cli.ts
@@ -68,9 +68,15 @@ export type RuntimeHostCliCommand =
expectedTarget?: RuntimeHostManagedServiceTarget;
retainManagedDeployment?: true;
}
+ | {
+ kind: 'runtime-host-managed-deployment-cleanup';
+ clientDataRoot?: string;
+ expectedTarget: RuntimeHostManagedServiceTarget;
+ }
| {
kind: 'runtime-host-access-issue';
rootPath?: string;
+ expectedRootId?: string;
principalKind: 'remote_owner' | 'capability_provider';
principalId: string;
operationGrants: string[];
@@ -78,7 +84,26 @@ export type RuntimeHostCliCommand =
canUseHostPaths: boolean;
preset?: 'desktop-client' | 'terminal-client';
}
- | { kind: 'runtime-host-access-revoke'; rootPath?: string; credentialId: string }
+ | {
+ kind: 'runtime-host-access-prepare';
+ rootPath?: string;
+ expectedRootId?: string;
+ currentCredentialFingerprint: string;
+ }
+ | {
+ kind: 'runtime-host-access-list';
+ rootPath?: string;
+ expectedRootId?: string;
+ framed: boolean;
+ }
+ | {
+ kind: 'runtime-host-access-revoke';
+ rootPath?: string;
+ expectedRootId?: string;
+ credentialId: string;
+ currentCredentialFingerprint?: string;
+ framed: boolean;
+ }
| { kind: 'runtime-host-project-list'; rootPath?: string }
| { kind: 'runtime-host-project-add'; rootPath?: string; path: string }
| {
@@ -170,6 +195,30 @@ function parseSetupCommand(argv: string[]): RuntimeHostCliCommand {
function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
const action = argv[0];
+ if (action === 'cleanup-deployment') {
+ let clientDataRoot: string | undefined;
+ const options = parseManagedServiceOptions(argv.slice(1), {
+ allowConfiguration: false,
+ valueOptions: {
+ '--client-data-root': (value) => {
+ if (clientDataRoot !== undefined) return error('Duplicate --client-data-root');
+ if (!isSafeAbsolutePath(value))
+ return error('--client-data-root must be an absolute path');
+ clientDataRoot = value;
+ },
+ },
+ });
+ if ('kind' in options) return options;
+ if (options.json) return error('Unexpected argument: --json');
+ if (!options.expectedTarget) {
+ return error('runtime-host service cleanup-deployment requires an expected target');
+ }
+ return {
+ kind: 'runtime-host-managed-deployment-cleanup',
+ ...(clientDataRoot ? { clientDataRoot } : {}),
+ expectedTarget: options.expectedTarget,
+ };
+ }
if (
action !== 'install' &&
action !== 'status' &&
@@ -719,24 +768,32 @@ function parseProjectRoot(value: string): { label: string; path: string } | Runt
function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
const action = argv[0];
- if (action !== 'issue' && action !== 'revoke') {
+ if (action !== 'list' && action !== 'issue' && action !== 'prepare' && action !== 'revoke') {
return error(
action
? `Unexpected runtime-host access command: ${action}`
- : 'runtime-host access requires the issue or revoke command',
+ : 'runtime-host access requires list, issue, prepare, or revoke',
);
}
let rootPath: string | undefined;
+ let expectedRootId: string | undefined;
+ let framed = false;
let principalId: string | undefined;
let principalKind: 'remote_owner' | 'capability_provider' = 'remote_owner';
let principalKindSpecified = false;
let credentialId: string | undefined;
+ let currentCredentialFingerprint: string | undefined;
const operationGrants: string[] = [];
let canPublishClientCapabilities = false;
let canUseHostPaths = false;
let preset: 'desktop-client' | 'terminal-client' | undefined;
for (let index = 1; index < argv.length; index += 1) {
const argument = argv[index];
+ if (argument === '--framed') {
+ if (framed) return error('Duplicate --framed');
+ framed = true;
+ continue;
+ }
if (argument === '--publish-client-capabilities') {
canPublishClientCapabilities = true;
continue;
@@ -747,15 +804,18 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
}
if (
argument === '--root' ||
+ argument === '--expected-root' ||
argument === '--kind' ||
argument === '--preset' ||
argument === '--principal' ||
argument === '--grant' ||
- argument === '--credential'
+ argument === '--credential' ||
+ argument === '--current-fingerprint'
) {
const parsed = optionValue(argv, index, argument);
if (typeof parsed !== 'string') return parsed;
if (argument === '--root') rootPath = parsed;
+ if (argument === '--expected-root') expectedRootId = parsed;
if (argument === '--kind') {
if (parsed !== 'remote-owner' && parsed !== 'capability-provider') {
return error('--kind must be remote-owner or capability-provider');
@@ -772,14 +832,65 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
if (argument === '--principal') principalId = parsed;
if (argument === '--grant') operationGrants.push(parsed);
if (argument === '--credential') credentialId = parsed;
+ if (argument === '--current-fingerprint') currentCredentialFingerprint = parsed;
index += 1;
continue;
}
return error(`Unexpected argument: ${argument ?? ''}`);
}
+ if (expectedRootId && !/^[a-f0-9]{64}$/u.test(expectedRootId)) {
+ return error('--expected-root must be a Runtime Host root identity');
+ }
+ if (action === 'list') {
+ if (
+ principalId ||
+ principalKindSpecified ||
+ operationGrants.length > 0 ||
+ canPublishClientCapabilities ||
+ canUseHostPaths ||
+ preset ||
+ credentialId ||
+ currentCredentialFingerprint
+ ) {
+ return error('Credential mutation options are not valid for access list');
+ }
+ return {
+ kind: 'runtime-host-access-list',
+ ...(rootPath ? { rootPath } : {}),
+ ...(expectedRootId ? { expectedRootId } : {}),
+ framed,
+ };
+ }
+ if (action === 'prepare') {
+ if (!framed) return error('access prepare is reserved for framed operator management');
+ if (!currentCredentialFingerprint) return error('--current-fingerprint is required');
+ if (!/^[a-f0-9]{32}$/u.test(currentCredentialFingerprint)) {
+ return error('--current-fingerprint must be a Runtime Host credential fingerprint');
+ }
+ if (
+ principalId ||
+ principalKindSpecified ||
+ operationGrants.length > 0 ||
+ canPublishClientCapabilities ||
+ canUseHostPaths ||
+ preset ||
+ credentialId
+ ) {
+ return error('Credential issue options are not valid for access prepare');
+ }
+ return {
+ kind: 'runtime-host-access-prepare',
+ ...(rootPath ? { rootPath } : {}),
+ ...(expectedRootId ? { expectedRootId } : {}),
+ currentCredentialFingerprint,
+ };
+ }
if (action === 'issue') {
+ if (framed) return error('--framed is only valid for access management');
if (!principalId) return error('--principal is required');
- if (credentialId) return error('--credential is only valid for access revoke');
+ if (credentialId || currentCredentialFingerprint) {
+ return error('Credential target options are only valid for access revoke');
+ }
if (
preset &&
(principalKindSpecified ||
@@ -793,6 +904,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
return {
kind: 'runtime-host-access-issue',
...(rootPath ? { rootPath } : {}),
+ ...(expectedRootId ? { expectedRootId } : {}),
principalKind: 'remote_owner',
principalId,
operationGrants,
@@ -818,6 +930,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
return {
kind: 'runtime-host-access-issue',
...(rootPath ? { rootPath } : {}),
+ ...(expectedRootId ? { expectedRootId } : {}),
principalKind,
principalId,
operationGrants,
@@ -826,6 +939,12 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
};
}
if (!credentialId) return error('--credential is required');
+ if (framed && !currentCredentialFingerprint) {
+ return error('--current-fingerprint is required for framed access revoke');
+ }
+ if (currentCredentialFingerprint && !/^[a-f0-9]{32}$/u.test(currentCredentialFingerprint)) {
+ return error('--current-fingerprint must be a Runtime Host credential fingerprint');
+ }
if (
principalId ||
principalKindSpecified ||
@@ -839,7 +958,10 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
return {
kind: 'runtime-host-access-revoke',
...(rootPath ? { rootPath } : {}),
+ ...(expectedRootId ? { expectedRootId } : {}),
credentialId,
+ ...(currentCredentialFingerprint ? { currentCredentialFingerprint } : {}),
+ framed,
};
}
diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts
index 66cc2f56a..dacdf8e87 100644
--- a/packages/cli/src/runtime-host-managed-deployment.ts
+++ b/packages/cli/src/runtime-host-managed-deployment.ts
@@ -283,19 +283,15 @@ async function writeOperatorLauncher(
clientDataRoot: string,
): Promise<void> {
const temporaryPath = `${path}.${randomUUID()}.tmp`;
- const deploymentRoot = dirname(path);
const contents = [
'#!/bin/sh',
- 'if [ "$#" -eq 1 ] && [ "$1" = "__cleanup-managed-deployment" ]; then',
- ` for entry in ${quotePosix(deploymentRoot)}/* ${quotePosix(deploymentRoot)}/.[!.]* ${quotePosix(deploymentRoot)}/..?*; do`,
- ` [ "$entry" = ${quotePosix(path)} ] && continue`,
- ' if [ -e "$entry" ] || [ -L "$entry" ]; then',
- ' rm -rf -- "$entry" || exit 1',
- ' fi',
- ' done',
- ` rm -f -- ${quotePosix(path)} || exit 1`,
- ` rmdir -- ${quotePosix(deploymentRoot)} || exit 1`,
- ' exit 0',
+ 'if [ "$#" -ge 1 ] && [ "$1" = "__cleanup-managed-deployment" ]; then',
+ ' shift',
+ ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host service cleanup-deployment "$@" --client-data-root ${quotePosix(clientDataRoot)}`,
+ 'fi',
+ 'if [ "$#" -ge 1 ] && [ "$1" = "access" ]; then',
+ ' shift',
+ ` exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host access "$@"`,
'fi',
`exec ${quotePosix(nodePath)} ${quotePosix(cliPath)} runtime-host service "$@" --client-data-root ${quotePosix(clientDataRoot)}`,
'',
diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts
index 9afa0e37a..183cc86a4 100644
--- a/packages/cli/src/runtime-host-service-management-command.ts
+++ b/packages/cli/src/runtime-host-service-management-command.ts
@@ -21,17 +21,21 @@ import { truncateUtf8 } from '@maka/core/diagnostic-log';
import { release } from 'node:os';
import {
encodeRuntimeHostServiceManagementFrame,
+ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+ RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES,
RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES,
type RuntimeHostServiceManagementFrame,
type RuntimeHostServiceSummary,
} from '@maka/runtime-host/operator';
import {
+ cleanupRuntimeHostManagedDeployment,
manageRuntimeHostService,
resolveRuntimeHostManagedServiceId,
RuntimeHostServiceManagerError,
type RuntimeHostManagedServiceInput,
type RuntimeHostManagedServiceResult,
+ type RuntimeHostManagedServiceTarget,
type RuntimeHostServiceBackend,
} from './runtime-host-service-manager.js';
import { createSystemdUserRuntimeHostService } from './runtime-host-systemd-service.js';
@@ -102,6 +106,24 @@ export async function runManagedRuntimeHostServiceCli(
}
}
+export async function runManagedRuntimeHostDeploymentCleanupCli(options: {
+ readonly clientDataRoot: string;
+ readonly cliPath: string;
+ readonly expectedTarget: RuntimeHostManagedServiceTarget;
+}): Promise<number> {
+ try {
+ const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot);
+ await cleanupRuntimeHostManagedDeployment(
+ options,
+ createPlatformRuntimeHostServiceBackend(serviceId),
+ );
+ return 0;
+ } catch (error) {
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
+ return 1;
+ }
+}
+
function formatHumanResult(result: RuntimeHostManagedServiceResult): string {
const service = result.service;
if (result.action === 'uninstall') {
@@ -140,6 +162,10 @@ function successFrame(result: RuntimeHostManagedServiceResult): RuntimeHostServi
kind: 'result',
action: result.action,
service,
+ ...(process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] ===
+ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY
+ ? { operatorCapabilities: [RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY] }
+ : {}),
...(result.retainedStateRoot ? { retainedStateRoot: result.retainedStateRoot } : {}),
...(result.logs !== undefined ? { logs: result.logs } : {}),
};
diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts
index faa97a48f..d37980f81 100644
--- a/packages/cli/src/runtime-host-service-manager.ts
+++ b/packages/cli/src/runtime-host-service-manager.ts
@@ -40,6 +40,7 @@ import {
} from './runtime-host-managed-deployment.js';
const SERVICE_CONFIG_FILE = 'runtime-host-service.json';
+const SERVICE_LIFECYCLE_LOCK_FILE = 'runtime-host-setup';
const DEFAULT_WEBSOCKET_PATH = '/runtime-host';
const SERVICE_OPERATION_LOCK_TIMEOUT_MS = 60_000;
const SERVICE_READY_TIMEOUT_MS = 45_000;
@@ -138,6 +139,12 @@ export interface RuntimeHostManagedServiceTarget {
readonly rootId: string;
}
+export interface RuntimeHostManagedDeploymentCleanupInput {
+ readonly clientDataRoot: string;
+ readonly cliPath: string;
+ readonly expectedTarget: RuntimeHostManagedServiceTarget;
+}
+
interface RuntimeHostServiceManagerDeps {
readonly allocateLoopbackPort: () => Promise<number>;
readonly waitForReady: (
@@ -194,6 +201,52 @@ export async function manageRuntimeHostService(
);
}
+export async function withRuntimeHostManagedServiceLifecycleLock<T>(
+ clientDataRoot: string,
+ operation: () => Promise<T>,
+ timeoutMs = SERVICE_OPERATION_LOCK_TIMEOUT_MS,
+): Promise<T> {
+ await mkdir(clientDataRoot, { recursive: true, mode: 0o700 });
+ return withFileUpdateLock(
+ join(clientDataRoot, SERVICE_LIFECYCLE_LOCK_FILE),
+ operation,
+ timeoutMs,
+ );
+}
+
+export async function cleanupRuntimeHostManagedDeployment(
+ input: RuntimeHostManagedDeploymentCleanupInput,
+ backend: RuntimeHostServiceBackend,
+): Promise<void> {
+ await withRuntimeHostManagedServiceLifecycleLock(input.clientDataRoot, async () => {
+ const configPath = resolveRuntimeHostManagedServiceConfigPath(input.clientDataRoot);
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
+ await withFileUpdateLock(
+ configPath,
+ async () => {
+ const serviceId = resolveRuntimeHostManagedServiceId(input.clientDataRoot);
+ assertExpectedServiceIdentity(serviceId, input.expectedTarget);
+ const service = await readServiceStatus(configPath, backend);
+ if (service.installed || service.active || service.enabled || service.config !== null) {
+ throw new RuntimeHostServiceManagerError(
+ 'uninstall_incomplete',
+ 'Runtime Host service was installed again; refusing to remove its managed deployment',
+ );
+ }
+ const deploymentRoot = resolveRuntimeHostManagedDeploymentForCli(serviceId, input.cliPath);
+ if (!deploymentRoot) {
+ throw new RuntimeHostServiceManagerError(
+ 'invalid_launch',
+ 'The Runtime Host operator does not belong to the expected managed deployment',
+ );
+ }
+ await removeRuntimeHostManagedDeployment(deploymentRoot, serviceId);
+ },
+ SERVICE_OPERATION_LOCK_TIMEOUT_MS,
+ );
+ });
+}
+
async function manageRuntimeHostServiceLocked(
input: RuntimeHostManagedServiceInput,
backend: RuntimeHostServiceBackend,
@@ -201,16 +254,7 @@ async function manageRuntimeHostServiceLocked(
configPath: string,
): Promise<RuntimeHostManagedServiceResult> {
const serviceId = resolveRuntimeHostManagedServiceId(input.clientDataRoot);
- if (
- input.expectedTarget &&
- (!/^[a-f0-9]{64}$/u.test(input.expectedTarget.serviceId) ||
- input.expectedTarget.serviceId !== serviceId)
- ) {
- throw new RuntimeHostServiceManagerError(
- 'target_mismatch',
- 'The managed Runtime Host service does not match the expected service identity',
- );
- }
+ if (input.expectedTarget) assertExpectedServiceIdentity(serviceId, input.expectedTarget);
if (input.action === 'install') {
const previous = await readServiceConfigForRepair(configPath);
const expectedRootPath = await resolveExpectedServiceRoot(previous, input);
@@ -302,6 +346,18 @@ async function manageRuntimeHostServiceLocked(
return result(input.action, await readServiceStatus(configPath, backend));
}
+function assertExpectedServiceIdentity(
+ serviceId: string,
+ expectedTarget: RuntimeHostManagedServiceTarget,
+): void {
+ if (!/^[a-f0-9]{64}$/u.test(expectedTarget.serviceId) || expectedTarget.serviceId !== serviceId) {
+ throw new RuntimeHostServiceManagerError(
+ 'target_mismatch',
+ 'The managed Runtime Host service does not match the expected service identity',
+ );
+ }
+}
+
async function resolveExpectedServiceRoot(
config: RuntimeHostManagedServiceConfig | null,
input: Pick<RuntimeHostManagedServiceInput, 'expectedTarget'>,
diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts
index cf7c5fbf7..e411be953 100644
--- a/packages/cli/src/runtime-host-setup-command.ts
+++ b/packages/cli/src/runtime-host-setup-command.ts
@@ -17,7 +17,6 @@
* under the License.
*/
-import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { truncateUtf8 } from '@maka/core/diagnostic-log';
import { connectRemoteRuntimeHost } from '@maka/runtime-host/client';
@@ -32,7 +31,6 @@ import {
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_PROTOCOL_VERSION,
} from '@maka/runtime-host/protocol';
-import { withFileUpdateLock } from '@maka/storage/file-update-lock';
import {
prepareRuntimeHostAccessCredential,
replaceRuntimeHostAccessCredential,
@@ -49,6 +47,7 @@ import {
manageRuntimeHostService,
resolveRuntimeHostManagedServiceId,
RuntimeHostServiceManagerError,
+ withRuntimeHostManagedServiceLifecycleLock,
type RuntimeHostManagedServiceResult,
type RuntimeHostManagedServiceTarget,
type RuntimeHostServiceBackend,
@@ -113,9 +112,8 @@ export async function runRuntimeHostSetupCli(
};
const emit = createEmitter(options.json, deps);
try {
- await mkdir(options.clientDataRoot, { recursive: true, mode: 0o700 });
- await withFileUpdateLock(
- join(options.clientDataRoot, 'runtime-host-setup'),
+ await withRuntimeHostManagedServiceLifecycleLock(
+ options.clientDataRoot,
() => runRuntimeHostSetupLocked(options, deps, emit),
SETUP_LOCK_TIMEOUT_MS,
);
diff --git a/packages/runtime-host/protocol-compatible-changes/credential-rotation-operations.json b/packages/runtime-host/protocol-compatible-changes/credential-rotation-operations.json
new file mode 100644
index 000000000..a54efce2e
--- /dev/null
+++ b/packages/runtime-host/protocol-compatible-changes/credential-rotation-operations.json
@@ -0,0 +1,5 @@
+{
+ "epoch": 39,
+ "files": ["packages/runtime-host/src/protocol/access-authority.ts"],
+ "reason": "Adds separately named credential rotation operations without changing existing operation codecs"
+}
diff --git a/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts b/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts
new file mode 100644
index 000000000..b97c7d8c7
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { access, mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
+import { runtimeHostAccessCredentialFingerprintFromHash } from '../access-credential-identity.js';
+import { readRuntimeHostAccessCredentialMetadata } from '../server/access-credential-metadata.js';
+import {
+ ACCESS_FILE_NAME,
+ createAccessCredentialFile,
+ writeAccessCredentialFile,
+ type StoredAccessCredential,
+} from '../server/access-credential-store.js';
+
+test('credential metadata inspection does not create missing State Roots', async (t) => {
+ const parent = await mkdtemp(join(tmpdir(), 'maka-access-metadata-'));
+ t.after(() => rm(parent, { recursive: true, force: true }));
+
+ for (const expectedRootId of [undefined, 'a'.repeat(64)]) {
+ const root = join(parent, expectedRootId ? 'expected' : 'discovered');
+ await assert.rejects(readRuntimeHostAccessCredentialMetadata(root, expectedRootId));
+ await assert.rejects(access(root));
+ }
+});
+
+test('credential metadata exposes only usable public access state', async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-access-metadata-'));
+ const capability = await resolveStorageRoot({ path: root, kind: 'interactive' });
+ const owner = await tryAcquireInteractiveRootOwner(capability);
+ assert.ok(owner);
+ t.after(async () => {
+ await owner.close();
+ await rm(root, { recursive: true, force: true });
+ });
+ const activeSecret = 'maka_rh_active_secret';
+ const pendingSecret = 'maka_rh_pending_secret';
+ const expiredSecret = 'maka_rh_expired_secret';
+ const revokedSecret = 'maka_rh_revoked_secret';
+ const credential = (
+ credentialId: string,
+ secret: string,
+ status: StoredAccessCredential['status'],
+ expiresAt?: string,
+ ): StoredAccessCredential => ({
+ credentialId,
+ credentialHash: createHash('sha256').update(secret).digest('hex'),
+ principalId: `${credentialId}-client`,
+ principalKind: 'remote_owner',
+ status,
+ operationGrants: ['host.status'],
+ canPublishClientCapabilities: false,
+ canUseHostPaths: false,
+ createdAt: '2026-08-22T00:00:00.000Z',
+ ...(expiresAt ? { expiresAt } : {}),
+ ...(status === 'revoked' ? { revokedAt: '2026-08-22T00:01:00.000Z' } : {}),
+ });
+ const active = credential('active', activeSecret, 'active');
+ const pending = credential(
+ 'pending',
+ pendingSecret,
+ 'pending',
+ new Date(Date.now() + 60_000).toISOString(),
+ );
+ const expired = credential(
+ 'expired',
+ expiredSecret,
+ 'pending',
+ new Date(Date.now() - 60_000).toISOString(),
+ );
+ const revoked = credential('revoked', revokedSecret, 'revoked');
+ await writeAccessCredentialFile(
+ join(owner.controlDirectory, ACCESS_FILE_NAME),
+ createAccessCredentialFile([active, pending, expired, revoked]),
+ );
+
+ const metadata = await readRuntimeHostAccessCredentialMetadata(root, capability.rootId);
+
+ assert.deepEqual(
+ metadata.credentials.map(({ credentialId, credentialFingerprint, status }) => ({
+ credentialId,
+ credentialFingerprint,
+ status,
+ })),
+ [
+ {
+ credentialId: 'active',
+ credentialFingerprint: runtimeHostAccessCredentialFingerprintFromHash(
+ active.credentialHash,
+ ),
+ status: 'active',
+ },
+ {
+ credentialId: 'pending',
+ credentialFingerprint: runtimeHostAccessCredentialFingerprintFromHash(
+ pending.credentialHash,
+ ),
+ status: 'pending',
+ },
+ ],
+ );
+ const serialized = JSON.stringify(metadata);
+ for (const sensitive of [
+ activeSecret,
+ pendingSecret,
+ expiredSecret,
+ revokedSecret,
+ active.credentialHash,
+ pending.credentialHash,
+ expired.credentialHash,
+ revoked.credentialHash,
+ ]) {
+ assert.equal(serialized.includes(sensitive), false);
+ }
+});
diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
index 874ba0d97..f90b20867 100644
--- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
+++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
@@ -596,6 +596,82 @@ test('access credentials persist only as hashes and stay revoked after reload',
}
});
+test('credential rotation preserves authority and cannot outlive its active source', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-rotation-'));
+ const authority = await openRuntimeHostAccessAuthority(directory);
+ try {
+ const source = await authority.issue({
+ principalKind: 'remote_owner',
+ principalId: 'desktop-client',
+ operationGrants: ['access.credential.finalize', 'session.catalog.query'],
+ canPublishClientCapabilities: true,
+ canUseHostPaths: false,
+ });
+ const replacement = await authority.prepareRotation({
+ replacementOfCredentialId: source.credentialId,
+ });
+ assert.deepEqual(replacement.operationGrants, source.operationGrants);
+ assert.equal(replacement.principalId, source.principalId);
+ assert.equal(replacement.canPublishClientCapabilities, source.canPublishClientCapabilities);
+
+ await authority.revoke({ credentialId: source.credentialId });
+ await assert.rejects(authority.finalize(replacement.credentialId), /no longer active/u);
+ await assert.rejects(
+ authority.prepareRotation({ replacementOfCredentialId: source.credentialId }),
+ /no longer active/u,
+ );
+ } finally {
+ await authority.close();
+ await rm(directory, { recursive: true, force: true });
+ }
+});
+
+test('guarded credential revocation requires its active credential', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-guarded-revoke-'));
+ const authority = await openRuntimeHostAccessAuthority(directory);
+ try {
+ const desktop = await authority.issue({
+ principalKind: 'remote_owner',
+ principalId: 'desktop-client',
+ operationGrants: ['access.credential.finalize'],
+ canPublishClientCapabilities: true,
+ canUseHostPaths: false,
+ });
+ const target = await authority.issue({
+ principalKind: 'remote_owner',
+ principalId: 'other-client',
+ operationGrants: ['access.credential.finalize'],
+ canPublishClientCapabilities: false,
+ canUseHostPaths: false,
+ });
+ assert.deepEqual(
+ await authority.revokeRotation({
+ credentialId: 'already-absent',
+ requiredActiveCredentialId: desktop.credentialId,
+ }),
+ { credentialId: 'already-absent', revoked: false },
+ );
+ await assert.rejects(
+ authority.revokeRotation({
+ credentialId: desktop.credentialId,
+ requiredActiveCredentialId: desktop.credentialId,
+ }),
+ /cannot revoke itself/u,
+ );
+ await authority.revoke({ credentialId: desktop.credentialId });
+ await assert.rejects(
+ authority.revokeRotation({
+ credentialId: target.credentialId,
+ requiredActiveCredentialId: desktop.credentialId,
+ }),
+ /required credential is no longer active/u,
+ );
+ } finally {
+ await authority.close();
+ await rm(directory, { recursive: true, force: true });
+ }
+});
+
test('keeps published credential state authoritative when directory sync is uncertain', async () => {
const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-unknown-commit-'));
let failNextCommit = false;
diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts
index 4c1c6f0d4..624b55463 100644
--- a/packages/runtime-host/src/__tests__/protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/protocol.test.ts
@@ -164,6 +164,47 @@ describe('Runtime Host bootstrap protocol', () => {
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 38);
});
+ test('adds credential rotation without changing existing credential inputs', () => {
+ const issueInput = {
+ principalKind: 'remote_owner',
+ principalId: 'desktop:test',
+ operationGrants: ['host.status'],
+ canPublishClientCapabilities: false,
+ canUseHostPaths: false,
+ };
+ assert.deepEqual(
+ HOST_OPERATION_SPECS['access.credential.prepare'].decodeInput(issueInput),
+ issueInput,
+ );
+ assert.throws(() =>
+ HOST_OPERATION_SPECS['access.credential.prepare'].decodeInput({
+ replacementOfCredentialId: 'credential-current',
+ }),
+ );
+ assert.throws(() =>
+ HOST_OPERATION_SPECS['access.credential.revoke'].decodeInput({
+ credentialId: 'credential-target',
+ requiredActiveCredentialId: 'credential-current',
+ }),
+ );
+ assert.deepEqual(
+ HOST_OPERATION_SPECS['access.credential.rotation.prepare'].decodeInput({
+ replacementOfCredentialId: 'credential-current',
+ }),
+ { replacementOfCredentialId: 'credential-current' },
+ );
+ assert.deepEqual(
+ HOST_OPERATION_SPECS['access.credential.rotation.revoke'].decodeInput({
+ credentialId: 'credential-target',
+ requiredActiveCredentialId: 'credential-current',
+ }),
+ {
+ credentialId: 'credential-target',
+ requiredActiveCredentialId: 'credential-current',
+ },
+ );
+ });
+
test('selects the highest mutually supported protocol and rejects a gap', () => {
assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0);
assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3);
diff --git a/packages/runtime-host/src/__tests__/websocket-listener.test.ts b/packages/runtime-host/src/__tests__/websocket-listener.test.ts
index 480ccdfa1..948161ee9 100644
--- a/packages/runtime-host/src/__tests__/websocket-listener.test.ts
+++ b/packages/runtime-host/src/__tests__/websocket-listener.test.ts
@@ -189,7 +189,9 @@ test('credential revocation during WebSocket upgrade cannot admit stale authorit
issue: async () => assert.fail('Credential issue is not expected'),
replace: async () => assert.fail('Credential replacement is not expected'),
prepare: async () => assert.fail('Credential preparation is not expected'),
+ prepareRotation: async () => assert.fail('Credential rotation is not expected'),
revoke: async () => assert.fail('Credential revoke is not expected'),
+ revokeRotation: async () => assert.fail('Credential rotation revoke is not expected'),
finalize: async () => assert.fail('Credential finalize is not expected'),
subscribeRevocations: () => () => undefined,
close: async () => undefined,
diff --git a/packages/runtime-host/src/server/index.ts b/packages/runtime-host/src/access-credential-identity.ts
similarity index 57%
copy from packages/runtime-host/src/server/index.ts
copy to packages/runtime-host/src/access-credential-identity.ts
index 67a4d1022..cb2124738 100644
--- a/packages/runtime-host/src/server/index.ts
+++ b/packages/runtime-host/src/access-credential-identity.ts
@@ -17,12 +17,20 @@
* under the License.
*/
-export {
- RuntimeHostKernel,
- type RuntimeHostComposition,
-} from './host-kernel.js';
-export { defineInteractiveRuntimeHostComposition } from './host-composition.js';
-export { createUnavailableDomainOperationHandlers } from './operation-dispatcher.js';
-export { startExecutionRuntimeHostService } from './execution-service.js';
-export { runRuntimeHostProcessLifecycle } from './process-lifecycle.js';
-export { installRuntimeHostLogCapture } from '../process-diagnostics.js';
+import { createHash } from 'node:crypto';
+
+const CREDENTIAL_FINGERPRINT_HEX_LENGTH = 32;
+
+export function runtimeHostAccessCredentialHash(credential: string): Buffer {
+ return createHash('sha256').update(credential, 'utf8').digest();
+}
+
+export function runtimeHostAccessCredentialFingerprintFromHash(hash: string): string {
+ return hash.slice(0, CREDENTIAL_FINGERPRINT_HEX_LENGTH);
+}
+
+export function runtimeHostAccessCredentialFingerprint(credential: string): string {
+ return runtimeHostAccessCredentialFingerprintFromHash(
+ runtimeHostAccessCredentialHash(credential).toString('hex'),
+ );
+}
diff --git a/packages/runtime-host/src/operator/access-management-frame.ts b/packages/runtime-host/src/operator/access-management-frame.ts
new file mode 100644
index 000000000..62b41c764
--- /dev/null
+++ b/packages/runtime-host/src/operator/access-management-frame.ts
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { z } from 'zod';
+
+export const RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX =
+ 'MAKA_RUNTIME_HOST_ACCESS_MANAGEMENT_V1 ';
+export const RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_CODE_MAX_BYTES = 128;
+export const RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES = 2 * 1024;
+
+const FRAME_MAX_BYTES = 768 * 1024;
+const CREDENTIAL_MAX_BYTES = 8 * 1024;
+const ACCESS_ACTIONS = ['list', 'prepare', 'revoke'] as const;
+
+const boundedString = (maxBytes: number) =>
+ z
+ .string()
+ .min(1)
+ .refine((value) => Buffer.byteLength(value, 'utf8') <= maxBytes);
+const CREDENTIAL_METADATA_SCHEMA = z
+ .object({
+ credentialId: boundedString(128),
+ credentialFingerprint: z.string().regex(/^[a-f0-9]{32}$/u),
+ principalKind: z.enum(['remote_owner', 'capability_provider']),
+ principalId: boundedString(128),
+ status: z.enum(['active', 'pending']),
+ operationGrants: z.array(boundedString(128)).max(256),
+ canPublishClientCapabilities: z.boolean(),
+ canUseHostPaths: z.boolean(),
+ createdAt: boundedString(64),
+ expiresAt: boundedString(64).optional(),
+ })
+ .strict();
+
+const ACCESS_MANAGEMENT_FRAME_SCHEMA = z.union([
+ z
+ .object({
+ schemaVersion: z.literal(1),
+ kind: z.literal('result'),
+ action: z.literal('list'),
+ credentials: z.array(CREDENTIAL_METADATA_SCHEMA),
+ })
+ .strict(),
+ z
+ .object({
+ schemaVersion: z.literal(1),
+ kind: z.literal('result'),
+ action: z.literal('prepare'),
+ credential: boundedString(CREDENTIAL_MAX_BYTES),
+ credentials: z.array(CREDENTIAL_METADATA_SCHEMA),
+ })
+ .strict(),
+ z
+ .object({
+ schemaVersion: z.literal(1),
+ kind: z.literal('result'),
+ action: z.literal('revoke'),
+ credentialId: boundedString(128),
+ revoked: z.boolean(),
+ credentials: z.array(CREDENTIAL_METADATA_SCHEMA),
+ })
+ .strict(),
+ z
+ .object({
+ schemaVersion: z.literal(1),
+ kind: z.literal('error'),
+ action: z.enum(ACCESS_ACTIONS),
+ error: z
+ .object({
+ code: boundedString(RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_CODE_MAX_BYTES),
+ message: boundedString(RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES),
+ })
+ .strict(),
+ })
+ .strict(),
+]);
+
+export type RuntimeHostAccessManagementAction = (typeof ACCESS_ACTIONS)[number];
+export type RuntimeHostAccessManagementFrame = z.infer<typeof ACCESS_MANAGEMENT_FRAME_SCHEMA>;
+export type RuntimeHostAccessCredentialMetadata = z.infer<typeof CREDENTIAL_METADATA_SCHEMA>;
+
+export function encodeRuntimeHostAccessManagementFrame(
+ frame: RuntimeHostAccessManagementFrame,
+): string {
+ const encoded = Buffer.from(JSON.stringify(ACCESS_MANAGEMENT_FRAME_SCHEMA.parse(frame))).toString(
+ 'base64url',
+ );
+ if (Buffer.byteLength(encoded, 'utf8') > FRAME_MAX_BYTES) {
+ throw new RangeError('Runtime Host access management frame exceeds the encoded size limit');
+ }
+ return `${RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX}${encoded}\n`;
+}
+
+export function decodeRuntimeHostAccessManagementFrame(
+ line: string,
+): RuntimeHostAccessManagementFrame | undefined {
+ const marker = line.indexOf(RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX);
+ if (marker === -1) return undefined;
+ try {
+ const encoded = line.slice(marker + RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX.length).trim();
+ if (encoded.length === 0 || Buffer.byteLength(encoded, 'utf8') > FRAME_MAX_BYTES) {
+ return undefined;
+ }
+ const value: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
+ const decoded = ACCESS_MANAGEMENT_FRAME_SCHEMA.safeParse(value);
+ return decoded.success ? decoded.data : undefined;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts
index bfa98662d..93d415745 100644
--- a/packages/runtime-host/src/operator/index.ts
+++ b/packages/runtime-host/src/operator/index.ts
@@ -18,6 +18,19 @@
*/
export {
+ RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_CODE_MAX_BYTES,
+ RUNTIME_HOST_ACCESS_MANAGEMENT_ERROR_MESSAGE_MAX_BYTES,
+ RUNTIME_HOST_ACCESS_MANAGEMENT_FRAME_PREFIX,
+ decodeRuntimeHostAccessManagementFrame,
+ encodeRuntimeHostAccessManagementFrame,
+ type RuntimeHostAccessCredentialMetadata,
+ type RuntimeHostAccessManagementAction,
+ type RuntimeHostAccessManagementFrame,
+} from './access-management-frame.js';
+export { runtimeHostAccessCredentialFingerprint } from '../access-credential-identity.js';
+export {
+ RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+ RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES,
RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES,
RUNTIME_HOST_SERVICE_LOG_MAX_BYTES,
@@ -26,6 +39,7 @@ export {
encodeRuntimeHostServiceManagementFrame,
type RuntimeHostServiceManagementAction,
type RuntimeHostServiceManagementFrame,
+ type RuntimeHostOperatorCapability,
type RuntimeHostServiceSummary,
} from './service-management-frame.js';
export {
diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts
index fb7a397d9..27e552232 100644
--- a/packages/runtime-host/src/operator/service-management-frame.ts
+++ b/packages/runtime-host/src/operator/service-management-frame.ts
@@ -24,6 +24,9 @@ export const RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX =
export const RUNTIME_HOST_SERVICE_LOG_MAX_BYTES = 48 * 1024;
export const RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES = 128;
export const RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES = 2 * 1024;
+export const RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY = 'access-management-v1';
+export const RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV =
+ 'MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST';
const FRAME_MAX_BYTES = 96 * 1024;
const PATH_MAX_BYTES = 4 * 1024;
@@ -38,6 +41,7 @@ const SERVICE_ACTIONS = [
'uninstall',
] as const;
const SERVICE_STATES = ['not_installed', 'stopped', 'starting', 'running', 'failed'] as const;
+const OPERATOR_CAPABILITIES = [RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY] as const;
const boundedString = (maxBytes: number) =>
z.string().refine((value) => Buffer.byteLength(value, 'utf8') <= maxBytes);
@@ -77,6 +81,7 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.discriminatedUnion('kind', [
kind: z.literal('result'),
action: z.enum(SERVICE_ACTIONS),
service: SERVICE_SUMMARY_SCHEMA,
+ operatorCapabilities: z.array(z.enum(OPERATOR_CAPABILITIES)).max(16).optional(),
retainedStateRoot: boundedString(PATH_MAX_BYTES).optional(),
logs: boundedString(RUNTIME_HOST_SERVICE_LOG_MAX_BYTES).optional(),
})
@@ -98,6 +103,7 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.discriminatedUnion('kind', [
export type RuntimeHostServiceManagementAction = (typeof SERVICE_ACTIONS)[number];
export type RuntimeHostServiceManagementFrame = z.infer<typeof SERVICE_MANAGEMENT_FRAME_SCHEMA>;
+export type RuntimeHostOperatorCapability = (typeof OPERATOR_CAPABILITIES)[number];
export type RuntimeHostServiceSummary = z.infer<typeof SERVICE_SUMMARY_SCHEMA>;
export function encodeRuntimeHostServiceManagementFrame(
diff --git a/packages/runtime-host/src/protocol/access-authority.ts b/packages/runtime-host/src/protocol/access-authority.ts
index b4f84088c..492bdd7a1 100644
--- a/packages/runtime-host/src/protocol/access-authority.ts
+++ b/packages/runtime-host/src/protocol/access-authority.ts
@@ -63,6 +63,19 @@ export interface AccessCredentialRevokeInput {
readonly credentialId: string;
}
+export interface AccessCredentialRotationPrepareInput {
+ readonly replacementOfCredentialId: string;
+}
+
+export type AccessCredentialRotationPrepareResult = AccessCredentialPrepareResult;
+
+export interface AccessCredentialRotationRevokeInput {
+ readonly credentialId: string;
+ readonly requiredActiveCredentialId: string;
+}
+
+export type AccessCredentialRotationRevokeResult = AccessCredentialRevokeResult;
+
export interface AccessCredentialRevokeResult {
readonly credentialId: string;
readonly revoked: boolean;
@@ -116,6 +129,28 @@ export const ACCESS_AUTHORITY_OPERATION_SPECS = {
decodeInput: decodeAccessCredentialRevokeInput,
decodeOutput: decodeAccessCredentialRevokeResult,
}),
+ 'access.credential.rotation.prepare': defineOperation<
+ AccessCredentialRotationPrepareInput,
+ AccessCredentialRotationPrepareResult,
+ (typeof ACCESS_ERRORS)[number]
+ >({
+ mode: 'command',
+ availability: 'ready',
+ errors: ACCESS_ERRORS,
+ decodeInput: decodeAccessCredentialRotationPrepareInput,
+ decodeOutput: decodeAccessCredentialIssueResult,
+ }),
+ 'access.credential.rotation.revoke': defineOperation<
+ AccessCredentialRotationRevokeInput,
+ AccessCredentialRotationRevokeResult,
+ (typeof ACCESS_ERRORS)[number]
+ >({
+ mode: 'command',
+ availability: 'ready',
+ errors: ACCESS_ERRORS,
+ decodeInput: decodeAccessCredentialRotationRevokeInput,
+ decodeOutput: decodeAccessCredentialRevokeResult,
+ }),
'access.credential.finalize': defineOperation<
AccessCredentialFinalizeInput,
AccessCredentialFinalizeResult,
@@ -185,6 +220,36 @@ export function decodeAccessCredentialRevokeInput(value: unknown): AccessCredent
return { credentialId: requireId(record.credentialId, 'credentialId') };
}
+export function decodeAccessCredentialRotationPrepareInput(
+ value: unknown,
+): AccessCredentialRotationPrepareInput {
+ const record = requireExactRecord(value, 'access credential rotation prepare input', [
+ 'replacementOfCredentialId',
+ ]);
+ return {
+ replacementOfCredentialId: requireId(
+ record.replacementOfCredentialId,
+ 'replacementOfCredentialId',
+ ),
+ };
+}
+
+export function decodeAccessCredentialRotationRevokeInput(
+ value: unknown,
+): AccessCredentialRotationRevokeInput {
+ const record = requireExactRecord(value, 'access credential rotation revoke input', [
+ 'credentialId',
+ 'requiredActiveCredentialId',
+ ]);
+ return {
+ credentialId: requireId(record.credentialId, 'credentialId'),
+ requiredActiveCredentialId: requireId(
+ record.requiredActiveCredentialId,
+ 'requiredActiveCredentialId',
+ ),
+ };
+}
+
export function decodeAccessCredentialRevokeResult(value: unknown): AccessCredentialRevokeResult {
const record = requireExactRecord(value, 'access credential revoke result', [
'credentialId',
diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts
index 78d8876ba..23d197555 100644
--- a/packages/runtime-host/src/server/access-authority.ts
+++ b/packages/runtime-host/src/server/access-authority.ts
@@ -17,8 +17,9 @@
* under the License.
*/
-import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
+import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { join } from 'node:path';
+import { runtimeHostAccessCredentialHash } from '../access-credential-identity.js';
import {
type AccessCredentialIssueInput,
type AccessCredentialIssueResult,
@@ -29,6 +30,10 @@ import {
type AccessCredentialReplaceResult,
type AccessCredentialRevokeInput,
type AccessCredentialRevokeResult,
+ type AccessCredentialRotationPrepareInput,
+ type AccessCredentialRotationPrepareResult,
+ type AccessCredentialRotationRevokeInput,
+ type AccessCredentialRotationRevokeResult,
} from '../protocol/index.js';
import {
createRuntimeHostConnectionAuthority,
@@ -67,6 +72,12 @@ export interface RuntimeHostAccessAuthority {
replace(input: AccessCredentialReplaceInput): Promise<AccessCredentialReplaceResult>;
prepare(input: AccessCredentialPrepareInput): Promise<AccessCredentialPrepareResult>;
revoke(input: AccessCredentialRevokeInput): Promise<AccessCredentialRevokeResult>;
+ prepareRotation(
+ input: AccessCredentialRotationPrepareInput,
+ ): Promise<AccessCredentialRotationPrepareResult>;
+ revokeRotation(
+ input: AccessCredentialRotationRevokeInput,
+ ): Promise<AccessCredentialRotationRevokeResult>;
finalize(credentialId: string): Promise<AccessCredentialFinalizeResult>;
subscribeRevocations(listener: (credentialId: string) => void): () => void;
close(): Promise<void>;
@@ -113,7 +124,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
authenticate(credential: string): RuntimeHostConnectionAuthority | undefined {
if (this.#closed) return undefined;
- const candidate = hashCredential(credential);
+ const candidate = runtimeHostAccessCredentialHash(credential);
let match: StoredAccessCredential | undefined;
for (const stored of this.#file.credentials) {
const storedHash = Buffer.from(stored.credentialHash, 'hex');
@@ -151,110 +162,170 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
return this.#issue(input, 'prepare');
}
+ prepareRotation(
+ input: AccessCredentialRotationPrepareInput,
+ ): Promise<AccessCredentialRotationPrepareResult> {
+ return this.#mutate(async () => {
+ const current = this.#file.credentials.find(
+ (credential) =>
+ credential.credentialId === input.replacementOfCredentialId &&
+ credential.status === 'active',
+ );
+ if (!current) {
+ throw new RuntimeHostAccessInputError('The credential being rotated is no longer active');
+ }
+ return this.#createCredential(current, 'prepare', current.operationGrants);
+ });
+ }
+
#issue(
input: AccessCredentialIssueInput,
mode: 'issue' | 'replace' | 'prepare',
): Promise<AccessCredentialIssueResult> {
- return this.#mutate(async () => {
- const operationGrants = issuedAccessGrants(input.operationGrants);
- assertCredentialAuthority(input, operationGrants);
- if (
- mode === 'prepare' &&
- (input.principalKind !== 'remote_owner' ||
- !operationGrants.includes('access.credential.finalize'))
- ) {
- throw new RuntimeHostAccessInputError(
- 'A pairing candidate must be a remote owner that can finalize its pairing',
- );
- }
- const credentialId = randomUUID();
- createRuntimeHostConnectionAuthority({
- principalKind: input.principalKind,
- principalId: input.principalId,
- credentialId,
- operationGrants,
- canPublishClientCapabilities: input.canPublishClientCapabilities,
- canUseHostPaths: input.canUseHostPaths,
- });
- const credential = `${ACCESS_CREDENTIAL_PREFIX}${randomBytes(32).toString('base64url')}`;
- const createdAt = new Date();
- const stored: StoredAccessCredential = {
- credentialId,
- credentialHash: hashCredential(credential).toString('hex'),
- principalId: input.principalId,
- principalKind: input.principalKind,
- status: mode === 'prepare' ? 'pending' : 'active',
- operationGrants,
- canPublishClientCapabilities: input.canPublishClientCapabilities,
- canUseHostPaths: input.canUseHostPaths,
- createdAt: createdAt.toISOString(),
- ...(mode === 'prepare'
- ? {
- expiresAt: new Date(
- createdAt.getTime() + PENDING_CREDENTIAL_LIFETIME_MS,
- ).toISOString(),
- }
- : {}),
- };
- const replaced = this.#file.credentials.filter(
- (candidate) =>
- candidate.principalKind === input.principalKind &&
- candidate.principalId === input.principalId &&
- ((mode === 'replace' && candidate.status !== 'revoked') ||
- (mode === 'prepare' && candidate.status === 'pending')),
- );
- const retained =
- replaced.length === 0
- ? this.#file.credentials
- : this.#file.credentials.filter((candidate) => !replaced.includes(candidate));
- const nextFile = createAccessCredentialFile([...retained, stored]);
- assertAccessCredentialFileCapacity(nextFile);
- const deliveryId = await createAccessCredentialDelivery(
- this.#controlDirectory,
- credentialId,
- credential,
+ return this.#mutate(() => this.#createCredential(input, mode));
+ }
+
+ async #createCredential(
+ input: AccessCredentialIssueInput,
+ mode: 'issue' | 'replace' | 'prepare',
+ operationGrants = issuedAccessGrants(input.operationGrants),
+ ): Promise<AccessCredentialIssueResult> {
+ assertCredentialAuthority(input, operationGrants);
+ if (
+ mode === 'prepare' &&
+ (input.principalKind !== 'remote_owner' ||
+ !operationGrants.includes('access.credential.finalize'))
+ ) {
+ throw new RuntimeHostAccessInputError(
+ 'A pairing candidate must be a remote owner that can finalize its pairing',
);
- try {
- await this.#commit(
- nextFile,
- replaced.map((credential) => credential.credentialId),
- );
- } catch (error) {
- await discardAccessCredentialDelivery(this.#controlDirectory, deliveryId);
- throw error;
- }
- return {
- credentialId,
- deliveryId,
- principalId: stored.principalId,
- principalKind: stored.principalKind,
- operationGrants,
- canPublishClientCapabilities: stored.canPublishClientCapabilities,
- canUseHostPaths: stored.canUseHostPaths,
- };
+ }
+ const credentialId = randomUUID();
+ createRuntimeHostConnectionAuthority({
+ principalKind: input.principalKind,
+ principalId: input.principalId,
+ credentialId,
+ operationGrants,
+ canPublishClientCapabilities: input.canPublishClientCapabilities,
+ canUseHostPaths: input.canUseHostPaths,
});
+ const credential = `${ACCESS_CREDENTIAL_PREFIX}${randomBytes(32).toString('base64url')}`;
+ const createdAt = new Date();
+ const stored: StoredAccessCredential = {
+ credentialId,
+ credentialHash: runtimeHostAccessCredentialHash(credential).toString('hex'),
+ principalId: input.principalId,
+ principalKind: input.principalKind,
+ status: mode === 'prepare' ? 'pending' : 'active',
+ operationGrants,
+ canPublishClientCapabilities: input.canPublishClientCapabilities,
+ canUseHostPaths: input.canUseHostPaths,
+ createdAt: createdAt.toISOString(),
+ ...(mode === 'prepare'
+ ? {
+ expiresAt: new Date(createdAt.getTime() + PENDING_CREDENTIAL_LIFETIME_MS).toISOString(),
+ }
+ : {}),
+ };
+ const replaced = this.#file.credentials.filter(
+ (candidate) =>
+ candidate.principalKind === input.principalKind &&
+ candidate.principalId === input.principalId &&
+ ((mode === 'replace' && candidate.status !== 'revoked') ||
+ (mode === 'prepare' && candidate.status === 'pending')),
+ );
+ const retained =
+ replaced.length === 0
+ ? this.#file.credentials
+ : this.#file.credentials.filter((candidate) => !replaced.includes(candidate));
+ const nextFile = createAccessCredentialFile([...retained, stored]);
+ assertAccessCredentialFileCapacity(nextFile);
+ const deliveryId = await createAccessCredentialDelivery(
+ this.#controlDirectory,
+ credentialId,
+ credential,
+ );
+ try {
+ await this.#commit(
+ nextFile,
+ replaced.map((credential) => credential.credentialId),
+ );
+ } catch (error) {
+ await discardAccessCredentialDelivery(this.#controlDirectory, deliveryId);
+ throw error;
+ }
+ return {
+ credentialId,
+ deliveryId,
+ principalId: stored.principalId,
+ principalKind: stored.principalKind,
+ operationGrants,
+ canPublishClientCapabilities: stored.canPublishClientCapabilities,
+ canUseHostPaths: stored.canUseHostPaths,
+ };
}
revoke(input: AccessCredentialRevokeInput): Promise<AccessCredentialRevokeResult> {
return this.#mutate(async () => {
- const index = this.#file.credentials.findIndex(
- (credential) => credential.credentialId === input.credentialId,
+ return this.#revoke(input.credentialId);
+ });
+ }
+
+ revokeRotation(
+ input: AccessCredentialRotationRevokeInput,
+ ): Promise<AccessCredentialRotationRevokeResult> {
+ return this.#mutate(async () => {
+ const requiredActiveCredential = this.#file.credentials.find(
+ (credential) => credential.credentialId === input.requiredActiveCredentialId,
);
- if (index === -1 || this.#file.credentials[index]?.status === 'revoked') {
- return { credentialId: input.credentialId, revoked: false };
+ if (!requiredActiveCredential || requiredActiveCredential.status !== 'active') {
+ throw new RuntimeHostAccessInputError(
+ 'The required credential is no longer active on this Runtime Host',
+ );
+ }
+ if (input.credentialId === input.requiredActiveCredentialId) {
+ throw new RuntimeHostAccessInputError('A credential cannot revoke itself');
}
- const current = this.#file.credentials[index]!;
- const credentials =
- current.status === 'pending'
- ? this.#file.credentials.filter((credential) => credential !== current)
- : this.#file.credentials.map((credential, candidateIndex) =>
- candidateIndex === index
- ? { ...credential, status: 'revoked' as const, revokedAt: new Date().toISOString() }
+ return this.#revoke(input.credentialId);
+ });
+ }
+
+ async #revoke(credentialId: string): Promise<AccessCredentialRevokeResult> {
+ const index = this.#file.credentials.findIndex(
+ (credential) => credential.credentialId === credentialId,
+ );
+ if (index === -1 || this.#file.credentials[index]?.status === 'revoked') {
+ return { credentialId, revoked: false };
+ }
+ const current = this.#file.credentials[index]!;
+ const pendingForPrincipal =
+ current.status === 'active'
+ ? this.#file.credentials.filter(
+ (credential) =>
+ credential.status === 'pending' &&
+ credential.principalKind === current.principalKind &&
+ credential.principalId === current.principalId,
+ )
+ : [];
+ const credentials =
+ current.status === 'pending'
+ ? this.#file.credentials.filter((credential) => credential !== current)
+ : this.#file.credentials
+ .filter((credential) => !pendingForPrincipal.includes(credential))
+ .map((credential) =>
+ credential === current
+ ? {
+ ...credential,
+ status: 'revoked' as const,
+ revokedAt: new Date().toISOString(),
+ }
: credential,
);
- await this.#commit(createAccessCredentialFile(credentials), [input.credentialId]);
- return { credentialId: input.credentialId, revoked: true };
- });
+ await this.#commit(
+ createAccessCredentialFile(credentials),
+ [current, ...pendingForPrincipal].map((credential) => credential.credentialId),
+ );
+ return { credentialId, revoked: true };
}
finalize(credentialId: string): Promise<AccessCredentialFinalizeResult> {
@@ -474,6 +545,25 @@ export async function prepareAccessCredential(
}
}
+export async function prepareAccessCredentialRotation(
+ authority: RuntimeHostAccessAuthority | undefined,
+ input: AccessCredentialRotationPrepareInput,
+): Promise<OperationOutcome<'access.credential.rotation.prepare'>> {
+ if (!authority) return unavailable('rotation.prepare');
+ try {
+ return { ok: true, result: await authority.prepareRotation(input) };
+ } catch (error) {
+ if (error instanceof RuntimeHostAccessInputError) {
+ return { ok: false, error: { code: 'invalid_request', message: error.message } };
+ }
+ return accessPersistenceFailure(
+ error,
+ 'Access credential rotation preparation outcome is unknown',
+ 'Access credential rotation could not begin',
+ );
+ }
+}
+
export async function revokeAccessCredential(
authority: RuntimeHostAccessAuthority | undefined,
input: AccessCredentialRevokeInput,
@@ -482,6 +572,9 @@ export async function revokeAccessCredential(
try {
return { ok: true, result: await authority.revoke(input) };
} catch (error) {
+ if (error instanceof RuntimeHostAccessInputError) {
+ return { ok: false, error: { code: 'invalid_request', message: error.message } };
+ }
return accessPersistenceFailure(
error,
'Access credential revocation outcome is unknown',
@@ -490,6 +583,25 @@ export async function revokeAccessCredential(
}
}
+export async function revokeAccessCredentialRotation(
+ authority: RuntimeHostAccessAuthority | undefined,
+ input: AccessCredentialRotationRevokeInput,
+): Promise<OperationOutcome<'access.credential.rotation.revoke'>> {
+ if (!authority) return unavailable('rotation.revoke');
+ try {
+ return { ok: true, result: await authority.revokeRotation(input) };
+ } catch (error) {
+ if (error instanceof RuntimeHostAccessInputError) {
+ return { ok: false, error: { code: 'invalid_request', message: error.message } };
+ }
+ return accessPersistenceFailure(
+ error,
+ 'Access credential rotation revocation outcome is unknown',
+ 'Access credential could not be revoked',
+ );
+ }
+}
+
export async function finalizeAccessCredential(
authority: RuntimeHostAccessAuthority | undefined,
credentialId: string | undefined,
@@ -529,14 +641,29 @@ function unavailable(operation: 'issue'): OperationOutcome<'access.credential.is
function unavailable(operation: 'replace'): OperationOutcome<'access.credential.replace'>;
function unavailable(operation: 'prepare'): OperationOutcome<'access.credential.prepare'>;
function unavailable(operation: 'revoke'): OperationOutcome<'access.credential.revoke'>;
+function unavailable(
+ operation: 'rotation.prepare',
+): OperationOutcome<'access.credential.rotation.prepare'>;
+function unavailable(
+ operation: 'rotation.revoke',
+): OperationOutcome<'access.credential.rotation.revoke'>;
function unavailable(operation: 'finalize'): OperationOutcome<'access.credential.finalize'>;
function unavailable(
- _operation: 'issue' | 'replace' | 'prepare' | 'revoke' | 'finalize',
+ _operation:
+ | 'issue'
+ | 'replace'
+ | 'prepare'
+ | 'revoke'
+ | 'rotation.prepare'
+ | 'rotation.revoke'
+ | 'finalize',
):
| OperationOutcome<'access.credential.issue'>
| OperationOutcome<'access.credential.replace'>
| OperationOutcome<'access.credential.prepare'>
| OperationOutcome<'access.credential.revoke'>
+ | OperationOutcome<'access.credential.rotation.prepare'>
+ | OperationOutcome<'access.credential.rotation.revoke'>
| OperationOutcome<'access.credential.finalize'> {
return {
ok: false,
@@ -546,7 +673,3 @@ function unavailable(
},
};
}
-
-function hashCredential(credential: string): Buffer {
- return createHash('sha256').update(credential, 'utf8').digest();
-}
diff --git a/packages/runtime-host/src/server/access-credential-metadata.ts b/packages/runtime-host/src/server/access-credential-metadata.ts
new file mode 100644
index 000000000..91e4aa0dd
--- /dev/null
+++ b/packages/runtime-host/src/server/access-credential-metadata.ts
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { join } from 'node:path';
+import { runtimeHostAccessCredentialFingerprintFromHash } from '../access-credential-identity.js';
+import {
+ discoverMarkedStorageRoot,
+ resolveExistingStorageRootControlDirectory,
+ resolveExistingStorageRoot,
+} from '@maka/storage/root-authority';
+import type { OperationKey } from '../protocol/index.js';
+import { ACCESS_FILE_NAME, readAccessCredentialFile } from './access-credential-store.js';
+
+export interface RuntimeHostAccessCredentialMetadata {
+ readonly credentialId: string;
+ readonly credentialFingerprint: string;
+ readonly principalKind: 'remote_owner' | 'capability_provider';
+ readonly principalId: string;
+ readonly status: 'active' | 'pending';
+ readonly operationGrants: readonly OperationKey[];
+ readonly canPublishClientCapabilities: boolean;
+ readonly canUseHostPaths: boolean;
+ readonly createdAt: string;
+ readonly expiresAt?: string;
+}
+
+export async function readRuntimeHostAccessCredentialMetadata(
+ rootPath: string,
+ expectedRootId?: string,
+): Promise<{ readonly credentials: readonly RuntimeHostAccessCredentialMetadata[] }> {
+ const capability = expectedRootId
+ ? await resolveExistingStorageRoot({
+ path: rootPath,
+ kind: 'interactive',
+ expectedRootId,
+ })
+ : await discoverMarkedStorageRoot({ path: rootPath });
+ const { controlDirectory } = await resolveExistingStorageRootControlDirectory(capability);
+ const file = await readAccessCredentialFile(join(controlDirectory, ACCESS_FILE_NAME));
+ const now = Date.now();
+ return {
+ credentials: file.credentials.flatMap((credential) => {
+ if (
+ credential.status !== 'active' &&
+ !(credential.status === 'pending' && Date.parse(credential.expiresAt!) > now)
+ ) {
+ return [];
+ }
+ return [
+ {
+ credentialId: credential.credentialId,
+ credentialFingerprint: runtimeHostAccessCredentialFingerprintFromHash(
+ credential.credentialHash,
+ ),
+ principalKind: credential.principalKind,
+ principalId: credential.principalId,
+ status: credential.status,
+ operationGrants: credential.operationGrants,
+ canPublishClientCapabilities: credential.canPublishClientCapabilities,
+ canUseHostPaths: credential.canUseHostPaths,
+ createdAt: credential.createdAt,
+ ...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}),
+ },
+ ];
+ }),
+ };
+}
diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts
index eb3a0734a..a8d714bc1 100644
--- a/packages/runtime-host/src/server/host-kernel.ts
+++ b/packages/runtime-host/src/server/host-kernel.ts
@@ -61,8 +61,10 @@ import {
issueAccessCredential,
finalizeAccessCredential,
prepareAccessCredential,
+ prepareAccessCredentialRotation,
replaceAccessCredential,
revokeAccessCredential,
+ revokeAccessCredentialRotation,
type RuntimeHostAccessAuthority,
} from './access-authority.js';
import type { RuntimeHostConnectionAuthority } from './connection-authority.js';
@@ -661,6 +663,14 @@ export class RuntimeHostKernel {
this.#settleAccessCredentialMutation(
revokeAccessCredential(this.#options.accessAuthority, input),
),
+ 'access.credential.rotation.prepare': async (input) =>
+ this.#settleAccessCredentialMutation(
+ prepareAccessCredentialRotation(this.#options.accessAuthority, input),
+ ),
+ 'access.credential.rotation.revoke': async (input) =>
+ this.#settleAccessCredentialMutation(
+ revokeAccessCredentialRotation(this.#options.accessAuthority, input),
+ ),
'access.credential.finalize': async (_input, context) =>
this.#settleAccessCredentialMutation(
finalizeAccessCredential(this.#options.accessAuthority, context.credentialId),
diff --git a/packages/runtime-host/src/server/index.ts b/packages/runtime-host/src/server/index.ts
index 67a4d1022..1621c78c6 100644
--- a/packages/runtime-host/src/server/index.ts
+++ b/packages/runtime-host/src/server/index.ts
@@ -26,3 +26,7 @@ export { createUnavailableDomainOperationHandlers } from './operation-dispatcher
export { startExecutionRuntimeHostService } from './execution-service.js';
export { runRuntimeHostProcessLifecycle } from './process-lifecycle.js';
export { installRuntimeHostLogCapture } from '../process-diagnostics.js';
+export {
+ readRuntimeHostAccessCredentialMetadata,
+ type RuntimeHostAccessCredentialMetadata,
+} from './access-credential-metadata.js';
diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts
index 7eb4ed297..0bf4da921 100644
--- a/packages/runtime-host/src/server/operation-dispatcher.ts
+++ b/packages/runtime-host/src/server/operation-dispatcher.ts
@@ -285,6 +285,20 @@ export function createUnavailableAccessAuthorityOperationHandlers(): AccessAutho
message: 'Runtime Host access credentials are unavailable',
},
}),
+ 'access.credential.rotation.prepare': async () => ({
+ ok: false,
+ error: {
+ code: 'operation_unavailable',
+ message: 'Runtime Host access credentials are unavailable',
+ },
+ }),
+ 'access.credential.rotation.revoke': async () => ({
+ ok: false,
+ error: {
+ code: 'operation_unavailable',
+ message: 'Runtime Host access credentials are unavailable',
+ },
+ }),
'access.credential.finalize': async () => ({
ok: false,
error: {
diff --git a/scripts/protocol-epoch-check.mjs b/scripts/protocol-epoch-check.mjs
index 26d9ba26f..db4dc9651 100644
--- a/scripts/protocol-epoch-check.mjs
+++ b/scripts/protocol-epoch-check.mjs
@@ -24,9 +24,9 @@
// so git's three-way merge resolves them without a conflict and two
// incompatible protocols end up advertising one epoch. This check runs on the
// PR merge result and compares it with the synthetic merge's first parent: the
-// current base branch. It fails when anything under the protocol directory
-// changed while the epoch still equals that parent — exactly the state a silent
-// same-number merge produces.
+// current base branch. An incompatible change must move the epoch. A compatible
+// extension may keep it only when a newly added declaration names every changed
+// protocol file, keeping that exception explicit and reviewable.
import { execFileSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
@@ -39,6 +39,7 @@ const defaultRepoRoot = dirname(dirname(scriptPath));
export const EPOCH_FILE = 'packages/runtime-host/src/protocol/index.ts';
export const PROTOCOL_DIR = 'packages/runtime-host/src/protocol/';
+export const COMPATIBLE_CHANGE_DIR = 'packages/runtime-host/protocol-compatible-changes/';
const EPOCH_PATTERN = /^export const RUNTIME_HOST_COMPATIBILITY_EPOCH = (\d+) as const;$/gm;
@@ -52,7 +53,12 @@ export function extractCompatibilityEpoch(source) {
return Number(matches[0][1]);
}
-export function evaluateEpochCheck({ baseEpoch, headEpoch, changedProtocolFiles }) {
+export function evaluateEpochCheck({
+ baseEpoch,
+ headEpoch,
+ changedProtocolFiles,
+ compatibleProtocolFiles = [],
+}) {
if (headEpoch < baseEpoch) {
return {
ok: false,
@@ -62,7 +68,9 @@ export function evaluateEpochCheck({ baseEpoch, headEpoch, changedProtocolFiles
`incompatible protocol. Bump it forward instead, even for a revert.`,
};
}
- if (changedProtocolFiles.length > 0 && headEpoch === baseEpoch) {
+ const compatible = new Set(compatibleProtocolFiles);
+ const incompatibleChanges = changedProtocolFiles.filter((file) => !compatible.has(file));
+ if (incompatibleChanges.length > 0 && headEpoch === baseEpoch) {
return {
ok: false,
reason:
@@ -70,14 +78,17 @@ export function evaluateEpochCheck({ baseEpoch, headEpoch, changedProtocolFiles
`the current base parent's value. Same-number bumps on sibling branches merge without ` +
`a git conflict (#3313), so every protocol change must land with an epoch the current ` +
`base has not seen: rebase onto current main and set the epoch past ${baseEpoch}. ` +
- `Changed files:\n${changedProtocolFiles.map((file) => ` ${file}`).join('\n')}`,
+ `Changed files without a compatible-change declaration:\n` +
+ `${incompatibleChanges.map((file) => ` ${file}`).join('\n')}`,
};
}
return {
ok: true,
reason:
changedProtocolFiles.length > 0
- ? `Protocol changed and the epoch moved: ${baseEpoch} -> ${headEpoch}.`
+ ? headEpoch === baseEpoch
+ ? `Protocol added a declared compatible extension at epoch ${headEpoch}.`
+ : `Protocol changed and the epoch moved: ${baseEpoch} -> ${headEpoch}.`
: `No protocol changes against the current base parent (epoch ${headEpoch}).`,
};
}
@@ -92,6 +103,39 @@ export function changedProtocolFilesBetween(base, head, exec = execFileSync) {
.filter(Boolean);
}
+export function compatibleProtocolFilesBetween(base, head, headEpoch, exec = execFileSync) {
+ const declarations = git(
+ ['diff', '--diff-filter=A', '--name-only', base, head, '--', COMPATIBLE_CHANGE_DIR],
+ exec,
+ )
+ .split('\n')
+ .filter(Boolean);
+ const compatibleFiles = new Set();
+ for (const declaration of declarations) {
+ const value = JSON.parse(git(['show', `${head}:${declaration}`], exec));
+ if (
+ !value ||
+ typeof value !== 'object' ||
+ Array.isArray(value) ||
+ value.epoch !== headEpoch ||
+ !Array.isArray(value.files) ||
+ value.files.length === 0 ||
+ typeof value.reason !== 'string' ||
+ value.reason.trim().length === 0 ||
+ Object.keys(value).some((key) => !['epoch', 'files', 'reason'].includes(key))
+ ) {
+ throw new Error(`Invalid compatible protocol change declaration: ${declaration}`);
+ }
+ for (const file of value.files) {
+ if (typeof file !== 'string' || !file.startsWith(PROTOCOL_DIR)) {
+ throw new Error(`Invalid protocol file in compatible change declaration: ${declaration}`);
+ }
+ compatibleFiles.add(file);
+ }
+ }
+ return [...compatibleFiles];
+}
+
export function epochAtRevision(revision, exec = execFileSync) {
return extractCompatibilityEpoch(git(['show', `${revision}:${EPOCH_FILE}`], exec));
}
@@ -143,12 +187,14 @@ function parseArgs(args) {
function main(args) {
const { base, head } = parseArgs(args);
+ const headEpoch = epochAtRevision(head);
const verdict = evaluateEpochCheck({
baseEpoch: epochAtRevision(base),
- headEpoch: epochAtRevision(head),
+ headEpoch,
changedProtocolFiles: changedProtocolFilesBetween(base, head).filter(
(file) => !isHeaderOnlyChange(file, base, head),
),
+ compatibleProtocolFiles: compatibleProtocolFilesBetween(base, head, headEpoch),
});
process.stderr.write(`Protocol epoch guard: ${verdict.reason}\n`);
if (!verdict.ok) process.exitCode = 1;
diff --git a/scripts/protocol-epoch-check.test.mjs b/scripts/protocol-epoch-check.test.mjs
index fe40a0f2c..69f8e8d23 100644
--- a/scripts/protocol-epoch-check.test.mjs
+++ b/scripts/protocol-epoch-check.test.mjs
@@ -24,6 +24,8 @@ import { tmpdir } from 'node:os';
import test from 'node:test';
import {
changedProtocolFilesBetween,
+ compatibleProtocolFilesBetween,
+ COMPATIBLE_CHANGE_DIR,
EPOCH_FILE,
epochAtRevision,
evaluateEpochCheck,
@@ -70,6 +72,63 @@ test('fails a protocol change whose epoch equals the current base parent', () =>
assert.match(verdict.reason, /operations\.ts/);
});
+test('allows only files covered by a newly added compatible-change declaration', () => {
+ const changedProtocolFiles = [
+ 'packages/runtime-host/src/protocol/access-authority.ts',
+ 'packages/runtime-host/src/protocol/operations.ts',
+ ];
+ const compatible = evaluateEpochCheck({
+ baseEpoch: 27,
+ headEpoch: 27,
+ changedProtocolFiles,
+ compatibleProtocolFiles: changedProtocolFiles,
+ });
+ assert.equal(compatible.ok, true);
+
+ const incomplete = evaluateEpochCheck({
+ baseEpoch: 27,
+ headEpoch: 27,
+ changedProtocolFiles,
+ compatibleProtocolFiles: [changedProtocolFiles[0]],
+ });
+ assert.equal(incomplete.ok, false);
+ assert.match(incomplete.reason, /operations\.ts/);
+});
+
+test('reads newly added compatible-change declarations from the compared revision', () => {
+ const repo = mkdtempSync(join(tmpdir(), 'maka-protocol-compatible-change-'));
+ const runGit = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' });
+ const runInFixture = (file, args, options) =>
+ execFileSync(file, args, { ...options, cwd: repo, encoding: 'utf8' });
+ const protocolFile = 'packages/runtime-host/src/protocol/example.ts';
+
+ try {
+ runGit('init', '--initial-branch=main');
+ runGit('config', 'user.email', 'epoch-guard@example.invalid');
+ runGit('config', 'user.name', 'Epoch Guard Test');
+ mkdirSync(join(repo, dirname(protocolFile)), { recursive: true });
+ writeFileSync(join(repo, protocolFile), 'export const example = 1;\n');
+ runGit('add', '.');
+ runGit('commit', '-m', 'base');
+ runGit('tag', 'base');
+
+ writeFileSync(join(repo, protocolFile), 'export const example = 2;\n');
+ mkdirSync(join(repo, COMPATIBLE_CHANGE_DIR), { recursive: true });
+ writeFileSync(
+ join(repo, COMPATIBLE_CHANGE_DIR, 'example.json'),
+ JSON.stringify({ epoch: 27, files: [protocolFile], reason: 'Adds an optional operation' }),
+ );
+ runGit('add', '.');
+ runGit('commit', '-m', 'compatible extension');
+
+ assert.deepEqual(compatibleProtocolFilesBetween('base', 'HEAD', 27, runInFixture), [
+ protocolFile,
+ ]);
+ } finally {
+ rmSync(repo, { recursive: true, force: true });
+ }
+});
+
test('catches sibling same-number bumps against the synthetic merge first parent', () => {
const repo = mkdtempSync(join(tmpdir(), 'maka-protocol-epoch-graph-'));
const epochPath = join(repo, EPOCH_FILE);
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org