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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/cli/src/runtime-host-access-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
REMOTE_OWNER_OPERATION_GRANTS,
RUNTIME_HOST_PROTOCOL_VERSION,
type AccessCredentialRotationRevokeInput,
type AccessCredentialPrincipalKind,
type ManagedAccessCredentialPrincipalKind,
type OperationKey,
} from '@maka/runtime-host/protocol';
import {
Expand Down Expand Up @@ -58,7 +58,7 @@ export class RuntimeHostAccessUnavailableError extends Error {
export interface RuntimeHostAccessIssueOptions {
readonly rootPath: string;
readonly expectedRootId?: string;
readonly principalKind: AccessCredentialPrincipalKind;
readonly principalKind: ManagedAccessCredentialPrincipalKind;
readonly principalId: string;
readonly operationGrants: readonly string[];
readonly canPublishClientCapabilities: boolean;
Expand All @@ -70,7 +70,7 @@ export interface RuntimeHostAccessIssueOptions {
export type RuntimeHostAccessPreset = 'desktop-client' | 'terminal-client';

export interface ResolvedRuntimeHostAccessIssue {
readonly principalKind: AccessCredentialPrincipalKind;
readonly principalKind: ManagedAccessCredentialPrincipalKind;
readonly operationGrants: readonly OperationKey[];
readonly canPublishClientCapabilities: boolean;
readonly canUseHostPaths: boolean;
Expand Down Expand Up @@ -99,7 +99,7 @@ export interface IssuedRuntimeHostAccessCredential {
readonly rootId: string;
readonly credential: string;
readonly credentialId: string;
readonly principalKind: AccessCredentialPrincipalKind;
readonly principalKind: ManagedAccessCredentialPrincipalKind;
readonly principalId: string;
readonly operationGrants: readonly OperationKey[];
readonly canPublishClientCapabilities: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,13 @@ test('credential metadata exposes only usable public access state', async (t) =>
new Date(Date.now() - 60_000).toISOString(),
);
const revoked = credential('revoked', revokedSecret, 'revoked');
const guest = {
...credential('guest', 'maka_rh_guest_secret', 'active'),
principalKind: 'session_guest' as const,
};
await writeAccessCredentialFile(
join(owner.controlDirectory, ACCESS_FILE_NAME),
createAccessCredentialFile([active, pending, expired, revoked]),
createAccessCredentialFile([active, pending, expired, revoked, guest]),
);

const metadata = await readRuntimeHostAccessCredentialMetadata(root, capability.rootId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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 { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { decodeCollaborationInvitationCode } from '../protocol/index.js';
import { openRuntimeHostAccessAuthority } from '../server/access-authority.js';

test('Session Guest invitation, grants, and revocation form one durable authority lifecycle', async () => {
const directory = await mkdtemp(join(tmpdir(), 'maka-session-collaboration-'));
const authority = await openRuntimeHostAccessAuthority(directory);
try {
const prepared = await authority.prepareCollaborationInvitation('root-1', {
sessionId: 'session-1',
grantKinds: ['session_observation', 'session_turn_request'],
});
const invitation = decodeCollaborationInvitationCode(prepared.invitationCode);

assert.deepEqual(authority.authenticate(invitation.credential)?.operationGrants, [
'host.status',
'access.credential.finalize',
]);
const credentialId = authority.authenticate(invitation.credential)?.credentialId;
assert.ok(credentialId);
await authority.finalize(credentialId, 'guest-client');
assert.deepEqual(authority.authenticate(invitation.credential)?.operationGrants, [
'host.status',
]);

const observation = prepared.grants.find((grant) => grant.kind === 'session_observation')!;
assert.equal(
authority.activeSessionGrant(prepared.principalId, 'session-1', 'session_observation')
?.grantId,
observation.grantId,
);
assert.equal(
(await authority.revokeCollaborationGrant({ grantId: observation.grantId })).revoked,
true,
);
assert.equal(
authority.activeSessionGrant(prepared.principalId, 'session-1', 'session_observation'),
undefined,
);

assert.deepEqual(await authority.revokeCollaborationPrincipal(prepared.principalId), {
revoked: true,
});
assert.equal(authority.authenticate(invitation.credential), undefined);
assert.equal(authority.queryCollaborationAccess({ sessionId: 'session-1' }).grants.length, 0);
} finally {
await authority.close();
await rm(directory, { recursive: true, force: true });
}
});
12 changes: 1 addition & 11 deletions packages/runtime-host/src/__tests__/websocket-listener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ test('WebSocket admission, health, Origin, and message policy fail closed', {
test('credential revocation during WebSocket upgrade cannot admit stale authority', async () => {
let credentialActive = true;
let accepted = false;
const authority: RuntimeHostAccessAuthority = {
const authority: Pick<RuntimeHostAccessAuthority, 'authenticate'> = {
authenticate: () => {
if (!credentialActive) return undefined;
credentialActive = false;
Expand All @@ -186,16 +186,6 @@ test('credential revocation during WebSocket upgrade cannot admit stale authorit
canUseHostPaths: false,
});
},
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'),
revokePrincipal: async () => assert.fail('Principal 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,
};
const listener = await startRuntimeHostWebSocketListener({
host: '127.0.0.1',
Expand Down
24 changes: 19 additions & 5 deletions packages/runtime-host/src/protocol/access-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@ import type { OperationKey } from './operations.js';

export const ACCESS_CREDENTIAL_MAX_GRANTS = 256;

export type AccessCredentialPrincipalKind = 'remote_owner' | 'capability_provider';
export type AccessCredentialPrincipalKind =
| 'remote_owner'
| 'capability_provider'
| 'session_guest';
export type ManagedAccessCredentialPrincipalKind = Exclude<
AccessCredentialPrincipalKind,
'session_guest'
>;

const ACCESS_ERRORS = [
'host_not_ready',
Expand All @@ -43,7 +50,7 @@ const ACCESS_ERRORS = [
] as const;

export interface AccessCredentialIssueInput {
readonly principalKind: AccessCredentialPrincipalKind;
readonly principalKind: ManagedAccessCredentialPrincipalKind;
readonly principalId: string;
readonly operationGrants: readonly OperationKey[];
readonly canPublishClientCapabilities: boolean;
Expand All @@ -53,7 +60,7 @@ export interface AccessCredentialIssueInput {
export interface AccessCredentialIssueResult {
readonly credentialId: string;
readonly deliveryId: string;
readonly principalKind: AccessCredentialPrincipalKind;
readonly principalKind: ManagedAccessCredentialPrincipalKind;
readonly principalId: string;
readonly operationGrants: readonly OperationKey[];
readonly canPublishClientCapabilities: boolean;
Expand Down Expand Up @@ -266,13 +273,20 @@ export function decodeAccessCredentialIssueResult(value: unknown): AccessCredent
};
}

function principalKind(value: unknown): AccessCredentialPrincipalKind {
function principalKind(value: unknown): ManagedAccessCredentialPrincipalKind {
if (value !== 'remote_owner' && value !== 'capability_provider') {
throw invalidProtocolFrame('Invalid access credential principalKind');
}
return value;
}

function revocablePrincipalKind(value: unknown): AccessCredentialPrincipalKind {
if (value !== 'remote_owner' && value !== 'capability_provider' && value !== 'session_guest') {
throw invalidProtocolFrame('Invalid access credential principalKind');
}
return value;
}

export function decodeAccessCredentialRevokeInput(value: unknown): AccessCredentialRevokeInput {
const record = requireExactRecord(value, 'access credential revoke input', ['credentialId']);
return { credentialId: requireId(record.credentialId, 'credentialId') };
Expand All @@ -284,7 +298,7 @@ export function decodeAccessPrincipalRevokeInput(value: unknown): AccessPrincipa
'principalId',
]);
return {
principalKind: principalKind(record.principalKind),
principalKind: revocablePrincipalKind(record.principalKind),
principalId: principalId(record.principalId),
};
}
Expand Down
6 changes: 5 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export * from './operations.js';
export * from './runtime-resource.js';
export * from './session-continuity.js';
export * from './session-catalog-change.js';
export * from './session-collaboration.js';
export * from './scheduled-task-change.js';
export * from './session-retirement.js';
export * from './session-transcript.js';
Expand All @@ -94,7 +95,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 68 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 69 as const;
// 69: Runtime Host access authority recognizes restricted Session Guest
// principals and typed Session collaboration grants. Older Hosts would either
// reject the new operations or misclassify the authenticated principal.
// 68: Connection onboarding replaces nullable canonical-slug targeting with
// explicit create/existing identity and returns the committed Connection.
// Older peers reject the closed target and saved-result shapes.
Expand Down
7 changes: 7 additions & 0 deletions packages/runtime-host/src/protocol/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { SESSION_CATALOG_OPERATION_SPECS } from './session-catalog.js';
import { SESSION_CONTINUITY_OPERATION_SPECS } from './session-continuity.js';
import { SESSION_TRANSCRIPT_OPERATION_SPECS } from './session-transcript.js';
import { SESSION_TURNS_OPERATION_SPECS } from './session-turns.js';
import { SESSION_COLLABORATION_OPERATION_SPECS } from './session-collaboration.js';
import { SESSION_REVISION_OPERATION_SPECS } from './session-revision.js';
import { SESSION_RETIREMENT_OPERATION_SPECS } from './session-retirement.js';
import { SESSION_EFFECT_OPERATION_SPECS } from './session-effects.js';
Expand Down Expand Up @@ -166,6 +167,7 @@ export * from './runtime-policy.js';
export * from './runtime-resource.js';
export * from './scheduled-task.js';
export * from './session-catalog.js';
export * from './session-collaboration.js';
export * from './session-revision.js';
export * from './session-retirement.js';
export * from './session-transcript.js';
Expand All @@ -181,6 +183,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps(
PEER_MESH_OPERATION_SPECS,
HOSTED_EXECUTION_OPERATION_SPECS,
ACCESS_AUTHORITY_OPERATION_SPECS,
SESSION_COLLABORATION_OPERATION_SPECS,
AGENT_GRAPH_OPERATION_SPECS,
GOAL_OPERATION_SPECS,
TURN_OPERATION_SPECS,
Expand Down Expand Up @@ -234,6 +237,10 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([
'client.capability.replace',
'client.capability.unregister',
'configuration.credentials.export',
'collaboration.access.query',
'collaboration.grant.revoke',
'collaboration.invitation.prepare',
'collaboration.principal.revoke',
'connection.catalog.create',
'connection.catalog.query',
'connection.catalog.remove',
Expand Down
Loading