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/20 01:59:36 UTC
(maka) 04/12: fix(runtime-host): fence staged credential pairing
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/desktop-managed-remote-onboarding
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 2191e45a34654c5542a879429880d5ba2c4b4d7e
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Aug 19 15:23:46 2026 +0800
fix(runtime-host): fence staged credential pairing
Keep one expiring pending credential per principal while preserving immediate replacement for standalone CLI setup. Commit only the pending candidate, make retries harmless, and align SSH cancellation with the completion boundary.
Generated-by: Codex
---
.../main/__tests__/runtime-host-onboarding.test.ts | 39 ++--
.../__tests__/runtime-host-ssh-terminal.test.ts | 9 +-
apps/desktop/src/main/runtime-host-onboarding.ts | 28 ++-
apps/desktop/src/main/runtime-host-ssh-terminal.ts | 203 +++++++++++++--------
apps/desktop/src/preload/bridge-contract.d.ts | 2 +-
apps/desktop/src/preload/preload.ts | 2 +-
.../settings/runtime-host-onboarding-dialog.tsx | 25 ++-
.../runtime-host-operator-command.test.ts | 2 +
.../__tests__/runtime-host-service-manager.test.ts | 2 +
.../cli/src/__tests__/runtime-host-setup.test.ts | 22 ++-
packages/cli/src/cli-core.ts | 1 +
packages/cli/src/runtime-host-access-command.ts | 13 +-
packages/cli/src/runtime-host-cli.ts | 51 ++++--
packages/cli/src/runtime-host-setup-command.ts | 39 ++--
.../src/__tests__/authenticated-websocket.test.ts | 56 +++++-
.../src/__tests__/websocket-listener.test.ts | 1 +
.../runtime-host/src/protocol/access-authority.ts | 34 ++--
.../runtime-host/src/server/access-authority.ts | 175 ++++++++++++++----
.../src/server/access-credential-store.ts | 22 ++-
packages/runtime-host/src/server/host-kernel.ts | 3 +
.../src/server/operation-dispatcher.ts | 7 +
21 files changed, 527 insertions(+), 209 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
index 2842397e7..19e2c446c 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-onboarding.test.ts
@@ -66,6 +66,19 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn
finishPairing = resolve;
});
let pairingStarted = false;
+ let completeReceived = false;
+ let finishSetup!: (value: {
+ rootId: string;
+ endpoint: string;
+ credential: string;
+ }) => void;
+ const setupDrain = new Promise<{
+ rootId: string;
+ endpoint: string;
+ credential: string;
+ }>((resolve) => {
+ finishSetup = resolve;
+ });
const profiles: DesktopRuntimeHostProfileService = {
getSnapshot: async () => ({ defaultProfileId: 'local', entries: [] }),
addAndEnable: async () => assert.fail('manual profile path must not be used'),
@@ -85,11 +98,11 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn
clientInstanceId: 'stable-client',
profiles,
setupPackage: { kind: 'npm', specifier: 'maka-agent@0.2.0' },
- runSetup: async () => ({
- rootId: 'a'.repeat(64),
- endpoint: 'ws://127.0.0.1:7443/runtime-host',
- credential: 'candidate-token',
- }),
+ runSetup: async (_input, _onProgress, onComplete) => {
+ onComplete();
+ completeReceived = true;
+ return setupDrain;
+ },
send: () => undefined,
});
const start = handlers.get('runtime-host-onboarding:start');
@@ -98,17 +111,17 @@ test('finishes Host pairing after the cancellable SSH phase has completed', asyn
assert.ok(cancel);
const setup = start({}, { destination: 'operator@example.com' }) as Promise<unknown>;
- while (!pairingStarted) await Promise.resolve();
- const cancellation = cancel({}) as Promise<void>;
- let cancellationSettled = false;
- void cancellation.then(() => {
- cancellationSettled = true;
+ while (!completeReceived) await Promise.resolve();
+ assert.equal(await cancel({}), false);
+
+ finishSetup({
+ rootId: 'a'.repeat(64),
+ endpoint: 'ws://127.0.0.1:7443/runtime-host',
+ credential: 'candidate-token',
});
- await Promise.resolve();
- assert.equal(cancellationSettled, false);
+ while (!pairingStarted) await Promise.resolve();
finishPairing({ profileId: 'office' });
assert.deepEqual(await setup, { kind: 'complete', profileId: 'office', revision: 3 });
- await cancellation;
await onboarding.close();
});
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 ae75cf96c..fd342924f 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
@@ -48,6 +48,7 @@ test('dismisses a closed SSH prompt from the authoritative presentation', async
test('does not reopen a cancelled SSH prompt for late process output', async () => {
const harness = createHarness('exit');
harness.pty.deferKill = true;
+ harness.pty.exitOnForceKill = true;
const opening = openTunnel(harness);
harness.pty.emitData('Password: ');
const connecting = (await harness.getSnapshot()) as { sessionId?: string };
@@ -55,9 +56,9 @@ test('does not reopen a cancelled SSH prompt for late process output', async ()
await harness.cancel(connecting.sessionId);
harness.pty.emitData('late output');
- harness.pty.exit(1);
await assert.rejects(opening, /SSH exited/u);
+ assert.deepEqual(harness.pty.killSignals, ['SIGTERM', 'SIGKILL']);
assert.deepEqual(harness.eventKinds(), ['opened', 'data']);
assert.deepEqual(await harness.getSnapshot(), { kind: 'idle', revision: 3 });
await harness.terminal.close();
@@ -65,12 +66,14 @@ test('does not reopen a cancelled SSH prompt for late process output', async ()
test('keeps setup credentials out of the interactive terminal projection', async () => {
const harness = createHarness('pending');
+ const controller = new AbortController();
const progress: string[] = [];
const setup = harness.terminal.runSetup(
{
destination: 'operator@example.com',
setupPackage: { kind: 'npm', specifier: 'maka-agent@next' },
principalId: 'desktop:stable-client',
+ signal: controller.signal,
},
(frame) => progress.push(frame.phase),
);
@@ -95,6 +98,9 @@ test('keeps setup credentials out of the interactive terminal projection', async
credential: 'secret-access-token',
});
harness.pty.emitData(completeFrame);
+ controller.abort();
+ await Promise.resolve();
+ assert.deepEqual(harness.pty.killSignals, []);
harness.pty.exit(0);
const result = await setup;
@@ -170,6 +176,7 @@ test('uploads a development release archive before running the same remote setup
remoteCommand,
/npx.*--package.*maka-runtime-host-setup-.+\.tgz.*maka.*runtime-host.*setup/u,
);
+ assert.match(remoteCommand, /--defer-pairing-commit/u);
assert.match(remoteCommand, /rm -f/u);
assert.doesNotMatch(remoteCommand, /status=0; 'exec'/u);
launches[1]?.pty.emitData(
diff --git a/apps/desktop/src/main/runtime-host-onboarding.ts b/apps/desktop/src/main/runtime-host-onboarding.ts
index f7941f886..568cabc1a 100644
--- a/apps/desktop/src/main/runtime-host-onboarding.ts
+++ b/apps/desktop/src/main/runtime-host-onboarding.ts
@@ -24,6 +24,7 @@ export function createDesktopRuntimeHostOnboarding(input: {
readonly runSetup: (
input: DesktopRuntimeHostSshSetupInput,
onProgress: (frame: { readonly phase: RuntimeHostSetupPhase }) => void,
+ onComplete: () => void,
) => Promise<{
readonly rootId: string;
readonly endpoint: string;
@@ -56,7 +57,7 @@ export function createDesktopRuntimeHostOnboarding(input: {
const request = requireOnboardingInput(value);
const abort = new AbortController();
publish({ kind: 'running', phase: 'connecting_ssh' });
- const task = run(request, abort.signal).finally(() => {
+ const task = Promise.resolve().then(() => run(request, abort.signal)).finally(() => {
if (active?.task === task) active = undefined;
});
active = { abort, task, cancellable: true };
@@ -68,6 +69,16 @@ export function createDesktopRuntimeHostOnboarding(input: {
signal: AbortSignal,
): Promise<DesktopRuntimeHostOnboardingSnapshot> => {
try {
+ let commitStarted = false;
+ const beginCommit = () => {
+ if (commitStarted) return;
+ commitStarted = true;
+ if (active) active.cancellable = false;
+ publish({
+ kind: 'running',
+ phase: 'connecting_host',
+ });
+ };
const complete = await input.runSetup(
{
destination: request.destination,
@@ -77,18 +88,15 @@ export function createDesktopRuntimeHostOnboarding(input: {
signal,
},
(progress) => {
+ if (commitStarted) return;
publish({
kind: 'running',
phase: progress.phase,
});
},
+ beginCommit,
);
- signal.throwIfAborted();
- if (active) active.cancellable = false;
- publish({
- kind: 'running',
- phase: 'connecting_host',
- });
+ beginCommit();
const endpoint = requireSetupEndpoint(complete.endpoint);
const profileId = `remote-${randomUUID()}`;
const profileName = request.name?.trim() || request.destination;
@@ -131,9 +139,11 @@ export function createDesktopRuntimeHostOnboarding(input: {
input.ipcMain.handle(channels[1], (_event, value: unknown) => start(value));
input.ipcMain.handle(channels[2], async () => {
const current = active;
- if (!current) return;
- if (current.cancellable) current.abort.abort();
+ if (!current) return true;
+ if (!current.cancellable) return false;
+ current.abort.abort();
await current.task;
+ return true;
});
input.ipcMain.handle(channels[3], () => {
if (active) throw new Error('Remote Runtime Host setup is still running');
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index ae0812acf..969fc1ec5 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -62,6 +62,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
runSetup(
input: DesktopRuntimeHostSshSetupInput,
onProgress: (frame: Extract<RuntimeHostSetupFrame, { kind: 'progress' }>) => void,
+ onComplete?: (frame: RuntimeHostSetupCompleteFrame) => void,
): Promise<RuntimeHostSetupCompleteFrame>;
close(): Promise<void>;
} {
@@ -230,7 +231,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
terminal.pty.resize(request.cols, request.rows);
},
);
- input.ipcMain.handle(channels[3], (_event, sessionId: string) => {
+ input.ipcMain.handle(channels[3], async (_event, sessionId: string) => {
if (presentation?.sessionId === sessionId) {
presentation = undefined;
revision += 1;
@@ -238,11 +239,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
const terminal = findActive(active, sessionId);
if (!terminal) return;
terminal.dismissed = true;
- try {
- terminal.pty.kill();
- } catch {
- // The process may have exited between validation and cancellation.
- }
+ await terminateActiveTerminal(terminal, input.processStopGraceMs);
});
return {
@@ -254,73 +251,86 @@ export function createDesktopRuntimeHostSshTerminal(input: {
if (terminal) completePresentation(terminal);
return tunnel;
},
- runSetup: async (setupInput, onProgress) => {
+ runSetup: async (setupInput, onProgress, onComplete) => {
setupInput.signal?.throwIfAborted();
- const destination = requireSetupDestination(setupInput.destination);
- const sshPort = setupInput.sshPort === undefined
- ? undefined
- : requireSetupPort(setupInput.sshPort);
- const setupPackage = await prepareSetupPackage(
- setupInput.setupPackage,
- destination,
- sshPort,
- startTerminalProcess,
- setupInput.signal,
- input.processStopGraceMs,
- );
- const remoteCommand = runtimeHostSetupRemoteCommand(setupPackage, setupInput.principalId);
- let complete: RuntimeHostSetupCompleteFrame | undefined;
- let setupFailure: Error | undefined;
- let setupTerminal: ActiveTerminal | undefined;
- const filter = createSetupOutputFilter((frame) => {
- if (frame.kind === 'progress') onProgress(frame);
- else if (frame.kind === 'complete') {
- complete = frame;
- if (setupTerminal) completePresentation(setupTerminal);
- }
- else setupFailure = new Error(frame.error.message);
- }, (error) => {
- setupFailure = error;
- });
- const { process, terminal } = startTerminalProcess('ssh', [
- '-tt',
- '-o',
- 'BatchMode=no',
- '-o',
- 'ConnectTimeout=15',
- '-o',
- 'ControlMaster=no',
- '-o',
- 'ControlPath=none',
- '-o',
- 'ClearAllForwardings=yes',
- '-o',
- 'RemoteCommand=none',
- ...(sshPort === undefined ? [] : ['-p', String(sshPort)]),
- destination,
- remoteCommand,
- ], filter.push);
- setupTerminal = terminal;
- if (complete) completePresentation(terminal);
- const result = await waitForTerminalProcess(process, {
- signal: setupInput.signal,
- timeoutMs: SETUP_TIMEOUT_MS,
- timeoutMessage: 'Remote Maka setup timed out',
- stopGraceMs: input.processStopGraceMs,
- });
- filter.finish();
- if (setupFailure) throw setupFailure;
- if (!complete) {
- throw new Error(
- result.code === 0
- ? 'Remote Maka setup ended without a completion result'
- : result.code === 2
- ? 'The released Maka CLI on this channel does not support automated Runtime Host setup'
- : `Remote Maka setup exited with code ${String(result.code)}`,
+ const cancellation = cancellableUntilComplete(setupInput.signal);
+ try {
+ const destination = requireSetupDestination(setupInput.destination);
+ const sshPort = setupInput.sshPort === undefined
+ ? undefined
+ : requireSetupPort(setupInput.sshPort);
+ const setupPackage = await prepareSetupPackage(
+ setupInput.setupPackage,
+ destination,
+ sshPort,
+ startTerminalProcess,
+ cancellation.signal,
+ input.processStopGraceMs,
);
+ const remoteCommand = runtimeHostSetupRemoteCommand(setupPackage, setupInput.principalId);
+ let complete: RuntimeHostSetupCompleteFrame | undefined;
+ let setupFailure: Error | undefined;
+ let setupTerminal: ActiveTerminal | undefined;
+ const filter = createSetupOutputFilter(
+ (frame) => {
+ if (frame.kind === 'progress') onProgress(frame);
+ else if (frame.kind === 'complete') {
+ if (!cancellation.commit()) return;
+ complete = frame;
+ onComplete?.(frame);
+ if (setupTerminal) completePresentation(setupTerminal);
+ } else setupFailure = new Error(frame.error.message);
+ },
+ (error) => {
+ setupFailure = error;
+ },
+ );
+ const { process, terminal } = startTerminalProcess(
+ 'ssh',
+ [
+ '-tt',
+ '-o',
+ 'BatchMode=no',
+ '-o',
+ 'ConnectTimeout=15',
+ '-o',
+ 'ControlMaster=no',
+ '-o',
+ 'ControlPath=none',
+ '-o',
+ 'ClearAllForwardings=yes',
+ '-o',
+ 'RemoteCommand=none',
+ ...(sshPort === undefined ? [] : ['-p', String(sshPort)]),
+ destination,
+ remoteCommand,
+ ],
+ filter.push,
+ );
+ setupTerminal = terminal;
+ if (complete) completePresentation(terminal);
+ const result = await waitForTerminalProcess(process, {
+ signal: cancellation.signal,
+ timeoutMs: SETUP_TIMEOUT_MS,
+ timeoutMessage: 'Remote Maka setup timed out',
+ stopGraceMs: input.processStopGraceMs,
+ });
+ filter.finish();
+ if (setupFailure) throw setupFailure;
+ if (!complete) {
+ throw new Error(
+ result.code === 0
+ ? 'Remote Maka setup ended without a completion result'
+ : result.code === 2
+ ? 'The released Maka CLI on this channel does not support automated Runtime Host setup'
+ : `Remote Maka setup exited with code ${String(result.code)}`,
+ );
+ }
+ completePresentation(terminal);
+ return complete;
+ } finally {
+ cancellation.close();
}
- completePresentation(terminal);
- return complete;
},
close: async () => {
for (const channel of channels) input.ipcMain.removeHandler(channel);
@@ -330,17 +340,36 @@ export function createDesktopRuntimeHostSshTerminal(input: {
presentation = undefined;
terminal.dismissed = true;
if (terminal.revealTimer !== undefined) clearTimeout(terminal.revealTimer);
- await terminateTerminalProcess(
- {
- exited: terminal.exited.then(() => ({ code: null, signal: null })),
- kill: (signal) => terminal.pty.kill(signal),
- },
- input.processStopGraceMs,
- ).catch(() => undefined);
+ await terminateActiveTerminal(terminal, input.processStopGraceMs).catch(() => undefined);
},
};
}
+function cancellableUntilComplete(signal: AbortSignal | undefined): {
+ readonly signal: AbortSignal;
+ commit(): boolean;
+ close(): void;
+} {
+ const controller = new AbortController();
+ let committed = false;
+ const onAbort = () => {
+ if (!committed) controller.abort();
+ };
+ if (signal?.aborted) onAbort();
+ else signal?.addEventListener('abort', onAbort, { once: true });
+ const close = () => signal?.removeEventListener('abort', onAbort);
+ return {
+ signal: controller.signal,
+ commit() {
+ if (controller.signal.aborted) return false;
+ committed = true;
+ close();
+ return true;
+ },
+ close,
+ };
+}
+
function createSetupOutputFilter(
onFrame: (frame: RuntimeHostSetupFrame) => void,
onError: (error: Error) => void,
@@ -547,6 +576,7 @@ function runtimeHostSetupRemoteCommand(
principalId,
'--preset',
'desktop-client',
+ '--defer-pairing-commit',
'--json',
].map(quotePosix).join(' ');
const command = setupPackage.removeAfterSetup
@@ -593,6 +623,25 @@ function findActive(
return active?.sessionId === sessionId ? active : undefined;
}
+function terminateActiveTerminal(
+ terminal: ActiveTerminal,
+ graceMs: number | undefined,
+): Promise<void> {
+ return terminateTerminalProcess(
+ {
+ exited: terminal.exited.then(() => ({ code: null, signal: null })),
+ kill: (signal) => {
+ try {
+ terminal.pty.kill(signal);
+ } catch {
+ // The exit promise is the authority for concurrent process exit.
+ }
+ },
+ },
+ graceMs,
+ );
+}
+
function sshEnvironment(): Record<string, string> {
const allowed = new Set([
'APPDATA',
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 47e20b74a..011bda4ca 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -451,7 +451,7 @@ export interface MakaBridge {
runtimeHostOnboarding: {
getSnapshot(): Promise<DesktopRuntimeHostOnboardingSnapshot>;
start(input: DesktopRuntimeHostOnboardingInput): Promise<DesktopRuntimeHostOnboardingSnapshot>;
- cancel(): Promise<void>;
+ cancel(): Promise<boolean>;
reset(): Promise<void>;
subscribe(handler: (snapshot: DesktopRuntimeHostOnboardingSnapshot) => void): () => void;
};
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 9fdabb11d..0dc0bd3eb 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1074,7 +1074,7 @@ const makaBridge = {
start(input: DesktopRuntimeHostOnboardingInput): Promise<DesktopRuntimeHostOnboardingSnapshot> {
return ipcRenderer.invoke('runtime-host-onboarding:start', input);
},
- cancel(): Promise<void> {
+ cancel(): Promise<boolean> {
return ipcRenderer.invoke('runtime-host-onboarding:cancel');
},
reset(): Promise<void> {
diff --git a/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
index 243182222..64bf16ed6 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-onboarding-dialog.tsx
@@ -54,8 +54,13 @@ export function RuntimeHostOnboardingDialog(props: {
}
function close(): void {
- if (running) void window.maka.runtimeHostOnboarding.cancel();
- else void window.maka.runtimeHostOnboarding.reset();
+ if (running) {
+ void window.maka.runtimeHostOnboarding.cancel().then((cancelled) => {
+ if (cancelled) props.onClose();
+ });
+ return;
+ }
+ void window.maka.runtimeHostOnboarding.reset();
props.onClose();
}
@@ -140,12 +145,16 @@ export function RuntimeHostOnboardingDialog(props: {
}}
/>
</>
- ) : running && snapshot.phase !== 'connecting_host' ? (
- <Button
- variant="secondary"
- label={copy.setupCancel}
- clickAction={() => window.maka.runtimeHostOnboarding.cancel()}
- />
+ ) : running ? (
+ snapshot.phase === 'connecting_host' ? null : (
+ <Button
+ variant="secondary"
+ label={copy.setupCancel}
+ clickAction={async () => {
+ await window.maka.runtimeHostOnboarding.cancel();
+ }}
+ />
+ )
) : snapshot.kind === 'failed' ? (
<>
<Button variant="secondary" label={copy.setupCancel} onClick={close} />
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 102f6e590..ad3e51d43 100644
--- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
@@ -103,6 +103,7 @@ describe('Runtime Host operator commands', () => {
assert.equal(resolved.principalKind, 'remote_owner');
assert.equal(resolved.canUseHostPaths, false);
assert.equal(resolved.operationGrants.includes('access.credential.issue'), false);
+ 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('host.upgrade.prepare'), false);
@@ -137,6 +138,7 @@ describe('Runtime Host operator commands', () => {
.sort(),
[
'access.credential.issue',
+ 'access.credential.prepare',
'access.credential.replace',
'access.credential.revoke',
'host.upgrade.prepare',
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 3f6e5359c..1e2ffe21a 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -86,6 +86,7 @@ describe('managed Runtime Host service', () => {
'desktop.client-1',
'--preset',
'desktop-client',
+ '--defer-pairing-commit',
'--json',
]),
{
@@ -93,6 +94,7 @@ describe('managed Runtime Host service', () => {
json: true,
principalId: 'desktop.client-1',
preset: 'desktop-client',
+ deferPairingCommit: true,
},
);
});
diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts
index 2acf6b132..f3ca82331 100644
--- a/packages/cli/src/__tests__/runtime-host-setup.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts
@@ -75,7 +75,20 @@ test('managed setup converges on one exact package and verified Client pairing',
},
prepareDeployment: (input: Parameters<typeof prepareRuntimeHostManagedPackageDeployment>[0]) =>
prepareRuntimeHostManagedPackageDeployment(input, deploymentPathOptions),
- issueCredential: async () => {
+ replaceCredential: async () => {
+ pairCount += 1;
+ return {
+ rootId: 'a'.repeat(64),
+ credential: `secret-${pairCount}`,
+ credentialId: `credential-${pairCount}`,
+ principalKind: 'remote_owner' as const,
+ principalId: 'desktop.client-1',
+ operationGrants: ['host.status'] as const,
+ canPublishClientCapabilities: true,
+ canUseHostPaths: false,
+ };
+ },
+ prepareCredential: async () => {
pairCount += 1;
return {
rootId: 'a'.repeat(64),
@@ -103,7 +116,10 @@ test('managed setup converges on one exact package and verified Client pairing',
assert.equal(await runRuntimeHostSetupCli(options, overrides), 0);
assert.equal(await runRuntimeHostSetupCli(options, overrides), 0);
rejectVerification = true;
- assert.equal(await runRuntimeHostSetupCli(options, overrides), 1);
+ assert.equal(
+ await runRuntimeHostSetupCli({ ...options, deferPairingCommit: true }, overrides),
+ 1,
+ );
assert.equal(installCount, 3);
assert.equal(pairCount, 3);
assert.deepEqual(revokedCredentialIds, ['credential-3']);
@@ -200,7 +216,7 @@ test('managed setup replaces one exact development package with another', async
homeDir: join(base, 'home'),
platform: 'linux',
}),
- issueCredential: async () => ({
+ replaceCredential: async () => ({
rootId: 'a'.repeat(64),
credential: 'new-development-secret',
credentialId: 'new-development-credential',
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index df678f9bb..ad2865e67 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -227,6 +227,7 @@ export async function runMakaCli(
version,
principalId: command.principalId,
preset: command.preset,
+ deferPairingCommit: command.deferPairingCommit,
...(command.rootPath ? { rootPath: command.rootPath } : {}),
...(command.projectDirectoryRoots
? { projectDirectoryRoots: command.projectDirectoryRoots }
diff --git a/packages/cli/src/runtime-host-access-command.ts b/packages/cli/src/runtime-host-access-command.ts
index ce387d4e8..b1b2f904f 100644
--- a/packages/cli/src/runtime-host-access-command.ts
+++ b/packages/cli/src/runtime-host-access-command.ts
@@ -70,6 +70,12 @@ export function issueRuntimeHostAccessCredential(
return mutateRuntimeHostAccessCredential(options, 'access.credential.issue');
}
+export function prepareRuntimeHostAccessCredential(
+ options: RuntimeHostAccessIssueOptions,
+): Promise<IssuedRuntimeHostAccessCredential> {
+ return mutateRuntimeHostAccessCredential(options, 'access.credential.prepare');
+}
+
export async function replaceRuntimeHostAccessCredential(
options: RuntimeHostAccessIssueOptions,
): Promise<ReplacedRuntimeHostAccessCredential> {
@@ -80,7 +86,7 @@ export type ReplacedRuntimeHostAccessCredential = IssuedRuntimeHostAccessCredent
async function mutateRuntimeHostAccessCredential(
options: RuntimeHostAccessIssueOptions,
- operation: 'access.credential.issue' | 'access.credential.replace',
+ operation: 'access.credential.issue' | 'access.credential.prepare' | 'access.credential.replace',
): Promise<IssuedRuntimeHostAccessCredential> {
const resolved = resolveRuntimeHostAccessIssue(options);
const connection = await connectLocalOwner(options.rootPath);
@@ -92,10 +98,7 @@ async function mutateRuntimeHostAccessCredential(
canPublishClientCapabilities: resolved.canPublishClientCapabilities,
canUseHostPaths: resolved.canUseHostPaths,
};
- const result =
- operation === 'access.credential.issue'
- ? await connection.request('access.credential.issue', credentialInput)
- : await connection.request('access.credential.replace', credentialInput);
+ const result = await connection.request(operation, credentialInput);
const credential = await consumeAccessCredentialDelivery(
options.rootPath,
result.deliveryId,
diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts
index aec8f3c5e..c6073fadd 100644
--- a/packages/cli/src/runtime-host-cli.ts
+++ b/packages/cli/src/runtime-host-cli.ts
@@ -28,6 +28,7 @@ export type RuntimeHostCliCommand =
json: boolean;
principalId: string;
preset: 'desktop-client' | 'terminal-client';
+ deferPairingCommit: boolean;
rootPath?: string;
projectDirectoryRoots?: { label: string; path: string }[];
websocketPort?: number;
@@ -108,15 +109,24 @@ export function parseRuntimeHostCommand(argv: string[]): RuntimeHostCliCommand {
function parseSetupCommand(argv: string[]): RuntimeHostCliCommand {
let principalId: string | undefined;
let preset: 'desktop-client' | 'terminal-client' | undefined;
+ let deferPairingCommit = false;
const options = parseManagedServiceOptions(argv, {
- '--principal': (value) => {
- principalId = value;
+ valueOptions: {
+ '--principal': (value) => {
+ principalId = value;
+ },
+ '--preset': (value) => {
+ if (value !== 'desktop-client' && value !== 'terminal-client') {
+ return error('--preset must be desktop-client or terminal-client');
+ }
+ preset = value;
+ },
},
- '--preset': (value) => {
- if (value !== 'desktop-client' && value !== 'terminal-client') {
- return error('--preset must be desktop-client or terminal-client');
- }
- preset = value;
+ flagOptions: {
+ '--defer-pairing-commit': () => {
+ if (deferPairingCommit) return error('Duplicate --defer-pairing-commit');
+ deferPairingCommit = true;
+ },
},
});
if ('kind' in options) return options;
@@ -129,6 +139,7 @@ function parseSetupCommand(argv: string[]): RuntimeHostCliCommand {
...options,
principalId,
preset,
+ deferPairingCommit,
};
}
@@ -149,7 +160,9 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
);
}
- const options = parseManagedServiceOptions(argv.slice(1), {}, action === 'install');
+ const options = parseManagedServiceOptions(argv.slice(1), {
+ allowConfiguration: action === 'install',
+ });
if ('kind' in options) return options;
return {
kind: 'runtime-host-service-manage',
@@ -168,10 +181,11 @@ interface ManagedServiceOptions {
function parseManagedServiceOptions(
argv: string[],
- additionalValueOptions: Readonly<
- Record<string, (value: string) => RuntimeHostCliError | void>
- > = {},
- allowConfiguration = true,
+ input: {
+ readonly valueOptions?: Readonly<Record<string, (value: string) => RuntimeHostCliError | void>>;
+ readonly flagOptions?: Readonly<Record<string, () => RuntimeHostCliError | void>>;
+ readonly allowConfiguration?: boolean;
+ } = {},
): ManagedServiceOptions | RuntimeHostCliError {
let json = false;
let rootPath: string | undefined;
@@ -184,13 +198,20 @@ function parseManagedServiceOptions(
json = true;
continue;
}
- if (!allowConfiguration) return error(`Unexpected argument: ${argument ?? ''}`);
+ if (Object.hasOwn(input.flagOptions ?? {}, argument ?? '')) {
+ const optionError = input.flagOptions?.[argument ?? '']?.();
+ if (optionError) return optionError;
+ continue;
+ }
+ if (input.allowConfiguration === false) {
+ return error(`Unexpected argument: ${argument ?? ''}`);
+ }
if (
argument === '--root' ||
argument === '--websocket-port' ||
argument === '--websocket-path' ||
argument === '--project-root' ||
- Object.hasOwn(additionalValueOptions, argument ?? '')
+ Object.hasOwn(input.valueOptions ?? {}, argument ?? '')
) {
const parsed = optionValue(argv, index, argument ?? '');
if (typeof parsed !== 'string') return parsed;
@@ -210,7 +231,7 @@ function parseManagedServiceOptions(
}
projectDirectoryRoots.push(root);
} else {
- const optionError = additionalValueOptions[argument ?? '']?.(parsed);
+ const optionError = input.valueOptions?.[argument ?? '']?.(parsed);
if (optionError) return optionError;
}
index += 1;
diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts
index db16b3162..7652334aa 100644
--- a/packages/cli/src/runtime-host-setup-command.ts
+++ b/packages/cli/src/runtime-host-setup-command.ts
@@ -15,7 +15,8 @@ import {
} from '@maka/runtime-host/protocol';
import { withFileUpdateLock } from '@maka/storage/file-update-lock';
import {
- issueRuntimeHostAccessCredential,
+ prepareRuntimeHostAccessCredential,
+ replaceRuntimeHostAccessCredential,
revokeRuntimeHostAccessCredential,
type RuntimeHostAccessPreset,
} from './runtime-host-access-command.js';
@@ -42,6 +43,7 @@ export interface RuntimeHostSetupCliOptions {
readonly version: string;
readonly principalId: string;
readonly preset: RuntimeHostAccessPreset;
+ readonly deferPairingCommit?: boolean;
readonly rootPath?: string;
readonly projectDirectoryRoots?: readonly { readonly label: string; readonly path: string }[];
readonly websocketPort?: number;
@@ -52,7 +54,8 @@ interface RuntimeHostSetupDeps {
readonly manageService: typeof manageRuntimeHostService;
readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment;
- readonly issueCredential: typeof issueRuntimeHostAccessCredential;
+ readonly prepareCredential: typeof prepareRuntimeHostAccessCredential;
+ readonly replaceCredential: typeof replaceRuntimeHostAccessCredential;
readonly revokeCredential: typeof revokeRuntimeHostAccessCredential;
readonly verifyCredential: typeof verifyRuntimeHostSetupCredential;
readonly writeOutput: (value: string) => unknown;
@@ -78,7 +81,8 @@ export async function runRuntimeHostSetupCli(
manageService: manageRuntimeHostService,
createBackend: createPlatformRuntimeHostServiceBackend,
prepareDeployment: prepareRuntimeHostManagedPackageDeployment,
- issueCredential: issueRuntimeHostAccessCredential,
+ prepareCredential: prepareRuntimeHostAccessCredential,
+ replaceCredential: replaceRuntimeHostAccessCredential,
revokeCredential: revokeRuntimeHostAccessCredential,
verifyCredential: verifyRuntimeHostSetupCredential,
writeOutput: (value) => process.stdout.write(value),
@@ -156,9 +160,12 @@ async function runRuntimeHostSetupLocked(
}
emit({ kind: 'progress', phase: 'pairing_client' });
- let paired: Awaited<ReturnType<typeof issueRuntimeHostAccessCredential>>;
+ let paired: Awaited<ReturnType<typeof prepareRuntimeHostAccessCredential>>;
try {
- paired = await deps.issueCredential({
+ const pairCredential = options.deferPairingCommit
+ ? deps.prepareCredential
+ : deps.replaceCredential;
+ paired = await pairCredential({
rootPath: config.rootPath,
principalKind: 'remote_owner',
principalId: options.principalId,
@@ -192,16 +199,18 @@ async function runRuntimeHostSetupLocked(
credential: paired.credential,
});
} catch (error) {
- try {
- await deps.revokeCredential({
- rootPath: config.rootPath,
- credentialId: paired.credentialId,
- });
- } catch (rollbackError) {
- throw new AggregateError(
- [error, rollbackError],
- 'Runtime Host pairing failed and its candidate credential could not be revoked',
- );
+ if (options.deferPairingCommit) {
+ try {
+ await deps.revokeCredential({
+ rootPath: config.rootPath,
+ credentialId: paired.credentialId,
+ });
+ } catch (rollbackError) {
+ throw new AggregateError(
+ [error, rollbackError],
+ 'Runtime Host pairing failed and its candidate credential could not be revoked',
+ );
+ }
}
throw error;
}
diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
index f40b8d06f..2adb30fcc 100644
--- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
+++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
@@ -342,7 +342,7 @@ test('one Local IPC owner and one authenticated WebSocket Client control the sam
'session',
);
- const candidate = await local.request('access.credential.issue', {
+ const candidate = await local.request('access.credential.prepare', {
principalKind: 'remote_owner',
principalId: 'remote-device',
operationGrants: issued.operationGrants,
@@ -354,6 +354,7 @@ test('one Local IPC owner and one authenticated WebSocket Client control the sam
candidate.deliveryId,
candidate.credentialId,
);
+ assert.deepEqual(await remote.request('access.credential.finalize', {}), {});
const replacementConnection = await connectRemoteRuntimeHost({
url,
credential: replacementCredential,
@@ -366,16 +367,13 @@ test('one Local IPC owner and one authenticated WebSocket Client control the sam
if (replacementConnection.kind === 'connected') {
assert.deepEqual(
await replacementConnection.connection.request('access.credential.finalize', {}),
- {
- credentialId: candidate.credentialId,
- revokedCredentialIds: [issued.credentialId],
- },
+ {},
);
await remote.closed;
remote = undefined;
assert.deepEqual(
await replacementConnection.connection.request('access.credential.finalize', {}),
- { credentialId: candidate.credentialId, revokedCredentialIds: [] },
+ {},
);
await replacementConnection.connection.close();
}
@@ -587,6 +585,44 @@ test('access credentials persist only as hashes and stay revoked after reload',
}
});
+test('expired pairing candidates are denied and removed from durable access state', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-expired-pairing-'));
+ const credential = 'maka_rh_expired_pairing';
+ const path = join(directory, 'runtime-host-access.json');
+ try {
+ await writeFile(
+ path,
+ `${JSON.stringify({
+ schemaVersion: 1,
+ credentials: [
+ {
+ credentialId: 'expired-pairing',
+ credentialHash: createHash('sha256').update(credential).digest('hex'),
+ principalId: 'expired-client',
+ principalKind: 'remote_owner',
+ status: 'pending',
+ operationGrants: ['host.status', 'access.credential.finalize'],
+ canPublishClientCapabilities: false,
+ canUseHostPaths: false,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ expiresAt: '2026-01-01T00:05:00.000Z',
+ },
+ ],
+ })}\n`,
+ { mode: 0o600 },
+ );
+
+ const authority = await openRuntimeHostAccessAuthority(directory);
+ assert.equal(authority.authenticate(credential), undefined);
+ await waitForCondition(async () => {
+ const file = JSON.parse(await readFile(path, 'utf8')) as { credentials?: unknown[] };
+ return file.credentials?.length === 0;
+ });
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+});
+
test('keeps a formerly accepted local-only grant inert when opening an existing access file', async () => {
const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-legacy-'));
const credential = 'maka_rh_existing';
@@ -733,3 +769,11 @@ function requireConnection(
if (result.kind !== 'connected') throw new Error(`Local Client did not connect: ${result.kind}`);
return result.connection;
}
+
+async function waitForCondition(condition: () => Promise<boolean>): Promise<void> {
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ if (await condition()) return;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ assert.fail('condition did not become true');
+}
diff --git a/packages/runtime-host/src/__tests__/websocket-listener.test.ts b/packages/runtime-host/src/__tests__/websocket-listener.test.ts
index 8170de057..4976da2a3 100644
--- a/packages/runtime-host/src/__tests__/websocket-listener.test.ts
+++ b/packages/runtime-host/src/__tests__/websocket-listener.test.ts
@@ -169,6 +169,7 @@ 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'),
revoke: async () => assert.fail('Credential revoke is not expected'),
finalize: async () => assert.fail('Credential finalize is not expected'),
subscribeRevocations: () => () => undefined,
diff --git a/packages/runtime-host/src/protocol/access-authority.ts b/packages/runtime-host/src/protocol/access-authority.ts
index e73d04b6d..4666786d1 100644
--- a/packages/runtime-host/src/protocol/access-authority.ts
+++ b/packages/runtime-host/src/protocol/access-authority.ts
@@ -36,6 +36,8 @@ export interface AccessCredentialIssueResult {
export type AccessCredentialReplaceInput = AccessCredentialIssueInput;
export type AccessCredentialReplaceResult = AccessCredentialIssueResult;
+export type AccessCredentialPrepareInput = AccessCredentialIssueInput;
+export type AccessCredentialPrepareResult = AccessCredentialIssueResult;
export interface AccessCredentialRevokeInput {
readonly credentialId: string;
@@ -47,11 +49,7 @@ export interface AccessCredentialRevokeResult {
}
export type AccessCredentialFinalizeInput = Record<string, never>;
-
-export interface AccessCredentialFinalizeResult {
- readonly credentialId: string;
- readonly revokedCredentialIds: readonly string[];
-}
+export type AccessCredentialFinalizeResult = Record<string, never>;
export const ACCESS_AUTHORITY_OPERATION_SPECS = {
'access.credential.issue': defineOperation<
@@ -76,6 +74,17 @@ export const ACCESS_AUTHORITY_OPERATION_SPECS = {
decodeInput: decodeAccessCredentialIssueInput,
decodeOutput: decodeAccessCredentialIssueResult,
}),
+ 'access.credential.prepare': defineOperation<
+ AccessCredentialPrepareInput,
+ AccessCredentialPrepareResult,
+ (typeof ACCESS_ERRORS)[number]
+ >({
+ mode: 'command',
+ availability: 'ready',
+ errors: ACCESS_ERRORS,
+ decodeInput: decodeAccessCredentialIssueInput,
+ decodeOutput: decodeAccessCredentialIssueResult,
+ }),
'access.credential.revoke': defineOperation<
AccessCredentialRevokeInput,
AccessCredentialRevokeResult,
@@ -175,19 +184,8 @@ export function decodeAccessCredentialFinalizeInput(value: unknown): AccessCrede
export function decodeAccessCredentialFinalizeResult(
value: unknown,
): AccessCredentialFinalizeResult {
- const record = requireExactRecord(value, 'access credential finalize result', [
- 'credentialId',
- 'revokedCredentialIds',
- ]);
- if (!Array.isArray(record.revokedCredentialIds)) {
- throw invalidProtocolFrame('Invalid revokedCredentialIds');
- }
- return {
- credentialId: requireId(record.credentialId, 'credentialId'),
- revokedCredentialIds: record.revokedCredentialIds.map((credentialId) =>
- requireId(credentialId, 'revokedCredentialId'),
- ),
- };
+ requireExactRecord(value, 'access credential finalize result', []);
+ return {};
}
function operationGrants(value: unknown): readonly OperationKey[] {
diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts
index 7e5aa58f6..33b9865b2 100644
--- a/packages/runtime-host/src/server/access-authority.ts
+++ b/packages/runtime-host/src/server/access-authority.ts
@@ -4,6 +4,8 @@ import {
type AccessCredentialIssueInput,
type AccessCredentialIssueResult,
type AccessCredentialFinalizeResult,
+ type AccessCredentialPrepareInput,
+ type AccessCredentialPrepareResult,
type AccessCredentialReplaceInput,
type AccessCredentialReplaceResult,
type AccessCredentialRevokeInput,
@@ -32,6 +34,7 @@ import {
} from './access-credential-store.js';
const ACCESS_CREDENTIAL_PREFIX = 'maka_rh_';
+const PENDING_CREDENTIAL_LIFETIME_MS = 15 * 60_000;
const CAPABILITY_PROVIDER_GRANTS = new Set([
'host.status',
'client.capability.replace',
@@ -42,6 +45,7 @@ export interface RuntimeHostAccessAuthority {
authenticate(credential: string): RuntimeHostConnectionAuthority | undefined;
issue(input: AccessCredentialIssueInput): Promise<AccessCredentialIssueResult>;
replace(input: AccessCredentialReplaceInput): Promise<AccessCredentialReplaceResult>;
+ prepare(input: AccessCredentialPrepareInput): Promise<AccessCredentialPrepareResult>;
revoke(input: AccessCredentialRevokeInput): Promise<AccessCredentialRevokeResult>;
finalize(credentialId: string): Promise<AccessCredentialFinalizeResult>;
subscribeRevocations(listener: (credentialId: string) => void): () => void;
@@ -64,12 +68,14 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
readonly #path: string;
#file: AccessCredentialFile;
#mutation = Promise.resolve();
+ #expiryTimer: NodeJS.Timeout | undefined;
readonly #revocationListeners = new Set<(credentialId: string) => void>();
constructor(controlDirectory: string, path: string, file: AccessCredentialFile) {
this.#controlDirectory = controlDirectory;
this.#path = path;
this.#file = file;
+ this.#schedulePendingExpiry();
}
authenticate(credential: string): RuntimeHostConnectionAuthority | undefined {
@@ -79,7 +85,13 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
const storedHash = Buffer.from(stored.credentialHash, 'hex');
const equal =
storedHash.byteLength === candidate.byteLength && timingSafeEqual(storedHash, candidate);
- if (equal && stored.status === 'active') match = stored;
+ if (
+ equal &&
+ (stored.status === 'active' ||
+ (stored.status === 'pending' && Date.parse(stored.expiresAt!) > Date.now()))
+ ) {
+ match = stored;
+ }
}
return match
? createRuntimeHostConnectionAuthority({
@@ -94,20 +106,33 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
}
issue(input: AccessCredentialIssueInput): Promise<AccessCredentialIssueResult> {
- return this.#issue(input, false);
+ return this.#issue(input, 'issue');
}
replace(input: AccessCredentialReplaceInput): Promise<AccessCredentialReplaceResult> {
- return this.#issue(input, true);
+ return this.#issue(input, 'replace');
+ }
+
+ prepare(input: AccessCredentialPrepareInput): Promise<AccessCredentialPrepareResult> {
+ return this.#issue(input, 'prepare');
}
#issue(
input: AccessCredentialIssueInput,
- replacePrincipal: boolean,
+ 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,
@@ -118,25 +143,32 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
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: 'active',
+ status: mode === 'prepare' ? 'pending' : 'active',
operationGrants,
canPublishClientCapabilities: input.canPublishClientCapabilities,
canUseHostPaths: input.canUseHostPaths,
- createdAt: new Date().toISOString(),
+ createdAt: createdAt.toISOString(),
+ ...(mode === 'prepare'
+ ? {
+ expiresAt: new Date(
+ createdAt.getTime() + PENDING_CREDENTIAL_LIFETIME_MS,
+ ).toISOString(),
+ }
+ : {}),
};
- const replaced = replacePrincipal
- ? this.#file.credentials.filter(
- (candidate) =>
- candidate.status === 'active' &&
- candidate.principalKind === input.principalKind &&
- candidate.principalId === input.principalId,
- )
- : [];
+ 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
@@ -177,12 +209,15 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
if (index === -1 || this.#file.credentials[index]?.status === 'revoked') {
return { credentialId: input.credentialId, revoked: false };
}
- const credentials = [...this.#file.credentials];
- credentials[index] = {
- ...credentials[index]!,
- status: 'revoked',
- revokedAt: new Date().toISOString(),
- };
+ 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() }
+ : credential,
+ );
await this.#commit(createAccessCredentialFile(credentials));
this.#publishRevocation(input.credentialId);
return { credentialId: input.credentialId, revoked: true };
@@ -192,11 +227,16 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
finalize(credentialId: string): Promise<AccessCredentialFinalizeResult> {
return this.#mutate(async () => {
const retained = this.#file.credentials.find(
- (credential) => credential.credentialId === credentialId && credential.status === 'active',
+ (credential) => credential.credentialId === credentialId,
);
- if (!retained) {
+ if (!retained || retained.status === 'revoked') {
throw new RuntimeHostAccessInputError('The current access credential is no longer active');
}
+ if (retained.status === 'active') return {};
+ if (Date.parse(retained.expiresAt!) <= Date.now()) {
+ await this.#expirePending();
+ throw new RuntimeHostAccessInputError('The pairing candidate has expired');
+ }
const revoked = this.#file.credentials.filter(
(credential) =>
credential.credentialId !== credentialId &&
@@ -204,18 +244,17 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
credential.principalKind === retained.principalKind &&
credential.principalId === retained.principalId,
);
- if (revoked.length > 0) {
- await this.#commit(
- createAccessCredentialFile(
- this.#file.credentials.filter((credential) => !revoked.includes(credential)),
- ),
- );
- for (const credential of revoked) this.#publishRevocation(credential.credentialId);
- }
- return {
- credentialId,
- revokedCredentialIds: revoked.map((credential) => credential.credentialId),
- };
+ await this.#commit(
+ createAccessCredentialFile(
+ this.#file.credentials
+ .filter((credential) => !revoked.includes(credential))
+ .map((credential) =>
+ credential === retained ? activatePendingCredential(credential) : credential,
+ ),
+ ),
+ );
+ for (const credential of revoked) this.#publishRevocation(credential.credentialId);
+ return {};
});
}
@@ -236,6 +275,47 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
async #commit(file: AccessCredentialFile): Promise<void> {
await writeAccessCredentialFile(this.#path, file);
this.#file = file;
+ this.#schedulePendingExpiry();
+ }
+
+ async #expirePending(): Promise<void> {
+ const now = Date.now();
+ const expired = this.#file.credentials.filter(
+ (credential) => credential.status === 'pending' && Date.parse(credential.expiresAt!) <= now,
+ );
+ if (expired.length === 0) {
+ this.#schedulePendingExpiry();
+ return;
+ }
+ await this.#commit(
+ createAccessCredentialFile(
+ this.#file.credentials.filter((credential) => !expired.includes(credential)),
+ ),
+ );
+ for (const credential of expired) this.#publishRevocation(credential.credentialId);
+ }
+
+ #schedulePendingExpiry(retryMs?: number): void {
+ if (this.#expiryTimer) clearTimeout(this.#expiryTimer);
+ const nextExpiry = this.#file.credentials.reduce<number | undefined>((earliest, credential) => {
+ if (credential.status !== 'pending') return earliest;
+ const expiresAt = Date.parse(credential.expiresAt!);
+ return earliest === undefined || expiresAt < earliest ? expiresAt : earliest;
+ }, undefined);
+ if (nextExpiry === undefined) {
+ this.#expiryTimer = undefined;
+ return;
+ }
+ this.#expiryTimer = setTimeout(
+ () => {
+ this.#expiryTimer = undefined;
+ void this.#mutate(() => this.#expirePending()).catch(() => {
+ this.#schedulePendingExpiry(1_000);
+ });
+ },
+ retryMs ?? Math.max(0, nextExpiry - Date.now()),
+ );
+ this.#expiryTimer.unref();
}
#publishRevocation(credentialId: string): void {
@@ -249,6 +329,11 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
}
}
+function activatePendingCredential(credential: StoredAccessCredential): StoredAccessCredential {
+ const { expiresAt: _expiresAt, ...retained } = credential;
+ return { ...retained, status: 'active' };
+}
+
function assertCredentialAuthority(
input: AccessCredentialIssueInput,
operationGrants: readonly string[],
@@ -305,6 +390,24 @@ export async function replaceAccessCredential(
}
}
+export async function prepareAccessCredential(
+ authority: RuntimeHostAccessAuthority | undefined,
+ input: AccessCredentialPrepareInput,
+): Promise<OperationOutcome<'access.credential.prepare'>> {
+ if (!authority) return unavailable('prepare');
+ try {
+ return { ok: true, result: await authority.prepare(input) };
+ } catch (error) {
+ if (error instanceof RuntimeHostAccessInputError) {
+ return { ok: false, error: { code: 'invalid_request', message: error.message } };
+ }
+ return {
+ ok: false,
+ error: { code: 'persistence_failed', message: 'Access credential pairing could not begin' },
+ };
+ }
+}
+
export async function revokeAccessCredential(
authority: RuntimeHostAccessAuthority | undefined,
input: AccessCredentialRevokeInput,
@@ -349,13 +452,15 @@ export async function finalizeAccessCredential(
function unavailable(operation: 'issue'): OperationOutcome<'access.credential.issue'>;
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: 'finalize'): OperationOutcome<'access.credential.finalize'>;
function unavailable(
- _operation: 'issue' | 'replace' | 'revoke' | 'finalize',
+ _operation: 'issue' | 'replace' | 'prepare' | 'revoke' | 'finalize',
):
| OperationOutcome<'access.credential.issue'>
| OperationOutcome<'access.credential.replace'>
+ | OperationOutcome<'access.credential.prepare'>
| OperationOutcome<'access.credential.revoke'>
| OperationOutcome<'access.credential.finalize'> {
return {
diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts
index 167df39fe..a869fbd8c 100644
--- a/packages/runtime-host/src/server/access-credential-store.ts
+++ b/packages/runtime-host/src/server/access-credential-store.ts
@@ -28,11 +28,12 @@ export interface StoredAccessCredential {
readonly credentialHash: string;
readonly principalId: string;
readonly principalKind: AccessCredentialPrincipalKind;
- readonly status: 'active' | 'revoked';
+ readonly status: 'pending' | 'active' | 'revoked';
readonly operationGrants: readonly OperationKey[];
readonly canPublishClientCapabilities: boolean;
readonly canUseHostPaths: boolean;
readonly createdAt: string;
+ readonly expiresAt?: string;
readonly revokedAt?: string;
}
@@ -144,6 +145,12 @@ function decodeAccessFile(value: unknown): AccessCredentialFile {
) {
throw new Error('Duplicate Runtime Host access credential identity');
}
+ const pendingPrincipals = credentials
+ .filter((credential) => credential.status === 'pending')
+ .map((credential) => `${credential.principalKind}:${credential.principalId}`);
+ if (new Set(pendingPrincipals).size !== pendingPrincipals.length) {
+ throw new Error('Duplicate Runtime Host pending credential principal');
+ }
return createAccessCredentialFile(credentials);
}
@@ -158,7 +165,9 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential {
if (principalKind !== 'remote_owner' && principalKind !== 'capability_provider') {
throw new Error('Invalid principalKind');
}
- if (value.status !== 'active' && value.status !== 'revoked') throw new Error('Invalid status');
+ if (value.status !== 'pending' && value.status !== 'active' && value.status !== 'revoked') {
+ throw new Error('Invalid status');
+ }
if (!Array.isArray(value.operationGrants)) throw new Error('Invalid operationGrants');
const storedOperationGrants = value.operationGrants.map((grant) =>
requireStoredString(grant, 'operationGrant'),
@@ -181,6 +190,14 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential {
throw new Error('Invalid access credential authority');
}
const createdAt = requireStoredString(value.createdAt, 'createdAt');
+ const expiresAt = value.expiresAt;
+ if (value.status === 'pending') {
+ if (typeof expiresAt !== 'string' || !Number.isFinite(Date.parse(expiresAt))) {
+ throw new Error('Invalid expiresAt');
+ }
+ } else if (expiresAt !== undefined) {
+ throw new Error('Invalid expiresAt');
+ }
const revokedAt = value.revokedAt;
if (revokedAt !== undefined && typeof revokedAt !== 'string') {
throw new Error('Invalid revokedAt');
@@ -195,6 +212,7 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential {
canPublishClientCapabilities: value.canPublishClientCapabilities,
canUseHostPaths: value.canUseHostPaths,
createdAt,
+ ...(typeof expiresAt === 'string' ? { expiresAt } : {}),
...(revokedAt === undefined ? {} : { revokedAt }),
};
}
diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts
index 710ca9d09..3a70bcd10 100644
--- a/packages/runtime-host/src/server/host-kernel.ts
+++ b/packages/runtime-host/src/server/host-kernel.ts
@@ -40,6 +40,7 @@ import {
import {
issueAccessCredential,
finalizeAccessCredential,
+ prepareAccessCredential,
replaceAccessCredential,
revokeAccessCredential,
type RuntimeHostAccessAuthority,
@@ -626,6 +627,8 @@ export class RuntimeHostKernel {
issueAccessCredential(this.#options.accessAuthority, input),
'access.credential.replace': async (input) =>
replaceAccessCredential(this.#options.accessAuthority, input),
+ 'access.credential.prepare': async (input) =>
+ prepareAccessCredential(this.#options.accessAuthority, input),
'access.credential.revoke': async (input) =>
revokeAccessCredential(this.#options.accessAuthority, input),
'access.credential.finalize': async (_input, context) =>
diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts
index 34e335beb..986bd6f31 100644
--- a/packages/runtime-host/src/server/operation-dispatcher.ts
+++ b/packages/runtime-host/src/server/operation-dispatcher.ts
@@ -252,6 +252,13 @@ export function createUnavailableAccessAuthorityOperationHandlers(): AccessAutho
message: 'Runtime Host access credentials are unavailable',
},
}),
+ 'access.credential.prepare': async () => ({
+ ok: false,
+ error: {
+ code: 'operation_unavailable',
+ message: 'Runtime Host access credentials are unavailable',
+ },
+ }),
'access.credential.revoke': async () => ({
ok: false,
error: {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org