From 08651e90a29902ea857a6c39c50a1197b275dced Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 16:29:43 +0800 Subject: [PATCH 1/3] fix(runtime-host): separate the persisted grant record from the authority it derives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A credential's grant list served two masters. As a record it should hold what some earlier release wrote; as an authority it may only name operations this build defines and this principal's policy allows. Decoding reconciled them in place, so every vocabulary change had to be patched into the same array — first three hand-written constants, then a fourth for the SessionTodo cutover that stranded workspaces holding a credential issued before it (#4420). The record is now `StoredAccessCredential.grants`, kept as the file states it, and `effectiveOperationGrants` derives the authority on every decode without writing back. A grant this build cannot serve is absent from the authority and present in the record, so an unrelated later mutation no longer erases it — neither a newer build's key seen by an older one, nor a key whose migration entry was forgotten. `PERSISTED_GRANT_MIGRATIONS` is the only thing that rewrites the record, and a replacement naming no successor fails to compile. Deriving per principal also puts a Client Capability provider under its own policy rather than the remote owner's, which decoding never applied, and retires the Session Guest special case: its record was never authoritative, so it is simply one more derivation rule. The published JSON keeps `operationGrants` as its key. An explicit encoder states the on-disk shape once, so a field added to the runtime type cannot reach the file by accident. Refs #4420 Generated-by: Claude Code (claude-opus-5) --- .../access-credential-grant-migration.test.ts | 186 +++++++++++++++ .../access-credential-metadata.test.ts | 4 +- .../src/server/access-authority.ts | 27 ++- .../src/server/access-credential-metadata.ts | 10 +- .../src/server/access-credential-store.ts | 215 +++++++++++++----- scripts/qualify-released-cli-state-root.mjs | 147 ++++++++++-- .../qualify-released-cli-state-root.test.mjs | 57 ++++- scripts/released-cli-state-root-fixture.mjs | 59 +++++ 8 files changed, 603 insertions(+), 102 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts 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/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..252de0595d 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,62 @@ 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'); + if (!credential.operationGrants.includes('host.status')) { + throw new Error('The released access credential lost its liveness grant'); + } + return { credentialId: credential.credentialId, principalId: credential.principalId }; +} + async function inspectFixture(packageRoot, rootPath, rootOwner, rootId) { const sessionsModule = await loadInstalled( packageRoot, @@ -158,6 +216,7 @@ async function inspectFixture(packageRoot, rootPath, rootOwner, rootId) { schedule: task.schedule, effect: task.effect, }, + access: await inspectAccessCredential(packageRoot, rootOwner.controlDirectory), }; } finally { scheduledTasks.close(); From f05b1e5d6d14ce19a4734255ccc4f330e9eeac20 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 16:29:58 +0800 Subject: [PATCH 2/3] test(release): qualify the access record beside the State Root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forward roll restored and compared the State Root alone, but the record that decides whether a Host starts at all is the access credential file, and it lives in the account-local control namespace rather than inside the Root. The harness was structurally blind to it: no seeded credential, no golden capture of that directory, so a release could strand every existing workspace and still qualify. The fixture now asks the released build to issue a credential with everything it is able to grant, rather than naming operations here — a fixture that hard-codes today's keys stops covering the next rename the moment it lands. Golden capture and restore span both durable locations. Inspection also asserts that the reading build can account for every stored grant. That question is asked of whichever build is reading rather than compared between them, so a rename shipping without its migration entry fails on the candidate while the released build, which predates the check, skips it. Generated-by: Claude Code (claude-opus-5) --- scripts/released-cli-state-root-fixture.mjs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/released-cli-state-root-fixture.mjs b/scripts/released-cli-state-root-fixture.mjs index 252de0595d..a4a7c73153 100644 --- a/scripts/released-cli-state-root-fixture.mjs +++ b/scripts/released-cli-state-root-fixture.mjs @@ -172,9 +172,24 @@ async function inspectAccessCredential(packageRoot, controlDirectory) { (candidate) => candidate.principalId === ACCESS_PRINCIPAL_ID, ); if (!credential) throw new Error('The released access credential fact is missing'); - if (!credential.operationGrants.includes('host.status')) { + // 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 }; } From eb7a3a152fa1dd896e8b571be2eb2d50efcbc9a2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 16:32:32 +0800 Subject: [PATCH 3/3] ci: run the released forward roll when durable state decoders change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forward-roll job existed and already ran, but nothing woke it when the code that decodes durable state changed. The trigger now selects it from the decoders and, decisively, from the operation vocabulary they decode against: the SessionTodo cutover that caused #4420 changed `protocol/operations.ts` and no decoder, so a decoder-only trigger stays green on the exact change shape this guard exists to catch. The planner test pins that path. It runs on the heavy lane rather than the CLI packaging lane. Packaging is nine minutes that prove nothing this check needs; the baseline is instead the published predecessor, downloaded and integrity-checked against the registry's own digest, and read by the workspace already built on that lane. That costs about a minute of wall clock on roughly one commit in ten, against a full cross-platform matrix of runner time — and runner time is the scarcer resource here, which is why this lane is one job of serial steps to begin with. Generated-by: Claude Code (claude-opus-5) --- .github/workflows/ci.yml | 48 +++++++++++++++++++++++++++++++++-- scripts/ci-test-plan.mjs | 27 ++++++++++++++++++++ scripts/ci-test-plan.test.mjs | 31 ++++++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) 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/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]));