Skip to content
Open
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
34 changes: 34 additions & 0 deletions packages/cli/src/__tests__/runtime-host-operator-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,40 @@ describe('Runtime Host operator commands', () => {
]).kind,
'error',
);
assert.deepEqual(
parseRuntimeHostCommand([
'access',
'issue',
'--kind',
'capability-provider',
'--principal',
'terminal-mcp-provider',
'--capability-owner-credential',
'terminal-owner-credential',
]),
{
kind: 'runtime-host-access-issue',
principalKind: 'capability_provider',
principalId: 'terminal-mcp-provider',
operationGrants: ['client.capability.replace', 'client.capability.unregister'],
canPublishClientCapabilities: true,
canUseHostPaths: false,
capabilityOwnerCredentialId: 'terminal-owner-credential',
},
);
assert.equal(
parseRuntimeHostCommand([
'access',
'issue',
'--principal',
'terminal-owner',
'--grant',
'session.catalog.query',
'--capability-owner-credential',
'terminal-owner-credential',
]).kind,
'error',
);
assert.deepEqual(
parseRuntimeHostCommand([
'access',
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ function helpText(cliCommand: string): string {
' --preset <name> Grant the desktop-client or terminal-client operation set',
' --publish-client-capabilities Allow Client Capability publication',
' --allow-host-paths Allow operations that submit Host paths',
' --capability-owner-credential <id> Bind a provider to one Client-bound owner credential',
'',
'Runtime Host capability provider options:',
' --url <ws-url> Connect to an authenticated Runtime Host WebSocket',
Expand Down Expand Up @@ -624,6 +625,9 @@ export async function runMakaCli(
operationGrants: command.operationGrants,
canPublishClientCapabilities: command.canPublishClientCapabilities,
canUseHostPaths: command.canUseHostPaths,
...(command.capabilityOwnerCredentialId
? { capabilityOwnerCredentialId: command.capabilityOwnerCredentialId }
: {}),
...(command.preset ? { preset: command.preset } : {}),
});
}
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/runtime-host-access-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
RUNTIME_HOST_PROTOCOL_VERSION,
type AccessCredentialRotationRevokeInput,
type AccessCredentialPrincipalKind,
type ClientCapabilityOwnerIdentity,
type OperationKey,
} from '@maka/runtime-host/protocol';
import {
Expand Down Expand Up @@ -63,6 +64,7 @@ export interface RuntimeHostAccessIssueOptions {
readonly operationGrants: readonly string[];
readonly canPublishClientCapabilities: boolean;
readonly canUseHostPaths: boolean;
readonly capabilityOwnerCredentialId?: string;
readonly preset?: RuntimeHostAccessPreset;
readonly bindClientInstance?: boolean;
}
Expand All @@ -74,6 +76,7 @@ export interface ResolvedRuntimeHostAccessIssue {
readonly operationGrants: readonly OperationKey[];
readonly canPublishClientCapabilities: boolean;
readonly canUseHostPaths: boolean;
readonly capabilityOwner?: ClientCapabilityOwnerIdentity;
}

const CLIENT_CAPABILITY_PUBLICATION_OPERATIONS = new Set<OperationKey>([
Expand Down Expand Up @@ -228,6 +231,9 @@ async function mutateRuntimeHostAccessCredential(
operationGrants: resolved.operationGrants,
canPublishClientCapabilities: resolved.canPublishClientCapabilities,
canUseHostPaths: resolved.canUseHostPaths,
...(operation !== 'access.credential.prepare' && options.capabilityOwnerCredentialId
? { capabilityOwnerCredentialId: options.capabilityOwnerCredentialId }
: {}),
...(operation === 'access.credential.prepare' && options.bindClientInstance
? { bindClientInstance: true }
: {}),
Expand Down
22 changes: 19 additions & 3 deletions packages/cli/src/runtime-host-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export type RuntimeHostCliCommand =
operationGrants: string[];
canPublishClientCapabilities: boolean;
canUseHostPaths: boolean;
capabilityOwnerCredentialId?: string;
preset?: 'desktop-client' | 'terminal-client';
}
| {
Expand Down Expand Up @@ -1945,6 +1946,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
let principalKind: 'remote_owner' | 'capability_provider' = 'remote_owner';
let principalKindSpecified = false;
let credentialId: string | undefined;
let capabilityOwnerCredentialId: string | undefined;
let currentCredentialFingerprint: string | undefined;
const operationGrants: string[] = [];
let canPublishClientCapabilities = false;
Expand Down Expand Up @@ -1973,6 +1975,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
argument === '--principal' ||
argument === '--grant' ||
argument === '--credential' ||
argument === '--capability-owner-credential' ||
argument === '--current-fingerprint'
) {
const parsed = optionValue(argv, index, argument);
Expand All @@ -1995,6 +1998,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
if (argument === '--principal') principalId = parsed;
if (argument === '--grant') operationGrants.push(parsed);
if (argument === '--credential') credentialId = parsed;
if (argument === '--capability-owner-credential') capabilityOwnerCredentialId = parsed;
if (argument === '--current-fingerprint') currentCredentialFingerprint = parsed;
index += 1;
continue;
Expand All @@ -2013,6 +2017,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
canUseHostPaths ||
preset ||
credentialId ||
capabilityOwnerCredentialId ||
currentCredentialFingerprint
) {
return error('Credential mutation options are not valid for access list');
Expand All @@ -2037,7 +2042,8 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
canPublishClientCapabilities ||
canUseHostPaths ||
preset ||
credentialId
credentialId ||
capabilityOwnerCredentialId
) {
return error('Credential issue options are not valid for access prepare');
}
Expand All @@ -2064,6 +2070,9 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
return error('--preset cannot be combined with --kind, --grant, or authority flags');
}
if (preset) {
if (capabilityOwnerCredentialId) {
return error('--capability-owner-credential requires --kind capability-provider');
}
return {
kind: 'runtime-host-access-issue',
...(rootPath ? { rootPath } : {}),
Expand All @@ -2087,8 +2096,11 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
return error('A capability provider may grant only Client Capability publication');
}
canPublishClientCapabilities = true;
} else if (operationGrants.length === 0) {
return error('At least one --grant is required');
} else {
if (capabilityOwnerCredentialId) {
return error('--capability-owner-credential requires --kind capability-provider');
}
if (operationGrants.length === 0) return error('At least one --grant is required');
}
return {
kind: 'runtime-host-access-issue',
Expand All @@ -2099,8 +2111,12 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand {
operationGrants,
canPublishClientCapabilities,
canUseHostPaths,
...(capabilityOwnerCredentialId ? { capabilityOwnerCredentialId } : {}),
};
}
if (capabilityOwnerCredentialId) {
return error('--capability-owner-credential is only valid for access issue');
}
if (!credentialId) return error('--credential is required');
if (framed && !currentCredentialFingerprint) {
return error('--current-fingerprint is required for framed access revoke');
Expand Down
109 changes: 109 additions & 0 deletions packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,115 @@ test('access credentials persist only as hashes and stay revoked after reload',
}
});

test('capability-provider credentials retain a Host-verified Client owner identity', async () => {
const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-provider-owner-'));
try {
const authority = await openRuntimeHostAccessAuthority(directory);
const owner = await authority.prepare({
principalKind: 'remote_owner',
principalId: 'terminal-owner',
operationGrants: ['access.credential.finalize', 'session.catalog.query'],
canPublishClientCapabilities: false,
canUseHostPaths: false,
bindClientInstance: true,
});
await authority.finalize(owner.credentialId, 'terminal-client');
const unboundOwner = await authority.issue({
principalKind: 'remote_owner',
principalId: 'unbound-owner',
operationGrants: ['session.catalog.query'],
canPublishClientCapabilities: false,
canUseHostPaths: false,
});
const providerInput = {
principalKind: 'capability_provider' as const,
principalId: 'terminal-mcp-provider',
operationGrants: ['client.capability.replace', 'client.capability.unregister'] as const,
canPublishClientCapabilities: true,
canUseHostPaths: false,
};
const expectedOwner = {
principalId: 'terminal-owner',
clientInstanceId: 'terminal-client',
} as const;
await assert.rejects(
authority.issue({
...providerInput,
capabilityOwnerCredentialId: unboundOwner.credentialId,
}),
/must be bound to one Client identity/u,
);
await assert.rejects(
authority.issue({
principalKind: 'remote_owner',
principalId: 'invalid-owner-reference',
operationGrants: ['session.catalog.query'],
canPublishClientCapabilities: false,
canUseHostPaths: false,
capabilityOwnerCredentialId: owner.credentialId,
}),
/Only a capability provider/u,
);

const provider = await authority.issue({
...providerInput,
capabilityOwnerCredentialId: owner.credentialId,
});
assert.deepEqual(provider.capabilityOwner, expectedOwner);
assert.equal(
JSON.parse(await readFile(join(directory, 'runtime-host-access.json'), 'utf8')).schemaVersion,
2,
);
const { consumeAccessCredentialDeliveryFromControlDirectory } = await import(
'../control/access-credential-delivery.js'
);
const credential = await consumeAccessCredentialDeliveryFromControlDirectory(
directory,
provider.deliveryId,
provider.credentialId,
);
assert.deepEqual(authority.authenticate(credential)?.capabilityOwner, expectedOwner);

const replacementOwner = await authority.prepareRotation({
replacementOfCredentialId: owner.credentialId,
});
await authority.finalize(replacementOwner.credentialId, 'terminal-client');
const replacementProvider = await authority.issue({
...providerInput,
principalId: 'rotated-terminal-mcp-provider',
capabilityOwnerCredentialId: replacementOwner.credentialId,
});
assert.deepEqual(replacementProvider.capabilityOwner, provider.capabilityOwner);

const reopened = await openRuntimeHostAccessAuthority(directory);
assert.deepEqual(reopened.authenticate(credential)?.capabilityOwner, expectedOwner);
await assert.rejects(
reopened.issue({
...providerInput,
principalId: 'missing-owner-provider',
capabilityOwnerCredentialId: 'missing-owner-credential',
}),
/active remote-owner credential/u,
);
await authority.close();
await reopened.close();
const downgraded = JSON.parse(
await readFile(join(directory, 'runtime-host-access.json'), 'utf8'),
) as Record<string, unknown>;
await writeFile(
join(directory, 'runtime-host-access.json'),
`${JSON.stringify({ ...downgraded, schemaVersion: 1 })}\n`,
{ mode: 0o600 },
);
await assert.rejects(
openRuntimeHostAccessAuthority(directory),
/Legacy Runtime Host access files cannot declare Client Capability owners/u,
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});

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);
Expand Down
Loading
Loading