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
48 changes: 46 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:
release_contract: ${{ steps.plan.outputs.release_contract }}
runtime_host: ${{ steps.plan.outputs.runtime_host }}
runtime_sandbox: ${{ steps.plan.outputs.runtime_sandbox }}
state_root_compat: ${{ steps.plan.outputs.state_root_compat }}
storage_stress: ${{ steps.plan.outputs.storage_stress }}
storybook: ${{ steps.plan.outputs.storybook }}
standard_workspaces: ${{ steps.plan.outputs.standard_workspaces }}
Expand Down Expand Up @@ -156,13 +157,13 @@ jobs:
restore-keys: electron-${{ runner.os }}-

- name: Install Linux runtime dependencies
if: needs.plan.outputs.runtime_sandbox == 'true'
if: needs.plan.outputs.runtime_sandbox == 'true' || needs.plan.outputs.state_root_compat == 'true'
run: sudo apt-get update && sudo apt-get install -y ripgrep bubblewrap

# Ubuntu 24.04 hosted runners gate unprivileged user namespaces through
# AppArmor, which otherwise makes bwrap fail while configuring loopback.
- name: Enable bubblewrap user namespaces
if: needs.plan.outputs.runtime_sandbox == 'true'
if: needs.plan.outputs.runtime_sandbox == 'true' || needs.plan.outputs.state_root_compat == 'true'
run: |
if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
Expand Down Expand Up @@ -280,6 +281,49 @@ jobs:
if: needs.plan.outputs.runtime_host == 'true'
run: npm --workspace @maka/runtime-host run test:dist

# A published predecessor writes the durable state; the workspace built
# above reads it. Release packaging is deliberately not in front of this:
# it takes minutes and changes nothing about whether these decoders can
# read that state. The release lanes still qualify exact tarballs.
- id: forward-roll-baseline
name: Resolve the published forward-roll baseline
if: needs.plan.outputs.state_root_compat == 'true'
run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT"

- name: Download the forward-roll baseline
if: needs.plan.outputs.state_root_compat == 'true'
env:
SOURCE_URL: ${{ steps.forward-roll-baseline.outputs.tarball_url }}
SOURCE_INTEGRITY: ${{ steps.forward-roll-baseline.outputs.integrity }}
run: |
set -euo pipefail
source_path="$RUNNER_TEMP/forward-roll-source.tgz"
curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 \
--retry 3 --retry-connrefused --retry-delay 2 "$SOURCE_URL" --output "$source_path"
node - "$source_path" "$SOURCE_INTEGRITY" <<'NODE'
const { createHash } = require('node:crypto');
const { readFileSync } = require('node:fs');
const bytes = readFileSync(process.argv[2]);
const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
if (actual !== process.argv[3]) throw new Error('Forward-roll baseline integrity mismatch');
NODE
{
echo "FORWARD_ROLL_SOURCE=$source_path"
echo "FORWARD_ROLL_SOURCE_SHA256=$(sha256sum "$source_path" | cut -d ' ' -f 1)"
} >> "$GITHUB_ENV"

- name: Qualify durable state against the published baseline
if: needs.plan.outputs.state_root_compat == 'true'
env:
MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1'
run: |
set -o pipefail
npm run --silent release:cli:qualify-state-root -- \
--source "$FORWARD_ROLL_SOURCE" \
--source-sha256 "$FORWARD_ROLL_SOURCE_SHA256" \
--target-workspace "$PWD" \
| tee "$RUNNER_TEMP/durable-state-report.json"

- name: Ensure xvfb
if: needs.plan.outputs.e2e == 'true'
run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/*
* 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, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import {
ACCESS_FILE_NAME,
effectiveOperationGrants,
issuedAccessGrants,
readAccessCredentialFile,
unresolvedPersistedGrants,
writeAccessCredentialFile,
} from '../server/access-credential-store.js';

// These fixtures are hand-written JSON rather than output from the current
// writer on purpose. The subject is a file some earlier release left behind, so
// round-tripping today's encoder would only prove it agrees with itself.
async function writeAccessFile(contents: unknown): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'maka-access-migration-'));
const path = join(directory, ACCESS_FILE_NAME);
await writeFile(path, `${JSON.stringify(contents, null, 2)}\n`, 'utf8');
return path;
}

function storedCredential(operationGrants: readonly string[]): Record<string, unknown> {
return {
credentialId: 'c8f6a0f4-0d5a-4a2e-9a1a-3f3a5c8d1b20',
credentialHash: 'a'.repeat(64),
principalId: 'released-client',
principalKind: 'remote_owner',
status: 'active',
operationGrants,
canPublishClientCapabilities: false,
canUseHostPaths: false,
createdAt: '2026-01-01T00:00:00.000Z',
};
}

test('a renamed operation carries its stored authority to the successor', async () => {
const path = await writeAccessFile({
schemaVersion: 3,
credentials: [storedCredential(['host.status', 'task.ledger.query'])],
sessionGrants: [],
turnAccessRequests: [],
});

const file = await readAccessCredentialFile(path);
const credential = file.credentials[0];
assert.ok(credential);
assert.deepEqual(credential.grants, ['host.status', 'session.todo.query']);
assert.deepEqual(effectiveOperationGrants(credential), ['host.status', 'session.todo.query']);
assert.deepEqual(unresolvedPersistedGrants(file), []);
});

test('an unregistered grant opens the file and stays in the record', async () => {
const path = await writeAccessFile({
schemaVersion: 3,
credentials: [storedCredential(['host.status', 'session.futures.query'])],
sessionGrants: [],
turnAccessRequests: [],
});

const file = await readAccessCredentialFile(path);
const credential = file.credentials[0];
assert.ok(credential);
// The record keeps it — erasing it here is what a later unrelated write would
// make permanent.
assert.deepEqual(credential.grants, ['host.status', 'session.futures.query']);
// The authority does not, because this build cannot serve it.
assert.deepEqual(effectiveOperationGrants(credential), ['host.status']);
// And it is reported, because no migration entry accounts for it.
assert.deepEqual(unresolvedPersistedGrants(file), ['session.futures.query']);
});

test('an unaccountable grant survives a rewrite of the file', async () => {
const path = await writeAccessFile({
schemaVersion: 3,
credentials: [storedCredential(['host.status', 'session.futures.query'])],
sessionGrants: [],
turnAccessRequests: [],
});

const file = await readAccessCredentialFile(path);
await writeAccessCredentialFile(path, file);
const rewritten = JSON.parse(await readFile(path, 'utf8'));

// The published key keeps its name, and the grant this build could not
// account for is still under it.
assert.deepEqual(rewritten.credentials[0].operationGrants, [
'host.status',
'session.futures.query',
]);
assert.equal(rewritten.credentials[0].grants, undefined);
});

test('a released operation is dropped from the record and reported as accounted for', async () => {
const path = await writeAccessFile({
schemaVersion: 3,
credentials: [storedCredential(['host.status', 'execution.inspect.resolve'])],
sessionGrants: [],
turnAccessRequests: [],
});

const file = await readAccessCredentialFile(path);
assert.deepEqual(file.credentials[0]?.grants, ['host.status']);
assert.deepEqual(unresolvedPersistedGrants(file), []);
});

test('a Session Guest holds the current guest policy, not what its record says', async () => {
const path = await writeAccessFile({
schemaVersion: 3,
credentials: [
{
...storedCredential(['host.status', 'session.futures.query']),
principalId: 'session_guest:8f2c1d3e-4b5a-6c7d-8e9f-0a1b2c3d4e5f',
principalKind: 'session_guest',
},
],
sessionGrants: [],
turnAccessRequests: [],
});

const file = await readAccessCredentialFile(path);
const credential = file.credentials[0];
assert.ok(credential);
// Widened to compare against a key the current protocol does not define —
// which the derived type will not admit, itself part of what is under test.
const effective: readonly string[] = effectiveOperationGrants(credential);
assert.ok(effective.includes('session.shared.query'));
assert.ok(!effective.includes('session.futures.query'));
});

test('a local-owner-only operation is withheld from a remote credential but kept on file', async () => {
const path = await writeAccessFile({
schemaVersion: 3,
credentials: [storedCredential(['host.status', 'access.credential.issue'])],
sessionGrants: [],
turnAccessRequests: [],
});

const file = await readAccessCredentialFile(path);
const credential = file.credentials[0];
assert.ok(credential);
assert.deepEqual(credential.grants, ['host.status', 'access.credential.issue']);
assert.deepEqual(effectiveOperationGrants(credential), ['host.status']);
// Policy contraction is not a missing migration, so it is not unresolved.
assert.deepEqual(unresolvedPersistedGrants(file), []);
});

test('a schema 1 file opens without the members later versions added', async () => {
const path = await writeAccessFile({
schemaVersion: 1,
credentials: [storedCredential(['host.status'])],
});

const file = await readAccessCredentialFile(path);
assert.equal(file.credentials.length, 1);
assert.deepEqual(file.sessionGrants, []);
assert.deepEqual(file.turnAccessRequests, []);
});

test('issuance still refuses an operation the protocol does not define', () => {
assert.throws(
() => issuedAccessGrants(['not.an.operation' as never]),
/Unknown Runtime Host operation grant/,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ test('credential metadata exposes only usable public access state', async (t) =>
principalId: `${credentialId}-client`,
principalKind: 'remote_owner',
status,
operationGrants: ['host.status'],
grants: ['host.status'],
canPublishClientCapabilities: false,
canUseHostPaths: false,
createdAt: '2026-08-22T00:00:00.000Z',
Expand Down Expand Up @@ -168,5 +168,5 @@ test('releases a retired execution.inspect.resolve grant from an existing access
);

const file = await readAccessCredentialFile(path);
assert.deepEqual(file.credentials[0]?.operationGrants, ['host.status']);
assert.deepEqual(file.credentials[0]?.grants, ['host.status']);
});
27 changes: 15 additions & 12 deletions packages/runtime-host/src/server/access-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ import {
import {
ACCESS_FILE_NAME,
assertAccessCredentialFileCapacity,
CAPABILITY_PROVIDER_OPERATION_GRANTS,
createAccessCredentialFile,
effectiveOperationGrants,
issuedAccessGrants,
readAccessCredentialFile,
RuntimeHostAccessCommitOutcomeUnknownError,
Expand All @@ -83,11 +85,7 @@ import {
const ACCESS_CREDENTIAL_PREFIX = 'maka_rh_';
const PENDING_CREDENTIAL_LIFETIME_MS = 15 * 60_000;
const TURN_ACCESS_REQUEST_ACTIVE_MAX = 4;
const CAPABILITY_PROVIDER_GRANTS = new Set([
'host.status',
'client.capability.replace',
'client.capability.unregister',
]);
const CAPABILITY_PROVIDER_GRANTS = new Set<string>(CAPABILITY_PROVIDER_OPERATION_GRANTS);

function createNextAccessCredentialFile(
current: AccessCredentialFile,
Expand Down Expand Up @@ -231,8 +229,8 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
operationGrants: match.bindClientInstanceOnFinalize
? ['host.status', 'access.credential.finalize']
: match.clientInstanceId
? [...match.operationGrants, 'access.credential.finalize']
: match.operationGrants,
? [...effectiveOperationGrants(match), 'access.credential.finalize']
: effectiveOperationGrants(match),
canPublishClientCapabilities:
!match.bindClientInstanceOnFinalize && match.canPublishClientCapabilities,
canUseHostPaths: !match.bindClientInstanceOnFinalize && match.canUseHostPaths,
Expand Down Expand Up @@ -271,7 +269,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
principalId,
principalKind: 'session_guest',
status: 'pending',
operationGrants: SESSION_GUEST_OPERATION_GRANTS,
grants: SESSION_GUEST_OPERATION_GRANTS,
canPublishClientCapabilities: false,
canUseHostPaths: false,
createdAt,
Expand Down Expand Up @@ -617,13 +615,13 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
{
principalId: current.principalId,
principalKind: current.principalKind,
operationGrants: current.operationGrants,
operationGrants: effectiveOperationGrants(current),
canPublishClientCapabilities: current.canPublishClientCapabilities,
canUseHostPaths: current.canUseHostPaths,
bindClientInstance: current.clientInstanceId !== undefined,
},
'prepare',
current.operationGrants,
current.grants,
);
});
}
Expand All @@ -638,8 +636,13 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
async #createCredential(
input: AccessCredentialIssueInput | AccessCredentialPrepareInput,
mode: 'issue' | 'replace' | 'prepare',
operationGrants = issuedAccessGrants(input.operationGrants),
// Rotation exchanges the secret and keeps the authority, so it hands over
// the predecessor's record verbatim — including keys this build cannot
// account for. Every other path records exactly what it just issued.
inheritedGrants?: readonly string[],
): Promise<AccessCredentialIssueResult> {
const operationGrants = issuedAccessGrants(input.operationGrants);
const grants = inheritedGrants ?? operationGrants;
assertCredentialAuthority(input, operationGrants);
const capabilityOwner = this.#resolveCapabilityOwner(
'capabilityOwnerCredentialId' in input ? input.capabilityOwnerCredentialId : undefined,
Expand Down Expand Up @@ -671,7 +674,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
principalId: input.principalId,
principalKind: input.principalKind,
status: mode === 'prepare' ? 'pending' : 'active',
operationGrants,
grants,
canPublishClientCapabilities: input.canPublishClientCapabilities,
canUseHostPaths: input.canUseHostPaths,
...(capabilityOwner ? { capabilityOwner } : {}),
Expand Down
10 changes: 8 additions & 2 deletions packages/runtime-host/src/server/access-credential-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import {
resolveExistingStorageRoot,
} from '@maka/storage/root-authority';
import type { OperationKey } from '../protocol/index.js';
import { ACCESS_FILE_NAME, readAccessCredentialFile } from './access-credential-store.js';
import {
ACCESS_FILE_NAME,
effectiveOperationGrants,
readAccessCredentialFile,
} from './access-credential-store.js';

export interface RuntimeHostAccessCredentialMetadata {
readonly credentialId: string;
Expand Down Expand Up @@ -72,7 +76,9 @@ export async function readRuntimeHostAccessCredentialMetadata(
principalKind: credential.principalKind,
principalId: credential.principalId,
status: credential.status,
operationGrants: credential.operationGrants,
// What the credential can exercise on this build, not what its record
// happens to hold — an operator reading this is asking about now.
operationGrants: effectiveOperationGrants(credential),
canPublishClientCapabilities: credential.canPublishClientCapabilities,
canUseHostPaths: credential.canUseHostPaths,
createdAt: credential.createdAt,
Expand Down
Loading
Loading