diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64e1340252..b96b70a3c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} @@ -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 @@ -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; } diff --git a/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts b/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts new file mode 100644 index 0000000000..0f94946fce --- /dev/null +++ b/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts @@ -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 { + 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 { + 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/, + ); +}); diff --git a/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts b/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts index a89f2257a7..152b581f66 100644 --- a/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts +++ b/packages/runtime-host/src/__tests__/access-credential-metadata.test.ts @@ -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', @@ -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']); }); diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts index 922162afb1..2d7176ce28 100644 --- a/packages/runtime-host/src/server/access-authority.ts +++ b/packages/runtime-host/src/server/access-authority.ts @@ -69,7 +69,9 @@ import { import { ACCESS_FILE_NAME, assertAccessCredentialFileCapacity, + CAPABILITY_PROVIDER_OPERATION_GRANTS, createAccessCredentialFile, + effectiveOperationGrants, issuedAccessGrants, readAccessCredentialFile, RuntimeHostAccessCommitOutcomeUnknownError, @@ -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(CAPABILITY_PROVIDER_OPERATION_GRANTS); function createNextAccessCredentialFile( current: AccessCredentialFile, @@ -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, @@ -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, @@ -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, ); }); } @@ -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 { + const operationGrants = issuedAccessGrants(input.operationGrants); + const grants = inheritedGrants ?? operationGrants; assertCredentialAuthority(input, operationGrants); const capabilityOwner = this.#resolveCapabilityOwner( 'capabilityOwnerCredentialId' in input ? input.capabilityOwnerCredentialId : undefined, @@ -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 } : {}), diff --git a/packages/runtime-host/src/server/access-credential-metadata.ts b/packages/runtime-host/src/server/access-credential-metadata.ts index d29e56c0c3..964fba6ece 100644 --- a/packages/runtime-host/src/server/access-credential-metadata.ts +++ b/packages/runtime-host/src/server/access-credential-metadata.ts @@ -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; @@ -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, diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index d45860c692..dfdc7e1a49 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -37,31 +37,47 @@ import { const ACCESS_FILE_SCHEMA_VERSION = 4; const PRE_CAPABILITY_OWNER_ACCESS_FILE_SCHEMA_VERSION = 3; const ACCESS_FILE_MAX_BYTES = 512 * 1024; -const LEGACY_TRANSCRIPT_QUERY_GRANT = 'session.transcript.query'; -const TRANSCRIPT_QUERY_REPLACEMENT_GRANTS = [ - 'session.transcript.page', - 'session.transcript.overlay.release', -] as const satisfies readonly OperationKey[]; -const TURN_QUERY_GRANT = 'session.turns.query'; -const TURN_QUERY_REPLACEMENT_GRANTS = [ - TURN_QUERY_GRANT, - 'session.turn_landmarks.query', -] as const satisfies readonly OperationKey[]; -const LEGACY_TASK_LEDGER_QUERY_GRANT = 'task.ledger.query'; -const TASK_LEDGER_QUERY_REPLACEMENT_GRANTS = [ - 'session.todo.query', -] as const satisfies readonly OperationKey[]; -// Operations that left the protocol entirely. A previously issued access file -// may still grant them; the grant is released on decode — there is nothing to -// migrate it to — because failing the whole file would keep the Host from -// starting over a capability it could not serve anyway. -const RETIRED_OPERATION_GRANTS = new Set([ +// What a stored grant means to the current protocol. The access file is the +// Host's own record of what it already granted, written by a build that may be +// several releases old, and no peer is present to negotiate a version. An entry +// here is the only thing that rewrites that record: `replace` carries the +// stored authority to the operations that succeeded it, `release` drops it +// because nothing did. A stored grant naming no entry is kept verbatim — see +// `migratePersistedGrants`. +type PersistedGrantMigration = + | { + readonly kind: 'replace'; + // Non-empty by construction: a migration that names no successor is a + // release, and has to say so. + readonly successors: readonly [OperationKey, ...OperationKey[]]; + } + | { readonly kind: 'release' }; + +const PERSISTED_GRANT_MIGRATIONS: ReadonlyMap = new Map< + string, + PersistedGrantMigration +>([ + // The transcript query split into paging and its overlay release. + [ + 'session.transcript.query', + { + kind: 'replace', + successors: ['session.transcript.page', 'session.transcript.overlay.release'], + }, + ], + // The Turn query kept its name and gained a separate landmark query beside it. + [ + 'session.turns.query', + { kind: 'replace', successors: ['session.turns.query', 'session.turn_landmarks.query'] }, + ], + // TaskLedger became SessionTodo; the query carried its authority over. + ['task.ledger.query', { kind: 'replace', successors: ['session.todo.query'] }], // Retired with the Claude subscription provider, whose client identity the // usage report required. - 'oauth.account.usage.fetch', + ['oauth.account.usage.fetch', { kind: 'release' }], // Retired with the second execution-inspection contract; no shipped surface - // called execution.inspect.resolve, so a stored grant is released on decode. - 'execution.inspect.resolve', + // called execution.inspect.resolve. + ['execution.inspect.resolve', { kind: 'release' }], ]); export const ACCESS_FILE_NAME = 'runtime-host-access.json'; @@ -80,13 +96,27 @@ export const SESSION_GUEST_OPERATION_GRANTS = Object.freeze([ 'session.transcript.overlay.release', ] as const satisfies readonly OperationKey[]); +// A Client Capability provider serves exactly this much and nothing else. It +// sits beside the Session Guest list because both are principal policy that +// `effectiveOperationGrants` re-applies on every decode: what a principal may +// hold is decided by the running build, never by what its record happens to say. +export const CAPABILITY_PROVIDER_OPERATION_GRANTS = Object.freeze([ + 'host.status', + 'client.capability.replace', + 'client.capability.unregister', +] as const satisfies readonly OperationKey[]); + export interface StoredAccessCredential { readonly credentialId: string; readonly credentialHash: string; readonly principalId: string; readonly principalKind: AccessCredentialPrincipalKind; readonly status: 'pending' | 'active' | 'revoked'; - readonly operationGrants: readonly OperationKey[]; + // What this credential was granted, as the file records it. Kept verbatim + // across versions the current build does not share a vocabulary with, so it + // is a `string[]`, not an `OperationKey[]`. What the credential may actually + // exercise is derived by `effectiveOperationGrants` and never written back. + readonly grants: readonly string[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; readonly capabilityOwner?: ClientCapabilityOwnerIdentity; @@ -146,6 +176,46 @@ export function issuedAccessGrants(grants: readonly OperationKey[]): readonly Op return validateIssuedGrants([...new Set(['host.status', ...grants])]); } +// What the running build lets this credential exercise. Derived from the record +// on every decode and never persisted: a grant the current protocol does not +// define, or that the current policy for this principal no longer allows, is +// absent here while staying in the record. Dropping it from the record instead +// would make an unrelated later write erase it for good (#4420). +export function effectiveOperationGrants( + credential: StoredAccessCredential, +): readonly OperationKey[] { + // A Session Guest holds whatever the guest policy grants now. Its record was + // never authoritative — issuance writes the policy list wholesale. + if (credential.principalKind === 'session_guest') return SESSION_GUEST_OPERATION_GRANTS; + const permitted = + credential.principalKind === 'capability_provider' + ? new Set(CAPABILITY_PROVIDER_OPERATION_GRANTS) + : undefined; + return Object.freeze( + credential.grants.filter( + (grant): grant is OperationKey => + Object.hasOwn(HOST_OPERATION_SPECS, grant) && + operationAllowsRemoteOwner(grant as OperationKey) && + (permitted === undefined || permitted.has(grant)), + ), + ); +} + +// Stored grants this build can neither serve nor account for: absent from the +// protocol and named by no migration entry. A rename that ships without its +// entry leaves its old key here, which is what the released forward roll +// asserts against — the record survives, so the omission is recoverable, but it +// is still an omission. +export function unresolvedPersistedGrants(file: AccessCredentialFile): readonly string[] { + const unresolved = new Set(); + for (const credential of file.credentials) { + for (const grant of credential.grants) { + if (!Object.hasOwn(HOST_OPERATION_SPECS, grant)) unresolved.add(grant); + } + } + return Object.freeze([...unresolved].sort()); +} + export function assertAccessCredentialFileCapacity(file: AccessCredentialFile): void { const fullyRevoked = createAccessCredentialFile( file.credentials.map((credential) => @@ -228,8 +298,40 @@ export async function writeAccessCredentialFile( } } +// The on-disk shape, stated once. The record's field is `grants` in memory and +// `operationGrants` on disk, and only what this function names is written — so +// a field added to the runtime type cannot reach the file by accident, and the +// key an older build reads keeps its published name. +function encodeAccessCredentialFile(file: AccessCredentialFile): unknown { + return { + schemaVersion: file.schemaVersion, + credentials: file.credentials.map((credential) => ({ + credentialId: credential.credentialId, + credentialHash: credential.credentialHash, + principalId: credential.principalId, + principalKind: credential.principalKind, + status: credential.status, + operationGrants: credential.grants, + canPublishClientCapabilities: credential.canPublishClientCapabilities, + canUseHostPaths: credential.canUseHostPaths, + ...(credential.capabilityOwner ? { capabilityOwner: credential.capabilityOwner } : {}), + createdAt: credential.createdAt, + ...(credential.bindClientInstanceOnFinalize === true + ? { bindClientInstanceOnFinalize: true } + : {}), + ...(credential.clientInstanceId === undefined + ? {} + : { clientInstanceId: credential.clientInstanceId }), + ...(credential.expiresAt === undefined ? {} : { expiresAt: credential.expiresAt }), + ...(credential.revokedAt === undefined ? {} : { revokedAt: credential.revokedAt }), + })), + sessionGrants: file.sessionGrants, + turnAccessRequests: file.turnAccessRequests, + }; +} + function serializeAccessCredentialFile(file: AccessCredentialFile): string { - const contents = `${JSON.stringify(file, null, 2)}\n`; + const contents = `${JSON.stringify(encodeAccessCredentialFile(file), null, 2)}\n`; if (Buffer.byteLength(contents) > ACCESS_FILE_MAX_BYTES) { throw new RuntimeHostAccessCapacityError(); } @@ -320,29 +422,14 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { throw new Error('Invalid status'); } if (!Array.isArray(value.operationGrants)) throw new Error('Invalid operationGrants'); - const storedOperationGrants = value.operationGrants.map((grant) => + const storedGrants = value.operationGrants.map((grant) => requireStoredString(grant, 'operationGrant'), ); - if (new Set(storedOperationGrants).size !== storedOperationGrants.length) { + if (new Set(storedGrants).size !== storedGrants.length) { throw new Error('Duplicate Runtime Host access operation grant'); } - const migratedOperationGrants = validateStoredGrants( - migrateStoredOperationGrants(storedOperationGrants), - ); - if ( - principalKind === 'session_guest' && - migratedOperationGrants.some( - (grant) => !(SESSION_GUEST_OPERATION_GRANTS as readonly OperationKey[]).includes(grant), - ) - ) { - throw new Error('Session Guest credential has an invalid operation grant'); - } - const operationGrants = Object.freeze( - principalKind === 'session_guest' - ? [...SESSION_GUEST_OPERATION_GRANTS] - : migratedOperationGrants.filter(operationAllowsRemoteOwner), - ); - if (!operationGrants.includes('host.status')) { + const grants = migratePersistedGrants(storedGrants); + if (!grants.includes('host.status')) { throw new Error('Runtime Host access credential lacks its liveness grant'); } if ( @@ -394,7 +481,7 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { principalId, principalKind, status: value.status, - operationGrants, + grants, canPublishClientCapabilities: value.canPublishClientCapabilities, canUseHostPaths: value.canUseHostPaths, ...(capabilityOwner ? { capabilityOwner } : {}), @@ -444,39 +531,43 @@ function decodeCapabilityOwner(value: unknown): ClientCapabilityOwnerIdentity | return Object.freeze({ principalId, clientInstanceId }); } -function migrateStoredOperationGrants(grants: readonly string[]): readonly string[] { +// Rewrites the record, and only where a migration entry says to. A stored grant +// naming no entry is carried through unchanged, whether or not this build knows +// it: an older build must not erase a key a newer one wrote, and a newer build +// must not erase a key whose migration entry was forgotten. Both erasures are +// permanent, because the next unrelated mutation rewrites the whole file. +function migratePersistedGrants(grants: readonly string[]): readonly string[] { const migrated: string[] = []; const seen = new Set(); for (const stored of grants) { - const replacements = RETIRED_OPERATION_GRANTS.has(stored) - ? [] - : stored === LEGACY_TRANSCRIPT_QUERY_GRANT - ? TRANSCRIPT_QUERY_REPLACEMENT_GRANTS - : stored === TURN_QUERY_GRANT - ? TURN_QUERY_REPLACEMENT_GRANTS - : stored === LEGACY_TASK_LEDGER_QUERY_GRANT - ? TASK_LEDGER_QUERY_REPLACEMENT_GRANTS - : [stored]; - for (const replacement of replacements) { - if (seen.has(replacement)) continue; - seen.add(replacement); - migrated.push(replacement); + const migration = PERSISTED_GRANT_MIGRATIONS.get(stored); + const successors: readonly string[] = migration + ? migration.kind === 'replace' + ? migration.successors + : [] + : [stored]; + for (const successor of successors) { + if (seen.has(successor)) continue; + seen.add(successor); + migrated.push(successor); } } - return migrated; + return Object.freeze(migrated); } -function validateStoredGrants(grants: readonly string[]): readonly OperationKey[] { +// The issuance gate. Nothing the current protocol does not define may enter the +// record through this Host; what a predecessor already wrote is the decoder's +// problem, not this one's. +function assertCurrentOperations(grants: readonly OperationKey[]): void { for (const grant of grants) { if (!Object.hasOwn(HOST_OPERATION_SPECS, grant)) { throw new RuntimeHostAccessInputError(`Unknown Runtime Host operation grant: ${grant}`); } } - return Object.freeze([...grants] as OperationKey[]); } function validateIssuedGrants(grants: readonly OperationKey[]): readonly OperationKey[] { - validateStoredGrants(grants); + assertCurrentOperations(grants); for (const grant of grants) { if (!operationAllowsRemoteOwner(grant)) { throw new RuntimeHostAccessInputError(`Runtime Host operation ${grant} is local-owner only`); diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 2e06311c71..7567dfc7cf 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -77,6 +77,22 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/windows-package-source-closure.test.mjs', ]); +// What decides whether a build can read durable state an earlier release wrote. +// `operations.ts` is here because it owns the operation vocabulary: the rename +// that stranded workspaces holding an older credential (#4420) changed that file +// and none of the decoders, so a trigger listing only decoders would not have +// run on the very change it exists to catch. +const DURABLE_STATE_DECODER_FILES = new Set([ + 'packages/runtime-host/src/protocol/operations.ts', + 'packages/runtime-host/src/server/access-authority.ts', + 'packages/runtime-host/src/server/access-credential-store.ts', + 'packages/storage/src/operational-state-store.ts', + 'packages/storage/src/root-authority.ts', + 'packages/storage/src/state-root-composition.ts', + 'scripts/qualify-released-cli-state-root.mjs', + 'scripts/released-cli-state-root-fixture.mjs', +]); + const TYPECHECK_ONLY_FILES = new Set([ 'biome.jsonc', 'components.json', @@ -359,6 +375,7 @@ export function planTests(changedFiles, options = {}) { // Stress multipliers and native child-process lock probes run only when // their owning storage seam changes; making --full imply stress turned // every unrelated merge into a 10K-chunk pressure run. + stateRootCompat: true, storageStress: false, storybook: true, workspaces, @@ -430,6 +447,14 @@ export function planTests(changedFiles, options = {}) { // the cli workspace runs in the dependency closure, not only for direct // cli/runtime edits (e.g. a storage-only change still selects cli via runtime). runtimeSandbox: workspaces.includes('packages/cli'), + // The released forward roll: a build under test reads durable state a + // published predecessor wrote. Selected by the decoders and the operation + // vocabulary they decode against, plus the SQLite schemas. + stateRootCompat: files.some( + (path) => + DURABLE_STATE_DECODER_FILES.has(path) || + /^packages\/storage\/src\/sqlite-[^/]*schema[^/]*\.ts$/u.test(path), + ), storageStress, // Storybook build + smoke: catalog/harness only. Not every desktop/ui/core // PR — product ship gates are typecheck, unit, and Electron e2e. See @@ -450,6 +475,7 @@ export function requiresHeavyValidation(plan) { plan.releaseContract || plan.runtimeHost || plan.runtimeSandbox || + plan.stateRootCompat || plan.storybook || plan.standardWorkspaces.length > 0, ); @@ -466,6 +492,7 @@ export function formatGitHubOutputs(plan) { `runtime_host=${plan.runtimeHost}`, `runtime_sandbox=${plan.runtimeSandbox}`, `release_contract=${plan.releaseContract}`, + `state_root_compat=${plan.stateRootCompat}`, `storage_stress=${plan.storageStress}`, `storybook=${plan.storybook}`, `standard_workspaces=${plan.standardWorkspaces.join(',')}`, diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 48ca5966f5..16e0c011ff 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -368,6 +368,37 @@ test('full-suite authority files select every surface', () => { } }); +// The SessionTodo cutover (#4351) retired an operation and stranded every +// workspace holding a credential issued before it (#4420). It changed the +// vocabulary, not the decoders — so a trigger listing only decoders stays green +// on the exact change shape this guard exists to catch. +test('retiring an operation selects the released forward roll', () => { + const plan = planTests( + [ + 'packages/runtime-host/src/protocol/operations.ts', + 'apps/desktop/src/renderer/features/workbar/tools/tasks/use-session-todo.ts', + ], + { graph }, + ); + + assert.equal(plan.stateRootCompat, true); + assert.equal(plan.full, false); +}); + +test('a durable-state decoder selects the released forward roll', () => { + const plan = planTests(['packages/runtime-host/src/server/access-credential-store.ts'], { + graph, + }); + + assert.equal(plan.stateRootCompat, true); +}); + +test('ordinary changes do not pay for the released forward roll', () => { + const plan = planTests(['apps/desktop/src/renderer/features/workbar/ports.ts'], { graph }); + + assert.equal(plan.stateRootCompat, false); +}); + test('GitHub output matches the selections consumed by CI', () => { const output = formatGitHubOutputs(planTests([], { graph, forceFull: true })); const outputKeys = new Set(output.split('\n').map((line) => line.split('=', 1)[0])); diff --git a/scripts/qualify-released-cli-state-root.mjs b/scripts/qualify-released-cli-state-root.mjs index 5a4faf250a..4da9bb7293 100644 --- a/scripts/qualify-released-cli-state-root.mjs +++ b/scripts/qualify-released-cli-state-root.mjs @@ -21,6 +21,7 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { cpSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -50,6 +51,7 @@ export function parseQualificationArgs(argv) { '--target', '--source-sha256', '--target-sha256', + '--target-workspace', '--expect-epoch-relation', ]); const values = new Map(); @@ -64,13 +66,29 @@ export function parseQualificationArgs(argv) { values.set(name, value); } const source = requireAbsolutePath(values, '--source'); - const target = requireAbsolutePath(values, '--target'); const sourceSha256 = requireSha256(values, '--source-sha256'); - const targetSha256 = requireSha256(values, '--target-sha256'); const expectedEpochRelation = values.get('--expect-epoch-relation') ?? 'any'; if (!['same', 'different', 'any'].includes(expectedEpochRelation)) { throw new Error('Expected epoch relation must be same, different, or any'); } + // A pull request qualifies the built workspace rather than a packaged + // artifact: what decides whether durable state still opens is the compiled + // storage and Runtime Host code, and packaging only moves it. The release + // lanes keep naming an exact tarball, which is why identity stays required + // there rather than optional everywhere. + if (values.has('--target-workspace')) { + if (values.has('--target') || values.has('--target-sha256')) { + throw new Error('A workspace target cannot also name a tarball target'); + } + return { + source, + sourceSha256, + targetWorkspace: requireAbsolutePath(values, '--target-workspace'), + expectedEpochRelation, + }; + } + const target = requireAbsolutePath(values, '--target'); + const targetSha256 = requireSha256(values, '--target-sha256'); return { source, target, sourceSha256, targetSha256, expectedEpochRelation }; } @@ -134,7 +152,9 @@ export async function qualifyReleasedCliStateRoot(input) { assertCommandAvailable('/usr/bin/setpriv'); } assertTarballDigest(input.source, input.sourceSha256, 'source'); - assertTarballDigest(input.target, input.targetSha256, 'target'); + if (!input.targetWorkspace) { + assertTarballDigest(input.target, input.targetSha256, 'target'); + } const scope = mkdtempSync(join(tmpdir(), 'maka-released-state-root-')); try { const sandbox = prepareSandbox(scope); @@ -144,12 +164,14 @@ export async function qualifyReleasedCliStateRoot(input) { scope, sandbox, }); - const target = installArtifact({ - role: 'target', - tarball: input.target, - scope, - sandbox, - }); + const target = input.targetWorkspace + ? workspaceArtifact({ repoRoot: input.targetWorkspace, scope, sandbox }) + : installArtifact({ + role: 'target', + tarball: input.target, + scope, + sandbox, + }); const epochRelation = assertExpectedEpochRelation( source.compatibilityEpoch, target.compatibilityEpoch, @@ -174,23 +196,41 @@ export async function qualifyReleasedCliStateRoot(input) { } } +// The State Root is not the whole durable surface. The Runtime Host access file +// and the rest of the control records live in the account-local control +// namespace, and the Host opens them before the Kernel starts — so a golden +// copy that captured only the State Root could not restore, or observe, the +// state that decides whether the Host starts at all. The control path mirrors +// resolveRootControlNamespace in @maka/storage; this harness runs on Linux +// only, so it names the Linux location rather than importing across the +// installed artifacts it is here to compare. +export function durableStateLocations(scope) { + return [ + { live: join(scope, 'state-root'), golden: join(scope, 'golden-root') }, + { + live: join(userInfo().homedir, '.cache', 'maka', 'runtime-hosts'), + golden: join(scope, 'golden-control'), + }, + ]; +} + async function qualifyInstalledArtifacts(input) { const { scope, source, target } = input; - const rootPath = join(scope, 'state-root'); - const goldenPath = join(scope, 'golden-root'); + const locations = durableStateLocations(scope); + const rootPath = locations[0].live; mkdirSync(rootPath, { recursive: true }); const seeded = runFixture({ action: 'seed', artifact: source, rootPath, scope }); assertFacts(seeded); - cpSync(rootPath, goldenPath, { recursive: true, force: true }); + captureGolden(locations); const sourceReady = await runInstalledRuntimeHost({ artifact: source, rootPath, scope }); const sourceFacts = runFixture({ action: 'inspect', artifact: source, rootPath, scope }); assertSameFacts(seeded, sourceFacts, 'source self-reopen'); - restoreGolden(rootPath, goldenPath); + restoreGolden(locations); const writerFence = await proveWriterFence({ source, target, rootPath, scope }); - restoreGolden(rootPath, goldenPath); + restoreGolden(locations); const targetReady = await runInstalledRuntimeHost({ artifact: target, rootPath, scope }); const targetFacts = runFixture({ action: 'inspect', artifact: target, rootPath, scope }); assertSameFacts(seeded, targetFacts, 'target transition'); @@ -334,6 +374,42 @@ function installArtifact({ role, tarball, scope, sandbox }) { return { role, prefix, packageRoot, cliPath, version, compatibilityEpoch: Number(epoch) }; } +// The workspace already carries the compiled packages the fixture loads: npm +// workspaces link node_modules/@maka/* at the repository root, which is the +// same shape an installed tarball presents. Nothing is installed, so the +// packaging chain in front of this check is skipped entirely. +function workspaceArtifact({ repoRoot, scope, sandbox }) { + const cliPath = join(repoRoot, 'packages/cli/dist/cli.js'); + const protocolPath = join(repoRoot, 'node_modules/@maka/runtime-host/dist/protocol/index.js'); + for (const required of [cliPath, protocolPath]) { + if (!existsSync(required)) { + throw new Error(`The workspace target is not built: ${required} is missing`); + } + } + const epoch = readFileSync(protocolPath, 'utf8').match( + /RUNTIME_HOST_COMPATIBILITY_EPOCH\s*=\s*(\d+)/u, + )?.[1]; + if (!epoch) throw new Error('The workspace target has no compatibility epoch'); + const versionResult = spawnSync(process.execPath, [cliPath, '--version'], { + cwd: scope, + env: sandbox.environment, + encoding: 'utf8', + maxBuffer: MAX_OUTPUT_BYTES, + timeout: PROCESS_TIMEOUT_MS, + }); + if (versionResult.status !== 0) { + throw new Error(`The workspace CLI version check failed: ${versionResult.stderr}`); + } + return { + role: 'target', + kind: 'workspace', + packageRoot: repoRoot, + cliPath, + version: versionResult.stdout.trim(), + compatibilityEpoch: Number(epoch), + }; +} + export function qualificationSandboxArgs({ innerInputPath, sandbox, scope }) { return [ '--die-with-parent', @@ -529,7 +605,8 @@ function assertFacts(value) { !value.rootId || !value.session?.id || !value.session?.message?.id || - !value.scheduledTask?.id + !value.scheduledTask?.id || + !value.access?.credentialId ) { throw new Error('Released fixture evidence is incomplete'); } @@ -542,19 +619,43 @@ function assertSameFacts(expected, actual, stage) { } } -function restoreGolden(rootPath, goldenPath) { - for (const entry of readdirSync(rootPath)) { - rmSync(join(rootPath, entry), { recursive: true, force: true }); +function captureGolden(locations) { + for (const { live, golden } of locations) { + rmSync(golden, { recursive: true, force: true }); + if (!existsSync(live)) continue; + cpSync(live, golden, { recursive: true, force: true }); } - for (const entry of readdirSync(goldenPath)) { - cpSync(join(goldenPath, entry), join(rootPath, entry), { - recursive: true, - force: true, - }); +} + +function restoreGolden(locations) { + for (const { live, golden } of locations) { + if (existsSync(live)) { + for (const entry of readdirSync(live)) { + rmSync(join(live, entry), { recursive: true, force: true }); + } + } + if (!existsSync(golden)) continue; + mkdirSync(live, { recursive: true }); + for (const entry of readdirSync(golden)) { + cpSync(join(golden, entry), join(live, entry), { + recursive: true, + force: true, + }); + } } } function artifactEvidence(artifact, sha256) { + // A workspace target has no published identity to pin, and saying so keeps + // this report from reading as evidence about a release it never touched. + if (artifact.kind === 'workspace') { + return { + version: artifact.version, + compatibilityEpoch: artifact.compatibilityEpoch, + kind: 'workspace', + sha256: 'not_applicable', + }; + } return { version: artifact.version, compatibilityEpoch: artifact.compatibilityEpoch, diff --git a/scripts/qualify-released-cli-state-root.test.mjs b/scripts/qualify-released-cli-state-root.test.mjs index 87c3bd8822..9a02a466ba 100644 --- a/scripts/qualify-released-cli-state-root.test.mjs +++ b/scripts/qualify-released-cli-state-root.test.mjs @@ -20,10 +20,11 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import test from 'node:test'; import { assertExpectedEpochRelation, + durableStateLocations, parseQualificationArgs, qualificationSandboxArgs, qualificationSandboxInvocation, @@ -190,3 +191,57 @@ test('computes the exact artifact SHA-256', () => { rmSync(root, { recursive: true, force: true }); } }); + +test('durable state covers the control namespace, not only the State Root', () => { + // The access file the Host opens before its Kernel starts lives beside the + // State Root, not inside it. A golden copy scoped to the State Root alone + // restored a workspace whose control records had already moved on, so the + // transition it proved was never the one a user performs. + const locations = durableStateLocations('/qualification-scope'); + assert.ok(locations.length >= 2); + assert.ok(locations.some(({ live }) => live === '/qualification-scope/state-root')); + assert.ok( + locations.some(({ live }) => live.endsWith(join('.cache', 'maka', 'runtime-hosts'))), + 'the account-local control namespace must be captured and restored', + ); + for (const { live, golden } of locations) { + assert.ok(isAbsolute(live) && isAbsolute(golden)); + assert.ok(!golden.startsWith(`${live}/`), 'a golden copy must not nest inside its live path'); + } +}); + +test('a workspace target replaces tarball identity instead of weakening it', () => { + const source = resolve(tmpdir(), 'source.tgz'); + const repo = resolve(tmpdir(), 'checkout'); + assert.deepEqual( + parseQualificationArgs([ + '--source', + source, + '--source-sha256', + SHA_A, + '--target-workspace', + repo, + ]), + { source, sourceSha256: SHA_A, targetWorkspace: repo, expectedEpochRelation: 'any' }, + ); + // The source stays an exact published artifact either way: the point of the + // run is that state written by a real release still opens. + assert.throws( + () => parseQualificationArgs(['--target-workspace', repo]), + /--source must be an absolute path/u, + ); + assert.throws( + () => + parseQualificationArgs([ + '--source', + source, + '--source-sha256', + SHA_A, + '--target-workspace', + repo, + '--target-sha256', + SHA_B, + ]), + /cannot also name a tarball target/u, + ); +}); diff --git a/scripts/released-cli-state-root-fixture.mjs b/scripts/released-cli-state-root-fixture.mjs index b67f374057..a4a7c73153 100644 --- a/scripts/released-cli-state-root-fixture.mjs +++ b/scripts/released-cli-state-root-fixture.mjs @@ -23,6 +23,7 @@ import { pathToFileURL } from 'node:url'; const SESSION_NAME = 'Released State Root qualification'; const MESSAGE_ID = 'released-state-root-message'; const TASK_TITLE = 'Released State Root durable task'; +const ACCESS_PRINCIPAL_ID = 'released-state-root-qualification-client'; const FUTURE_FIRE_DELAY_MS = 24 * 60 * 60 * 1_000; const input = parseFixtureArgs(process.argv.slice(2)); @@ -113,6 +114,7 @@ async function seedFixture(packageRoot, rootPath, rootOwner, rootId) { schedule: task.schedule, effect: task.effect, }, + access: await seedAccessCredential(packageRoot, rootOwner.controlDirectory), }; } finally { scheduledTasks.close(); @@ -120,6 +122,77 @@ async function seedFixture(packageRoot, rootPath, rootOwner, rootId) { } } +// The access file lives in the account-local control namespace rather than the +// State Root, and the Host opens it before the Kernel starts. A credential +// issued by the released build is therefore the one durable record that decides +// whether the current build can start at all. +async function seedAccessCredential(packageRoot, controlDirectory) { + const accessAuthority = await loadInstalled( + packageRoot, + 'node_modules/@maka/runtime-host/dist/server/access-authority.js', + ); + const protocol = await loadInstalled( + packageRoot, + 'node_modules/@maka/runtime-host/dist/protocol/index.js', + ); + const authority = await accessAuthority.openRuntimeHostAccessAuthority(controlDirectory); + try { + // Ask the released build what it is able to grant instead of naming + // operations here. A fixture that hard-codes today's keys stops covering + // the next rename the moment that rename lands. + const issued = await accessAuthority.issueAccessCredential(authority, { + principalKind: 'remote_owner', + principalId: ACCESS_PRINCIPAL_ID, + operationGrants: [...protocol.REMOTE_OWNER_OPERATION_GRANTS], + canPublishClientCapabilities: false, + canUseHostPaths: false, + }); + if (!issued.ok) { + throw new Error( + `The released access credential could not be issued: ${issued.error.message}`, + ); + } + return { credentialId: issued.result.credentialId, principalId: issued.result.principalId }; + } finally { + await authority.close(); + } +} + +// Grants are deliberately absent from the facts: a rename is supposed to move a +// stored grant to its successor, so comparing the grant list across builds +// would report a correct migration as a lost fact. The credential identity is +// what must survive, and the Host refusing to open the file is what fails. +async function inspectAccessCredential(packageRoot, controlDirectory) { + const store = await loadInstalled( + packageRoot, + 'node_modules/@maka/runtime-host/dist/server/access-credential-store.js', + ); + const file = await store.readAccessCredentialFile(join(controlDirectory, store.ACCESS_FILE_NAME)); + const credential = file.credentials.find( + (candidate) => candidate.principalId === ACCESS_PRINCIPAL_ID, + ); + if (!credential) throw new Error('The released access credential fact is missing'); + // This fixture runs on both builds, which do not share a field name here: the + // record is `grants` on builds that separate it from the derived authority and + // `operationGrants` on those that did not. The published JSON key never moved. + const grants = credential.grants ?? credential.operationGrants; + if (!grants.includes('host.status')) { + throw new Error('The released access credential lost its liveness grant'); + } + // Asked of whichever build is reading, so it is deliberately not a shared + // fact: a grant the reader can neither serve nor account for means a rename + // shipped without its migration entry. Builds predating the check skip it. + if (typeof store.unresolvedPersistedGrants === 'function') { + const unresolved = store.unresolvedPersistedGrants(file); + if (unresolved.length > 0) { + throw new Error( + `The released access credential carries grants this build cannot account for: ${unresolved.join(', ')}`, + ); + } + } + return { credentialId: credential.credentialId, principalId: credential.principalId }; +} + async function inspectFixture(packageRoot, rootPath, rootOwner, rootId) { const sessionsModule = await loadInstalled( packageRoot, @@ -158,6 +231,7 @@ async function inspectFixture(packageRoot, rootPath, rootOwner, rootId) { schedule: task.schedule, effect: task.effect, }, + access: await inspectAccessCredential(packageRoot, rootOwner.controlDirectory), }; } finally { scheduledTasks.close();